@lotics/cli 0.190.0 → 0.191.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/dist/src/cli.js CHANGED
@@ -26822,12 +26822,12 @@ var require_tokens = __commonJS({
26822
26822
  });
26823
26823
  }
26824
26824
  exports2.assignCategoriesMapProp = assignCategoriesMapProp;
26825
- function singleAssignCategoriesToksMap(path13, nextNode) {
26826
- utils_1.forEach(path13, function(pathNode) {
26825
+ function singleAssignCategoriesToksMap(path14, nextNode) {
26826
+ utils_1.forEach(path14, function(pathNode) {
26827
26827
  nextNode.categoryMatchesMap[pathNode.tokenTypeIdx] = true;
26828
26828
  });
26829
26829
  utils_1.forEach(nextNode.CATEGORIES, function(nextCategory) {
26830
- var newPath = path13.concat(nextNode);
26830
+ var newPath = path14.concat(nextNode);
26831
26831
  if (!utils_1.contains(newPath, nextCategory)) {
26832
26832
  singleAssignCategoriesToksMap(newPath, nextCategory);
26833
26833
  }
@@ -28590,10 +28590,10 @@ var require_interpreter = __commonJS({
28590
28590
  /** @class */
28591
28591
  (function(_super) {
28592
28592
  __extends(AbstractNextPossibleTokensWalker2, _super);
28593
- function AbstractNextPossibleTokensWalker2(topProd, path13) {
28593
+ function AbstractNextPossibleTokensWalker2(topProd, path14) {
28594
28594
  var _this = _super.call(this) || this;
28595
28595
  _this.topProd = topProd;
28596
- _this.path = path13;
28596
+ _this.path = path14;
28597
28597
  _this.possibleTokTypes = [];
28598
28598
  _this.nextProductionName = "";
28599
28599
  _this.nextProductionOccurrence = 0;
@@ -28647,9 +28647,9 @@ var require_interpreter = __commonJS({
28647
28647
  /** @class */
28648
28648
  (function(_super) {
28649
28649
  __extends(NextAfterTokenWalker2, _super);
28650
- function NextAfterTokenWalker2(topProd, path13) {
28651
- var _this = _super.call(this, topProd, path13) || this;
28652
- _this.path = path13;
28650
+ function NextAfterTokenWalker2(topProd, path14) {
28651
+ var _this = _super.call(this, topProd, path14) || this;
28652
+ _this.path = path14;
28653
28653
  _this.nextTerminalName = "";
28654
28654
  _this.nextTerminalOccurrence = 0;
28655
28655
  _this.nextTerminalName = _this.path.lastTok.name;
@@ -29346,10 +29346,10 @@ var require_lookahead = __commonJS({
29346
29346
  }
29347
29347
  return result;
29348
29348
  }
29349
- function pathToHashKeys(path13) {
29349
+ function pathToHashKeys(path14) {
29350
29350
  var keys3 = [""];
29351
- for (var i2 = 0; i2 < path13.length; i2++) {
29352
- var tokType = path13[i2];
29351
+ for (var i2 = 0; i2 < path14.length; i2++) {
29352
+ var tokType = path14[i2];
29353
29353
  var longerKeys = [];
29354
29354
  for (var j = 0; j < keys3.length; j++) {
29355
29355
  var currShorterKey = keys3[j];
@@ -29661,9 +29661,9 @@ var require_checks = __commonJS({
29661
29661
  return errors2;
29662
29662
  }
29663
29663
  exports2.validateRuleIsOverridden = validateRuleIsOverridden;
29664
- function validateNoLeftRecursion(topRule, currRule, errMsgProvider, path13) {
29665
- if (path13 === void 0) {
29666
- path13 = [];
29664
+ function validateNoLeftRecursion(topRule, currRule, errMsgProvider, path14) {
29665
+ if (path14 === void 0) {
29666
+ path14 = [];
29667
29667
  }
29668
29668
  var errors2 = [];
29669
29669
  var nextNonTerminals = getFirstNoneTerminal(currRule.definition);
@@ -29676,15 +29676,15 @@ var require_checks = __commonJS({
29676
29676
  errors2.push({
29677
29677
  message: errMsgProvider.buildLeftRecursionError({
29678
29678
  topLevelRule: topRule,
29679
- leftRecursionPath: path13
29679
+ leftRecursionPath: path14
29680
29680
  }),
29681
29681
  type: parser_1.ParserDefinitionErrorType.LEFT_RECURSION,
29682
29682
  ruleName
29683
29683
  });
29684
29684
  }
29685
- var validNextSteps = utils2.difference(nextNonTerminals, path13.concat([topRule]));
29685
+ var validNextSteps = utils2.difference(nextNonTerminals, path14.concat([topRule]));
29686
29686
  var errorsFromNextSteps = utils2.map(validNextSteps, function(currRefRule) {
29687
- var newPath = utils2.cloneArr(path13);
29687
+ var newPath = utils2.cloneArr(path14);
29688
29688
  newPath.push(currRefRule);
29689
29689
  return validateNoLeftRecursion(topRule, currRefRule, errMsgProvider, newPath);
29690
29690
  });
@@ -34706,7 +34706,7 @@ var require_BufferList = __commonJS({
34706
34706
  this.head = this.tail = null;
34707
34707
  this.length = 0;
34708
34708
  };
34709
- BufferList.prototype.join = function join3(s) {
34709
+ BufferList.prototype.join = function join5(s) {
34710
34710
  if (this.length === 0) return "";
34711
34711
  var p = this.head;
34712
34712
  var ret = "" + p.data;
@@ -37293,8 +37293,8 @@ var require_utils4 = __commonJS({
37293
37293
  var result = transform2[inputType][outputType](input);
37294
37294
  return result;
37295
37295
  };
37296
- exports2.resolve = function(path13) {
37297
- var parts = path13.split("/");
37296
+ exports2.resolve = function(path14) {
37297
+ var parts = path14.split("/");
37298
37298
  var result = [];
37299
37299
  for (var index = 0; index < parts.length; index++) {
37300
37300
  var part = parts[index];
@@ -43147,18 +43147,18 @@ var require_object = __commonJS({
43147
43147
  var object2 = new ZipObject(name2, zipObjectContent, o);
43148
43148
  this.files[name2] = object2;
43149
43149
  };
43150
- var parentFolder = function(path13) {
43151
- if (path13.slice(-1) === "/") {
43152
- path13 = path13.substring(0, path13.length - 1);
43150
+ var parentFolder = function(path14) {
43151
+ if (path14.slice(-1) === "/") {
43152
+ path14 = path14.substring(0, path14.length - 1);
43153
43153
  }
43154
- var lastSlash = path13.lastIndexOf("/");
43155
- return lastSlash > 0 ? path13.substring(0, lastSlash) : "";
43154
+ var lastSlash = path14.lastIndexOf("/");
43155
+ return lastSlash > 0 ? path14.substring(0, lastSlash) : "";
43156
43156
  };
43157
- var forceTrailingSlash = function(path13) {
43158
- if (path13.slice(-1) !== "/") {
43159
- path13 += "/";
43157
+ var forceTrailingSlash = function(path14) {
43158
+ if (path14.slice(-1) !== "/") {
43159
+ path14 += "/";
43160
43160
  }
43161
- return path13;
43161
+ return path14;
43162
43162
  };
43163
43163
  var folderAdd = function(name2, createFolders) {
43164
43164
  createFolders = typeof createFolders !== "undefined" ? createFolders : defaults2.createFolders;
@@ -44162,7 +44162,7 @@ import dns from "node:dns";
44162
44162
  import net2 from "node:net";
44163
44163
  import fs15 from "node:fs";
44164
44164
  import os4 from "node:os";
44165
- import path12 from "node:path";
44165
+ import path13 from "node:path";
44166
44166
  import readline from "node:readline";
44167
44167
 
44168
44168
  // ../shared/src/transport_error.ts
@@ -44531,8 +44531,8 @@ var LoticsClient = class {
44531
44531
  headers["x-request-id"] = newRequestId();
44532
44532
  return headers;
44533
44533
  }
44534
- async request(method, path13, body) {
44535
- const url2 = `${this.baseUrl}${path13}`;
44534
+ async request(method, path14, body) {
44535
+ const url2 = `${this.baseUrl}${path14}`;
44536
44536
  const headers = this.buildHeaders();
44537
44537
  const init = { method, headers };
44538
44538
  if (body !== void 0) {
@@ -46001,7 +46001,7 @@ function resultSideEffects(result) {
46001
46001
  }
46002
46002
 
46003
46003
  // src/version.ts
46004
- var VERSION = "0.190.0";
46004
+ var VERSION = "0.191.0";
46005
46005
 
46006
46006
  // src/timezone.ts
46007
46007
  function machineTimezone() {
@@ -46201,9 +46201,10 @@ var COMMANDS = [
46201
46201
  " and a worked example. Offline, no account",
46202
46202
  " lotics scaffold check <file> Prove a model before anyone sees it \u2014 offline, no",
46203
46203
  " account, no network. Reports EVERY problem in one",
46204
- " run; exits 1 if there is one, else a one-line",
46205
- " summary of what it would create. --json prints",
46206
- " the counts, or the findings that stopped it",
46204
+ " run; exits 1 if there is one, else what it would",
46205
+ " create, every planned screen with the field in each",
46206
+ " slot, and what the first rows would show. --json",
46207
+ " prints the counts and the plan, or the findings",
46207
46208
  " lotics scaffold apply <file> Create the model's tables, fields, options, views,",
46208
46209
  " roles and first rows in this workspace, then copy in",
46209
46210
  " every package its apply list names. Additive:",
@@ -46350,7 +46351,7 @@ var COMMANDS = [
46350
46351
  " offer one",
46351
46352
  " lotics app codegen [path] Regenerate .lotics/* (types + field/option ids)",
46352
46353
  " from the manifest + workspace schema \u2014 no deploy",
46353
- " lotics app check Run every deploy pre-flight WITHOUT building or",
46354
+ " lotics app check [--screens] Run every deploy pre-flight WITHOUT building or",
46354
46355
  " shipping: the app's own typecheck over regenerated",
46355
46356
  " .lotics types, agent schemas vs the live app, workflow",
46356
46357
  " declarations and BODIES vs what is live, aliases",
@@ -46360,6 +46361,9 @@ var COMMANDS = [
46360
46361
  " the pulled types, and a concrete id or an",
46361
46362
  " id-shaped placeholder in source a starter",
46362
46363
  " would ship.",
46364
+ " --screens also renders the app headless (needs",
46365
+ " Chrome) at 1280 and 375 and measures every screen",
46366
+ " against the review probes (lotics docs cli_reference).",
46363
46367
  " Exits 1 on any of it, so CI can gate on it",
46364
46368
  " lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
46365
46369
  " through set_app_workflow (server verifies)",
@@ -46437,7 +46441,9 @@ var COMMANDS = [
46437
46441
  " lotics file preview <file|fil_id> [-o png]",
46438
46442
  " Render a .docx/.xlsx to a PNG \u2014 a local path OR a",
46439
46443
  " stored file id (downloaded first). Frontend engines;",
46440
- " needs a Chrome/Chromium on the machine"
46444
+ " needs a Chrome/Chromium on the machine. A .html",
46445
+ " renders as the page it is, sized to its content \u2014",
46446
+ " the way a demo's paper props are made"
46441
46447
  ]
46442
46448
  }
46443
46449
  ];
@@ -46628,9 +46634,9 @@ async function reportCommand(client, options) {
46628
46634
  // src/app_commands.ts
46629
46635
  import fs8 from "node:fs";
46630
46636
  import path9 from "node:path";
46631
- import { spawn as spawn2 } from "node:child_process";
46637
+ import { spawn as spawn3 } from "node:child_process";
46632
46638
  import { createHash as createHash3 } from "node:crypto";
46633
- import { tmpdir } from "node:os";
46639
+ import { tmpdir as tmpdir2 } from "node:os";
46634
46640
 
46635
46641
  // src/starter_template.ts
46636
46642
  var STARTER_REACT_NATIVE_VERSION = "0.85.3";
@@ -47567,9 +47573,22 @@ async function dispatchRpc(client, body, opts) {
47567
47573
  }
47568
47574
  }
47569
47575
 
47576
+ // ../shared/src/open_app_target.ts
47577
+ function openAppTarget(payload) {
47578
+ const appId = payload?.app_id;
47579
+ const route = payload?.route;
47580
+ if (typeof appId !== "string" || !/^app_[A-Za-z0-9]+$/.test(appId)) {
47581
+ throw new Error("openApp requires an app id (app_\u2026)");
47582
+ }
47583
+ if (typeof route !== "string" || !/^\/(?:[^/\\][^\\]*)?$/.test(route)) {
47584
+ throw new Error("openApp requires an in-app route starting with /");
47585
+ }
47586
+ return { app_id: appId, href: `/apps/${appId}?_loc=${encodeURIComponent(route)}` };
47587
+ }
47588
+
47570
47589
  // src/dev/wrapper_page.ts
47571
47590
  function buildWrapperPage(args) {
47572
- const { app_name, app_id, workspace_id, vite_url, api_url } = args;
47591
+ const { app_name, app_id, workspace_id, vite_url, api_url, web_app_url } = args;
47573
47592
  return `<!doctype html>
47574
47593
  <html lang="en">
47575
47594
  <head>
@@ -47733,6 +47752,19 @@ function buildWrapperPage(args) {
47733
47752
  return undefined;
47734
47753
  }
47735
47754
 
47755
+ // openApp \u2014 the cross-app hop. The product host routes to the sibling in
47756
+ // place; this wrapper serves ONE app and has no shell to route inside, so
47757
+ // the hop opens the sibling on the web app in a new tab. The check is the
47758
+ // host's own function, inlined by source.
47759
+ var openAppTarget = (${openAppTarget.toString()});
47760
+ function handleOpenApp(payload) {
47761
+ var target = openAppTarget(payload);
47762
+ // Same refusal as the host: a hop to itself is in-app navigation.
47763
+ if (target.app_id === ${JSON.stringify(app_id)}) throw new Error("openApp is for a sibling app; navigate inside this app with its own router");
47764
+ window.open(${JSON.stringify(web_app_url)} + target.href, "_blank", "noopener,noreferrer");
47765
+ return undefined;
47766
+ }
47767
+
47736
47768
  // urlState.get/set \u2014 useUrlState keeps app view-state (filters, search) in
47737
47769
  // THIS wrapper page's address bar so it survives refresh and is shareable,
47738
47770
  // mirroring the production host. Handled locally (the URL is the store; no
@@ -47832,6 +47864,8 @@ function buildWrapperPage(args) {
47832
47864
  ? await handleUpload(msg.payload)
47833
47865
  : msg.op === "openExternal"
47834
47866
  ? handleOpenExternal(msg.payload)
47867
+ : msg.op === "openApp"
47868
+ ? handleOpenApp(msg.payload)
47835
47869
  : msg.op === "urlState.get"
47836
47870
  ? readUrlParams()
47837
47871
  : msg.op === "urlState.set"
@@ -47982,7 +48016,8 @@ async function startDevServer(args) {
47982
48016
  app_id: args.app_id,
47983
48017
  workspace_id: args.workspace_id,
47984
48018
  vite_url: viteUrl,
47985
- api_url: args.api_url
48019
+ api_url: args.api_url,
48020
+ web_app_url: WEB_APP_URL
47986
48021
  });
47987
48022
  const viteEntry = path7.join(args.projectDir, "node_modules", "vite", "bin", "vite.js");
47988
48023
  if (!existsSync(viteEntry)) {
@@ -48292,7 +48327,8 @@ async function startDevServer(args) {
48292
48327
  stop: async () => {
48293
48328
  await stopAll();
48294
48329
  await stoppingPromise;
48295
- }
48330
+ },
48331
+ pendingRpc: () => inFlight
48296
48332
  };
48297
48333
  }
48298
48334
  async function pickPort(preferred, ...avoid) {
@@ -48346,6 +48382,272 @@ function openBrowser(url2) {
48346
48382
  child.unref();
48347
48383
  }
48348
48384
 
48385
+ // src/app_screens_check.ts
48386
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
48387
+ import { dirname, join as join2 } from "node:path";
48388
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
48389
+ import { setTimeout as sleep3 } from "node:timers/promises";
48390
+
48391
+ // src/chrome.ts
48392
+ import { spawn as spawn2 } from "node:child_process";
48393
+ import { existsSync as existsSync2, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
48394
+ import { tmpdir } from "node:os";
48395
+ import { join } from "node:path";
48396
+ import { setTimeout as sleep2 } from "node:timers/promises";
48397
+ function findChrome() {
48398
+ const env = process.env.LOTICS_CHROME || process.env.CHROME_PATH;
48399
+ if (env && existsSync2(env)) return env;
48400
+ const home = process.env.HOME || "";
48401
+ const pwDir = process.platform === "darwin" ? `${home}/Library/Caches/ms-playwright` : `${home}/.cache/ms-playwright`;
48402
+ if (existsSync2(pwDir)) {
48403
+ const rel = process.platform === "darwin" ? ["chrome-mac/Chromium.app/Contents/MacOS/Chromium"] : [
48404
+ "chrome-linux64/chrome",
48405
+ "chrome-linux/chrome",
48406
+ "chrome-headless-shell-linux64/chrome-headless-shell",
48407
+ "chrome-linux/headless_shell"
48408
+ ];
48409
+ const revs = readdirSync(pwDir).filter((n) => n.startsWith("chromium-") || n.startsWith("chromium_headless_shell-")).sort().reverse();
48410
+ for (const rev2 of revs) {
48411
+ for (const r of rel) {
48412
+ const bin = join(pwDir, rev2, r);
48413
+ if (existsSync2(bin)) return bin;
48414
+ }
48415
+ }
48416
+ }
48417
+ const systemPaths = [
48418
+ "/usr/bin/google-chrome",
48419
+ "/usr/bin/google-chrome-stable",
48420
+ "/usr/bin/chromium",
48421
+ "/usr/bin/chromium-browser",
48422
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
48423
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
48424
+ ];
48425
+ return systemPaths.find((p) => existsSync2(p)) ?? null;
48426
+ }
48427
+ var NO_CHROME_MESSAGE = "No Chrome/Chromium found. Set CHROME_PATH to a Chrome binary, or install one:\n npx playwright install chromium (then it's auto-detected)\n or install Google Chrome / Chromium via your package manager.";
48428
+ async function cdpConnect(wsUrl) {
48429
+ const ws = new WebSocket(wsUrl);
48430
+ await new Promise((res, rej) => {
48431
+ ws.onopen = () => res();
48432
+ ws.onerror = () => rej(new Error("CDP websocket failed to open"));
48433
+ });
48434
+ let id = 0;
48435
+ const pending = /* @__PURE__ */ new Map();
48436
+ ws.onclose = () => {
48437
+ for (const done of pending.values()) done(Promise.reject(new Error("CDP connection closed (Chrome exited?)")));
48438
+ pending.clear();
48439
+ };
48440
+ ws.onmessage = (e) => {
48441
+ const m = JSON.parse(String(e.data));
48442
+ if (m.id == null) return;
48443
+ const done = pending.get(m.id);
48444
+ if (done === void 0) return;
48445
+ pending.delete(m.id);
48446
+ done(m.error ? Promise.reject(new Error(m.error.message)) : m.result);
48447
+ };
48448
+ const send = (method, params = {}) => new Promise((res) => {
48449
+ const i2 = ++id;
48450
+ pending.set(i2, (r) => res(r));
48451
+ ws.send(JSON.stringify({ id: i2, method, params }));
48452
+ });
48453
+ return { send, close: () => ws.close() };
48454
+ }
48455
+ async function launchChrome(options = {}) {
48456
+ const chrome = findChrome();
48457
+ if (chrome === null) throw new Error(NO_CHROME_MESSAGE);
48458
+ if (typeof WebSocket === "undefined") {
48459
+ throw new Error("This command needs Node 22+ (it drives Chrome over CDP via the built-in WebSocket). Upgrade Node and retry.");
48460
+ }
48461
+ const udd = mkdtempSync(join(tmpdir(), "lotics-chrome-"));
48462
+ const child = spawn2(chrome, [
48463
+ "--headless=new",
48464
+ "--disable-gpu",
48465
+ "--no-sandbox",
48466
+ "--hide-scrollbars",
48467
+ // Large renders exhaust the (often tiny) /dev/shm in containers/WSL2 and
48468
+ // crash the tab; back shared memory with /tmp instead.
48469
+ "--disable-dev-shm-usage",
48470
+ `--force-device-scale-factor=${options.deviceScaleFactor ?? 2}`,
48471
+ ...options.sameProcessFrames ? ["--disable-features=IsolateOrigins,site-per-process"] : [],
48472
+ "--remote-debugging-port=0",
48473
+ `--user-data-dir=${udd}`,
48474
+ "about:blank"
48475
+ ], { stdio: "ignore" });
48476
+ const close = () => {
48477
+ child.kill();
48478
+ rmSync(udd, { recursive: true, force: true });
48479
+ };
48480
+ try {
48481
+ let cdpPort = 0;
48482
+ const portFile = join(udd, "DevToolsActivePort");
48483
+ for (let i2 = 0; i2 < 100 && !cdpPort; i2++) {
48484
+ if (existsSync2(portFile)) {
48485
+ const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
48486
+ if (p) cdpPort = p;
48487
+ }
48488
+ if (!cdpPort) await sleep2(100);
48489
+ }
48490
+ if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
48491
+ let target;
48492
+ for (let i2 = 0; i2 < 60 && !target?.webSocketDebuggerUrl; i2++) {
48493
+ try {
48494
+ const list2 = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
48495
+ target = list2.find((t) => t.type === "page");
48496
+ } catch {
48497
+ }
48498
+ if (!target?.webSocketDebuggerUrl) await sleep2(100);
48499
+ }
48500
+ if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
48501
+ const cdp = await cdpConnect(target.webSocketDebuggerUrl);
48502
+ await cdp.send("Page.enable");
48503
+ await cdp.send("Runtime.enable");
48504
+ return { cdp, chrome, close: () => {
48505
+ cdp.close();
48506
+ close();
48507
+ } };
48508
+ } catch (error52) {
48509
+ close();
48510
+ throw error52;
48511
+ }
48512
+ }
48513
+
48514
+ // src/app_screens_check.ts
48515
+ var HERE = dirname(fileURLToPath2(import.meta.url));
48516
+ var SCREEN_WIDTHS = [1280, 375];
48517
+ async function findAppFrame(cdp, viteUrl) {
48518
+ for (let attempt = 0; attempt < 100; attempt++) {
48519
+ const tree = await cdp.send("Page.getFrameTree");
48520
+ const child = (tree.frameTree.childFrames ?? []).find((frame) => frame.frame.url.startsWith(viteUrl));
48521
+ if (child !== void 0) return child.frame.id;
48522
+ await sleep3(100);
48523
+ }
48524
+ throw new Error(`The app's frame never appeared under the dev wrapper (${viteUrl}).`);
48525
+ }
48526
+ async function evaluate(cdp, contextId, expression) {
48527
+ const result = await cdp.send(
48528
+ "Runtime.evaluate",
48529
+ { expression, contextId, returnByValue: true, awaitPromise: true }
48530
+ );
48531
+ if (result.exceptionDetails !== void 0) {
48532
+ throw new Error(
48533
+ `probe failed in the page: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`
48534
+ );
48535
+ }
48536
+ return result.result.value;
48537
+ }
48538
+ var SETTLE_BUDGET_MS = 15e3;
48539
+ var SETTLE_TICK_MS = 250;
48540
+ async function settle(cdp, contextId, pendingRpc) {
48541
+ let previous = -1;
48542
+ let count2 = 0;
48543
+ for (let elapsed = 0; elapsed < SETTLE_BUDGET_MS; elapsed += SETTLE_TICK_MS) {
48544
+ count2 = await evaluate(cdp, contextId, "__loticsProbe.textLeafCount()");
48545
+ if (pendingRpc() === 0 && count2 > 0 && count2 === previous) return { count: count2, settled: true };
48546
+ previous = pendingRpc() === 0 ? count2 : -1;
48547
+ await sleep3(SETTLE_TICK_MS);
48548
+ }
48549
+ return { count: count2, settled: false };
48550
+ }
48551
+ function emptyCensus(screen, width) {
48552
+ return { screen, width, leaves: 0, money: 0, bare: 0, encoded: 0, strips: 0 };
48553
+ }
48554
+ async function checkAppScreens(client, args) {
48555
+ const bundlePath = join2(HERE, "..", "probe_page.js");
48556
+ if (!existsSync3(bundlePath)) {
48557
+ throw new Error(`Probe bundle missing at ${bundlePath} \u2014 reinstall @lotics/cli (build step failed).`);
48558
+ }
48559
+ const bundle = readFileSync2(bundlePath, "utf8");
48560
+ const dev = await startDevServer({
48561
+ projectDir: args.projectDir,
48562
+ app_id: args.app_id,
48563
+ app_name: args.app_name,
48564
+ workspace_id: args.workspace_id,
48565
+ api_url: client.baseUrl,
48566
+ client,
48567
+ commentsEnabled: args.commentsEnabled
48568
+ });
48569
+ await dev.ready;
48570
+ const wrapperUrl = `http://localhost:${dev.port}/`;
48571
+ const viteUrl = `http://localhost:${dev.vitePort}/`;
48572
+ const findings = [];
48573
+ const census = [];
48574
+ let session;
48575
+ try {
48576
+ session = await launchChrome({ deviceScaleFactor: 1, sameProcessFrames: true });
48577
+ for (const width of SCREEN_WIDTHS) {
48578
+ await session.cdp.send("Emulation.setDeviceMetricsOverride", {
48579
+ width,
48580
+ height: 900,
48581
+ deviceScaleFactor: 1,
48582
+ mobile: width < 600
48583
+ });
48584
+ await session.cdp.send("Page.navigate", { url: wrapperUrl });
48585
+ const frameId = await findAppFrame(session.cdp, viteUrl);
48586
+ const world = await session.cdp.send("Page.createIsolatedWorld", {
48587
+ frameId,
48588
+ worldName: "lotics-probe"
48589
+ });
48590
+ const contextId = world.executionContextId;
48591
+ await evaluate(session.cdp, contextId, bundle);
48592
+ await settle(session.cdp, contextId, dev.pendingRpc);
48593
+ const tabs = await evaluate(session.cdp, contextId, "__loticsProbe.tabs()");
48594
+ const screens = tabs.length > 0 ? tabs : ["root"];
48595
+ for (let index = 0; index < screens.length; index++) {
48596
+ const screen = screens[index];
48597
+ if (tabs.length > 0) {
48598
+ await evaluate(session.cdp, contextId, `__loticsProbe.pressTab(${index})`);
48599
+ }
48600
+ const rendered = await settle(session.cdp, contextId, dev.pendingRpc);
48601
+ if (rendered.count === 0) {
48602
+ findings.push({ screen, width, probe: "render", total: 0, count: 1, detail: ["no text rendered \u2014 the screen did not load, so nothing below it was measured"] });
48603
+ census.push(emptyCensus(screen, width));
48604
+ continue;
48605
+ }
48606
+ if (!rendered.settled) {
48607
+ findings.push({
48608
+ screen,
48609
+ width,
48610
+ probe: "settle",
48611
+ total: rendered.count,
48612
+ count: 1,
48613
+ detail: [`still changing after ${SETTLE_BUDGET_MS / 1e3}s \u2014 measured as it was; a request still in flight, or a render that never stops`]
48614
+ });
48615
+ }
48616
+ const results = await evaluate(
48617
+ session.cdp,
48618
+ contextId,
48619
+ `__loticsProbe.run({ truncation: ${width < 600} })`
48620
+ );
48621
+ for (const result of results.findings) findings.push({ screen, width, ...result });
48622
+ census.push({ screen, width, ...results.census });
48623
+ }
48624
+ }
48625
+ } finally {
48626
+ session?.close();
48627
+ await dev.stop();
48628
+ }
48629
+ return { findings, census };
48630
+ }
48631
+ function formatScreenFindings(report) {
48632
+ const lines = ["Screens measured:"];
48633
+ for (const row of report.census) {
48634
+ lines.push(
48635
+ ` ${row.screen} @${row.width} \xB7 ${row.leaves} text runs \xB7 ${row.money} money strings \xB7 ${row.bare} bare values / ${row.encoded} encoded \xB7 ${row.strips} tab strip${row.strips === 1 ? "" : "s"}`
48636
+ );
48637
+ }
48638
+ if (report.findings.length === 0) {
48639
+ lines.push(`Every screen measured clean at ${SCREEN_WIDTHS.join(" and ")}.`);
48640
+ return lines.join("\n");
48641
+ }
48642
+ lines.push(`Screens \u2014 ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}:`);
48643
+ for (const finding of report.findings) {
48644
+ lines.push(` ${finding.screen} @${finding.width} \xB7 ${finding.probe} (${finding.count} of ${finding.total})`);
48645
+ for (const detail of finding.detail) lines.push(` ${detail}`);
48646
+ }
48647
+ lines.push(" The probes are @lotics/ui docs/reviewing.md, by name \u2014 each names its measurement and its fix.");
48648
+ return lines.join("\n");
48649
+ }
48650
+
48349
48651
  // src/output.ts
48350
48652
  var machineMode = false;
48351
48653
  var collectedWarnings = [];
@@ -49142,10 +49444,10 @@ function mergeDefs(...defs) {
49142
49444
  function cloneDef(schema) {
49143
49445
  return mergeDefs(schema._zod.def);
49144
49446
  }
49145
- function getElementAtPath(obj, path13) {
49146
- if (!path13)
49447
+ function getElementAtPath(obj, path14) {
49448
+ if (!path14)
49147
49449
  return obj;
49148
- return path13.reduce((acc, key) => acc?.[key], obj);
49450
+ return path14.reduce((acc, key) => acc?.[key], obj);
49149
49451
  }
49150
49452
  function promiseAllObject(promisesObj) {
49151
49453
  const keys3 = Object.keys(promisesObj);
@@ -49554,11 +49856,11 @@ function explicitlyAborted(x2, startIndex = 0) {
49554
49856
  }
49555
49857
  return false;
49556
49858
  }
49557
- function prefixIssues(path13, issues) {
49859
+ function prefixIssues(path14, issues) {
49558
49860
  return issues.map((iss) => {
49559
49861
  var _a4;
49560
49862
  (_a4 = iss).path ?? (_a4.path = []);
49561
- iss.path.unshift(path13);
49863
+ iss.path.unshift(path14);
49562
49864
  return iss;
49563
49865
  });
49564
49866
  }
@@ -49705,16 +50007,16 @@ function flattenError(error52, mapper = (issue2) => issue2.message) {
49705
50007
  }
49706
50008
  function formatError(error52, mapper = (issue2) => issue2.message) {
49707
50009
  const fieldErrors = { _errors: [] };
49708
- const processError = (error53, path13 = []) => {
50010
+ const processError = (error53, path14 = []) => {
49709
50011
  for (const issue2 of error53.issues) {
49710
50012
  if (issue2.code === "invalid_union" && issue2.errors.length) {
49711
- issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
50013
+ issue2.errors.map((issues) => processError({ issues }, [...path14, ...issue2.path]));
49712
50014
  } else if (issue2.code === "invalid_key") {
49713
- processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
50015
+ processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
49714
50016
  } else if (issue2.code === "invalid_element") {
49715
- processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
50017
+ processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
49716
50018
  } else {
49717
- const fullpath = [...path13, ...issue2.path];
50019
+ const fullpath = [...path14, ...issue2.path];
49718
50020
  if (fullpath.length === 0) {
49719
50021
  fieldErrors._errors.push(mapper(issue2));
49720
50022
  } else {
@@ -49741,17 +50043,17 @@ function formatError(error52, mapper = (issue2) => issue2.message) {
49741
50043
  }
49742
50044
  function treeifyError(error52, mapper = (issue2) => issue2.message) {
49743
50045
  const result = { errors: [] };
49744
- const processError = (error53, path13 = []) => {
50046
+ const processError = (error53, path14 = []) => {
49745
50047
  var _a4, _b2;
49746
50048
  for (const issue2 of error53.issues) {
49747
50049
  if (issue2.code === "invalid_union" && issue2.errors.length) {
49748
- issue2.errors.map((issues) => processError({ issues }, [...path13, ...issue2.path]));
50050
+ issue2.errors.map((issues) => processError({ issues }, [...path14, ...issue2.path]));
49749
50051
  } else if (issue2.code === "invalid_key") {
49750
- processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
50052
+ processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
49751
50053
  } else if (issue2.code === "invalid_element") {
49752
- processError({ issues: issue2.issues }, [...path13, ...issue2.path]);
50054
+ processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
49753
50055
  } else {
49754
- const fullpath = [...path13, ...issue2.path];
50056
+ const fullpath = [...path14, ...issue2.path];
49755
50057
  if (fullpath.length === 0) {
49756
50058
  result.errors.push(mapper(issue2));
49757
50059
  continue;
@@ -49783,8 +50085,8 @@ function treeifyError(error52, mapper = (issue2) => issue2.message) {
49783
50085
  }
49784
50086
  function toDotPath(_path) {
49785
50087
  const segs = [];
49786
- const path13 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
49787
- for (const seg of path13) {
50088
+ const path14 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
50089
+ for (const seg of path14) {
49788
50090
  if (typeof seg === "number")
49789
50091
  segs.push(`[${seg}]`);
49790
50092
  else if (typeof seg === "symbol")
@@ -62476,13 +62778,13 @@ function resolveRef(ref2, ctx) {
62476
62778
  if (!ref2.startsWith("#")) {
62477
62779
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
62478
62780
  }
62479
- const path13 = ref2.slice(1).split("/").filter(Boolean);
62480
- if (path13.length === 0) {
62781
+ const path14 = ref2.slice(1).split("/").filter(Boolean);
62782
+ if (path14.length === 0) {
62481
62783
  return ctx.rootSchema;
62482
62784
  }
62483
62785
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
62484
- if (path13[0] === defsKey) {
62485
- const key = path13[1];
62786
+ if (path14[0] === defsKey) {
62787
+ const key = path14[1];
62486
62788
  if (!key || !ctx.defs[key]) {
62487
62789
  throw new Error(`Reference not found: ${ref2}`);
62488
62790
  }
@@ -65180,16 +65482,16 @@ function conditionValueIssue(type, operator, value2, fieldKey) {
65180
65482
  return null;
65181
65483
  }
65182
65484
  }
65183
- function conditionIssues(node, path13, fieldMap) {
65485
+ function conditionIssues(node, path14, fieldMap) {
65184
65486
  if (!node || typeof node !== "object") {
65185
- return [`${path13}: expected condition object, got ${typeof node}`];
65487
+ return [`${path14}: expected condition object, got ${typeof node}`];
65186
65488
  }
65187
65489
  const obj = node;
65188
65490
  if (obj.type === "locked") {
65189
65491
  const validOps2 = VALID_OPERATORS.locked;
65190
65492
  if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
65191
65493
  return [
65192
- `${path13}: invalid operator '${String(obj.operator)}' for locked filter. Valid: ${validOps2.join(", ")}`
65494
+ `${path14}: invalid operator '${String(obj.operator)}' for locked filter. Valid: ${validOps2.join(", ")}`
65193
65495
  ];
65194
65496
  }
65195
65497
  return [];
@@ -65198,12 +65500,12 @@ function conditionIssues(node, path13, fieldMap) {
65198
65500
  const validOps2 = VALID_OPERATORS.current_member;
65199
65501
  if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
65200
65502
  return [
65201
- `${path13}: invalid operator '${String(obj.operator)}' for current_member filter. Valid: ${validOps2.join(", ")}`
65503
+ `${path14}: invalid operator '${String(obj.operator)}' for current_member filter. Valid: ${validOps2.join(", ")}`
65202
65504
  ];
65203
65505
  }
65204
65506
  if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
65205
65507
  return [
65206
- `${path13}: current_member '${obj.operator}' expects a non-empty string[] of group IDs.`
65508
+ `${path14}: current_member '${obj.operator}' expects a non-empty string[] of group IDs.`
65207
65509
  ];
65208
65510
  }
65209
65511
  return [];
@@ -65212,45 +65514,45 @@ function conditionIssues(node, path13, fieldMap) {
65212
65514
  const validOps2 = VALID_OPERATORS.record_id;
65213
65515
  if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
65214
65516
  return [
65215
- `${path13}: invalid operator '${String(obj.operator)}' for record_id filter. Valid: ${validOps2.join(", ")}`
65517
+ `${path14}: invalid operator '${String(obj.operator)}' for record_id filter. Valid: ${validOps2.join(", ")}`
65216
65518
  ];
65217
65519
  }
65218
65520
  if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
65219
- return [`${path13}: record_id '${obj.operator}' expects a non-empty string[] of record ids.`];
65521
+ return [`${path14}: record_id '${obj.operator}' expects a non-empty string[] of record ids.`];
65220
65522
  }
65221
65523
  return [];
65222
65524
  }
65223
65525
  if (typeof obj.field_key !== "string" || !obj.field_key) {
65224
- return [`${path13}: field_key must be a non-empty string`];
65526
+ return [`${path14}: field_key must be a non-empty string`];
65225
65527
  }
65226
65528
  let filterType;
65227
65529
  if (fieldMap) {
65228
65530
  filterType = fieldMap.get(obj.field_key);
65229
65531
  if (filterType === void 0) {
65230
65532
  const available = Array.from(fieldMap.keys()).join(", ");
65231
- return [`${path13}: unknown field_key '${obj.field_key}'. Available: ${available}`];
65533
+ return [`${path14}: unknown field_key '${obj.field_key}'. Available: ${available}`];
65232
65534
  }
65233
65535
  } else if (typeof obj.type === "string") {
65234
65536
  filterType = obj.type;
65235
65537
  }
65236
65538
  if (filterType === "button") {
65237
- return [`${path13}: a button field holds no data and cannot be filtered.`];
65539
+ return [`${path14}: a button field holds no data and cannot be filtered.`];
65238
65540
  }
65239
65541
  if (typeof filterType !== "string" || !VALID_FILTER_TYPES.includes(filterType)) {
65240
65542
  const named = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
65241
- return [`${path13}: invalid filter type ${named}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
65543
+ return [`${path14}: invalid filter type ${named}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
65242
65544
  }
65243
65545
  const validOps = VALID_OPERATORS[filterType];
65244
65546
  if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
65245
65547
  return [
65246
- `${path13}: invalid operator '${String(obj.operator)}' for ${filterType} filter. Valid: ${validOps.join(", ")}`
65548
+ `${path14}: invalid operator '${String(obj.operator)}' for ${filterType} filter. Valid: ${validOps.join(", ")}`
65247
65549
  ];
65248
65550
  }
65249
65551
  const valueIssue = conditionValueIssue(filterType, obj.operator, obj.value, obj.field_key);
65250
65552
  return valueIssue ? [valueIssue] : [];
65251
65553
  }
65252
- function validateFilterCondition(condition, path13 = "filter") {
65253
- return conditionIssues(condition, path13, void 0);
65554
+ function validateFilterCondition(condition, path14 = "filter") {
65555
+ return conditionIssues(condition, path14, void 0);
65254
65556
  }
65255
65557
  function inheritedFilterType(type, format2, fallback) {
65256
65558
  switch (type) {
@@ -67910,7 +68212,7 @@ __export(expression_string_exports, {
67910
68212
  contains: () => contains2,
67911
68213
  endsWith: () => endsWith,
67912
68214
  formatNumber: () => formatNumber,
67913
- join: () => join,
68215
+ join: () => join3,
67914
68216
  length: () => length,
67915
68217
  lower: () => lower,
67916
68218
  numberToWords: () => numberToWords,
@@ -67924,7 +68226,7 @@ __export(expression_string_exports, {
67924
68226
  trim: () => trim,
67925
68227
  upper: () => upper
67926
68228
  });
67927
- function join(arr, separator) {
68229
+ function join3(arr, separator) {
67928
68230
  if (arr == null) return "";
67929
68231
  if (!Array.isArray(arr)) {
67930
68232
  throw new Error("join: expected array, got " + typeof arr);
@@ -68377,9 +68679,9 @@ __export(expression_object_exports, {
68377
68679
  pick: () => pick3,
68378
68680
  values: () => values2
68379
68681
  });
68380
- function getNestedValue(obj, path13) {
68682
+ function getNestedValue(obj, path14) {
68381
68683
  if (obj == null) return void 0;
68382
- const parts = path13.split(".");
68684
+ const parts = path14.split(".");
68383
68685
  let value2 = obj;
68384
68686
  for (const part of parts) {
68385
68687
  if (value2 == null) return void 0;
@@ -68418,11 +68720,11 @@ function entries(obj) {
68418
68720
  }
68419
68721
  return Object.entries(obj);
68420
68722
  }
68421
- function get(obj, path13, defaultValue) {
68422
- if (typeof path13 !== "string") {
68723
+ function get(obj, path14, defaultValue) {
68724
+ if (typeof path14 !== "string") {
68423
68725
  throw new Error("get: path must be a string");
68424
68726
  }
68425
- const value2 = getNestedValue(obj, path13);
68727
+ const value2 = getNestedValue(obj, path14);
68426
68728
  return value2 !== void 0 ? value2 : defaultValue;
68427
68729
  }
68428
68730
  function pick3(obj, fields) {
@@ -72448,24 +72750,24 @@ function isBelowGeneratedWidth(token) {
72448
72750
  }
72449
72751
  var SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".css", ".json"];
72450
72752
  var SCAN_FILE_MAX_BYTES = 1024 * 1024;
72451
- function normalizeScanPath(path13) {
72452
- return path13.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
72753
+ function normalizeScanPath(path14) {
72754
+ return path14.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
72453
72755
  }
72454
- function hasScannableExtension(path13) {
72455
- return SCAN_EXTENSIONS.some((ext) => path13.endsWith(ext));
72756
+ function hasScannableExtension(path14) {
72757
+ return SCAN_EXTENSIONS.some((ext) => path14.endsWith(ext));
72456
72758
  }
72457
- function isConcreteIdScanPath(path13) {
72458
- const name2 = normalizeScanPath(path13);
72759
+ function isConcreteIdScanPath(path14) {
72760
+ const name2 = normalizeScanPath(path14);
72459
72761
  if (name2.endsWith(".md")) return true;
72460
72762
  if (!name2.startsWith("src/")) return false;
72461
72763
  if (name2.startsWith("src/workflows/")) return false;
72462
72764
  return hasScannableExtension(name2);
72463
72765
  }
72464
- function isTestFile(path13) {
72465
- return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path13);
72766
+ function isTestFile(path14) {
72767
+ return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path14);
72466
72768
  }
72467
- function isIdPlaceholderScanPath(path13) {
72468
- const name2 = normalizeScanPath(path13);
72769
+ function isIdPlaceholderScanPath(path14) {
72770
+ const name2 = normalizeScanPath(path14);
72469
72771
  if (isTestFile(name2)) return false;
72470
72772
  if (name2.endsWith(".md")) return true;
72471
72773
  if (name2 === "package.json") return true;
@@ -72482,24 +72784,24 @@ function firstLines(text, pattern, keep, into) {
72482
72784
  });
72483
72785
  return into;
72484
72786
  }
72485
- function toFindings(path13, rule, lines) {
72486
- return [...lines].map(([id, line]) => ({ path: path13, line, id, rule }));
72787
+ function toFindings(path14, rule, lines) {
72788
+ return [...lines].map(([id, line]) => ({ path: path14, line, id, rule }));
72487
72789
  }
72488
- function scanFileForConcreteIds(path13, text) {
72489
- if (!isConcreteIdScanPath(path13)) return [];
72790
+ function scanFileForConcreteIds(path14, text) {
72791
+ if (!isConcreteIdScanPath(path14)) return [];
72490
72792
  const lines = firstLines(text, CONCRETE_ID_SCAN, () => true, /* @__PURE__ */ new Map());
72491
72793
  firstLines(text, WORKSPACE_ID_SCAN, isGeneratedIdBody, lines);
72492
- return toFindings(path13, "concrete", lines);
72794
+ return toFindings(path14, "concrete", lines);
72493
72795
  }
72494
- function scanFileForIdPlaceholders(path13, text) {
72495
- if (!isIdPlaceholderScanPath(path13)) return [];
72796
+ function scanFileForIdPlaceholders(path14, text) {
72797
+ if (!isIdPlaceholderScanPath(path14)) return [];
72496
72798
  const lines = firstLines(text, ID_PLACEHOLDER_SCAN, isBelowGeneratedWidth, /* @__PURE__ */ new Map());
72497
- return toFindings(path13, "placeholder", lines);
72799
+ return toFindings(path14, "placeholder", lines);
72498
72800
  }
72499
- function scanFileForIds(path13, text) {
72500
- const concrete = scanFileForConcreteIds(path13, text);
72801
+ function scanFileForIds(path14, text) {
72802
+ const concrete = scanFileForConcreteIds(path14, text);
72501
72803
  const seen = new Set(concrete.map((finding) => finding.id));
72502
- return [...concrete, ...scanFileForIdPlaceholders(path13, text).filter((f) => !seen.has(f.id))].sort(
72804
+ return [...concrete, ...scanFileForIdPlaceholders(path14, text).filter((f) => !seen.has(f.id))].sort(
72503
72805
  (a, b) => a.line - b.line || (a.id < b.id ? -1 : 1)
72504
72806
  );
72505
72807
  }
@@ -72988,7 +73290,7 @@ async function fetchWorkflowDts(client, app_id, alias, declaration) {
72988
73290
  }
72989
73291
  function runTar(args, cwd) {
72990
73292
  return new Promise((resolve2, reject2) => {
72991
- const proc = spawn2("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
73293
+ const proc = spawn3("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
72992
73294
  let stderr = "";
72993
73295
  proc.stderr.on("data", (chunk) => {
72994
73296
  stderr += chunk.toString();
@@ -73002,7 +73304,7 @@ function runTar(args, cwd) {
73002
73304
  }
73003
73305
  function npmIsInstalled() {
73004
73306
  return new Promise((resolve2) => {
73005
- const probe = spawn2("npm", ["--version"], {
73307
+ const probe = spawn3("npm", ["--version"], {
73006
73308
  shell: process.platform === "win32",
73007
73309
  stdio: "ignore",
73008
73310
  env: ipv4ChildEnv(process.env)
@@ -73014,7 +73316,7 @@ function npmIsInstalled() {
73014
73316
  function runNpm(args, cwd) {
73015
73317
  const quiet = isMachineOutput();
73016
73318
  return new Promise((resolve2, reject2) => {
73017
- const proc = spawn2("npm", args, {
73319
+ const proc = spawn3("npm", args, {
73018
73320
  cwd,
73019
73321
  // On Windows `npm` is `npm.cmd`, a batch file Node will not execute
73020
73322
  // without a shell — bare `spawn("npm")` dies at ENOENT, which took every
@@ -73819,12 +74121,12 @@ async function appPull(client, args) {
73819
74121
  const targetPath = path9.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
73820
74122
  fs8.mkdirSync(targetPath, { recursive: true });
73821
74123
  const prior = readPriorStamp(targetPath);
73822
- const tmpFile = path9.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
74124
+ const tmpFile = path9.join(tmpdir2(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
73823
74125
  console.error(`Downloading source archive...`);
73824
74126
  await downloadToFile(sourceUrl, tmpFile);
73825
74127
  const keptLocal = [];
73826
74128
  const restoredFromArchive = [];
73827
- const stagingDir = fs8.mkdtempSync(path9.join(tmpdir(), `lotics-pull-${app.id}-`));
74129
+ const stagingDir = fs8.mkdtempSync(path9.join(tmpdir2(), `lotics-pull-${app.id}-`));
73828
74130
  try {
73829
74131
  await runTar(["-xzf", tmpFile, "-C", stagingDir], stagingDir);
73830
74132
  console.error(`Extracting to ${targetPath}...`);
@@ -73963,8 +74265,8 @@ async function appDeploy(client, args) {
73963
74265
  `Build did not produce a dist/ directory in ${projectDir}. Check that 'npm run build' is configured correctly (Vite/etc. defaults to dist/).`
73964
74266
  );
73965
74267
  }
73966
- const tmpSource = path9.join(tmpdir(), `lotics-source-${Date.now()}.tar.gz`);
73967
- const tmpDist = path9.join(tmpdir(), `lotics-dist-${Date.now()}.tar.gz`);
74268
+ const tmpSource = path9.join(tmpdir2(), `lotics-source-${Date.now()}.tar.gz`);
74269
+ const tmpDist = path9.join(tmpdir2(), `lotics-dist-${Date.now()}.tar.gz`);
73968
74270
  try {
73969
74271
  note("Packaging source...");
73970
74272
  await runTar(
@@ -74154,11 +74456,27 @@ async function appCheck(client, args = {}) {
74154
74456
  console.error(err2 instanceof Error ? err2.message : String(err2));
74155
74457
  typesFailing = true;
74156
74458
  }
74157
- if (!nothingPending(pending) || unportable.length > 0 || bodiesFailing > 0 || typesFailing) {
74459
+ let screenFindings = 0;
74460
+ if (args.screens === true && !typesFailing) {
74461
+ const report = await checkAppScreens(client, {
74462
+ projectDir,
74463
+ app_id: meta3.app_id,
74464
+ app_name: app.name,
74465
+ workspace_id: meta3.workspace_id,
74466
+ commentsEnabled: meta3.capabilities?.comments
74467
+ });
74468
+ screenFindings = report.findings.length;
74469
+ noteScope({
74470
+ probe_findings: screenFindings,
74471
+ probes: [...new Set(report.findings.map((finding) => finding.probe))]
74472
+ });
74473
+ console.error(formatScreenFindings(report));
74474
+ }
74475
+ if (!nothingPending(pending) || unportable.length > 0 || bodiesFailing > 0 || typesFailing || screenFindings > 0) {
74158
74476
  process.exit(1);
74159
74477
  }
74160
74478
  console.error(
74161
- "Checked the app's types, bindings, capabilities, agent schemas, workflow bodies and id portability \u2014 nothing blocking."
74479
+ "Checked the app's types, bindings, capabilities, agent schemas, workflow bodies and id portability" + (args.screens === true ? ", and every screen at 1280 and 375" : "") + " \u2014 nothing blocking."
74162
74480
  );
74163
74481
  }
74164
74482
  async function countFailingWorkflowBodies(client, projectDir, meta3) {
@@ -74440,13 +74758,13 @@ function derivedDeployMessage(pending) {
74440
74758
  ];
74441
74759
  return parts.length === 0 ? "Deployed app code" : `Deployed app code; pushed ${parts.join(", ")}`;
74442
74760
  }
74443
- function copyTree(from, to, opts, relative = "") {
74761
+ function copyTree(from, to, opts, relative2 = "") {
74444
74762
  const kept = [];
74445
74763
  const created = [];
74446
74764
  for (const entry of fs8.readdirSync(from, { withFileTypes: true })) {
74447
74765
  const src = path9.join(from, entry.name);
74448
74766
  const dest = path9.join(to, entry.name);
74449
- const rel = path9.join(relative, entry.name);
74767
+ const rel = path9.join(relative2, entry.name);
74450
74768
  if (opts.skip?.includes(rel.split(path9.sep).join("/"))) continue;
74451
74769
  if (entry.isDirectory()) {
74452
74770
  fs8.mkdirSync(dest, { recursive: true });
@@ -75187,6 +75505,7 @@ function parseArgs(argv) {
75187
75505
  printCreated: false,
75188
75506
  cleanup: false,
75189
75507
  prune: false,
75508
+ screens: false,
75190
75509
  noSampleData: false,
75191
75510
  adopt: false,
75192
75511
  entity: [],
@@ -75357,6 +75676,9 @@ function parseArgs(argv) {
75357
75676
  case "--prune":
75358
75677
  flags.prune = true;
75359
75678
  break;
75679
+ case "--screens":
75680
+ flags.screens = true;
75681
+ break;
75360
75682
  case "--version":
75361
75683
  case "-v":
75362
75684
  flags.version = true;
@@ -75949,10 +76271,10 @@ var packageContractSchema = zod_default.object({
75949
76271
  // copy WARNS (advisory) when a name has no doc in the workspace.
75950
76272
  knowledge_expects: zod_default.array(zod_default.string()).default([])
75951
76273
  });
75952
- function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors2) {
76274
+ function checkTraversalFilter(node, path14, contextEntity, fieldByEntity, errors2) {
75953
76275
  if (contextEntity === null) {
75954
76276
  errors2.push({
75955
- path: path13,
76277
+ path: path14,
75956
76278
  message: `traversal filter "${node.path[0]}" resolves only directly over a from_table`
75957
76279
  });
75958
76280
  return;
@@ -75962,14 +76284,14 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
75962
76284
  const parsed2 = parseFieldKeyName(hop);
75963
76285
  if (parsed2 === null) {
75964
76286
  errors2.push({
75965
- path: path13,
76287
+ path: path14,
75966
76288
  message: `traversal path[${index}] "${hop}" is not an entity.field alias`
75967
76289
  });
75968
76290
  return;
75969
76291
  }
75970
76292
  if (parsed2.entity !== current) {
75971
76293
  errors2.push({
75972
- path: path13,
76294
+ path: path14,
75973
76295
  message: index === 0 ? `traversal path[0] "${hop}" is addressed on entity "${parsed2.entity}", but the filter holding it reads "${current}"` : `traversal path[${index}] "${hop}" is addressed on entity "${parsed2.entity}", but the hop before it lands on "${current}"`
75974
76296
  });
75975
76297
  return;
@@ -75977,14 +76299,14 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
75977
76299
  const field = fieldByEntity.get(parsed2.entity)?.get(parsed2.field);
75978
76300
  if (field === void 0) {
75979
76301
  errors2.push({
75980
- path: path13,
76302
+ path: path14,
75981
76303
  message: `traversal path[${index}] "${hop}" is not a field on entity "${parsed2.entity}"`
75982
76304
  });
75983
76305
  return;
75984
76306
  }
75985
76307
  if (field.type !== "select_record_link") {
75986
76308
  errors2.push({
75987
- path: path13,
76309
+ path: path14,
75988
76310
  message: `traversal path[${index}] "${hop}" is a ${field.type} field, not select_record_link`
75989
76311
  });
75990
76312
  return;
@@ -75996,7 +76318,7 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
75996
76318
  const parsed = parseFieldKeyName(innerField);
75997
76319
  if (parsed === null || parsed.entity !== current) {
75998
76320
  errors2.push({
75999
- path: path13,
76321
+ path: path14,
76000
76322
  message: `traversal condition "${innerField}" must be an entity.field alias on "${current}", the entity its path lands on`
76001
76323
  });
76002
76324
  return;
@@ -76004,12 +76326,12 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
76004
76326
  const target = fieldByEntity.get(parsed.entity)?.get(parsed.field);
76005
76327
  if (target === void 0) {
76006
76328
  errors2.push({
76007
- path: path13,
76329
+ path: path14,
76008
76330
  message: `traversal condition "${innerField}" is not a field on entity "${parsed.entity}"`
76009
76331
  });
76010
76332
  return;
76011
76333
  }
76012
- checkConditionShape(node.condition, parsed.entity, target, path13, fieldByEntity, errors2);
76334
+ checkConditionShape(node.condition, parsed.entity, target, path14, fieldByEntity, errors2);
76013
76335
  }
76014
76336
  function contractFilterType(entityAlias, field, fieldByEntity, hops = 0) {
76015
76337
  if (field.type === "formula") {
@@ -76025,12 +76347,12 @@ function contractFilterType(entityAlias, field, fieldByEntity, hops = 0) {
76025
76347
  }
76026
76348
  return resolveFilterType(field);
76027
76349
  }
76028
- function checkConditionShape(condition, entityAlias, field, path13, fieldByEntity, errors2) {
76350
+ function checkConditionShape(condition, entityAlias, field, path14, fieldByEntity, errors2) {
76029
76351
  const type = contractFilterType(entityAlias, field, fieldByEntity);
76030
76352
  if (type === void 0 && condition.type === void 0) return;
76031
76353
  const typed = type === void 0 ? condition : { ...condition, type };
76032
- for (const message2 of validateFilterCondition(typed, path13)) {
76033
- errors2.push({ path: path13, message: message2.startsWith(`${path13}: `) ? message2.slice(path13.length + 2) : message2 });
76354
+ for (const message2 of validateFilterCondition(typed, path14)) {
76355
+ errors2.push({ path: path14, message: message2.startsWith(`${path14}: `) ? message2.slice(path14.length + 2) : message2 });
76034
76356
  }
76035
76357
  if (field.type !== "select") return;
76036
76358
  const declared = new Set(field.options.map((option) => option.alias));
@@ -76038,20 +76360,20 @@ function checkConditionShape(condition, entityAlias, field, path13, fieldByEntit
76038
76360
  for (const raw of values3) {
76039
76361
  if (typeof raw !== "string" || isTemplateExpression(raw) || declared.has(raw)) continue;
76040
76362
  errors2.push({
76041
- path: path13,
76363
+ path: path14,
76042
76364
  message: `filters field "${field.alias}" on option "${raw}", which that field does not declare (${declared.size > 0 ? [...declared].join(", ") : "it declares none"})`
76043
76365
  });
76044
76366
  }
76045
76367
  }
76046
- function checkFilterFields(filters, path13, contextEntity, fieldByEntity, errors2, checkField) {
76368
+ function checkFilterFields(filters, path14, contextEntity, fieldByEntity, errors2, checkField) {
76047
76369
  if (filters.node_type === "group") {
76048
76370
  for (const child of filters.children) {
76049
- checkFilterFields(child, path13, contextEntity, fieldByEntity, errors2, checkField);
76371
+ checkFilterFields(child, path14, contextEntity, fieldByEntity, errors2, checkField);
76050
76372
  }
76051
76373
  return;
76052
76374
  }
76053
76375
  if (filters.node_type === "traversal") {
76054
- checkTraversalFilter(filters, path13, contextEntity, fieldByEntity, errors2);
76376
+ checkTraversalFilter(filters, path14, contextEntity, fieldByEntity, errors2);
76055
76377
  return;
76056
76378
  }
76057
76379
  if (filters.type === "current_member") return;
@@ -76059,13 +76381,13 @@ function checkFilterFields(filters, path13, contextEntity, fieldByEntity, errors
76059
76381
  checkField(filters.field_key);
76060
76382
  if (contextEntity === null) return;
76061
76383
  const field = fieldByEntity.get(contextEntity)?.get(filters.field_key);
76062
- if (field !== void 0) checkConditionShape(filters, contextEntity, field, path13, fieldByEntity, errors2);
76384
+ if (field !== void 0) checkConditionShape(filters, contextEntity, field, path14, fieldByEntity, errors2);
76063
76385
  }
76064
- function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases, fieldAliases, errors2) {
76386
+ function checkInputRefs(input, path14, entityAliases, roleAliases, optionAliases, fieldAliases, errors2) {
76065
76387
  if (input.type === "record_link") {
76066
76388
  if (!entityAliases.has(input.table_id)) {
76067
76389
  errors2.push({
76068
- path: path13,
76390
+ path: path14,
76069
76391
  message: `record_link input references entity "${input.table_id}" which is not a declared entity`
76070
76392
  });
76071
76393
  }
@@ -76074,7 +76396,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
76074
76396
  if (input.type === "member") {
76075
76397
  if (input.group !== void 0 && !roleAliases.has(input.group)) {
76076
76398
  errors2.push({
76077
- path: path13,
76399
+ path: path14,
76078
76400
  message: `member input references role "${input.group}" which is not a declared role`
76079
76401
  });
76080
76402
  }
@@ -76084,7 +76406,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
76084
76406
  if (input.field !== void 0) {
76085
76407
  if (!fieldAliases.has(input.field)) {
76086
76408
  errors2.push({
76087
- path: path13,
76409
+ path: path14,
76088
76410
  message: `select input references field "${input.field}" which is not a declared field alias (entity.field)`
76089
76411
  });
76090
76412
  }
@@ -76093,7 +76415,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
76093
76415
  for (const option of input.options ?? []) {
76094
76416
  if (option.value.includes(":") && !optionAliases.has(option.value)) {
76095
76417
  errors2.push({
76096
- path: path13,
76418
+ path: path14,
76097
76419
  message: `select input option value "${option.value}" is not a declared option alias (entity.field:opt)`
76098
76420
  });
76099
76421
  }
@@ -76102,19 +76424,19 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
76102
76424
  }
76103
76425
  if (input.type === "object") {
76104
76426
  for (const [key, child] of Object.entries(input.fields)) {
76105
- checkInputRefs(child, `${path13}.${key}`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
76427
+ checkInputRefs(child, `${path14}.${key}`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
76106
76428
  }
76107
76429
  return;
76108
76430
  }
76109
76431
  if (input.type === "array") {
76110
- checkInputRefs(input.items, `${path13}[]`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
76432
+ checkInputRefs(input.items, `${path14}[]`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
76111
76433
  }
76112
76434
  }
76113
- function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAliases, errors2) {
76435
+ function checkOutputRefs(output, path14, entityAliases, optionAliases, fieldAliases, errors2) {
76114
76436
  if (output.type === "record_link") {
76115
76437
  if (!entityAliases.has(output.table_id)) {
76116
76438
  errors2.push({
76117
- path: path13,
76439
+ path: path14,
76118
76440
  message: `record_link output references entity "${output.table_id}" which is not a declared entity`
76119
76441
  });
76120
76442
  }
@@ -76124,7 +76446,7 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
76124
76446
  if (output.field !== void 0) {
76125
76447
  if (!fieldAliases.has(output.field)) {
76126
76448
  errors2.push({
76127
- path: path13,
76449
+ path: path14,
76128
76450
  message: `select output references field "${output.field}" which is not a declared field alias (entity.field)`
76129
76451
  });
76130
76452
  }
@@ -76133,7 +76455,7 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
76133
76455
  for (const option of output.options ?? []) {
76134
76456
  if (option.value.includes(":") && !optionAliases.has(option.value)) {
76135
76457
  errors2.push({
76136
- path: path13,
76458
+ path: path14,
76137
76459
  message: `select output option value "${option.value}" is not a declared option alias (entity.field:opt)`
76138
76460
  });
76139
76461
  }
@@ -76142,16 +76464,16 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
76142
76464
  }
76143
76465
  if (output.type === "object") {
76144
76466
  for (const [key, child] of Object.entries(output.fields)) {
76145
- checkOutputRefs(child, `${path13}.${key}`, entityAliases, optionAliases, fieldAliases, errors2);
76467
+ checkOutputRefs(child, `${path14}.${key}`, entityAliases, optionAliases, fieldAliases, errors2);
76146
76468
  }
76147
76469
  return;
76148
76470
  }
76149
76471
  if (output.type === "array") {
76150
- checkOutputRefs(output.items, `${path13}[]`, entityAliases, optionAliases, fieldAliases, errors2);
76472
+ checkOutputRefs(output.items, `${path14}[]`, entityAliases, optionAliases, fieldAliases, errors2);
76151
76473
  }
76152
76474
  }
76153
- function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2) {
76154
- const recur = (child) => checkSourceLinks(child, path13, parentEntity, fieldByEntity, errors2);
76475
+ function checkSourceLinks(source, path14, parentEntity, fieldByEntity, errors2) {
76476
+ const recur = (child) => checkSourceLinks(child, path14, parentEntity, fieldByEntity, errors2);
76155
76477
  if (typeof source === "string") return;
76156
76478
  if ("eq" in source) return source.eq.forEach(recur);
76157
76479
  if ("neq" in source) return source.neq.forEach(recur);
@@ -76162,7 +76484,7 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
76162
76484
  if (!("link" in source)) return;
76163
76485
  if (parentEntity === null) {
76164
76486
  errors2.push({
76165
- path: path13,
76487
+ path: path14,
76166
76488
  message: `link source "${source.link.source}" resolves only directly over a from_table`
76167
76489
  });
76168
76490
  return;
@@ -76172,14 +76494,14 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
76172
76494
  const sourceField = parentFields.get(source.link.source);
76173
76495
  if (sourceField === void 0) {
76174
76496
  errors2.push({
76175
- path: path13,
76497
+ path: path14,
76176
76498
  message: `link source "${source.link.source}" is not a field on entity "${parentEntity}"`
76177
76499
  });
76178
76500
  return;
76179
76501
  }
76180
76502
  if (sourceField.type !== "select_record_link") {
76181
76503
  errors2.push({
76182
- path: path13,
76504
+ path: path14,
76183
76505
  message: `link source "${source.link.source}" on entity "${parentEntity}" is a ${sourceField.type} field, not select_record_link`
76184
76506
  });
76185
76507
  return;
@@ -76188,68 +76510,68 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
76188
76510
  if (targetFields === void 0) return;
76189
76511
  if (!targetFields.has(source.link.field)) {
76190
76512
  errors2.push({
76191
- path: path13,
76513
+ path: path14,
76192
76514
  message: `link field "${source.link.field}" is not a field on target entity "${sourceField.target_entity}"`
76193
76515
  });
76194
76516
  }
76195
76517
  }
76196
- function checkQueryFilterTraversals(filters, path13, contextEntity, fieldByEntity, errors2) {
76197
- checkFilterFields(filters, path13, contextEntity, fieldByEntity, errors2, () => {
76518
+ function checkQueryFilterTraversals(filters, path14, contextEntity, fieldByEntity, errors2) {
76519
+ checkFilterFields(filters, path14, contextEntity, fieldByEntity, errors2, () => {
76198
76520
  });
76199
76521
  }
76200
- function checkQueryEntityRefs(node, path13, entityAliases, fieldByEntity, errors2) {
76522
+ function checkQueryEntityRefs(node, path14, entityAliases, fieldByEntity, errors2) {
76201
76523
  const recur = (child, childPath) => checkQueryEntityRefs(child, childPath, entityAliases, fieldByEntity, errors2);
76202
76524
  const linkParent = (from) => from.kind === "from_table" ? from.from_entity : null;
76203
76525
  switch (node.kind) {
76204
76526
  case "from_table":
76205
76527
  if (!entityAliases.has(node.from_entity)) {
76206
76528
  errors2.push({
76207
- path: path13,
76529
+ path: path14,
76208
76530
  message: `from_table references entity "${node.from_entity}" which is not a declared entity`
76209
76531
  });
76210
76532
  return;
76211
76533
  }
76212
76534
  if (node.filter !== void 0) {
76213
- checkQueryFilterTraversals(node.filter, `${path13}.filter`, node.from_entity, fieldByEntity, errors2);
76535
+ checkQueryFilterTraversals(node.filter, `${path14}.filter`, node.from_entity, fieldByEntity, errors2);
76214
76536
  }
76215
76537
  return;
76216
76538
  case "filter":
76217
76539
  checkQueryFilterTraversals(
76218
76540
  node.predicate,
76219
- `${path13}.predicate`,
76541
+ `${path14}.predicate`,
76220
76542
  node.from.kind === "from_table" ? node.from.from_entity : null,
76221
76543
  fieldByEntity,
76222
76544
  errors2
76223
76545
  );
76224
- recur(node.from, `${path13}.from`);
76546
+ recur(node.from, `${path14}.from`);
76225
76547
  return;
76226
76548
  case "join":
76227
- recur(node.left, `${path13}.left`);
76228
- recur(node.right, `${path13}.right`);
76549
+ recur(node.left, `${path14}.left`);
76550
+ recur(node.right, `${path14}.right`);
76229
76551
  return;
76230
76552
  case "union":
76231
- node.sources.forEach((source, i2) => recur(source, `${path13}.sources[${i2}]`));
76553
+ node.sources.forEach((source, i2) => recur(source, `${path14}.sources[${i2}]`));
76232
76554
  return;
76233
76555
  case "project":
76234
76556
  node.columns.forEach((column, i2) => {
76235
76557
  if (typeof column === "string") return;
76236
- checkSourceLinks(column.source, `${path13}.columns[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
76558
+ checkSourceLinks(column.source, `${path14}.columns[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
76237
76559
  });
76238
- recur(node.from, `${path13}.from`);
76560
+ recur(node.from, `${path14}.from`);
76239
76561
  return;
76240
76562
  case "unpivot":
76241
76563
  node.passthrough.forEach((column, i2) => {
76242
- checkSourceLinks(column.source, `${path13}.passthrough[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
76564
+ checkSourceLinks(column.source, `${path14}.passthrough[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
76243
76565
  });
76244
76566
  node.rows.forEach((row, i2) => {
76245
76567
  for (const [key, source] of Object.entries(row)) {
76246
- checkSourceLinks(source, `${path13}.rows[${i2}].${key}`, linkParent(node.from), fieldByEntity, errors2);
76568
+ checkSourceLinks(source, `${path14}.rows[${i2}].${key}`, linkParent(node.from), fieldByEntity, errors2);
76247
76569
  }
76248
76570
  });
76249
- recur(node.from, `${path13}.from`);
76571
+ recur(node.from, `${path14}.from`);
76250
76572
  return;
76251
76573
  default:
76252
- recur(node.from, `${path13}.from`);
76574
+ recur(node.from, `${path14}.from`);
76253
76575
  }
76254
76576
  }
76255
76577
  var WORKFLOW_BODY_TOKEN_PATTERN = /@@(entity|field|option|role|template):([^@]+)@@/g;
@@ -76753,6 +77075,357 @@ var bindingDriftSchema = zod_default.object({
76753
77075
  id: zod_default.string().describe("The stale concrete id the alias was bound to")
76754
77076
  });
76755
77077
 
77078
+ // ../shared/src/schemas/field_roles.ts
77079
+ var fieldRoleSchema = zod_default.enum([
77080
+ "identity",
77081
+ "mark",
77082
+ "lifecycle",
77083
+ "measure",
77084
+ "expected_set",
77085
+ "amount",
77086
+ "when",
77087
+ "party",
77088
+ "contact",
77089
+ "verdict"
77090
+ ]);
77091
+ var FIELD_ROLE_TYPES = {
77092
+ identity: ["text", "autonumber", "select_record_link"],
77093
+ mark: ["files"],
77094
+ lifecycle: ["select"],
77095
+ measure: ["number", "formula", "rollup"],
77096
+ expected_set: ["select"],
77097
+ amount: ["number", "formula", "rollup"],
77098
+ when: ["date"],
77099
+ party: ["select_record_link"],
77100
+ contact: ["text"],
77101
+ verdict: ["boolean", "formula"]
77102
+ };
77103
+ var SINGLE_FIELD_ROLES = [
77104
+ "identity",
77105
+ "mark",
77106
+ "lifecycle",
77107
+ "amount",
77108
+ "when",
77109
+ "party",
77110
+ "contact",
77111
+ "verdict"
77112
+ ];
77113
+ var fieldRoleDeclSchema = zod_default.object({
77114
+ role: fieldRoleSchema,
77115
+ against: zod_default.union([contractAliasSchema, zod_default.number()]).optional().describe("For a measure: the limit it is read against \u2014 a number field on the same entity, by alias, or a constant"),
77116
+ alert: zod_default.enum(["over", "under"]).optional().describe("With against: which side of the limit needs attention \u2014 over a capacity, under a minimum")
77117
+ }).strict();
77118
+ var fieldRolesSchema = zod_default.record(
77119
+ contractAliasSchema,
77120
+ zod_default.record(
77121
+ contractAliasSchema,
77122
+ zod_default.preprocess((value2) => typeof value2 === "string" ? { role: value2 } : value2, fieldRoleDeclSchema).nullable()
77123
+ )
77124
+ );
77125
+ function roleOf(roles, entity, field) {
77126
+ if (!Object.hasOwn(roles, entity)) return void 0;
77127
+ const fields = roles[entity];
77128
+ return Object.hasOwn(fields, field) ? fields[field] ?? void 0 : void 0;
77129
+ }
77130
+ function checkFieldRoles(entities, roles) {
77131
+ const errors2 = [];
77132
+ const entityByAlias = new Map(entities.map((entity) => [entity.alias, entity]));
77133
+ for (const [entityAlias, fields] of Object.entries(roles)) {
77134
+ const entityPath = `field_roles.${entityAlias}`;
77135
+ const entity = entityByAlias.get(entityAlias);
77136
+ if (entity === void 0) {
77137
+ errors2.push({ path: entityPath, message: `names entity "${entityAlias}", which this model does not declare` });
77138
+ continue;
77139
+ }
77140
+ const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
77141
+ const seen = /* @__PURE__ */ new Map();
77142
+ for (const [fieldAlias, decl] of Object.entries(fields)) {
77143
+ const path14 = `${entityPath}.${fieldAlias}`;
77144
+ const field = fieldByAlias.get(fieldAlias);
77145
+ if (field === void 0) {
77146
+ errors2.push({ path: path14, message: `names "${fieldAlias}", which is not a field of "${entityAlias}"` });
77147
+ continue;
77148
+ }
77149
+ if (decl === null) {
77150
+ errors2.push({ path: path14, message: `null clears a role a preset declared \u2014 this file starts from none` });
77151
+ continue;
77152
+ }
77153
+ const allowed = FIELD_ROLE_TYPES[decl.role];
77154
+ if (!allowed.includes(field.type)) {
77155
+ errors2.push({ path: path14, message: `role "${decl.role}" sits on a ${allowed.join(" or ")} field, not a ${field.type}` });
77156
+ }
77157
+ if (SINGLE_FIELD_ROLES.includes(decl.role)) {
77158
+ const first3 = seen.get(decl.role);
77159
+ if (first3 !== void 0) {
77160
+ errors2.push({ path: path14, message: `role "${decl.role}" is already "${first3}"'s \u2014 an entity carries it once` });
77161
+ } else {
77162
+ seen.set(decl.role, fieldAlias);
77163
+ }
77164
+ }
77165
+ if (decl.role === "lifecycle" && field.type === "select" && field.multi === true) {
77166
+ errors2.push({ path: path14, message: `a lifecycle is one stage at a time \u2014 a multi-select cannot be one` });
77167
+ }
77168
+ if (decl.against === void 0 && decl.alert === void 0) continue;
77169
+ if (decl.role !== "measure") {
77170
+ errors2.push({ path: path14, message: `against and alert belong to a measure \u2014 this role is "${decl.role}"` });
77171
+ continue;
77172
+ }
77173
+ if (decl.against === void 0 || decl.alert === void 0) {
77174
+ errors2.push({ path: path14, message: `against and alert are declared together \u2014 the limit, and which side of it needs attention` });
77175
+ continue;
77176
+ }
77177
+ if (typeof decl.against === "number") continue;
77178
+ if (decl.against === fieldAlias) {
77179
+ errors2.push({ path: path14, message: `against names this field itself \u2014 a measure is read against another` });
77180
+ continue;
77181
+ }
77182
+ const limit = fieldByAlias.get(decl.against);
77183
+ if (limit === void 0) {
77184
+ errors2.push({ path: path14, message: `against names "${decl.against}", which is not a field of "${entityAlias}"` });
77185
+ } else if (limit.type !== "number") {
77186
+ errors2.push({
77187
+ path: path14,
77188
+ message: `against names "${decl.against}", a ${limit.type} \u2014 a limit a row can state is a number field`
77189
+ });
77190
+ }
77191
+ }
77192
+ }
77193
+ return errors2;
77194
+ }
77195
+
77196
+ // ../shared/src/schemas/workspace_plan.ts
77197
+ var slot = (role, required2 = false) => ({ role, required: required2 });
77198
+ var SHAPE_REGISTRY = {
77199
+ lifecycle_desk: {
77200
+ label: "lifecycle desk",
77201
+ slots: { stage: slot("lifecycle", true), identity: slot("identity", true), party: slot("party"), amount: slot("amount"), when: slot("when") },
77202
+ record: "drawer",
77203
+ tabs: "lifecycle"
77204
+ },
77205
+ party_register: {
77206
+ label: "party register",
77207
+ slots: { identity: slot("identity", true), mark: slot("mark"), contact: slot("contact"), worth: slot("measure"), risk: slot("verdict") },
77208
+ record: "page",
77209
+ tabs: "none"
77210
+ },
77211
+ offering_register: {
77212
+ label: "offering register",
77213
+ slots: { identity: slot("identity", true), mark: slot("mark"), price: slot("amount"), availability: slot("measure") },
77214
+ record: "page",
77215
+ tabs: "none"
77216
+ },
77217
+ transaction_ledger: {
77218
+ label: "transaction ledger",
77219
+ slots: { when: slot("when", true), amount: slot("amount", true), party: slot("party"), document: slot("expected_set") },
77220
+ record: "drawer",
77221
+ tabs: "none"
77222
+ },
77223
+ monitored_asset_set: {
77224
+ label: "monitored-asset set",
77225
+ slots: { identity: slot("identity", true), level: slot("measure", true), stage: slot("lifecycle") },
77226
+ record: "drawer",
77227
+ tabs: "none"
77228
+ },
77229
+ trend_deep_dive: {
77230
+ label: "trend deep-dive",
77231
+ slots: { when: slot("when", true), measure: slot("measure"), amount: slot("amount") },
77232
+ record: "drawer",
77233
+ tabs: "none"
77234
+ }
77235
+ };
77236
+ var SHAPE_NAMES = Object.keys(SHAPE_REGISTRY);
77237
+ var CUSTOM_SHAPE = "custom";
77238
+ var contractScreenSchema = zod_default.object({
77239
+ alias: contractAliasSchema.describe("Stable screen alias, unique within the app"),
77240
+ label: zod_default.string().min(1).describe("What the screen is called in the app"),
77241
+ shape: zod_default.enum([...SHAPE_NAMES, CUSTOM_SHAPE]).describe(`A shape, or "${CUSTOM_SHAPE}" with its own \`roles\``),
77242
+ entity: contractAliasSchema.describe("The entity whose rows this screen is over"),
77243
+ record: zod_default.enum(["drawer", "page"]).optional().describe("How one record opens from the list; absent, the shape decides"),
77244
+ tabs: contractAliasSchema.nullable().optional().describe("A select field on the entity whose options are the tab strip; null for none; absent, the shape decides"),
77245
+ slots: zod_default.record(zod_default.string(), contractAliasSchema).optional().describe("Slot \u2192 the field that fills it, where roles alone cannot decide"),
77246
+ roles: zod_default.record(zod_default.string(), fieldRoleSchema).optional().describe(`For "${CUSTOM_SHAPE}" only: slot \u2192 the role that fills it`)
77247
+ });
77248
+ var modelAppSchema = contractAppSchema.pick({ alias: true, name: true, description: true, icon: true, theme: true }).extend({ screens: zod_default.array(contractScreenSchema).min(1) });
77249
+ function resolveScreen(app, screen, entityByAlias, roles) {
77250
+ const path14 = `apps.${app.alias}.screens.${screen.alias}`;
77251
+ const findings = [];
77252
+ const entity = entityByAlias.get(screen.entity);
77253
+ if (entity === void 0) {
77254
+ findings.push({ path: `${path14}.entity`, message: `names entity "${screen.entity}", which this model does not declare` });
77255
+ }
77256
+ let slotSpec;
77257
+ let shapeLabel;
77258
+ let record2;
77259
+ let tabsRule;
77260
+ if (screen.shape === CUSTOM_SHAPE) {
77261
+ if (screen.roles === void 0 || Object.keys(screen.roles).length === 0) {
77262
+ findings.push({ path: `${path14}.roles`, message: `a "${CUSTOM_SHAPE}" screen declares its slots \u2014 \`roles\` maps each slot to the field role that fills it` });
77263
+ }
77264
+ slotSpec = Object.fromEntries(Object.entries(screen.roles ?? {}).map(([name2, role]) => [name2, slot(role, true)]));
77265
+ shapeLabel = CUSTOM_SHAPE;
77266
+ record2 = screen.record ?? "drawer";
77267
+ tabsRule = "none";
77268
+ } else {
77269
+ const registry2 = SHAPE_REGISTRY[screen.shape];
77270
+ if (screen.roles !== void 0) {
77271
+ findings.push({ path: `${path14}.roles`, message: `"${screen.shape}" declares its own slots \u2014 \`roles\` is for a "${CUSTOM_SHAPE}" screen` });
77272
+ }
77273
+ slotSpec = registry2.slots;
77274
+ shapeLabel = registry2.label;
77275
+ record2 = screen.record ?? registry2.record;
77276
+ tabsRule = registry2.tabs;
77277
+ }
77278
+ for (const name2 of Object.keys(screen.slots ?? {})) {
77279
+ if (Object.hasOwn(slotSpec, name2)) continue;
77280
+ findings.push({ path: `${path14}.slots.${name2}`, message: `"${shapeLabel}" has no slot "${name2}" \u2014 its slots are ${Object.keys(slotSpec).join(", ")}` });
77281
+ }
77282
+ if (entity === void 0) return { type: "invalid", findings };
77283
+ const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
77284
+ const roleOfField = (field) => roleOf(roles, entity.alias, field.alias)?.role;
77285
+ const slots = [];
77286
+ const boundBy = /* @__PURE__ */ new Map();
77287
+ for (const [name2, spec] of Object.entries(slotSpec)) {
77288
+ const named = screen.slots?.[name2];
77289
+ let field = null;
77290
+ if (named !== void 0) {
77291
+ const candidate = fieldByAlias.get(named);
77292
+ if (candidate === void 0) {
77293
+ findings.push({ path: `${path14}.slots.${name2}`, message: `names "${named}", which is not a field of entity "${entity.alias}"` });
77294
+ continue;
77295
+ }
77296
+ const role = roleOfField(candidate);
77297
+ if (role !== spec.role) {
77298
+ findings.push({
77299
+ path: `${path14}.slots.${name2}`,
77300
+ message: `names "${named}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 this slot takes a "${spec.role}" field; declare the role in field_roles`
77301
+ });
77302
+ continue;
77303
+ }
77304
+ field = candidate;
77305
+ } else {
77306
+ const candidates = entity.fields.filter((f) => roleOfField(f) === spec.role);
77307
+ if (candidates.length > 1) {
77308
+ findings.push({
77309
+ path: `${path14}.slots.${name2}`,
77310
+ message: `"${entity.alias}" has ${candidates.length} "${spec.role}" fields (${candidates.map((f) => f.alias).join(", ")}) \u2014 name the one this slot takes`
77311
+ });
77312
+ continue;
77313
+ }
77314
+ if (candidates.length === 0 && spec.required) {
77315
+ findings.push({ path: path14, message: `a ${shapeLabel} needs a "${spec.role}" field on "${entity.alias}" for its ${name2} \u2014 none declares that role` });
77316
+ continue;
77317
+ }
77318
+ field = candidates[0] ?? null;
77319
+ }
77320
+ if (field !== null) {
77321
+ const other = boundBy.get(field.alias);
77322
+ if (other !== void 0) {
77323
+ findings.push({ path: `${path14}.slots.${name2}`, message: `"${field.alias}" already fills ${other} \u2014 a field fills one slot` });
77324
+ continue;
77325
+ }
77326
+ boundBy.set(field.alias, name2);
77327
+ }
77328
+ slots.push({ name: name2, role: spec.role, field });
77329
+ }
77330
+ let tabs = null;
77331
+ if (screen.tabs === void 0) {
77332
+ tabs = tabsRule === "lifecycle" ? entity.fields.find((field) => roleOfField(field) === "lifecycle") ?? null : null;
77333
+ } else if (screen.tabs !== null) {
77334
+ const field = fieldByAlias.get(screen.tabs);
77335
+ if (field === void 0) {
77336
+ findings.push({ path: `${path14}.tabs`, message: `names "${screen.tabs}", which is not a field of entity "${entity.alias}"` });
77337
+ } else if (field.type !== "select") {
77338
+ findings.push({ path: `${path14}.tabs`, message: `names "${screen.tabs}", a ${field.type} \u2014 a tab strip is a select field's options` });
77339
+ } else {
77340
+ tabs = field;
77341
+ }
77342
+ }
77343
+ if (findings.length > 0) return { type: "invalid", findings };
77344
+ return { type: "resolved", screen: { app, screen, entity, shapeLabel, record: record2, tabs, slots } };
77345
+ }
77346
+ function validateWorkspacePlan(model, roles, apps) {
77347
+ const findings = [];
77348
+ for (const dup of findDuplicates(apps.map((app) => app.alias))) {
77349
+ findings.push({ path: "apps", message: `duplicate app alias "${dup}"` });
77350
+ }
77351
+ for (const dup of findDuplicates(apps.map((app) => app.name))) {
77352
+ findings.push({ path: "apps", message: `duplicate app name "${dup}"` });
77353
+ }
77354
+ const entityByAlias = new Map(model.entities.map((entity) => [entity.alias, entity]));
77355
+ for (const app of apps) {
77356
+ for (const dup of findDuplicates(app.screens.map((screen) => screen.alias))) {
77357
+ findings.push({ path: `apps.${app.alias}.screens`, message: `duplicate screen alias "${dup}"` });
77358
+ }
77359
+ for (const dup of findDuplicates(app.screens.map((screen) => screen.label))) {
77360
+ findings.push({ path: `apps.${app.alias}.screens`, message: `duplicate screen label "${dup}"` });
77361
+ }
77362
+ for (const screen of app.screens) {
77363
+ const resolved = resolveScreen(app, screen, entityByAlias, roles);
77364
+ if (resolved.type === "invalid") findings.push(...resolved.findings);
77365
+ }
77366
+ }
77367
+ return findings;
77368
+ }
77369
+ function roleCoverage(model, roles, rows) {
77370
+ const notes = [];
77371
+ for (const entity of model.entities) {
77372
+ const entityRows = rows[entity.alias];
77373
+ if (entityRows === void 0 || entityRows.length === 0) continue;
77374
+ const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
77375
+ for (const field of entity.fields) {
77376
+ const decl = roleOf(roles, entity.alias, field.alias);
77377
+ if (decl === void 0) continue;
77378
+ const path14 = `rows.${entity.alias}.${field.alias}`;
77379
+ const values3 = entityRows.map((row) => row.fields[field.alias]);
77380
+ switch (decl.role) {
77381
+ case "lifecycle":
77382
+ case "expected_set": {
77383
+ if (field.type !== "select") break;
77384
+ const used = new Set(values3.flatMap((value2) => Array.isArray(value2) ? value2 : [value2]));
77385
+ const unused = field.options.filter((option) => !used.has(option.alias)).map((o) => o.label);
77386
+ if (unused.length === 0) break;
77387
+ notes.push({
77388
+ path: path14,
77389
+ message: decl.role === "lifecycle" ? `no row is in ${unused.length === 1 ? "stage" : "stages"} ${unused.join(", ")} \u2014 the desk shows ${unused.length === 1 ? "an empty band" : "empty bands"} there` : `no row has ${unused.join(", ")} \u2014 ${unused.length === 1 ? "that entry" : "those entries"} of the required set would show as ghosts on every subject`
77390
+ });
77391
+ break;
77392
+ }
77393
+ case "mark": {
77394
+ const carried = values3.some((value2) => Array.isArray(value2) ? value2.length > 0 : typeof value2 === "string");
77395
+ if (!carried) notes.push({ path: path14, message: `no row carries a picture \u2014 every register over it shows no mark` });
77396
+ break;
77397
+ }
77398
+ case "measure": {
77399
+ const { against, alert } = decl;
77400
+ if (field.type !== "number" || against === void 0 || alert === void 0) break;
77401
+ const read = entityRows.filter((row) => typeof row.fields[field.alias] === "number");
77402
+ if (read.length === 0) {
77403
+ notes.push({ path: path14, message: `no row carries a value \u2014 the meter has nothing to draw` });
77404
+ break;
77405
+ }
77406
+ const limitOf = (row) => {
77407
+ const raw = typeof against === "number" ? against : row.fields[against];
77408
+ return typeof raw === "number" ? raw : void 0;
77409
+ };
77410
+ const attention = read.filter((row) => {
77411
+ const limit = limitOf(row);
77412
+ const level = row.fields[field.alias];
77413
+ return limit !== void 0 && (alert === "over" ? level > limit : level < limit);
77414
+ });
77415
+ if (attention.length === 0) {
77416
+ const limitName = typeof against === "number" ? String(against) : fieldByAlias.get(against)?.label ?? against;
77417
+ notes.push({ path: path14, message: `no row is ${alert} ${limitName} \u2014 nothing on the screen needs attention` });
77418
+ }
77419
+ break;
77420
+ }
77421
+ default:
77422
+ break;
77423
+ }
77424
+ }
77425
+ }
77426
+ return notes;
77427
+ }
77428
+
76756
77429
  // ../shared/src/schemas/workspace_model.ts
76757
77430
  var workspaceModelContractSchema = packageContractSchema.omit({ apps: true, fixtures: true, knowledge: true, knowledge_expects: true }).extend({
76758
77431
  entities: zod_default.array(contractEntitySchema).describe("The tables this model creates, with their fields, options and views"),
@@ -76765,6 +77438,8 @@ var modelApplyEntrySchema = zod_default.object({
76765
77438
  }).strict();
76766
77439
  var workspaceModelFileSchema = workspaceModelContractSchema.extend({
76767
77440
  rows: contractRowsSchema.optional(),
77441
+ field_roles: fieldRolesSchema.optional(),
77442
+ apps: zod_default.array(modelAppSchema).optional(),
76768
77443
  preset: modelPresetSchema.optional(),
76769
77444
  apply: zod_default.array(modelApplyEntrySchema).optional()
76770
77445
  }).strict();
@@ -76774,6 +77449,8 @@ var workspaceModelOverlaySchema = zod_default.object({
76774
77449
  rename: packageBindSchema.optional().describe("Entity alias \u2192 what this business calls that table and its fields"),
76775
77450
  entities: zod_default.array(contractEntitySchema).optional().describe("Tables this business has that the preset does not declare"),
76776
77451
  rows: contractRowsSchema.optional(),
77452
+ field_roles: fieldRolesSchema.optional().describe("Roles on the preset's fields and this business's own, over whatever the preset declares"),
77453
+ apps: zod_default.array(modelAppSchema).optional(),
76777
77454
  apply: zod_default.array(modelApplyEntrySchema).optional()
76778
77455
  }).strict();
76779
77456
  function parseModelFile(raw) {
@@ -76832,13 +77509,25 @@ function resolveModelFrom(preset, overlay) {
76832
77509
  }))
76833
77510
  };
76834
77511
  }
77512
+ const field_roles = { ...preset.field_roles };
77513
+ for (const [entity, fields] of Object.entries(overlay.field_roles ?? {})) {
77514
+ const merged2 = { ...field_roles[entity] };
77515
+ for (const [field, decl] of Object.entries(fields)) {
77516
+ if (decl === null) delete merged2[field];
77517
+ else merged2[field] = decl;
77518
+ }
77519
+ if (Object.keys(merged2).length > 0) field_roles[entity] = merged2;
77520
+ else delete field_roles[entity];
77521
+ }
76835
77522
  return {
76836
77523
  type: "resolved",
76837
77524
  file: {
76838
77525
  entities: [...bound.contract.entities, ...overlay.entities ?? []],
76839
77526
  roles: contract.roles,
76840
77527
  templates: contract.templates,
77528
+ ...Object.keys(field_roles).length > 0 ? { field_roles } : {},
76841
77529
  ...overlay.rows !== void 0 ? { rows: overlay.rows } : {},
77530
+ ...overlay.apps !== void 0 ? { apps: overlay.apps } : {},
76842
77531
  ...overlay.apply !== void 0 ? { apply: overlay.apply } : {}
76843
77532
  }
76844
77533
  };
@@ -76848,9 +77537,29 @@ function toPackageContract(model) {
76848
77537
  }
76849
77538
  var ENTITIES_MAX = 50;
76850
77539
  var MODEL_ROWS_MAX = 2e3;
77540
+ var MODEL_DOCUMENTS_MAX = 2e3;
76851
77541
  function asList(value2) {
76852
77542
  return Array.isArray(value2) ? value2 : [value2];
76853
77543
  }
77544
+ function isStoredFileId(value2) {
77545
+ return /^fil_[A-Za-z0-9]+$/.test(value2);
77546
+ }
77547
+ function modelRowDocuments(model, rows) {
77548
+ const out = [];
77549
+ for (const entity of model.entities) {
77550
+ const filesFields = new Set(entity.fields.filter((field) => field.type === "files").map((field) => field.alias));
77551
+ for (const row of rows[entity.alias] ?? []) {
77552
+ for (const [field, value2] of Object.entries(row.fields)) {
77553
+ if (!filesFields.has(field)) continue;
77554
+ for (const raw of asList(value2)) {
77555
+ if (typeof raw !== "string") continue;
77556
+ out.push({ entity: entity.alias, ref: row.ref, field, value: raw, kind: isStoredFileId(raw) ? "file_id" : "path" });
77557
+ }
77558
+ }
77559
+ }
77560
+ }
77561
+ return out;
77562
+ }
76854
77563
  function quote(value2) {
76855
77564
  return typeof value2 === "string" ? `"${value2}"` : `a ${typeof value2}`;
76856
77565
  }
@@ -76881,29 +77590,29 @@ function validateModelRows(model, rows) {
76881
77590
  }
76882
77591
  seenRefs.add(row.ref);
76883
77592
  for (const [fieldAlias, value2] of Object.entries(row.fields)) {
76884
- const path13 = `rows.${alias}.${row.ref}.${fieldAlias}`;
77593
+ const path14 = `rows.${alias}.${row.ref}.${fieldAlias}`;
76885
77594
  const field = fieldByAlias.get(fieldAlias);
76886
77595
  if (field === void 0) {
76887
77596
  errors2.push({
76888
- path: path13,
77597
+ path: path14,
76889
77598
  message: `is not a field of entity "${alias}" \u2014 a row's keys are field aliases (${fieldByAlias.size > 0 ? [...fieldByAlias.keys()].join(", ") : "this entity declares none"})`
76890
77599
  });
76891
77600
  continue;
76892
77601
  }
76893
- checkValue(field, value2, path13, refs, errors2);
77602
+ checkValue(field, value2, path14, refs, errors2);
76894
77603
  }
76895
77604
  }
76896
77605
  }
76897
77606
  return errors2;
76898
77607
  }
76899
- function checkValue(field, value2, path13, refs, errors2) {
77608
+ function checkValue(field, value2, path14, refs, errors2) {
76900
77609
  switch (field.type) {
76901
77610
  case "select": {
76902
77611
  const declared = new Set(field.options.map((option) => option.alias));
76903
77612
  for (const raw of asList(value2)) {
76904
77613
  if (typeof raw !== "string" || !declared.has(raw)) {
76905
77614
  errors2.push({
76906
- path: path13,
77615
+ path: path14,
76907
77616
  message: `${quote(raw)} is not an option of this field \u2014 use an option alias (${declared.size > 0 ? [...declared].join(", ") : "this field declares none"})`
76908
77617
  });
76909
77618
  }
@@ -76914,7 +77623,7 @@ function checkValue(field, value2, path13, refs, errors2) {
76914
77623
  for (const raw of asList(value2)) {
76915
77624
  if (typeof raw !== "string" || !refs.has(raw)) {
76916
77625
  errors2.push({
76917
- path: path13,
77626
+ path: path14,
76918
77627
  message: `${quote(raw)} names no row in this request \u2014 use "<entity-alias>:<ref>"`
76919
77628
  });
76920
77629
  continue;
@@ -76922,7 +77631,7 @@ function checkValue(field, value2, path13, refs, errors2) {
76922
77631
  const named = raw.slice(0, raw.indexOf(":"));
76923
77632
  if (named === field.target_entity) continue;
76924
77633
  errors2.push({
76925
- path: path13,
77634
+ path: path14,
76926
77635
  message: `names a row of entity "${named}", but this field links to "${field.target_entity}"`
76927
77636
  });
76928
77637
  }
@@ -76931,17 +77640,23 @@ function checkValue(field, value2, path13, refs, errors2) {
76931
77640
  case "select_member": {
76932
77641
  if (value2 !== "self") {
76933
77642
  errors2.push({
76934
- path: path13,
77643
+ path: path14,
76935
77644
  message: `${quote(value2)} is not a member this model can name \u2014 use "self"`
76936
77645
  });
76937
77646
  }
76938
77647
  return;
76939
77648
  }
76940
77649
  case "files": {
76941
- errors2.push({
76942
- path: path13,
76943
- message: `attaches a document, which only a starter bundle carries \u2014 upload it to the record after scaffolding`
76944
- });
77650
+ for (const raw of asList(value2)) {
77651
+ if (typeof raw !== "string" || raw === "") {
77652
+ errors2.push({ path: path14, message: `${quote(raw)} is not a document \u2014 a path beside this file, or a fil_ id already uploaded here` });
77653
+ continue;
77654
+ }
77655
+ if (isStoredFileId(raw)) continue;
77656
+ if (raw.startsWith("/") || raw.split(/[\\/]/).includes("..")) {
77657
+ errors2.push({ path: path14, message: `"${raw}" must be a relative path beside this file, with no ".."` });
77658
+ }
77659
+ }
76945
77660
  return;
76946
77661
  }
76947
77662
  case "formula":
@@ -76949,7 +77664,7 @@ function checkValue(field, value2, path13, refs, errors2) {
76949
77664
  case "lookup":
76950
77665
  case "autonumber": {
76951
77666
  errors2.push({
76952
- path: path13,
77667
+ path: path14,
76953
77668
  message: `is a ${field.type} field, which the platform computes \u2014 drop the value`
76954
77669
  });
76955
77670
  return;
@@ -76957,13 +77672,13 @@ function checkValue(field, value2, path13, refs, errors2) {
76957
77672
  default: {
76958
77673
  if (!isFixtureScalarValue(value2)) {
76959
77674
  errors2.push({
76960
- path: path13,
77675
+ path: path14,
76961
77676
  message: `must be a string, number, boolean or null on a ${field.type} field`
76962
77677
  });
76963
77678
  return;
76964
77679
  }
76965
77680
  if (field.type === "date" && parseFixtureDateExpression(value2).kind === "invalid") {
76966
- errors2.push({ path: path13, message: `${quote(value2)} is not a date \u2014 use ${FIXTURE_DATE_GRAMMAR}` });
77681
+ errors2.push({ path: path14, message: `${quote(value2)} is not a date \u2014 use ${FIXTURE_DATE_GRAMMAR}` });
76967
77682
  }
76968
77683
  }
76969
77684
  }
@@ -76997,6 +77712,13 @@ function validateWorkspaceModel(model, rows) {
76997
77712
  message: `carries ${total} rows, above the ${MODEL_ROWS_MAX}-row cap for one model \u2014 a real data set belongs in an import`
76998
77713
  });
76999
77714
  }
77715
+ const documents = new Set(modelRowDocuments(model, rows).map((document) => document.value)).size;
77716
+ if (documents > MODEL_DOCUMENTS_MAX) {
77717
+ errors2.push({
77718
+ path: "rows",
77719
+ message: `attaches ${documents} documents, above the ${MODEL_DOCUMENTS_MAX}-document cap for one model`
77720
+ });
77721
+ }
77000
77722
  errors2.push(...validateModelRows(model, rows));
77001
77723
  return errors2;
77002
77724
  }
@@ -77037,14 +77759,14 @@ function validateModelPreset(model, preset, rows) {
77037
77759
  }
77038
77760
  return errors2;
77039
77761
  }
77040
- function unknownModelKeys(raw, kept, path13 = "") {
77762
+ function unknownModelKeys(raw, kept, path14 = "") {
77041
77763
  if (Array.isArray(raw)) {
77042
77764
  if (!Array.isArray(kept)) return [];
77043
- return raw.flatMap((item, i2) => unknownModelKeys(item, kept[i2], path13 === "" ? String(i2) : `${path13}.${i2}`));
77765
+ return raw.flatMap((item, i2) => unknownModelKeys(item, kept[i2], path14 === "" ? String(i2) : `${path14}.${i2}`));
77044
77766
  }
77045
77767
  if (!isPlainObject2(raw) || !isPlainObject2(kept)) return [];
77046
77768
  return Object.keys(raw).flatMap((key) => {
77047
- const here = path13 === "" ? key : `${path13}.${key}`;
77769
+ const here = path14 === "" ? key : `${path14}.${key}`;
77048
77770
  if (!(key in kept)) return [{ path: here, message: "unknown key \u2014 not part of the model" }];
77049
77771
  return unknownModelKeys(raw[key], kept[key], here);
77050
77772
  });
@@ -77061,16 +77783,18 @@ function readPresetModel(raw) {
77061
77783
  findings: [{ path: "from", message: "a preset IS the model it publishes, and this file names `from`" }]
77062
77784
  };
77063
77785
  }
77064
- const { preset, rows, apply, ...model } = parsed.parsed.file;
77786
+ const { preset, rows, apps, apply, field_roles = {}, ...model } = parsed.parsed.file;
77065
77787
  const shape = [
77066
77788
  ...preset === void 0 ? [{ path: "preset", message: "a preset file carries a `preset` block \u2014 name, description, questions, variants" }] : [],
77067
77789
  ...rows === void 0 ? [] : [{ path: "rows", message: "a preset carries no first rows \u2014 they belong to the model a reader writes" }],
77790
+ ...apps === void 0 ? [] : [{ path: "apps", message: "a preset carries no screens \u2014 the apps are the model a reader writes" }],
77068
77791
  ...apply === void 0 ? [] : [{ path: "apply", message: "a preset carries no `apply` \u2014 the packages to copy in are that reader's to name" }]
77069
77792
  ];
77070
77793
  if (preset === void 0 || shape.length > 0) return { type: "invalid", findings: shape };
77071
77794
  const findings = [
77072
77795
  ...unknownModelKeys(raw, parsed.parsed.file),
77073
77796
  ...validateWorkspaceModel(model, {}),
77797
+ ...checkFieldRoles(model.entities, field_roles),
77074
77798
  ...validateModelPreset(model, preset, {})
77075
77799
  ];
77076
77800
  if (findings.length > 0) return { type: "invalid", findings };
@@ -77527,9 +78251,10 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
77527
78251
 
77528
78252
  // src/scaffold_commands.ts
77529
78253
  import fs10 from "node:fs";
78254
+ import path11 from "node:path";
77530
78255
 
77531
78256
  // src/model_reference.md
77532
- var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are not a model\'s to declare \u2014 build one in the workspace the model\ncreated, then publish that workspace as a starter.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, and a value on a\n files or platform-computed field.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `apps`, `fixtures`, `knowledge` or `knowledge_expects`,\nand no `excel` / `word` / `pdf-form` template: each of those is content that\nlives in a published bundle, which a model has none of. An unknown top-level key is\nan error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model \u2014 a real data set belongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | not allowed |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] }\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`** and **`apply`** mean exactly what they mean in the full form \u2014\n `"rows"` are this business\'s real first records, `"apply"` the packages copied\n in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows`.\n';
78257
+ var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "san_pham": { "ten": "identity", "anh": "mark" },\n "ton_kho": { "ton": { "role": "measure", "against": "ton_toi_thieu", "alert": "under" },\n "hieu_suat": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "kinh_doanh", "name": "Kinh doanh",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "khach_hang", "label": "Kh\xE1ch h\xE0ng", "shape": "party_register", "entity": "customer" },\n { "alias": "don_hang", "label": "\u0110\u01A1n h\xE0ng", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way.\n\n```jsonc\n{ "alias": "bang_do", "label": "B\u1EA3ng \u0111o", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see.\n';
77533
78258
 
77534
78259
  // src/scaffold_commands.ts
77535
78260
  function printModelReference() {
@@ -77538,10 +78263,10 @@ function printModelReference() {
77538
78263
  function count(n, noun) {
77539
78264
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
77540
78265
  }
77541
- function issuePath(path13) {
77542
- return path13.length === 0 ? "the file" : path13.map((segment) => String(segment)).join(".");
78266
+ function issuePath(path14) {
78267
+ return path14.length === 0 ? "the file" : path14.map((segment) => String(segment)).join(".");
77543
78268
  }
77544
- function checkWorkspaceModel(raw) {
78269
+ function checkWorkspaceModel(raw, options = {}) {
77545
78270
  const parsed = workspaceModelFileSchema.safeParse(raw);
77546
78271
  if (!parsed.success) {
77547
78272
  return {
@@ -77552,28 +78277,87 @@ function checkWorkspaceModel(raw) {
77552
78277
  model: null
77553
78278
  };
77554
78279
  }
77555
- const { rows = {}, apply = [], preset, ...contract } = parsed.data;
78280
+ const { rows = {}, field_roles = {}, apps = [], apply = [], preset, ...contract } = parsed.data;
77556
78281
  const findings = [
77557
78282
  // What the parse dropped is a mistake the author cannot otherwise see.
77558
78283
  ...unknownModelKeys(raw, parsed.data),
77559
78284
  ...validateWorkspaceModel(contract, rows),
78285
+ // The roles, and every screen against them — the CLI's own checks, because
78286
+ // neither a role nor the plan reaches the server.
78287
+ ...checkFieldRoles(contract.entities, field_roles),
78288
+ ...validateWorkspacePlan(contract, field_roles, apps),
77560
78289
  // Every BRANCH of a preset, applied onto the base and checked as a model of
77561
78290
  // its own — the branch nobody took is the one that fails in the workspace of
77562
78291
  // whoever takes it, who is the one reader who cannot fix it.
77563
- ...preset === void 0 ? [] : validateModelPreset(contract, preset, rows)
78292
+ ...preset === void 0 ? [] : validateModelPreset(contract, preset, rows),
78293
+ // A document a row names by path has to be beside the file, and the check
78294
+ // is what proves it: `apply` uploading a path that is not there would fail
78295
+ // after the tables exist, which is the outcome a model's author is least
78296
+ // able to recover from.
78297
+ ...options.baseDir === void 0 ? [] : missingDocuments(contract, rows, options.baseDir)
77564
78298
  ];
77565
78299
  if (findings.length > 0) return { findings, model: null };
77566
- return { findings, model: { contract, rows, apply } };
78300
+ return { findings, model: { contract, rows, field_roles, apps, apply } };
78301
+ }
78302
+ function missingDocuments(contract, rows, baseDir) {
78303
+ return modelRowDocuments(contract, rows).filter((document) => document.kind === "path" && !fs10.existsSync(path11.resolve(baseDir, document.value))).map((document) => ({
78304
+ path: `rows.${document.entity}.${document.ref}.${document.field}`,
78305
+ message: `"${document.value}" is not a file beside this model (looked in ${baseDir})`
78306
+ }));
78307
+ }
78308
+ async function uploadModelDocuments(client, model, baseDir) {
78309
+ const documents = modelRowDocuments(model.contract, model.rows).filter((document) => document.kind === "path");
78310
+ if (documents.length === 0) return model.rows;
78311
+ const paths = [...new Set(documents.map((document) => document.value))];
78312
+ note(`Uploading ${count(paths.length, "document")} the rows attach\u2026`);
78313
+ const idByPath = /* @__PURE__ */ new Map();
78314
+ for (const relative2 of paths) {
78315
+ const uploaded = await client.uploadFiles([path11.resolve(baseDir, relative2)]);
78316
+ const error52 = uploaded.errors[0];
78317
+ if (error52 !== void 0) {
78318
+ throw new Error(`"${relative2}" could not be uploaded (${error52.error}), so no row was written.`);
78319
+ }
78320
+ const id = uploaded.files[0]?.id;
78321
+ if (id === void 0) throw new Error(`The upload returned no file for "${relative2}".`);
78322
+ idByPath.set(relative2, id);
78323
+ note(` uploaded ${relative2} ${id}`);
78324
+ }
78325
+ const filesFields = new Map(
78326
+ model.contract.entities.map((entity) => [
78327
+ entity.alias,
78328
+ new Set(entity.fields.filter((field) => field.type === "files").map((field) => field.alias))
78329
+ ])
78330
+ );
78331
+ return Object.fromEntries(
78332
+ Object.entries(model.rows).map(([entityAlias, entityRows]) => [
78333
+ entityAlias,
78334
+ entityRows.map((row) => ({
78335
+ ...row,
78336
+ fields: Object.fromEntries(
78337
+ Object.entries(row.fields).map(([fieldAlias, value2]) => [
78338
+ fieldAlias,
78339
+ filesFields.get(entityAlias)?.has(fieldAlias) ? (Array.isArray(value2) ? value2 : [value2]).map(
78340
+ (raw) => typeof raw === "string" ? idByPath.get(raw) ?? raw : raw
78341
+ ) : value2
78342
+ ])
78343
+ )
78344
+ }))
78345
+ ])
78346
+ );
77567
78347
  }
77568
78348
  function countModel(model) {
77569
78349
  const fields = model.contract.entities.flatMap((entity) => entity.fields);
78350
+ const screens = model.apps.flatMap((app) => app.screens);
77570
78351
  return {
77571
78352
  tables: model.contract.entities.length,
77572
78353
  fields: fields.length,
77573
78354
  links: fields.filter((field) => field.type === "select_record_link").length,
77574
78355
  views: model.contract.entities.reduce((n, entity) => n + entity.views.length, 0),
77575
78356
  roles: model.contract.roles.length,
77576
- rows: Object.values(model.rows).reduce((n, entityRows) => n + entityRows.length, 0)
78357
+ rows: Object.values(model.rows).reduce((n, entityRows) => n + entityRows.length, 0),
78358
+ apps: model.apps.length,
78359
+ screens: screens.length,
78360
+ custom: screens.filter((screen) => screen.shape === CUSTOM_SHAPE).length
77577
78361
  };
77578
78362
  }
77579
78363
  function summarizeModel(model) {
@@ -77584,13 +78368,65 @@ function summarizeModel(model) {
77584
78368
  count(counts.links, "link"),
77585
78369
  count(counts.views, "view"),
77586
78370
  count(counts.roles, "role"),
77587
- count(counts.rows, "row")
78371
+ count(counts.rows, "row"),
78372
+ ...counts.apps > 0 ? [count(counts.apps, "app"), count(counts.screens, "screen")] : [],
78373
+ ...counts.custom > 0 ? [`${counts.custom} custom`] : []
77588
78374
  ].join(", ");
77589
78375
  }
78376
+ function resolvePlan(model) {
78377
+ const entityByAlias = new Map(model.contract.entities.map((entity) => [entity.alias, entity]));
78378
+ return model.apps.flatMap(
78379
+ (app) => app.screens.flatMap((screen) => {
78380
+ const resolved = resolveScreen(app, screen, entityByAlias, model.field_roles);
78381
+ if (resolved.type === "invalid") {
78382
+ throw new Error(`plan did not resolve: ${JSON.stringify(resolved.findings)}`);
78383
+ }
78384
+ return [resolved.screen];
78385
+ })
78386
+ );
78387
+ }
78388
+ function describePlan(model) {
78389
+ const lines = [];
78390
+ let lastApp;
78391
+ for (const resolved of resolvePlan(model)) {
78392
+ if (resolved.app.alias !== lastApp) {
78393
+ lines.push(resolved.app.name);
78394
+ lastApp = resolved.app.alias;
78395
+ }
78396
+ const rows = model.rows[resolved.entity.alias]?.length ?? 0;
78397
+ const tabs = resolved.tabs === null ? "none" : resolved.tabs.type === "select" ? `${resolved.tabs.label} (${resolved.tabs.options.map((option) => option.label).join(" \u2192 ")})` : resolved.tabs.label;
78398
+ lines.push(
78399
+ ` ${resolved.screen.label} \u2014 ${resolved.shapeLabel} over ${resolved.entity.label} (${count(rows, "row")}) \xB7 ${resolved.record} \xB7 tabs: ${tabs}`
78400
+ );
78401
+ lines.push(
78402
+ ` ${resolved.slots.map((entry) => `${entry.name} ${entry.field === null ? "(none)" : entry.field.label}`).join(" \xB7 ")}`
78403
+ );
78404
+ }
78405
+ return lines;
78406
+ }
78407
+ function describeCoverage(model) {
78408
+ return roleCoverage(model.contract, model.field_roles, model.rows).map((note2) => ` ${note2.path}: ${note2.message}`);
78409
+ }
78410
+ function planJson(model) {
78411
+ return model.apps.map((app) => ({
78412
+ alias: app.alias,
78413
+ name: app.name,
78414
+ screens: resolvePlan(model).filter((resolved) => resolved.app.alias === app.alias).map((resolved) => ({
78415
+ alias: resolved.screen.alias,
78416
+ label: resolved.screen.label,
78417
+ shape: resolved.screen.shape,
78418
+ entity: resolved.entity.alias,
78419
+ record: resolved.record,
78420
+ tabs: resolved.tabs === null ? null : resolved.tabs.alias,
78421
+ slots: Object.fromEntries(resolved.slots.map((entry) => [entry.name, entry.field?.alias ?? null]))
78422
+ }))
78423
+ }));
78424
+ }
77590
78425
  var presetSourceSchema = zod_default.object({
77591
78426
  entities: zod_default.array(contractEntitySchema),
77592
78427
  roles: zod_default.array(contractRoleSchema),
77593
78428
  templates: zod_default.array(contractInlineTemplateSchema),
78429
+ field_roles: fieldRolesSchema.default({}),
77594
78430
  preset: modelPresetSchema
77595
78431
  });
77596
78432
  async function resolveOverlay(file2, raw, overlay) {
@@ -77622,7 +78458,7 @@ async function resolveOverlay(file2, raw, overlay) {
77622
78458
  const { preset, ...contract } = source.data;
77623
78459
  const resolved = resolveModelFrom({ ...contract, variants: preset.variants }, overlay);
77624
78460
  if (resolved.type === "invalid") return { kind: "error", findings: resolved.findings };
77625
- const { findings, model } = checkWorkspaceModel(resolved.file);
78461
+ const { findings, model } = checkWorkspaceModel(resolved.file, { baseDir: path11.dirname(path11.resolve(file2)) });
77626
78462
  const dropped = unknownModelKeys(raw, overlay);
77627
78463
  if (model === null || dropped.length > 0) {
77628
78464
  return { kind: "error", findings: [...dropped, ...findings] };
@@ -77674,7 +78510,7 @@ async function checkModelFile(file2) {
77674
78510
  if (parsed.parsed.form === "from") {
77675
78511
  return await resolveOverlay(file2, raw, parsed.parsed.overlay);
77676
78512
  }
77677
- const { findings, model } = checkWorkspaceModel(raw);
78513
+ const { findings, model } = checkWorkspaceModel(raw, { baseDir: path11.dirname(path11.resolve(file2)) });
77678
78514
  if (model === null) return { kind: "error", findings };
77679
78515
  return { kind: "ok", model };
77680
78516
  }
@@ -77748,7 +78584,8 @@ Applying ${entry.package}\u2026`);
77748
78584
  async function scaffoldApply(client, model, options) {
77749
78585
  const labels = new Map(model.contract.entities.map((entity) => [entity.alias, entity.label]));
77750
78586
  note(`Applying the model \u2014 ${summarizeModel(model)}\u2026`);
77751
- const result = await postModel(client, model, options);
78587
+ const rows = await uploadModelDocuments(client, model, path11.dirname(path11.resolve(options.file)));
78588
+ const result = await postModel(client, { ...model, rows }, options);
77752
78589
  const width = Math.max(...result.entities.map((entity) => entity.alias.length), 0);
77753
78590
  for (const entity of result.entities) {
77754
78591
  note(
@@ -77771,6 +78608,16 @@ No rows were written \u2014 a table this model bound already holds records${adop
77771
78608
  the sample data, or add the rows yourself.`
77772
78609
  );
77773
78610
  }
78611
+ const written = Object.values(result.record_ids).reduce((n, ids) => n + ids.length, 0);
78612
+ if (written > 0) {
78613
+ const runFile = `${path11.resolve(options.file)}.last_run.json`;
78614
+ fs10.writeFileSync(
78615
+ runFile,
78616
+ `${JSON.stringify({ applied_at: (/* @__PURE__ */ new Date()).toISOString(), record_ids: result.record_ids }, null, 2)}
78617
+ `
78618
+ );
78619
+ note(` rows written to ${path11.basename(runFile)} \u2014 delete_records over them resets the set`);
78620
+ }
77774
78621
  const applied = await applyModelPackages(client, model.apply);
77775
78622
  const created = result.entities.filter((entity) => entity.created).length;
77776
78623
  const apps = applied.flatMap((entry) => entry.apps);
@@ -77944,7 +78791,7 @@ import fs12 from "node:fs";
77944
78791
 
77945
78792
  // src/file_command_io.ts
77946
78793
  import fs11 from "node:fs";
77947
- import path11 from "node:path";
78794
+ import path12 from "node:path";
77948
78795
  var CliError = class extends Error {
77949
78796
  constructor(message2) {
77950
78797
  super(message2);
@@ -77967,8 +78814,8 @@ function rejectExtraArgs(rest2, arity, usage) {
77967
78814
  fail2(`${usage} takes ${arity} argument${arity === 1 ? "" : "s"} after <file>, got ${rest2.length}. Unused: ${rest2.slice(arity).join(" ")}`);
77968
78815
  }
77969
78816
  function writeFileAtomic(filePath, bytes) {
77970
- const dir = path11.dirname(path11.resolve(filePath));
77971
- const tmp = path11.join(dir, `.${path11.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
78817
+ const dir = path12.dirname(path12.resolve(filePath));
78818
+ const tmp = path12.join(dir, `.${path12.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
77972
78819
  fs11.writeFileSync(tmp, bytes);
77973
78820
  try {
77974
78821
  fs11.renameSync(tmp, filePath);
@@ -80771,16 +81618,16 @@ var MatcherView = class {
80771
81618
  * @returns {string|undefined}
80772
81619
  */
80773
81620
  getCurrentTag() {
80774
- const path13 = this._matcher.path;
80775
- return path13.length > 0 ? path13[path13.length - 1].tag : void 0;
81621
+ const path14 = this._matcher.path;
81622
+ return path14.length > 0 ? path14[path14.length - 1].tag : void 0;
80776
81623
  }
80777
81624
  /**
80778
81625
  * Get current namespace.
80779
81626
  * @returns {string|undefined}
80780
81627
  */
80781
81628
  getCurrentNamespace() {
80782
- const path13 = this._matcher.path;
80783
- return path13.length > 0 ? path13[path13.length - 1].namespace : void 0;
81629
+ const path14 = this._matcher.path;
81630
+ return path14.length > 0 ? path14[path14.length - 1].namespace : void 0;
80784
81631
  }
80785
81632
  /**
80786
81633
  * Get current node's attribute value.
@@ -80788,9 +81635,9 @@ var MatcherView = class {
80788
81635
  * @returns {*}
80789
81636
  */
80790
81637
  getAttrValue(attrName) {
80791
- const path13 = this._matcher.path;
80792
- if (path13.length === 0) return void 0;
80793
- return path13[path13.length - 1].values?.[attrName];
81638
+ const path14 = this._matcher.path;
81639
+ if (path14.length === 0) return void 0;
81640
+ return path14[path14.length - 1].values?.[attrName];
80794
81641
  }
80795
81642
  /**
80796
81643
  * Check if current node has an attribute.
@@ -80798,9 +81645,9 @@ var MatcherView = class {
80798
81645
  * @returns {boolean}
80799
81646
  */
80800
81647
  hasAttr(attrName) {
80801
- const path13 = this._matcher.path;
80802
- if (path13.length === 0) return false;
80803
- const current = path13[path13.length - 1];
81648
+ const path14 = this._matcher.path;
81649
+ if (path14.length === 0) return false;
81650
+ const current = path14[path14.length - 1];
80804
81651
  return current.values !== void 0 && attrName in current.values;
80805
81652
  }
80806
81653
  /**
@@ -80826,18 +81673,18 @@ var MatcherView = class {
80826
81673
  * @returns {number}
80827
81674
  */
80828
81675
  getPosition() {
80829
- const path13 = this._matcher.path;
80830
- if (path13.length === 0) return -1;
80831
- return path13[path13.length - 1].position ?? 0;
81676
+ const path14 = this._matcher.path;
81677
+ if (path14.length === 0) return -1;
81678
+ return path14[path14.length - 1].position ?? 0;
80832
81679
  }
80833
81680
  /**
80834
81681
  * Get current node's repeat counter (occurrence count of this tag name).
80835
81682
  * @returns {number}
80836
81683
  */
80837
81684
  getCounter() {
80838
- const path13 = this._matcher.path;
80839
- if (path13.length === 0) return -1;
80840
- return path13[path13.length - 1].counter ?? 0;
81685
+ const path14 = this._matcher.path;
81686
+ if (path14.length === 0) return -1;
81687
+ return path14[path14.length - 1].counter ?? 0;
80841
81688
  }
80842
81689
  /**
80843
81690
  * Get current node's sibling index (alias for getPosition).
@@ -86495,10 +87342,10 @@ function resolveRelativePath(basePath, relativePath) {
86495
87342
  }
86496
87343
  return resolved.join("/");
86497
87344
  }
86498
- function findZipEntry(zipEntries, path13) {
86499
- if (zipEntries[path13]) return zipEntries[path13];
86500
- if (zipEntries[`xl/${path13}`]) return zipEntries[`xl/${path13}`];
86501
- const noSlash = path13.replace(/^\//, "");
87345
+ function findZipEntry(zipEntries, path14) {
87346
+ if (zipEntries[path14]) return zipEntries[path14];
87347
+ if (zipEntries[`xl/${path14}`]) return zipEntries[`xl/${path14}`];
87348
+ const noSlash = path14.replace(/^\//, "");
86502
87349
  if (zipEntries[noSlash]) return zipEntries[noSlash];
86503
87350
  return void 0;
86504
87351
  }
@@ -88399,9 +89246,9 @@ function parseExcelFromZip(zip, options) {
88399
89246
  }
88400
89247
  return result;
88401
89248
  }
88402
- function findEntry(zip, path13) {
88403
- if (zip[path13]) return zip[path13];
88404
- const lower2 = path13.toLowerCase();
89249
+ function findEntry(zip, path14) {
89250
+ if (zip[path14]) return zip[path14];
89251
+ const lower2 = path14.toLowerCase();
88405
89252
  for (const key of Object.keys(zip)) {
88406
89253
  if (key.toLowerCase() === lower2) return zip[key];
88407
89254
  }
@@ -89038,12 +89885,12 @@ function extractPivotRoundTripInfo(originalZip) {
89038
89885
  if (paths.length > 0) pivotTablesBySheetIndex.set(i2, paths);
89039
89886
  }
89040
89887
  const pivotXmlPaths = [];
89041
- for (const path13 of Object.keys(originalZip)) {
89042
- if (path13.startsWith("xl/pivotTables/") && path13.endsWith(".xml")) {
89043
- pivotXmlPaths.push(path13);
89888
+ for (const path14 of Object.keys(originalZip)) {
89889
+ if (path14.startsWith("xl/pivotTables/") && path14.endsWith(".xml")) {
89890
+ pivotXmlPaths.push(path14);
89044
89891
  }
89045
- if (path13.startsWith("xl/pivotCache/") && path13.endsWith(".xml")) {
89046
- pivotXmlPaths.push(path13);
89892
+ if (path14.startsWith("xl/pivotCache/") && path14.endsWith(".xml")) {
89893
+ pivotXmlPaths.push(path14);
89047
89894
  }
89048
89895
  }
89049
89896
  return { cachesByWorkbook, pivotTablesBySheetIndex, pivotXmlPaths };
@@ -89051,15 +89898,15 @@ function extractPivotRoundTripInfo(originalZip) {
89051
89898
  function decode3(bytes) {
89052
89899
  return new TextDecoder().decode(bytes);
89053
89900
  }
89054
- function pivotContentTypeFor(path13) {
89055
- if (path13.startsWith("xl/pivotTables/") && path13.endsWith(".xml")) {
89056
- return `<Override PartName="/${path13}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"/>`;
89901
+ function pivotContentTypeFor(path14) {
89902
+ if (path14.startsWith("xl/pivotTables/") && path14.endsWith(".xml")) {
89903
+ return `<Override PartName="/${path14}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"/>`;
89057
89904
  }
89058
- if (path13.includes("/pivotCacheDefinition") && path13.endsWith(".xml")) {
89059
- return `<Override PartName="/${path13}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"/>`;
89905
+ if (path14.includes("/pivotCacheDefinition") && path14.endsWith(".xml")) {
89906
+ return `<Override PartName="/${path14}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"/>`;
89060
89907
  }
89061
- if (path13.includes("/pivotCacheRecords") && path13.endsWith(".xml")) {
89062
- return `<Override PartName="/${path13}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"/>`;
89908
+ if (path14.includes("/pivotCacheRecords") && path14.endsWith(".xml")) {
89909
+ return `<Override PartName="/${path14}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"/>`;
89063
89910
  }
89064
89911
  return void 0;
89065
89912
  }
@@ -89132,16 +89979,16 @@ function exportWorkbook(workbook, originalZip) {
89132
89979
  regeneratedPaths.add(`xl/worksheets/sheet${i2 + 1}.xml`);
89133
89980
  regeneratedPaths.add(`xl/worksheets/_rels/sheet${i2 + 1}.xml.rels`);
89134
89981
  }
89135
- for (const path13 of Object.keys(originalZip)) {
89136
- if (path13.startsWith("xl/drawings/") || path13.startsWith("xl/charts/") || path13.startsWith("xl/tables/") || path13.startsWith("xl/media/")) {
89137
- regeneratedPaths.add(path13);
89982
+ for (const path14 of Object.keys(originalZip)) {
89983
+ if (path14.startsWith("xl/drawings/") || path14.startsWith("xl/charts/") || path14.startsWith("xl/tables/") || path14.startsWith("xl/media/")) {
89984
+ regeneratedPaths.add(path14);
89138
89985
  }
89139
89986
  }
89140
- for (const [path13, data2] of Object.entries(originalZip)) {
89141
- if (regeneratedPaths.has(path13)) continue;
89142
- const ext = path13.slice(path13.lastIndexOf(".") + 1).toLowerCase();
89987
+ for (const [path14, data2] of Object.entries(originalZip)) {
89988
+ if (regeneratedPaths.has(path14)) continue;
89989
+ const ext = path14.slice(path14.lastIndexOf(".") + 1).toLowerCase();
89143
89990
  if (!DECLARED_EXTENSIONS.includes(ext)) continue;
89144
- entries2[path13] = data2;
89991
+ entries2[path14] = data2;
89145
89992
  }
89146
89993
  }
89147
89994
  const sharedStrings = buildSharedStrings(workbook);
@@ -89157,8 +90004,8 @@ function exportWorkbook(workbook, originalZip) {
89157
90004
  buildWorkbookRels(workbook, pivotInfo)
89158
90005
  );
89159
90006
  const extraContentTypes = [];
89160
- for (const path13 of pivotInfo.pivotXmlPaths) {
89161
- const ct = pivotContentTypeFor(path13);
90007
+ for (const path14 of pivotInfo.pivotXmlPaths) {
90008
+ const ct = pivotContentTypeFor(path14);
89162
90009
  if (ct) extraContentTypes.push(ct);
89163
90010
  }
89164
90011
  let globalChartIndex = 1;
@@ -91521,9 +92368,9 @@ function computeExpression(tokens) {
91521
92368
  break;
91522
92369
  }
91523
92370
  }
91524
- return evaluate(values3, operator);
92371
+ return evaluate2(values3, operator);
91525
92372
  }
91526
- function evaluate(values3, operator) {
92373
+ function evaluate2(values3, operator) {
91527
92374
  let result = false;
91528
92375
  switch (operator) {
91529
92376
  case ">":
@@ -98344,14 +99191,14 @@ var DependencyGraph = class {
98344
99191
  stack.push({ cell: p, path: [cell, p] });
98345
99192
  }
98346
99193
  while (stack.length > 0) {
98347
- const { cell: current, path: path13 } = stack.pop();
98348
- if (current === cell || originCoarse.has(current)) return path13;
99194
+ const { cell: current, path: path14 } = stack.pop();
99195
+ if (current === cell || originCoarse.has(current)) return path14;
98349
99196
  if (visited.has(current)) continue;
98350
99197
  visited.add(current);
98351
99198
  const nextPrec = this.precedents.get(current);
98352
99199
  if (!nextPrec) continue;
98353
99200
  for (const p of nextPrec) {
98354
- stack.push({ cell: p, path: [...path13, p] });
99201
+ stack.push({ cell: p, path: [...path14, p] });
98355
99202
  }
98356
99203
  }
98357
99204
  return null;
@@ -104869,8 +105716,8 @@ function buildParagraphElement(para, ctx, bookmarks) {
104869
105716
  for (let i2 = 0; i2 <= runElements.length; i2++) slots.push([]);
104870
105717
  if (bookmarks) {
104871
105718
  for (const bm of bookmarks) {
104872
- const slot = Math.max(0, Math.min(runElements.length, bm.beforeRun));
104873
- slots[slot].push(bm.element);
105719
+ const slot2 = Math.max(0, Math.min(runElements.length, bm.beforeRun));
105720
+ slots[slot2].push(bm.element);
104874
105721
  }
104875
105722
  }
104876
105723
  const children = [];
@@ -105310,8 +106157,8 @@ async function docModelToDocx(model) {
105310
106157
  // ../ooxml/src/opc.ts
105311
106158
  var import_jszip = __toESM(require_lib4(), 1);
105312
106159
  function dropFolderEntries(zip) {
105313
- for (const [path13, entry] of Object.entries(zip.files)) {
105314
- if (entry.dir) delete zip.files[path13];
106160
+ for (const [path14, entry] of Object.entries(zip.files)) {
106161
+ if (entry.dir) delete zip.files[path14];
105315
106162
  }
105316
106163
  }
105317
106164
 
@@ -105566,11 +106413,11 @@ async function parseDocx(buffer) {
105566
106413
  const zip = await import_jszip3.default.loadAsync(buffer);
105567
106414
  const parts = /* @__PURE__ */ new Map();
105568
106415
  const filePromises = [];
105569
- zip.forEach((path13, file2) => {
106416
+ zip.forEach((path14, file2) => {
105570
106417
  if (file2.dir) return;
105571
106418
  filePromises.push(
105572
106419
  file2.async("uint8array").then((bytes) => {
105573
- parts.set(path13, bytes);
106420
+ parts.set(path14, bytes);
105574
106421
  })
105575
106422
  );
105576
106423
  });
@@ -105802,9 +106649,9 @@ var DEFAULT_PARTS = [
105802
106649
  ["word/theme/theme1.xml", () => encoder.encode(THEME_XML)]
105803
106650
  ];
105804
106651
  function fillDefaultParts(parts) {
105805
- for (const [path13, makeBytes] of DEFAULT_PARTS) {
105806
- if (!parts.has(path13)) {
105807
- parts.set(path13, makeBytes());
106652
+ for (const [path14, makeBytes] of DEFAULT_PARTS) {
106653
+ if (!parts.has(path14)) {
106654
+ parts.set(path14, makeBytes());
105808
106655
  }
105809
106656
  }
105810
106657
  return parts;
@@ -105814,9 +106661,9 @@ function fillDefaultParts(parts) {
105814
106661
  async function serializeDocx(doc) {
105815
106662
  const zip = new import_jszip4.default();
105816
106663
  const parts = fillDefaultParts(new Map(doc.parts));
105817
- for (const [path13, bytes] of parts) {
105818
- if (path13 === "word/document.xml") continue;
105819
- zip.file(path13, bytes);
106664
+ for (const [path14, bytes] of parts) {
106665
+ if (path14 === "word/document.xml") continue;
106666
+ zip.file(path14, bytes);
105820
106667
  }
105821
106668
  const documentXml = serializeDocumentXml(doc);
105822
106669
  zip.file("word/document.xml", documentXml);
@@ -106155,18 +107002,18 @@ function replaceInParagraph(p, search, replace2) {
106155
107002
  }
106156
107003
  if (matches.length === 0) continue;
106157
107004
  count2 += matches.length;
106158
- for (const slot of segment) {
107005
+ for (const slot2 of segment) {
106159
107006
  let out = "";
106160
- let cursor = slot.start;
107007
+ let cursor = slot2.start;
106161
107008
  for (const m of matches) {
106162
- if (m.end <= slot.start || m.start >= slot.end) continue;
106163
- const keepUntil = Math.max(cursor, Math.min(m.start, slot.end));
107009
+ if (m.end <= slot2.start || m.start >= slot2.end) continue;
107010
+ const keepUntil = Math.max(cursor, Math.min(m.start, slot2.end));
106164
107011
  if (keepUntil > cursor) out += joined.slice(cursor, keepUntil);
106165
- if (m.start >= slot.start && m.start < slot.end) out += replace2;
106166
- cursor = Math.max(cursor, Math.min(m.end, slot.end));
107012
+ if (m.start >= slot2.start && m.start < slot2.end) out += replace2;
107013
+ cursor = Math.max(cursor, Math.min(m.end, slot2.end));
106167
107014
  }
106168
- if (cursor < slot.end) out += joined.slice(cursor, slot.end);
106169
- rewritten.set(`${slot.inlineIndex}:${slot.childIndex}`, out);
107015
+ if (cursor < slot2.end) out += joined.slice(cursor, slot2.end);
107016
+ rewritten.set(`${slot2.inlineIndex}:${slot2.childIndex}`, out);
106170
107017
  }
106171
107018
  }
106172
107019
  if (count2 === 0) return [p, 0];
@@ -106608,167 +107455,113 @@ async function runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs
106608
107455
  }
106609
107456
 
106610
107457
  // src/preview.ts
106611
- import { spawn as spawn3 } from "node:child_process";
106612
107458
  import { createServer } from "node:http";
106613
- import { readFileSync, writeFileSync, existsSync as existsSync2, mkdtempSync, rmSync, readdirSync } from "node:fs";
106614
- import { tmpdir as tmpdir2 } from "node:os";
106615
- import { join as join2, dirname, resolve, extname, basename } from "node:path";
106616
- import { fileURLToPath as fileURLToPath2 } from "node:url";
106617
- import { setTimeout as sleep2 } from "node:timers/promises";
106618
- var HERE = dirname(fileURLToPath2(import.meta.url));
107459
+ import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync4, realpathSync, statSync } from "node:fs";
107460
+ import { join as join4, dirname as dirname2, resolve, extname, basename, isAbsolute, relative } from "node:path";
107461
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
107462
+ import { setTimeout as sleep4 } from "node:timers/promises";
107463
+ var HERE2 = dirname2(fileURLToPath3(import.meta.url));
106619
107464
  function fail3(msg) {
106620
107465
  console.error(msg);
106621
107466
  process.exit(1);
106622
107467
  }
106623
- function findChrome() {
106624
- const env = process.env.LOTICS_CHROME || process.env.CHROME_PATH;
106625
- if (env && existsSync2(env)) return env;
106626
- const home = process.env.HOME || "";
106627
- const pwDir = process.platform === "darwin" ? `${home}/Library/Caches/ms-playwright` : `${home}/.cache/ms-playwright`;
106628
- if (existsSync2(pwDir)) {
106629
- const rel = process.platform === "darwin" ? ["chrome-mac/Chromium.app/Contents/MacOS/Chromium"] : ["chrome-linux/chrome", "chrome-linux/headless_shell"];
106630
- const revs = readdirSync(pwDir).filter((n) => n.startsWith("chromium-") || n.startsWith("chromium_headless_shell-")).sort().reverse();
106631
- for (const rev2 of revs) {
106632
- for (const r of rel) {
106633
- const bin = join2(pwDir, rev2, r);
106634
- if (existsSync2(bin)) return bin;
106635
- }
106636
- }
106637
- }
106638
- const systemPaths = [
106639
- "/usr/bin/google-chrome",
106640
- "/usr/bin/google-chrome-stable",
106641
- "/usr/bin/chromium",
106642
- "/usr/bin/chromium-browser",
106643
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
106644
- "/Applications/Chromium.app/Contents/MacOS/Chromium"
106645
- ];
106646
- return systemPaths.find((p) => existsSync2(p)) ?? null;
106647
- }
106648
- async function cdpConnect(wsUrl) {
106649
- const ws = new WebSocket(wsUrl);
106650
- await new Promise((res, rej) => {
106651
- ws.onopen = () => res();
106652
- ws.onerror = () => rej(new Error("CDP websocket failed to open"));
106653
- });
106654
- let id = 0;
106655
- const pending = /* @__PURE__ */ new Map();
106656
- ws.onclose = () => {
106657
- for (const done of pending.values()) done(Promise.reject(new Error("CDP connection closed (Chrome exited?)")));
106658
- pending.clear();
106659
- };
106660
- ws.onmessage = (e) => {
106661
- const m = JSON.parse(String(e.data));
106662
- if (m.id != null && pending.has(m.id)) {
106663
- const done = pending.get(m.id);
106664
- pending.delete(m.id);
106665
- done(m.error ? Promise.reject(new Error(m.error.message)) : m.result);
106666
- }
106667
- };
106668
- const send = (method, params = {}) => new Promise((res) => {
106669
- const i2 = ++id;
106670
- pending.set(i2, (r) => res(r));
106671
- ws.send(JSON.stringify({ id: i2, method, params }));
106672
- });
106673
- return { send, close: () => ws.close() };
106674
- }
106675
- function isStoredFileId(target) {
106676
- return /^fil_[A-Za-z0-9]+$/.test(target);
106677
- }
106678
107468
  function defaultPreviewOutputPath(filename, cwd) {
106679
- return join2(cwd, basename(filename, extname(filename)) + ".png");
107469
+ return join4(cwd, basename(filename, extname(filename)) + ".png");
106680
107470
  }
107471
+ var STATIC_TYPES = {
107472
+ ".html": "text/html; charset=utf-8",
107473
+ ".htm": "text/html; charset=utf-8",
107474
+ ".css": "text/css",
107475
+ ".js": "application/javascript",
107476
+ ".png": "image/png",
107477
+ ".jpg": "image/jpeg",
107478
+ ".jpeg": "image/jpeg",
107479
+ ".gif": "image/gif",
107480
+ ".svg": "image/svg+xml",
107481
+ ".webp": "image/webp",
107482
+ ".woff": "font/woff",
107483
+ ".woff2": "font/woff2",
107484
+ ".ttf": "font/ttf"
107485
+ };
106681
107486
  async function runPreviewCommand(filePath, flags) {
106682
- if (!filePath) fail3("Usage: lotics preview <file.docx|.xlsx | fil_id> [-o <file.png>]");
107487
+ if (!filePath) fail3("Usage: lotics preview <file.docx|.xlsx|.html | fil_id> [-o <file.png>]");
106683
107488
  const abs2 = resolve(filePath);
106684
- if (!existsSync2(abs2)) fail3(`File not found: ${abs2}`);
107489
+ if (!existsSync4(abs2)) fail3(`File not found: ${abs2}`);
106685
107490
  const ext = extname(abs2).toLowerCase();
106686
- const type = ext === ".docx" ? "docx" : ext === ".xlsx" || ext === ".xls" || ext === ".csv" ? "xlsx" : null;
106687
- if (!type) fail3(`Unsupported file type "${ext}" \u2014 preview supports .docx and .xlsx/.csv. (PDFs open directly \u2014 no preview needed.)`);
106688
- if (typeof WebSocket === "undefined") {
106689
- fail3("lotics preview needs Node 22+ (it drives Chrome over CDP via the built-in WebSocket). Upgrade Node and retry.");
106690
- }
106691
- const bundlePath = join2(HERE, "..", "render_page.js");
106692
- if (!existsSync2(bundlePath)) fail3(`Render bundle missing at ${bundlePath} \u2014 reinstall @lotics/cli (build step failed).`);
106693
- const chrome = findChrome();
106694
- if (!chrome) {
106695
- fail3(
106696
- "No Chrome/Chromium found. Set CHROME_PATH to a Chrome binary, or install one:\n npx playwright install chromium (then it's auto-detected)\n or install Google Chrome / Chromium via your package manager."
106697
- );
107491
+ const type = ext === ".docx" ? "docx" : ext === ".xlsx" || ext === ".xls" || ext === ".csv" ? "xlsx" : ext === ".html" || ext === ".htm" ? "html" : null;
107492
+ if (!type) fail3(`Unsupported file type "${ext}" \u2014 preview supports .docx, .xlsx/.csv and .html. (PDFs open directly \u2014 no preview needed.)`);
107493
+ let server;
107494
+ let pageUrl;
107495
+ if (type === "html") {
107496
+ const dir = realpathSync(dirname2(abs2));
107497
+ server = createServer((req, res) => {
107498
+ let urlPath;
107499
+ try {
107500
+ urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
107501
+ } catch {
107502
+ res.writeHead(400);
107503
+ res.end();
107504
+ return;
107505
+ }
107506
+ const target = resolve(dir, `.${urlPath}`);
107507
+ if (!existsSync4(target) || !statSync(target).isFile()) {
107508
+ res.writeHead(404);
107509
+ res.end();
107510
+ return;
107511
+ }
107512
+ const inside = relative(dir, realpathSync(target));
107513
+ if (inside.startsWith("..") || isAbsolute(inside)) {
107514
+ res.writeHead(404);
107515
+ res.end();
107516
+ return;
107517
+ }
107518
+ res.writeHead(200, { "content-type": STATIC_TYPES[extname(target).toLowerCase()] ?? "application/octet-stream" });
107519
+ res.end(readFileSync3(target));
107520
+ });
107521
+ pageUrl = (port) => `http://127.0.0.1:${port}/${encodeURIComponent(basename(abs2))}`;
107522
+ } else {
107523
+ const bundlePath = join4(HERE2, "..", "render_page.js");
107524
+ if (!existsSync4(bundlePath)) fail3(`Render bundle missing at ${bundlePath} \u2014 reinstall @lotics/cli (build step failed).`);
107525
+ const b64 = readFileSync3(abs2).toString("base64");
107526
+ const bundle = readFileSync3(bundlePath, "utf8");
107527
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#fff;font-family:sans-serif}#root{padding:20px;max-width:1240px;margin:0 auto}</style></head><body><div id="root"></div><script>window.__LOTICS_RENDER=${JSON.stringify({ type, b64 })}</script><script src="/render_page.js"></script></body></html>`;
107528
+ server = createServer((req, res) => {
107529
+ if (req.url === "/render_page.js") {
107530
+ res.writeHead(200, { "content-type": "application/javascript" });
107531
+ res.end(bundle);
107532
+ } else {
107533
+ res.writeHead(200, { "content-type": "text/html" });
107534
+ res.end(html);
107535
+ }
107536
+ });
107537
+ pageUrl = (port) => `http://127.0.0.1:${port}/`;
106698
107538
  }
106699
- const b64 = readFileSync(abs2).toString("base64");
106700
- const bundle = readFileSync(bundlePath, "utf8");
106701
- const html = `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#fff;font-family:sans-serif}#root{padding:20px;max-width:1240px;margin:0 auto}</style></head><body><div id="root"></div><script>window.__LOTICS_RENDER=${JSON.stringify({ type, b64 })}</script><script src="/render_page.js"></script></body></html>`;
106702
- const server = createServer((req, res) => {
106703
- if (req.url === "/render_page.js") {
106704
- res.writeHead(200, { "content-type": "application/javascript" });
106705
- res.end(bundle);
106706
- } else {
106707
- res.writeHead(200, { "content-type": "text/html" });
106708
- res.end(html);
106709
- }
106710
- });
106711
107539
  await new Promise((r) => server.listen(0, "127.0.0.1", () => r()));
106712
107540
  const httpPort = server.address().port;
106713
- const udd = mkdtempSync(join2(tmpdir2(), "lotics-render-"));
106714
- const child = spawn3(chrome, [
106715
- "--headless=new",
106716
- "--disable-gpu",
106717
- "--no-sandbox",
106718
- "--hide-scrollbars",
106719
- // Large multi-sheet renders exhaust the (often tiny) /dev/shm in containers/WSL2 and
106720
- // crash the tab; back shared memory with /tmp instead.
106721
- "--disable-dev-shm-usage",
106722
- "--force-device-scale-factor=2",
106723
- "--remote-debugging-port=0",
106724
- `--user-data-dir=${udd}`,
106725
- "about:blank"
106726
- ], { stdio: "ignore" });
107541
+ let session;
107542
+ try {
107543
+ session = await launchChrome();
107544
+ } catch (error52) {
107545
+ server.close();
107546
+ fail3(error52 instanceof Error ? error52.message : String(error52));
107547
+ }
107548
+ const { cdp, chrome } = session;
106727
107549
  const cleanup = () => {
106728
- try {
106729
- child.kill();
106730
- } catch {
106731
- }
107550
+ session.close();
106732
107551
  try {
106733
107552
  server.close();
106734
107553
  } catch {
106735
107554
  }
106736
- try {
106737
- rmSync(udd, { recursive: true, force: true });
106738
- } catch {
106739
- }
106740
107555
  };
106741
107556
  try {
106742
- let cdpPort = 0;
106743
- const portFile = join2(udd, "DevToolsActivePort");
106744
- for (let i2 = 0; i2 < 100 && !cdpPort; i2++) {
106745
- if (existsSync2(portFile)) {
106746
- const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
106747
- if (p) cdpPort = p;
106748
- }
106749
- if (!cdpPort) await sleep2(100);
106750
- }
106751
- if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
106752
- let target;
106753
- for (let i2 = 0; i2 < 60 && !target?.webSocketDebuggerUrl; i2++) {
106754
- try {
106755
- const list2 = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
106756
- target = list2.find((t) => t.type === "page");
106757
- } catch {
106758
- }
106759
- if (!target?.webSocketDebuggerUrl) await sleep2(100);
106760
- }
106761
- if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
106762
- const cdp = await cdpConnect(target.webSocketDebuggerUrl);
106763
- await cdp.send("Page.enable");
106764
- await cdp.send("Runtime.enable");
106765
- await cdp.send("Page.navigate", { url: `http://127.0.0.1:${httpPort}/` });
107557
+ await cdp.send("Page.navigate", { url: pageUrl(httpPort) });
107558
+ const doneExpression = type === "html" ? "({done: document.readyState === 'complete' && Array.from(document.images).every((i) => i.complete), err: '', warnings: []})" : "({done: !!window.__loticsDone, err: window.__loticsError || '', warnings: window.__loticsWarnings || []})";
106766
107559
  let err2;
106767
107560
  let done = false;
106768
107561
  const warnings = [];
106769
107562
  for (let i2 = 0; i2 < 200; i2++) {
106770
107563
  const r = await cdp.send("Runtime.evaluate", {
106771
- expression: "({done: !!window.__loticsDone, err: window.__loticsError || '', warnings: window.__loticsWarnings || []})",
107564
+ expression: doneExpression,
106772
107565
  returnByValue: true
106773
107566
  });
106774
107567
  const v = r.result?.value;
@@ -106778,15 +107571,24 @@ async function runPreviewCommand(filePath, flags) {
106778
107571
  if (v.warnings?.length) warnings.push(...v.warnings);
106779
107572
  break;
106780
107573
  }
106781
- await sleep2(75);
107574
+ await sleep4(75);
106782
107575
  }
106783
107576
  if (!done) throw new Error("Render timed out (page never signaled completion).");
106784
107577
  if (err2) throw new Error(`Render engine error: ${err2}`);
106785
- const metrics = await cdp.send("Page.getLayoutMetrics");
106786
- const size2 = metrics.cssContentSize ?? metrics.contentSize ?? { width: 1240, height: 1600 };
107578
+ const measure = async () => {
107579
+ const metrics = await cdp.send("Page.getLayoutMetrics");
107580
+ const size2 = metrics.cssContentSize ?? metrics.contentSize ?? { width: 1240, height: 1600 };
107581
+ return { w: Math.ceil(size2.width), h: Math.ceil(size2.height) };
107582
+ };
107583
+ const first3 = await measure();
107584
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
107585
+ width: Math.min(first3.w, 15e3),
107586
+ height: Math.min(first3.h, 15e3),
107587
+ deviceScaleFactor: 2,
107588
+ mobile: false
107589
+ });
107590
+ const { w, h } = await measure();
106787
107591
  const MAX_CAPTURE = 15e3;
106788
- const w = Math.ceil(size2.width);
106789
- const h = Math.ceil(size2.height);
106790
107592
  if (w > MAX_CAPTURE || h > MAX_CAPTURE) {
106791
107593
  warnings.push(`content ${w}\xD7${h}px clipped to ${Math.min(w, MAX_CAPTURE)}\xD7${Math.min(h, MAX_CAPTURE)}px (exceeds the ${MAX_CAPTURE}px capture limit)`);
106792
107594
  }
@@ -106795,8 +107597,7 @@ async function runPreviewCommand(filePath, flags) {
106795
107597
  captureBeyondViewport: true,
106796
107598
  clip: { x: 0, y: 0, width: Math.min(w, MAX_CAPTURE), height: Math.min(h, MAX_CAPTURE), scale: 1 }
106797
107599
  });
106798
- cdp.close();
106799
- const outPath = flags.output ? resolve(flags.output) : join2(dirname(abs2), basename(abs2, ext) + ".png");
107600
+ const outPath = flags.output ? resolve(flags.output) : join4(dirname2(abs2), basename(abs2, ext) + ".png");
106800
107601
  writeFileSync(outPath, Buffer.from(shot.data, "base64"));
106801
107602
  console.log(`Rendered ${basename(abs2)} \u2192 ${outPath} (${Math.min(w, MAX_CAPTURE)}\xD7${Math.min(h, MAX_CAPTURE)}, via ${basename(chrome)})`);
106802
107603
  for (const warn2 of warnings) console.error(` \u26A0 ${warn2}`);
@@ -106958,8 +107759,8 @@ function prompt(question) {
106958
107759
  });
106959
107760
  });
106960
107761
  }
106961
- async function publicPost(path13, body) {
106962
- const response = await fetch(`${API_BASE_URL}${path13}`, {
107762
+ async function publicPost(path14, body) {
107763
+ const response = await fetch(`${API_BASE_URL}${path14}`, {
106963
107764
  method: "POST",
106964
107765
  headers: { "Content-Type": "application/json" },
106965
107766
  body: JSON.stringify(body)
@@ -107262,13 +108063,13 @@ async function resolveWorkspace(client, ctx) {
107262
108063
  function resolveUploadPaths(rawPaths) {
107263
108064
  const result = [];
107264
108065
  for (const p of rawPaths) {
107265
- const resolved = path12.resolve(p);
108066
+ const resolved = path13.resolve(p);
107266
108067
  const stat = fs15.statSync(resolved);
107267
108068
  if (stat.isDirectory()) {
107268
108069
  const entries2 = fs15.readdirSync(resolved, { withFileTypes: true });
107269
108070
  for (const entry of entries2) {
107270
108071
  if (entry.isFile()) {
107271
- result.push(path12.join(resolved, entry.name));
108072
+ result.push(path13.join(resolved, entry.name));
107272
108073
  }
107273
108074
  }
107274
108075
  } else {
@@ -107439,7 +108240,7 @@ async function main() {
107439
108240
  if (!toolArgs) {
107440
108241
  console.error(`Usage: lotics scaffold ${subcommand} <model.json>`);
107441
108242
  console.error(
107442
- subcommand === "check" ? ' Validates the model offline \u2014 no account, no network. Reports every\n problem in one run; exits 1 if there is one.\n A file written as {"from": "<preset_id>", \u2026} is the exception: it names a\n published preset, so checking it READS that preset and needs the network\n for that one step. Everything after it is the same offline check.\n --json prints {ok, tables, fields, links, views, roles, rows} or the findings.' : " Creates the model's tables, fields, options, views, roles and first rows\n in this workspace. Additive: an existing table of the same name is\n ADOPTED and given the fields, options and views it is missing, never\n modified \u2014 while `setup` refuses that name instead. Rows land only\n where every bound table is empty.\n --json prints one object and nothing else."
108243
+ subcommand === "check" ? ' Validates the model offline \u2014 no account, no network. Reports every\n problem in one run; exits 1 if there is one.\n A file written as {"from": "<preset_id>", \u2026} is the exception: it names a\n published preset, so checking it READS that preset and needs the network\n for that one step. Everything after it is the same offline check.\n Prints the counts, then every planned screen with the field in each of\n its slots, then what the first rows would show.\n --json prints {ok, tables, fields, links, views, roles, rows, apps, screens,\n plan, coverage} or the findings.' : " Creates the model's tables, fields, options, views, roles and first rows\n in this workspace. Additive: an existing table of the same name is\n ADOPTED and given the fields, options and views it is missing, never\n modified \u2014 while `setup` refuses that name instead. Rows land only\n where every bound table is empty.\n --json prints one object and nothing else."
107443
108244
  );
107444
108245
  console.error(" How to write one: lotics scaffold docs");
107445
108246
  process.exit(1);
@@ -107447,13 +108248,30 @@ async function main() {
107447
108248
  if (flags.json) setMachineOutput(true);
107448
108249
  const checked = await checkModelFile(toolArgs);
107449
108250
  if (checked.kind === "error") {
108251
+ noteScope({ plan_findings: checked.findings.length });
107450
108252
  if (subcommand === "check" && flags.json) emitJson({ ok: false, findings: checked.findings });
107451
108253
  else reportModelFindings(toolArgs, checked.findings);
107452
108254
  process.exit(1);
107453
108255
  }
108256
+ const counts = countModel(checked.model);
108257
+ noteScope({ plan_screens: counts.screens, plan_custom: counts.custom, plan_findings: 0 });
107454
108258
  if (subcommand === "check") {
107455
- if (flags.json) emitJson({ ok: true, ...countModel(checked.model) });
107456
- else console.log(summarizeModel(checked.model));
108259
+ const coverage = describeCoverage(checked.model);
108260
+ if (flags.json) {
108261
+ emitJson({
108262
+ ok: true,
108263
+ ...countModel(checked.model),
108264
+ plan: planJson(checked.model),
108265
+ coverage: roleCoverage(checked.model.contract, checked.model.field_roles, checked.model.rows)
108266
+ });
108267
+ } else {
108268
+ console.log(summarizeModel(checked.model));
108269
+ for (const line of describePlan(checked.model)) console.log(line);
108270
+ if (coverage.length > 0) {
108271
+ console.log("What the rows would show:");
108272
+ for (const line of coverage) console.log(line);
108273
+ }
108274
+ }
107457
108275
  return;
107458
108276
  }
107459
108277
  const { client: client2, ctx: ctx2 } = await requireClient(flags);
@@ -107774,7 +108592,7 @@ async function main() {
107774
108592
  if (command === "preview") {
107775
108593
  if (subcommand && isStoredFileId(subcommand)) {
107776
108594
  const { client: client2 } = await requireClient(flags);
107777
- const tmpDir = fs15.mkdtempSync(path12.join(os4.tmpdir(), "lotics-preview-"));
108595
+ const tmpDir = fs15.mkdtempSync(path13.join(os4.tmpdir(), "lotics-preview-"));
107778
108596
  try {
107779
108597
  const { path: localPath, filename } = await client2.downloadFileById(subcommand, tmpDir);
107780
108598
  const output = flags.output ?? defaultPreviewOutputPath(filename, process.cwd());
@@ -108150,7 +108968,7 @@ Available workspaces:`);
108150
108968
  return;
108151
108969
  }
108152
108970
  if (subcommand === "check") {
108153
- await appCheck(client, {});
108971
+ await appCheck(client, { screens: flags.screens });
108154
108972
  return;
108155
108973
  }
108156
108974
  if (subcommand === "workflow") {