@lotics/cli 0.189.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/AGENTS.md +11 -6
- package/README.md +13 -3
- package/dist/probe_page.js +400 -0
- package/dist/src/cli.js +1507 -490
- package/dist/src/client.d.ts +35 -0
- package/dist/src/client.js +15 -0
- package/docs/building_an_app.md +14 -5
- package/docs/cli_reference.md +8 -7
- package/package.json +1 -1
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(
|
|
26826
|
-
utils_1.forEach(
|
|
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 =
|
|
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,
|
|
28593
|
+
function AbstractNextPossibleTokensWalker2(topProd, path14) {
|
|
28594
28594
|
var _this = _super.call(this) || this;
|
|
28595
28595
|
_this.topProd = topProd;
|
|
28596
|
-
_this.path =
|
|
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,
|
|
28651
|
-
var _this = _super.call(this, topProd,
|
|
28652
|
-
_this.path =
|
|
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(
|
|
29349
|
+
function pathToHashKeys(path14) {
|
|
29350
29350
|
var keys3 = [""];
|
|
29351
|
-
for (var i2 = 0; i2 <
|
|
29352
|
-
var tokType =
|
|
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,
|
|
29665
|
-
if (
|
|
29666
|
-
|
|
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:
|
|
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,
|
|
29685
|
+
var validNextSteps = utils2.difference(nextNonTerminals, path14.concat([topRule]));
|
|
29686
29686
|
var errorsFromNextSteps = utils2.map(validNextSteps, function(currRefRule) {
|
|
29687
|
-
var newPath = utils2.cloneArr(
|
|
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
|
|
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(
|
|
37297
|
-
var parts =
|
|
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(
|
|
43151
|
-
if (
|
|
43152
|
-
|
|
43150
|
+
var parentFolder = function(path14) {
|
|
43151
|
+
if (path14.slice(-1) === "/") {
|
|
43152
|
+
path14 = path14.substring(0, path14.length - 1);
|
|
43153
43153
|
}
|
|
43154
|
-
var lastSlash =
|
|
43155
|
-
return lastSlash > 0 ?
|
|
43154
|
+
var lastSlash = path14.lastIndexOf("/");
|
|
43155
|
+
return lastSlash > 0 ? path14.substring(0, lastSlash) : "";
|
|
43156
43156
|
};
|
|
43157
|
-
var forceTrailingSlash = function(
|
|
43158
|
-
if (
|
|
43159
|
-
|
|
43157
|
+
var forceTrailingSlash = function(path14) {
|
|
43158
|
+
if (path14.slice(-1) !== "/") {
|
|
43159
|
+
path14 += "/";
|
|
43160
43160
|
}
|
|
43161
|
-
return
|
|
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
|
|
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,
|
|
44535
|
-
const url2 = `${this.baseUrl}${
|
|
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) {
|
|
@@ -44579,6 +44579,21 @@ var LoticsClient = class {
|
|
|
44579
44579
|
async scaffoldWorkspace(body) {
|
|
44580
44580
|
return this.request("POST", "/v1/workspaces/scaffold", body);
|
|
44581
44581
|
}
|
|
44582
|
+
/**
|
|
44583
|
+
* Read this workspace's schema back as a model — the tables it has (or only
|
|
44584
|
+
* the ones named), their fields, options and views, plus its roles.
|
|
44585
|
+
*
|
|
44586
|
+
* A pure read, and admin-only for the same reason the scaffold is: the whole
|
|
44587
|
+
* schema is what comes back.
|
|
44588
|
+
*/
|
|
44589
|
+
async exportWorkspaceModel(opts = {}) {
|
|
44590
|
+
const params = new URLSearchParams();
|
|
44591
|
+
if (opts.tables !== void 0 && opts.tables.length > 0) {
|
|
44592
|
+
params.set("tables", opts.tables.join(","));
|
|
44593
|
+
}
|
|
44594
|
+
const qs = params.toString();
|
|
44595
|
+
return this.request("GET", `/v1/workspaces/model${qs ? `?${qs}` : ""}`);
|
|
44596
|
+
}
|
|
44582
44597
|
/** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
|
|
44583
44598
|
* target from the request's workspace, never a path id. */
|
|
44584
44599
|
async updateWorkspace(body) {
|
|
@@ -45810,10 +45825,23 @@ function upsertProfile(orgId, fields) {
|
|
|
45810
45825
|
profiles: { ...config2.profiles, [orgId]: profile },
|
|
45811
45826
|
active_org: config2.active_org,
|
|
45812
45827
|
email: config2.email,
|
|
45828
|
+
pending_login: config2.pending_login,
|
|
45813
45829
|
last_update_check: config2.last_update_check,
|
|
45814
45830
|
latest_version: config2.latest_version
|
|
45815
45831
|
});
|
|
45816
45832
|
}
|
|
45833
|
+
function loadPendingLogin() {
|
|
45834
|
+
return loadGlobalConfig()?.pending_login ?? null;
|
|
45835
|
+
}
|
|
45836
|
+
function savePendingLogin(pending) {
|
|
45837
|
+
saveGlobalConfig({ ...loadGlobalConfig(), pending_login: pending });
|
|
45838
|
+
}
|
|
45839
|
+
function clearPendingLogin() {
|
|
45840
|
+
const config2 = loadGlobalConfig();
|
|
45841
|
+
if (!config2?.pending_login) return;
|
|
45842
|
+
const { pending_login: _collected, ...rest2 } = config2;
|
|
45843
|
+
saveGlobalConfig(rest2);
|
|
45844
|
+
}
|
|
45817
45845
|
function clearProfileWorkspace(orgId) {
|
|
45818
45846
|
const config2 = loadGlobalConfig();
|
|
45819
45847
|
const existing = config2?.profiles?.[orgId];
|
|
@@ -45973,7 +46001,7 @@ function resultSideEffects(result) {
|
|
|
45973
46001
|
}
|
|
45974
46002
|
|
|
45975
46003
|
// src/version.ts
|
|
45976
|
-
var VERSION = "0.
|
|
46004
|
+
var VERSION = "0.191.0";
|
|
45977
46005
|
|
|
45978
46006
|
// src/timezone.ts
|
|
45979
46007
|
function machineTimezone() {
|
|
@@ -46158,6 +46186,10 @@ var COMMANDS = [
|
|
|
46158
46186
|
" It REFUSES a name a table here already has; use",
|
|
46159
46187
|
" lotics scaffold apply to add to what you have.",
|
|
46160
46188
|
" Drop --email to use the account you already have.",
|
|
46189
|
+
" An email that ALREADY has an account: it prints a",
|
|
46190
|
+
" sign-in page to open and stops, having created",
|
|
46191
|
+
" nothing \u2014 press Confirm and run the same command",
|
|
46192
|
+
" again. --wait holds the terminal instead",
|
|
46161
46193
|
" --json prints one object and nothing else"
|
|
46162
46194
|
]
|
|
46163
46195
|
},
|
|
@@ -46169,9 +46201,10 @@ var COMMANDS = [
|
|
|
46169
46201
|
" and a worked example. Offline, no account",
|
|
46170
46202
|
" lotics scaffold check <file> Prove a model before anyone sees it \u2014 offline, no",
|
|
46171
46203
|
" account, no network. Reports EVERY problem in one",
|
|
46172
|
-
" run; exits 1 if there is one, else
|
|
46173
|
-
"
|
|
46174
|
-
"
|
|
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",
|
|
46175
46208
|
" lotics scaffold apply <file> Create the model's tables, fields, options, views,",
|
|
46176
46209
|
" roles and first rows in this workspace, then copy in",
|
|
46177
46210
|
" every package its apply list names. Additive:",
|
|
@@ -46181,7 +46214,13 @@ var COMMANDS = [
|
|
|
46181
46214
|
" where every bound table is empty. Renaming or",
|
|
46182
46215
|
" deleting is not the file's to do \u2014 after the",
|
|
46183
46216
|
" first run the workspace is the truth.",
|
|
46184
|
-
" --json prints one object and nothing else"
|
|
46217
|
+
" --json prints one object and nothing else",
|
|
46218
|
+
" lotics scaffold export [--tables] Print this workspace's tables \u2014 or only the ids",
|
|
46219
|
+
" named \u2014 as a model.json: the file scaffold check",
|
|
46220
|
+
" reads, and what a preset is written from. Edit the",
|
|
46221
|
+
" labels into the trade's words and add a preset block",
|
|
46222
|
+
" with its two questions and its variants. A starting",
|
|
46223
|
+
" point, never a source of truth"
|
|
46185
46224
|
]
|
|
46186
46225
|
},
|
|
46187
46226
|
{
|
|
@@ -46312,7 +46351,7 @@ var COMMANDS = [
|
|
|
46312
46351
|
" offer one",
|
|
46313
46352
|
" lotics app codegen [path] Regenerate .lotics/* (types + field/option ids)",
|
|
46314
46353
|
" from the manifest + workspace schema \u2014 no deploy",
|
|
46315
|
-
" lotics app check
|
|
46354
|
+
" lotics app check [--screens] Run every deploy pre-flight WITHOUT building or",
|
|
46316
46355
|
" shipping: the app's own typecheck over regenerated",
|
|
46317
46356
|
" .lotics types, agent schemas vs the live app, workflow",
|
|
46318
46357
|
" declarations and BODIES vs what is live, aliases",
|
|
@@ -46322,6 +46361,9 @@ var COMMANDS = [
|
|
|
46322
46361
|
" the pulled types, and a concrete id or an",
|
|
46323
46362
|
" id-shaped placeholder in source a starter",
|
|
46324
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).",
|
|
46325
46367
|
" Exits 1 on any of it, so CI can gate on it",
|
|
46326
46368
|
" lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
|
|
46327
46369
|
" through set_app_workflow (server verifies)",
|
|
@@ -46399,7 +46441,9 @@ var COMMANDS = [
|
|
|
46399
46441
|
" lotics file preview <file|fil_id> [-o png]",
|
|
46400
46442
|
" Render a .docx/.xlsx to a PNG \u2014 a local path OR a",
|
|
46401
46443
|
" stored file id (downloaded first). Frontend engines;",
|
|
46402
|
-
" 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"
|
|
46403
46447
|
]
|
|
46404
46448
|
}
|
|
46405
46449
|
];
|
|
@@ -46590,9 +46634,9 @@ async function reportCommand(client, options) {
|
|
|
46590
46634
|
// src/app_commands.ts
|
|
46591
46635
|
import fs8 from "node:fs";
|
|
46592
46636
|
import path9 from "node:path";
|
|
46593
|
-
import { spawn as
|
|
46637
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
46594
46638
|
import { createHash as createHash3 } from "node:crypto";
|
|
46595
|
-
import { tmpdir } from "node:os";
|
|
46639
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
46596
46640
|
|
|
46597
46641
|
// src/starter_template.ts
|
|
46598
46642
|
var STARTER_REACT_NATIVE_VERSION = "0.85.3";
|
|
@@ -47529,9 +47573,22 @@ async function dispatchRpc(client, body, opts) {
|
|
|
47529
47573
|
}
|
|
47530
47574
|
}
|
|
47531
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
|
+
|
|
47532
47589
|
// src/dev/wrapper_page.ts
|
|
47533
47590
|
function buildWrapperPage(args) {
|
|
47534
|
-
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;
|
|
47535
47592
|
return `<!doctype html>
|
|
47536
47593
|
<html lang="en">
|
|
47537
47594
|
<head>
|
|
@@ -47695,6 +47752,19 @@ function buildWrapperPage(args) {
|
|
|
47695
47752
|
return undefined;
|
|
47696
47753
|
}
|
|
47697
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
|
+
|
|
47698
47768
|
// urlState.get/set \u2014 useUrlState keeps app view-state (filters, search) in
|
|
47699
47769
|
// THIS wrapper page's address bar so it survives refresh and is shareable,
|
|
47700
47770
|
// mirroring the production host. Handled locally (the URL is the store; no
|
|
@@ -47794,6 +47864,8 @@ function buildWrapperPage(args) {
|
|
|
47794
47864
|
? await handleUpload(msg.payload)
|
|
47795
47865
|
: msg.op === "openExternal"
|
|
47796
47866
|
? handleOpenExternal(msg.payload)
|
|
47867
|
+
: msg.op === "openApp"
|
|
47868
|
+
? handleOpenApp(msg.payload)
|
|
47797
47869
|
: msg.op === "urlState.get"
|
|
47798
47870
|
? readUrlParams()
|
|
47799
47871
|
: msg.op === "urlState.set"
|
|
@@ -47944,7 +48016,8 @@ async function startDevServer(args) {
|
|
|
47944
48016
|
app_id: args.app_id,
|
|
47945
48017
|
workspace_id: args.workspace_id,
|
|
47946
48018
|
vite_url: viteUrl,
|
|
47947
|
-
api_url: args.api_url
|
|
48019
|
+
api_url: args.api_url,
|
|
48020
|
+
web_app_url: WEB_APP_URL
|
|
47948
48021
|
});
|
|
47949
48022
|
const viteEntry = path7.join(args.projectDir, "node_modules", "vite", "bin", "vite.js");
|
|
47950
48023
|
if (!existsSync(viteEntry)) {
|
|
@@ -48254,7 +48327,8 @@ async function startDevServer(args) {
|
|
|
48254
48327
|
stop: async () => {
|
|
48255
48328
|
await stopAll();
|
|
48256
48329
|
await stoppingPromise;
|
|
48257
|
-
}
|
|
48330
|
+
},
|
|
48331
|
+
pendingRpc: () => inFlight
|
|
48258
48332
|
};
|
|
48259
48333
|
}
|
|
48260
48334
|
async function pickPort(preferred, ...avoid) {
|
|
@@ -48308,6 +48382,272 @@ function openBrowser(url2) {
|
|
|
48308
48382
|
child.unref();
|
|
48309
48383
|
}
|
|
48310
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
|
+
|
|
48311
48651
|
// src/output.ts
|
|
48312
48652
|
var machineMode = false;
|
|
48313
48653
|
var collectedWarnings = [];
|
|
@@ -49104,10 +49444,10 @@ function mergeDefs(...defs) {
|
|
|
49104
49444
|
function cloneDef(schema) {
|
|
49105
49445
|
return mergeDefs(schema._zod.def);
|
|
49106
49446
|
}
|
|
49107
|
-
function getElementAtPath(obj,
|
|
49108
|
-
if (!
|
|
49447
|
+
function getElementAtPath(obj, path14) {
|
|
49448
|
+
if (!path14)
|
|
49109
49449
|
return obj;
|
|
49110
|
-
return
|
|
49450
|
+
return path14.reduce((acc, key) => acc?.[key], obj);
|
|
49111
49451
|
}
|
|
49112
49452
|
function promiseAllObject(promisesObj) {
|
|
49113
49453
|
const keys3 = Object.keys(promisesObj);
|
|
@@ -49516,11 +49856,11 @@ function explicitlyAborted(x2, startIndex = 0) {
|
|
|
49516
49856
|
}
|
|
49517
49857
|
return false;
|
|
49518
49858
|
}
|
|
49519
|
-
function prefixIssues(
|
|
49859
|
+
function prefixIssues(path14, issues) {
|
|
49520
49860
|
return issues.map((iss) => {
|
|
49521
49861
|
var _a4;
|
|
49522
49862
|
(_a4 = iss).path ?? (_a4.path = []);
|
|
49523
|
-
iss.path.unshift(
|
|
49863
|
+
iss.path.unshift(path14);
|
|
49524
49864
|
return iss;
|
|
49525
49865
|
});
|
|
49526
49866
|
}
|
|
@@ -49667,16 +50007,16 @@ function flattenError(error52, mapper = (issue2) => issue2.message) {
|
|
|
49667
50007
|
}
|
|
49668
50008
|
function formatError(error52, mapper = (issue2) => issue2.message) {
|
|
49669
50009
|
const fieldErrors = { _errors: [] };
|
|
49670
|
-
const processError = (error53,
|
|
50010
|
+
const processError = (error53, path14 = []) => {
|
|
49671
50011
|
for (const issue2 of error53.issues) {
|
|
49672
50012
|
if (issue2.code === "invalid_union" && issue2.errors.length) {
|
|
49673
|
-
issue2.errors.map((issues) => processError({ issues }, [...
|
|
50013
|
+
issue2.errors.map((issues) => processError({ issues }, [...path14, ...issue2.path]));
|
|
49674
50014
|
} else if (issue2.code === "invalid_key") {
|
|
49675
|
-
processError({ issues: issue2.issues }, [...
|
|
50015
|
+
processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
|
|
49676
50016
|
} else if (issue2.code === "invalid_element") {
|
|
49677
|
-
processError({ issues: issue2.issues }, [...
|
|
50017
|
+
processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
|
|
49678
50018
|
} else {
|
|
49679
|
-
const fullpath = [...
|
|
50019
|
+
const fullpath = [...path14, ...issue2.path];
|
|
49680
50020
|
if (fullpath.length === 0) {
|
|
49681
50021
|
fieldErrors._errors.push(mapper(issue2));
|
|
49682
50022
|
} else {
|
|
@@ -49703,17 +50043,17 @@ function formatError(error52, mapper = (issue2) => issue2.message) {
|
|
|
49703
50043
|
}
|
|
49704
50044
|
function treeifyError(error52, mapper = (issue2) => issue2.message) {
|
|
49705
50045
|
const result = { errors: [] };
|
|
49706
|
-
const processError = (error53,
|
|
50046
|
+
const processError = (error53, path14 = []) => {
|
|
49707
50047
|
var _a4, _b2;
|
|
49708
50048
|
for (const issue2 of error53.issues) {
|
|
49709
50049
|
if (issue2.code === "invalid_union" && issue2.errors.length) {
|
|
49710
|
-
issue2.errors.map((issues) => processError({ issues }, [...
|
|
50050
|
+
issue2.errors.map((issues) => processError({ issues }, [...path14, ...issue2.path]));
|
|
49711
50051
|
} else if (issue2.code === "invalid_key") {
|
|
49712
|
-
processError({ issues: issue2.issues }, [...
|
|
50052
|
+
processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
|
|
49713
50053
|
} else if (issue2.code === "invalid_element") {
|
|
49714
|
-
processError({ issues: issue2.issues }, [...
|
|
50054
|
+
processError({ issues: issue2.issues }, [...path14, ...issue2.path]);
|
|
49715
50055
|
} else {
|
|
49716
|
-
const fullpath = [...
|
|
50056
|
+
const fullpath = [...path14, ...issue2.path];
|
|
49717
50057
|
if (fullpath.length === 0) {
|
|
49718
50058
|
result.errors.push(mapper(issue2));
|
|
49719
50059
|
continue;
|
|
@@ -49745,8 +50085,8 @@ function treeifyError(error52, mapper = (issue2) => issue2.message) {
|
|
|
49745
50085
|
}
|
|
49746
50086
|
function toDotPath(_path) {
|
|
49747
50087
|
const segs = [];
|
|
49748
|
-
const
|
|
49749
|
-
for (const seg of
|
|
50088
|
+
const path14 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
|
|
50089
|
+
for (const seg of path14) {
|
|
49750
50090
|
if (typeof seg === "number")
|
|
49751
50091
|
segs.push(`[${seg}]`);
|
|
49752
50092
|
else if (typeof seg === "symbol")
|
|
@@ -62438,13 +62778,13 @@ function resolveRef(ref2, ctx) {
|
|
|
62438
62778
|
if (!ref2.startsWith("#")) {
|
|
62439
62779
|
throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
|
|
62440
62780
|
}
|
|
62441
|
-
const
|
|
62442
|
-
if (
|
|
62781
|
+
const path14 = ref2.slice(1).split("/").filter(Boolean);
|
|
62782
|
+
if (path14.length === 0) {
|
|
62443
62783
|
return ctx.rootSchema;
|
|
62444
62784
|
}
|
|
62445
62785
|
const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
|
|
62446
|
-
if (
|
|
62447
|
-
const key =
|
|
62786
|
+
if (path14[0] === defsKey) {
|
|
62787
|
+
const key = path14[1];
|
|
62448
62788
|
if (!key || !ctx.defs[key]) {
|
|
62449
62789
|
throw new Error(`Reference not found: ${ref2}`);
|
|
62450
62790
|
}
|
|
@@ -65142,16 +65482,16 @@ function conditionValueIssue(type, operator, value2, fieldKey) {
|
|
|
65142
65482
|
return null;
|
|
65143
65483
|
}
|
|
65144
65484
|
}
|
|
65145
|
-
function conditionIssues(node,
|
|
65485
|
+
function conditionIssues(node, path14, fieldMap) {
|
|
65146
65486
|
if (!node || typeof node !== "object") {
|
|
65147
|
-
return [`${
|
|
65487
|
+
return [`${path14}: expected condition object, got ${typeof node}`];
|
|
65148
65488
|
}
|
|
65149
65489
|
const obj = node;
|
|
65150
65490
|
if (obj.type === "locked") {
|
|
65151
65491
|
const validOps2 = VALID_OPERATORS.locked;
|
|
65152
65492
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
65153
65493
|
return [
|
|
65154
|
-
`${
|
|
65494
|
+
`${path14}: invalid operator '${String(obj.operator)}' for locked filter. Valid: ${validOps2.join(", ")}`
|
|
65155
65495
|
];
|
|
65156
65496
|
}
|
|
65157
65497
|
return [];
|
|
@@ -65160,12 +65500,12 @@ function conditionIssues(node, path13, fieldMap) {
|
|
|
65160
65500
|
const validOps2 = VALID_OPERATORS.current_member;
|
|
65161
65501
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
65162
65502
|
return [
|
|
65163
|
-
`${
|
|
65503
|
+
`${path14}: invalid operator '${String(obj.operator)}' for current_member filter. Valid: ${validOps2.join(", ")}`
|
|
65164
65504
|
];
|
|
65165
65505
|
}
|
|
65166
65506
|
if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
|
|
65167
65507
|
return [
|
|
65168
|
-
`${
|
|
65508
|
+
`${path14}: current_member '${obj.operator}' expects a non-empty string[] of group IDs.`
|
|
65169
65509
|
];
|
|
65170
65510
|
}
|
|
65171
65511
|
return [];
|
|
@@ -65174,45 +65514,45 @@ function conditionIssues(node, path13, fieldMap) {
|
|
|
65174
65514
|
const validOps2 = VALID_OPERATORS.record_id;
|
|
65175
65515
|
if (typeof obj.operator !== "string" || !validOps2.includes(obj.operator)) {
|
|
65176
65516
|
return [
|
|
65177
|
-
`${
|
|
65517
|
+
`${path14}: invalid operator '${String(obj.operator)}' for record_id filter. Valid: ${validOps2.join(", ")}`
|
|
65178
65518
|
];
|
|
65179
65519
|
}
|
|
65180
65520
|
if (!Array.isArray(obj.value) || obj.value.length === 0 || !obj.value.every((v) => typeof v === "string" && v.length > 0)) {
|
|
65181
|
-
return [`${
|
|
65521
|
+
return [`${path14}: record_id '${obj.operator}' expects a non-empty string[] of record ids.`];
|
|
65182
65522
|
}
|
|
65183
65523
|
return [];
|
|
65184
65524
|
}
|
|
65185
65525
|
if (typeof obj.field_key !== "string" || !obj.field_key) {
|
|
65186
|
-
return [`${
|
|
65526
|
+
return [`${path14}: field_key must be a non-empty string`];
|
|
65187
65527
|
}
|
|
65188
65528
|
let filterType;
|
|
65189
65529
|
if (fieldMap) {
|
|
65190
65530
|
filterType = fieldMap.get(obj.field_key);
|
|
65191
65531
|
if (filterType === void 0) {
|
|
65192
65532
|
const available = Array.from(fieldMap.keys()).join(", ");
|
|
65193
|
-
return [`${
|
|
65533
|
+
return [`${path14}: unknown field_key '${obj.field_key}'. Available: ${available}`];
|
|
65194
65534
|
}
|
|
65195
65535
|
} else if (typeof obj.type === "string") {
|
|
65196
65536
|
filterType = obj.type;
|
|
65197
65537
|
}
|
|
65198
65538
|
if (filterType === "button") {
|
|
65199
|
-
return [`${
|
|
65539
|
+
return [`${path14}: a button field holds no data and cannot be filtered.`];
|
|
65200
65540
|
}
|
|
65201
65541
|
if (typeof filterType !== "string" || !VALID_FILTER_TYPES.includes(filterType)) {
|
|
65202
65542
|
const named = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
|
|
65203
|
-
return [`${
|
|
65543
|
+
return [`${path14}: invalid filter type ${named}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
|
|
65204
65544
|
}
|
|
65205
65545
|
const validOps = VALID_OPERATORS[filterType];
|
|
65206
65546
|
if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
|
|
65207
65547
|
return [
|
|
65208
|
-
`${
|
|
65548
|
+
`${path14}: invalid operator '${String(obj.operator)}' for ${filterType} filter. Valid: ${validOps.join(", ")}`
|
|
65209
65549
|
];
|
|
65210
65550
|
}
|
|
65211
65551
|
const valueIssue = conditionValueIssue(filterType, obj.operator, obj.value, obj.field_key);
|
|
65212
65552
|
return valueIssue ? [valueIssue] : [];
|
|
65213
65553
|
}
|
|
65214
|
-
function validateFilterCondition(condition,
|
|
65215
|
-
return conditionIssues(condition,
|
|
65554
|
+
function validateFilterCondition(condition, path14 = "filter") {
|
|
65555
|
+
return conditionIssues(condition, path14, void 0);
|
|
65216
65556
|
}
|
|
65217
65557
|
function inheritedFilterType(type, format2, fallback) {
|
|
65218
65558
|
switch (type) {
|
|
@@ -67872,7 +68212,7 @@ __export(expression_string_exports, {
|
|
|
67872
68212
|
contains: () => contains2,
|
|
67873
68213
|
endsWith: () => endsWith,
|
|
67874
68214
|
formatNumber: () => formatNumber,
|
|
67875
|
-
join: () =>
|
|
68215
|
+
join: () => join3,
|
|
67876
68216
|
length: () => length,
|
|
67877
68217
|
lower: () => lower,
|
|
67878
68218
|
numberToWords: () => numberToWords,
|
|
@@ -67886,7 +68226,7 @@ __export(expression_string_exports, {
|
|
|
67886
68226
|
trim: () => trim,
|
|
67887
68227
|
upper: () => upper
|
|
67888
68228
|
});
|
|
67889
|
-
function
|
|
68229
|
+
function join3(arr, separator) {
|
|
67890
68230
|
if (arr == null) return "";
|
|
67891
68231
|
if (!Array.isArray(arr)) {
|
|
67892
68232
|
throw new Error("join: expected array, got " + typeof arr);
|
|
@@ -68339,9 +68679,9 @@ __export(expression_object_exports, {
|
|
|
68339
68679
|
pick: () => pick3,
|
|
68340
68680
|
values: () => values2
|
|
68341
68681
|
});
|
|
68342
|
-
function getNestedValue(obj,
|
|
68682
|
+
function getNestedValue(obj, path14) {
|
|
68343
68683
|
if (obj == null) return void 0;
|
|
68344
|
-
const parts =
|
|
68684
|
+
const parts = path14.split(".");
|
|
68345
68685
|
let value2 = obj;
|
|
68346
68686
|
for (const part of parts) {
|
|
68347
68687
|
if (value2 == null) return void 0;
|
|
@@ -68380,11 +68720,11 @@ function entries(obj) {
|
|
|
68380
68720
|
}
|
|
68381
68721
|
return Object.entries(obj);
|
|
68382
68722
|
}
|
|
68383
|
-
function get(obj,
|
|
68384
|
-
if (typeof
|
|
68723
|
+
function get(obj, path14, defaultValue) {
|
|
68724
|
+
if (typeof path14 !== "string") {
|
|
68385
68725
|
throw new Error("get: path must be a string");
|
|
68386
68726
|
}
|
|
68387
|
-
const value2 = getNestedValue(obj,
|
|
68727
|
+
const value2 = getNestedValue(obj, path14);
|
|
68388
68728
|
return value2 !== void 0 ? value2 : defaultValue;
|
|
68389
68729
|
}
|
|
68390
68730
|
function pick3(obj, fields) {
|
|
@@ -72410,24 +72750,24 @@ function isBelowGeneratedWidth(token) {
|
|
|
72410
72750
|
}
|
|
72411
72751
|
var SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".css", ".json"];
|
|
72412
72752
|
var SCAN_FILE_MAX_BYTES = 1024 * 1024;
|
|
72413
|
-
function normalizeScanPath(
|
|
72414
|
-
return
|
|
72753
|
+
function normalizeScanPath(path14) {
|
|
72754
|
+
return path14.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
|
|
72415
72755
|
}
|
|
72416
|
-
function hasScannableExtension(
|
|
72417
|
-
return SCAN_EXTENSIONS.some((ext) =>
|
|
72756
|
+
function hasScannableExtension(path14) {
|
|
72757
|
+
return SCAN_EXTENSIONS.some((ext) => path14.endsWith(ext));
|
|
72418
72758
|
}
|
|
72419
|
-
function isConcreteIdScanPath(
|
|
72420
|
-
const name2 = normalizeScanPath(
|
|
72759
|
+
function isConcreteIdScanPath(path14) {
|
|
72760
|
+
const name2 = normalizeScanPath(path14);
|
|
72421
72761
|
if (name2.endsWith(".md")) return true;
|
|
72422
72762
|
if (!name2.startsWith("src/")) return false;
|
|
72423
72763
|
if (name2.startsWith("src/workflows/")) return false;
|
|
72424
72764
|
return hasScannableExtension(name2);
|
|
72425
72765
|
}
|
|
72426
|
-
function isTestFile(
|
|
72427
|
-
return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(
|
|
72766
|
+
function isTestFile(path14) {
|
|
72767
|
+
return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path14);
|
|
72428
72768
|
}
|
|
72429
|
-
function isIdPlaceholderScanPath(
|
|
72430
|
-
const name2 = normalizeScanPath(
|
|
72769
|
+
function isIdPlaceholderScanPath(path14) {
|
|
72770
|
+
const name2 = normalizeScanPath(path14);
|
|
72431
72771
|
if (isTestFile(name2)) return false;
|
|
72432
72772
|
if (name2.endsWith(".md")) return true;
|
|
72433
72773
|
if (name2 === "package.json") return true;
|
|
@@ -72444,24 +72784,24 @@ function firstLines(text, pattern, keep, into) {
|
|
|
72444
72784
|
});
|
|
72445
72785
|
return into;
|
|
72446
72786
|
}
|
|
72447
|
-
function toFindings(
|
|
72448
|
-
return [...lines].map(([id, line]) => ({ path:
|
|
72787
|
+
function toFindings(path14, rule, lines) {
|
|
72788
|
+
return [...lines].map(([id, line]) => ({ path: path14, line, id, rule }));
|
|
72449
72789
|
}
|
|
72450
|
-
function scanFileForConcreteIds(
|
|
72451
|
-
if (!isConcreteIdScanPath(
|
|
72790
|
+
function scanFileForConcreteIds(path14, text) {
|
|
72791
|
+
if (!isConcreteIdScanPath(path14)) return [];
|
|
72452
72792
|
const lines = firstLines(text, CONCRETE_ID_SCAN, () => true, /* @__PURE__ */ new Map());
|
|
72453
72793
|
firstLines(text, WORKSPACE_ID_SCAN, isGeneratedIdBody, lines);
|
|
72454
|
-
return toFindings(
|
|
72794
|
+
return toFindings(path14, "concrete", lines);
|
|
72455
72795
|
}
|
|
72456
|
-
function scanFileForIdPlaceholders(
|
|
72457
|
-
if (!isIdPlaceholderScanPath(
|
|
72796
|
+
function scanFileForIdPlaceholders(path14, text) {
|
|
72797
|
+
if (!isIdPlaceholderScanPath(path14)) return [];
|
|
72458
72798
|
const lines = firstLines(text, ID_PLACEHOLDER_SCAN, isBelowGeneratedWidth, /* @__PURE__ */ new Map());
|
|
72459
|
-
return toFindings(
|
|
72799
|
+
return toFindings(path14, "placeholder", lines);
|
|
72460
72800
|
}
|
|
72461
|
-
function scanFileForIds(
|
|
72462
|
-
const concrete = scanFileForConcreteIds(
|
|
72801
|
+
function scanFileForIds(path14, text) {
|
|
72802
|
+
const concrete = scanFileForConcreteIds(path14, text);
|
|
72463
72803
|
const seen = new Set(concrete.map((finding) => finding.id));
|
|
72464
|
-
return [...concrete, ...scanFileForIdPlaceholders(
|
|
72804
|
+
return [...concrete, ...scanFileForIdPlaceholders(path14, text).filter((f) => !seen.has(f.id))].sort(
|
|
72465
72805
|
(a, b) => a.line - b.line || (a.id < b.id ? -1 : 1)
|
|
72466
72806
|
);
|
|
72467
72807
|
}
|
|
@@ -72950,7 +73290,7 @@ async function fetchWorkflowDts(client, app_id, alias, declaration) {
|
|
|
72950
73290
|
}
|
|
72951
73291
|
function runTar(args, cwd) {
|
|
72952
73292
|
return new Promise((resolve2, reject2) => {
|
|
72953
|
-
const proc =
|
|
73293
|
+
const proc = spawn3("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
72954
73294
|
let stderr = "";
|
|
72955
73295
|
proc.stderr.on("data", (chunk) => {
|
|
72956
73296
|
stderr += chunk.toString();
|
|
@@ -72964,7 +73304,7 @@ function runTar(args, cwd) {
|
|
|
72964
73304
|
}
|
|
72965
73305
|
function npmIsInstalled() {
|
|
72966
73306
|
return new Promise((resolve2) => {
|
|
72967
|
-
const probe =
|
|
73307
|
+
const probe = spawn3("npm", ["--version"], {
|
|
72968
73308
|
shell: process.platform === "win32",
|
|
72969
73309
|
stdio: "ignore",
|
|
72970
73310
|
env: ipv4ChildEnv(process.env)
|
|
@@ -72976,7 +73316,7 @@ function npmIsInstalled() {
|
|
|
72976
73316
|
function runNpm(args, cwd) {
|
|
72977
73317
|
const quiet = isMachineOutput();
|
|
72978
73318
|
return new Promise((resolve2, reject2) => {
|
|
72979
|
-
const proc =
|
|
73319
|
+
const proc = spawn3("npm", args, {
|
|
72980
73320
|
cwd,
|
|
72981
73321
|
// On Windows `npm` is `npm.cmd`, a batch file Node will not execute
|
|
72982
73322
|
// without a shell — bare `spawn("npm")` dies at ENOENT, which took every
|
|
@@ -73781,12 +74121,12 @@ async function appPull(client, args) {
|
|
|
73781
74121
|
const targetPath = path9.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
|
|
73782
74122
|
fs8.mkdirSync(targetPath, { recursive: true });
|
|
73783
74123
|
const prior = readPriorStamp(targetPath);
|
|
73784
|
-
const tmpFile = path9.join(
|
|
74124
|
+
const tmpFile = path9.join(tmpdir2(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
|
|
73785
74125
|
console.error(`Downloading source archive...`);
|
|
73786
74126
|
await downloadToFile(sourceUrl, tmpFile);
|
|
73787
74127
|
const keptLocal = [];
|
|
73788
74128
|
const restoredFromArchive = [];
|
|
73789
|
-
const stagingDir = fs8.mkdtempSync(path9.join(
|
|
74129
|
+
const stagingDir = fs8.mkdtempSync(path9.join(tmpdir2(), `lotics-pull-${app.id}-`));
|
|
73790
74130
|
try {
|
|
73791
74131
|
await runTar(["-xzf", tmpFile, "-C", stagingDir], stagingDir);
|
|
73792
74132
|
console.error(`Extracting to ${targetPath}...`);
|
|
@@ -73925,8 +74265,8 @@ async function appDeploy(client, args) {
|
|
|
73925
74265
|
`Build did not produce a dist/ directory in ${projectDir}. Check that 'npm run build' is configured correctly (Vite/etc. defaults to dist/).`
|
|
73926
74266
|
);
|
|
73927
74267
|
}
|
|
73928
|
-
const tmpSource = path9.join(
|
|
73929
|
-
const tmpDist = path9.join(
|
|
74268
|
+
const tmpSource = path9.join(tmpdir2(), `lotics-source-${Date.now()}.tar.gz`);
|
|
74269
|
+
const tmpDist = path9.join(tmpdir2(), `lotics-dist-${Date.now()}.tar.gz`);
|
|
73930
74270
|
try {
|
|
73931
74271
|
note("Packaging source...");
|
|
73932
74272
|
await runTar(
|
|
@@ -74116,11 +74456,27 @@ async function appCheck(client, args = {}) {
|
|
|
74116
74456
|
console.error(err2 instanceof Error ? err2.message : String(err2));
|
|
74117
74457
|
typesFailing = true;
|
|
74118
74458
|
}
|
|
74119
|
-
|
|
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) {
|
|
74120
74476
|
process.exit(1);
|
|
74121
74477
|
}
|
|
74122
74478
|
console.error(
|
|
74123
|
-
"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."
|
|
74124
74480
|
);
|
|
74125
74481
|
}
|
|
74126
74482
|
async function countFailingWorkflowBodies(client, projectDir, meta3) {
|
|
@@ -74402,13 +74758,13 @@ function derivedDeployMessage(pending) {
|
|
|
74402
74758
|
];
|
|
74403
74759
|
return parts.length === 0 ? "Deployed app code" : `Deployed app code; pushed ${parts.join(", ")}`;
|
|
74404
74760
|
}
|
|
74405
|
-
function copyTree(from, to, opts,
|
|
74761
|
+
function copyTree(from, to, opts, relative2 = "") {
|
|
74406
74762
|
const kept = [];
|
|
74407
74763
|
const created = [];
|
|
74408
74764
|
for (const entry of fs8.readdirSync(from, { withFileTypes: true })) {
|
|
74409
74765
|
const src = path9.join(from, entry.name);
|
|
74410
74766
|
const dest = path9.join(to, entry.name);
|
|
74411
|
-
const rel = path9.join(
|
|
74767
|
+
const rel = path9.join(relative2, entry.name);
|
|
74412
74768
|
if (opts.skip?.includes(rel.split(path9.sep).join("/"))) continue;
|
|
74413
74769
|
if (entry.isDirectory()) {
|
|
74414
74770
|
fs8.mkdirSync(dest, { recursive: true });
|
|
@@ -75143,15 +75499,18 @@ function parseArgs(argv) {
|
|
|
75143
75499
|
port: void 0,
|
|
75144
75500
|
vitePort: void 0,
|
|
75145
75501
|
local: false,
|
|
75502
|
+
wait: false,
|
|
75146
75503
|
all: false,
|
|
75147
75504
|
yes: false,
|
|
75148
75505
|
printCreated: false,
|
|
75149
75506
|
cleanup: false,
|
|
75150
75507
|
prune: false,
|
|
75508
|
+
screens: false,
|
|
75151
75509
|
noSampleData: false,
|
|
75152
75510
|
adopt: false,
|
|
75153
75511
|
entity: [],
|
|
75154
75512
|
limit: void 0,
|
|
75513
|
+
tables: [],
|
|
75155
75514
|
bind: [],
|
|
75156
75515
|
version: false,
|
|
75157
75516
|
help: false
|
|
@@ -75169,6 +75528,11 @@ function parseArgs(argv) {
|
|
|
75169
75528
|
missingValue ??= { flag, expects };
|
|
75170
75529
|
return "";
|
|
75171
75530
|
};
|
|
75531
|
+
const takeList = (flag, expects) => {
|
|
75532
|
+
const parts = splitList(takeValue(flag, expects));
|
|
75533
|
+
if (parts.length === 0) missingValue ??= { flag, expects };
|
|
75534
|
+
return parts;
|
|
75535
|
+
};
|
|
75172
75536
|
while (i2 < argv.length) {
|
|
75173
75537
|
const arg = argv[i2];
|
|
75174
75538
|
if (subcommand !== void 0 && commandOwnsTail(command) && arg !== "--help" && arg !== "-h") {
|
|
@@ -75270,6 +75634,9 @@ function parseArgs(argv) {
|
|
|
75270
75634
|
case "--local":
|
|
75271
75635
|
flags.local = true;
|
|
75272
75636
|
break;
|
|
75637
|
+
case "--wait":
|
|
75638
|
+
flags.wait = true;
|
|
75639
|
+
break;
|
|
75273
75640
|
case "--all":
|
|
75274
75641
|
flags.all = true;
|
|
75275
75642
|
break;
|
|
@@ -75293,11 +75660,14 @@ function parseArgs(argv) {
|
|
|
75293
75660
|
// Comma-separated AND repeatable, like `--tags` above: an agent scripting
|
|
75294
75661
|
// this reaches for whichever form it knows, and both accumulate.
|
|
75295
75662
|
case "--entity":
|
|
75296
|
-
flags.entity = [...flags.entity, ...
|
|
75663
|
+
flags.entity = [...flags.entity, ...takeList(arg, "<alias>")];
|
|
75297
75664
|
break;
|
|
75298
75665
|
case "--limit":
|
|
75299
75666
|
flags.limit = takeValue(arg, "<n>");
|
|
75300
75667
|
break;
|
|
75668
|
+
case "--tables":
|
|
75669
|
+
flags.tables = [...flags.tables, ...takeList(arg, "<tbl_id,tbl_id>")];
|
|
75670
|
+
break;
|
|
75301
75671
|
// Repeatable and never split: the value's right half is a table or field
|
|
75302
75672
|
// NAME, which may hold a comma.
|
|
75303
75673
|
case "--bind":
|
|
@@ -75306,6 +75676,9 @@ function parseArgs(argv) {
|
|
|
75306
75676
|
case "--prune":
|
|
75307
75677
|
flags.prune = true;
|
|
75308
75678
|
break;
|
|
75679
|
+
case "--screens":
|
|
75680
|
+
flags.screens = true;
|
|
75681
|
+
break;
|
|
75309
75682
|
case "--version":
|
|
75310
75683
|
case "-v":
|
|
75311
75684
|
flags.version = true;
|
|
@@ -75898,10 +76271,10 @@ var packageContractSchema = zod_default.object({
|
|
|
75898
76271
|
// copy WARNS (advisory) when a name has no doc in the workspace.
|
|
75899
76272
|
knowledge_expects: zod_default.array(zod_default.string()).default([])
|
|
75900
76273
|
});
|
|
75901
|
-
function checkTraversalFilter(node,
|
|
76274
|
+
function checkTraversalFilter(node, path14, contextEntity, fieldByEntity, errors2) {
|
|
75902
76275
|
if (contextEntity === null) {
|
|
75903
76276
|
errors2.push({
|
|
75904
|
-
path:
|
|
76277
|
+
path: path14,
|
|
75905
76278
|
message: `traversal filter "${node.path[0]}" resolves only directly over a from_table`
|
|
75906
76279
|
});
|
|
75907
76280
|
return;
|
|
@@ -75911,14 +76284,14 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
|
|
|
75911
76284
|
const parsed2 = parseFieldKeyName(hop);
|
|
75912
76285
|
if (parsed2 === null) {
|
|
75913
76286
|
errors2.push({
|
|
75914
|
-
path:
|
|
76287
|
+
path: path14,
|
|
75915
76288
|
message: `traversal path[${index}] "${hop}" is not an entity.field alias`
|
|
75916
76289
|
});
|
|
75917
76290
|
return;
|
|
75918
76291
|
}
|
|
75919
76292
|
if (parsed2.entity !== current) {
|
|
75920
76293
|
errors2.push({
|
|
75921
|
-
path:
|
|
76294
|
+
path: path14,
|
|
75922
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}"`
|
|
75923
76296
|
});
|
|
75924
76297
|
return;
|
|
@@ -75926,14 +76299,14 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
|
|
|
75926
76299
|
const field = fieldByEntity.get(parsed2.entity)?.get(parsed2.field);
|
|
75927
76300
|
if (field === void 0) {
|
|
75928
76301
|
errors2.push({
|
|
75929
|
-
path:
|
|
76302
|
+
path: path14,
|
|
75930
76303
|
message: `traversal path[${index}] "${hop}" is not a field on entity "${parsed2.entity}"`
|
|
75931
76304
|
});
|
|
75932
76305
|
return;
|
|
75933
76306
|
}
|
|
75934
76307
|
if (field.type !== "select_record_link") {
|
|
75935
76308
|
errors2.push({
|
|
75936
|
-
path:
|
|
76309
|
+
path: path14,
|
|
75937
76310
|
message: `traversal path[${index}] "${hop}" is a ${field.type} field, not select_record_link`
|
|
75938
76311
|
});
|
|
75939
76312
|
return;
|
|
@@ -75945,7 +76318,7 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
|
|
|
75945
76318
|
const parsed = parseFieldKeyName(innerField);
|
|
75946
76319
|
if (parsed === null || parsed.entity !== current) {
|
|
75947
76320
|
errors2.push({
|
|
75948
|
-
path:
|
|
76321
|
+
path: path14,
|
|
75949
76322
|
message: `traversal condition "${innerField}" must be an entity.field alias on "${current}", the entity its path lands on`
|
|
75950
76323
|
});
|
|
75951
76324
|
return;
|
|
@@ -75953,12 +76326,12 @@ function checkTraversalFilter(node, path13, contextEntity, fieldByEntity, errors
|
|
|
75953
76326
|
const target = fieldByEntity.get(parsed.entity)?.get(parsed.field);
|
|
75954
76327
|
if (target === void 0) {
|
|
75955
76328
|
errors2.push({
|
|
75956
|
-
path:
|
|
76329
|
+
path: path14,
|
|
75957
76330
|
message: `traversal condition "${innerField}" is not a field on entity "${parsed.entity}"`
|
|
75958
76331
|
});
|
|
75959
76332
|
return;
|
|
75960
76333
|
}
|
|
75961
|
-
checkConditionShape(node.condition, parsed.entity, target,
|
|
76334
|
+
checkConditionShape(node.condition, parsed.entity, target, path14, fieldByEntity, errors2);
|
|
75962
76335
|
}
|
|
75963
76336
|
function contractFilterType(entityAlias, field, fieldByEntity, hops = 0) {
|
|
75964
76337
|
if (field.type === "formula") {
|
|
@@ -75974,12 +76347,12 @@ function contractFilterType(entityAlias, field, fieldByEntity, hops = 0) {
|
|
|
75974
76347
|
}
|
|
75975
76348
|
return resolveFilterType(field);
|
|
75976
76349
|
}
|
|
75977
|
-
function checkConditionShape(condition, entityAlias, field,
|
|
76350
|
+
function checkConditionShape(condition, entityAlias, field, path14, fieldByEntity, errors2) {
|
|
75978
76351
|
const type = contractFilterType(entityAlias, field, fieldByEntity);
|
|
75979
76352
|
if (type === void 0 && condition.type === void 0) return;
|
|
75980
76353
|
const typed = type === void 0 ? condition : { ...condition, type };
|
|
75981
|
-
for (const message2 of validateFilterCondition(typed,
|
|
75982
|
-
errors2.push({ path:
|
|
76354
|
+
for (const message2 of validateFilterCondition(typed, path14)) {
|
|
76355
|
+
errors2.push({ path: path14, message: message2.startsWith(`${path14}: `) ? message2.slice(path14.length + 2) : message2 });
|
|
75983
76356
|
}
|
|
75984
76357
|
if (field.type !== "select") return;
|
|
75985
76358
|
const declared = new Set(field.options.map((option) => option.alias));
|
|
@@ -75987,20 +76360,20 @@ function checkConditionShape(condition, entityAlias, field, path13, fieldByEntit
|
|
|
75987
76360
|
for (const raw of values3) {
|
|
75988
76361
|
if (typeof raw !== "string" || isTemplateExpression(raw) || declared.has(raw)) continue;
|
|
75989
76362
|
errors2.push({
|
|
75990
|
-
path:
|
|
76363
|
+
path: path14,
|
|
75991
76364
|
message: `filters field "${field.alias}" on option "${raw}", which that field does not declare (${declared.size > 0 ? [...declared].join(", ") : "it declares none"})`
|
|
75992
76365
|
});
|
|
75993
76366
|
}
|
|
75994
76367
|
}
|
|
75995
|
-
function checkFilterFields(filters,
|
|
76368
|
+
function checkFilterFields(filters, path14, contextEntity, fieldByEntity, errors2, checkField) {
|
|
75996
76369
|
if (filters.node_type === "group") {
|
|
75997
76370
|
for (const child of filters.children) {
|
|
75998
|
-
checkFilterFields(child,
|
|
76371
|
+
checkFilterFields(child, path14, contextEntity, fieldByEntity, errors2, checkField);
|
|
75999
76372
|
}
|
|
76000
76373
|
return;
|
|
76001
76374
|
}
|
|
76002
76375
|
if (filters.node_type === "traversal") {
|
|
76003
|
-
checkTraversalFilter(filters,
|
|
76376
|
+
checkTraversalFilter(filters, path14, contextEntity, fieldByEntity, errors2);
|
|
76004
76377
|
return;
|
|
76005
76378
|
}
|
|
76006
76379
|
if (filters.type === "current_member") return;
|
|
@@ -76008,13 +76381,13 @@ function checkFilterFields(filters, path13, contextEntity, fieldByEntity, errors
|
|
|
76008
76381
|
checkField(filters.field_key);
|
|
76009
76382
|
if (contextEntity === null) return;
|
|
76010
76383
|
const field = fieldByEntity.get(contextEntity)?.get(filters.field_key);
|
|
76011
|
-
if (field !== void 0) checkConditionShape(filters, contextEntity, field,
|
|
76384
|
+
if (field !== void 0) checkConditionShape(filters, contextEntity, field, path14, fieldByEntity, errors2);
|
|
76012
76385
|
}
|
|
76013
|
-
function checkInputRefs(input,
|
|
76386
|
+
function checkInputRefs(input, path14, entityAliases, roleAliases, optionAliases, fieldAliases, errors2) {
|
|
76014
76387
|
if (input.type === "record_link") {
|
|
76015
76388
|
if (!entityAliases.has(input.table_id)) {
|
|
76016
76389
|
errors2.push({
|
|
76017
|
-
path:
|
|
76390
|
+
path: path14,
|
|
76018
76391
|
message: `record_link input references entity "${input.table_id}" which is not a declared entity`
|
|
76019
76392
|
});
|
|
76020
76393
|
}
|
|
@@ -76023,7 +76396,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
|
|
|
76023
76396
|
if (input.type === "member") {
|
|
76024
76397
|
if (input.group !== void 0 && !roleAliases.has(input.group)) {
|
|
76025
76398
|
errors2.push({
|
|
76026
|
-
path:
|
|
76399
|
+
path: path14,
|
|
76027
76400
|
message: `member input references role "${input.group}" which is not a declared role`
|
|
76028
76401
|
});
|
|
76029
76402
|
}
|
|
@@ -76033,7 +76406,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
|
|
|
76033
76406
|
if (input.field !== void 0) {
|
|
76034
76407
|
if (!fieldAliases.has(input.field)) {
|
|
76035
76408
|
errors2.push({
|
|
76036
|
-
path:
|
|
76409
|
+
path: path14,
|
|
76037
76410
|
message: `select input references field "${input.field}" which is not a declared field alias (entity.field)`
|
|
76038
76411
|
});
|
|
76039
76412
|
}
|
|
@@ -76042,7 +76415,7 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
|
|
|
76042
76415
|
for (const option of input.options ?? []) {
|
|
76043
76416
|
if (option.value.includes(":") && !optionAliases.has(option.value)) {
|
|
76044
76417
|
errors2.push({
|
|
76045
|
-
path:
|
|
76418
|
+
path: path14,
|
|
76046
76419
|
message: `select input option value "${option.value}" is not a declared option alias (entity.field:opt)`
|
|
76047
76420
|
});
|
|
76048
76421
|
}
|
|
@@ -76051,19 +76424,19 @@ function checkInputRefs(input, path13, entityAliases, roleAliases, optionAliases
|
|
|
76051
76424
|
}
|
|
76052
76425
|
if (input.type === "object") {
|
|
76053
76426
|
for (const [key, child] of Object.entries(input.fields)) {
|
|
76054
|
-
checkInputRefs(child, `${
|
|
76427
|
+
checkInputRefs(child, `${path14}.${key}`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
|
|
76055
76428
|
}
|
|
76056
76429
|
return;
|
|
76057
76430
|
}
|
|
76058
76431
|
if (input.type === "array") {
|
|
76059
|
-
checkInputRefs(input.items, `${
|
|
76432
|
+
checkInputRefs(input.items, `${path14}[]`, entityAliases, roleAliases, optionAliases, fieldAliases, errors2);
|
|
76060
76433
|
}
|
|
76061
76434
|
}
|
|
76062
|
-
function checkOutputRefs(output,
|
|
76435
|
+
function checkOutputRefs(output, path14, entityAliases, optionAliases, fieldAliases, errors2) {
|
|
76063
76436
|
if (output.type === "record_link") {
|
|
76064
76437
|
if (!entityAliases.has(output.table_id)) {
|
|
76065
76438
|
errors2.push({
|
|
76066
|
-
path:
|
|
76439
|
+
path: path14,
|
|
76067
76440
|
message: `record_link output references entity "${output.table_id}" which is not a declared entity`
|
|
76068
76441
|
});
|
|
76069
76442
|
}
|
|
@@ -76073,7 +76446,7 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
|
|
|
76073
76446
|
if (output.field !== void 0) {
|
|
76074
76447
|
if (!fieldAliases.has(output.field)) {
|
|
76075
76448
|
errors2.push({
|
|
76076
|
-
path:
|
|
76449
|
+
path: path14,
|
|
76077
76450
|
message: `select output references field "${output.field}" which is not a declared field alias (entity.field)`
|
|
76078
76451
|
});
|
|
76079
76452
|
}
|
|
@@ -76082,7 +76455,7 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
|
|
|
76082
76455
|
for (const option of output.options ?? []) {
|
|
76083
76456
|
if (option.value.includes(":") && !optionAliases.has(option.value)) {
|
|
76084
76457
|
errors2.push({
|
|
76085
|
-
path:
|
|
76458
|
+
path: path14,
|
|
76086
76459
|
message: `select output option value "${option.value}" is not a declared option alias (entity.field:opt)`
|
|
76087
76460
|
});
|
|
76088
76461
|
}
|
|
@@ -76091,16 +76464,16 @@ function checkOutputRefs(output, path13, entityAliases, optionAliases, fieldAlia
|
|
|
76091
76464
|
}
|
|
76092
76465
|
if (output.type === "object") {
|
|
76093
76466
|
for (const [key, child] of Object.entries(output.fields)) {
|
|
76094
|
-
checkOutputRefs(child, `${
|
|
76467
|
+
checkOutputRefs(child, `${path14}.${key}`, entityAliases, optionAliases, fieldAliases, errors2);
|
|
76095
76468
|
}
|
|
76096
76469
|
return;
|
|
76097
76470
|
}
|
|
76098
76471
|
if (output.type === "array") {
|
|
76099
|
-
checkOutputRefs(output.items, `${
|
|
76472
|
+
checkOutputRefs(output.items, `${path14}[]`, entityAliases, optionAliases, fieldAliases, errors2);
|
|
76100
76473
|
}
|
|
76101
76474
|
}
|
|
76102
|
-
function checkSourceLinks(source,
|
|
76103
|
-
const recur = (child) => checkSourceLinks(child,
|
|
76475
|
+
function checkSourceLinks(source, path14, parentEntity, fieldByEntity, errors2) {
|
|
76476
|
+
const recur = (child) => checkSourceLinks(child, path14, parentEntity, fieldByEntity, errors2);
|
|
76104
76477
|
if (typeof source === "string") return;
|
|
76105
76478
|
if ("eq" in source) return source.eq.forEach(recur);
|
|
76106
76479
|
if ("neq" in source) return source.neq.forEach(recur);
|
|
@@ -76111,7 +76484,7 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
|
|
|
76111
76484
|
if (!("link" in source)) return;
|
|
76112
76485
|
if (parentEntity === null) {
|
|
76113
76486
|
errors2.push({
|
|
76114
|
-
path:
|
|
76487
|
+
path: path14,
|
|
76115
76488
|
message: `link source "${source.link.source}" resolves only directly over a from_table`
|
|
76116
76489
|
});
|
|
76117
76490
|
return;
|
|
@@ -76121,14 +76494,14 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
|
|
|
76121
76494
|
const sourceField = parentFields.get(source.link.source);
|
|
76122
76495
|
if (sourceField === void 0) {
|
|
76123
76496
|
errors2.push({
|
|
76124
|
-
path:
|
|
76497
|
+
path: path14,
|
|
76125
76498
|
message: `link source "${source.link.source}" is not a field on entity "${parentEntity}"`
|
|
76126
76499
|
});
|
|
76127
76500
|
return;
|
|
76128
76501
|
}
|
|
76129
76502
|
if (sourceField.type !== "select_record_link") {
|
|
76130
76503
|
errors2.push({
|
|
76131
|
-
path:
|
|
76504
|
+
path: path14,
|
|
76132
76505
|
message: `link source "${source.link.source}" on entity "${parentEntity}" is a ${sourceField.type} field, not select_record_link`
|
|
76133
76506
|
});
|
|
76134
76507
|
return;
|
|
@@ -76137,68 +76510,68 @@ function checkSourceLinks(source, path13, parentEntity, fieldByEntity, errors2)
|
|
|
76137
76510
|
if (targetFields === void 0) return;
|
|
76138
76511
|
if (!targetFields.has(source.link.field)) {
|
|
76139
76512
|
errors2.push({
|
|
76140
|
-
path:
|
|
76513
|
+
path: path14,
|
|
76141
76514
|
message: `link field "${source.link.field}" is not a field on target entity "${sourceField.target_entity}"`
|
|
76142
76515
|
});
|
|
76143
76516
|
}
|
|
76144
76517
|
}
|
|
76145
|
-
function checkQueryFilterTraversals(filters,
|
|
76146
|
-
checkFilterFields(filters,
|
|
76518
|
+
function checkQueryFilterTraversals(filters, path14, contextEntity, fieldByEntity, errors2) {
|
|
76519
|
+
checkFilterFields(filters, path14, contextEntity, fieldByEntity, errors2, () => {
|
|
76147
76520
|
});
|
|
76148
76521
|
}
|
|
76149
|
-
function checkQueryEntityRefs(node,
|
|
76522
|
+
function checkQueryEntityRefs(node, path14, entityAliases, fieldByEntity, errors2) {
|
|
76150
76523
|
const recur = (child, childPath) => checkQueryEntityRefs(child, childPath, entityAliases, fieldByEntity, errors2);
|
|
76151
76524
|
const linkParent = (from) => from.kind === "from_table" ? from.from_entity : null;
|
|
76152
76525
|
switch (node.kind) {
|
|
76153
76526
|
case "from_table":
|
|
76154
76527
|
if (!entityAliases.has(node.from_entity)) {
|
|
76155
76528
|
errors2.push({
|
|
76156
|
-
path:
|
|
76529
|
+
path: path14,
|
|
76157
76530
|
message: `from_table references entity "${node.from_entity}" which is not a declared entity`
|
|
76158
76531
|
});
|
|
76159
76532
|
return;
|
|
76160
76533
|
}
|
|
76161
76534
|
if (node.filter !== void 0) {
|
|
76162
|
-
checkQueryFilterTraversals(node.filter, `${
|
|
76535
|
+
checkQueryFilterTraversals(node.filter, `${path14}.filter`, node.from_entity, fieldByEntity, errors2);
|
|
76163
76536
|
}
|
|
76164
76537
|
return;
|
|
76165
76538
|
case "filter":
|
|
76166
76539
|
checkQueryFilterTraversals(
|
|
76167
76540
|
node.predicate,
|
|
76168
|
-
`${
|
|
76541
|
+
`${path14}.predicate`,
|
|
76169
76542
|
node.from.kind === "from_table" ? node.from.from_entity : null,
|
|
76170
76543
|
fieldByEntity,
|
|
76171
76544
|
errors2
|
|
76172
76545
|
);
|
|
76173
|
-
recur(node.from, `${
|
|
76546
|
+
recur(node.from, `${path14}.from`);
|
|
76174
76547
|
return;
|
|
76175
76548
|
case "join":
|
|
76176
|
-
recur(node.left, `${
|
|
76177
|
-
recur(node.right, `${
|
|
76549
|
+
recur(node.left, `${path14}.left`);
|
|
76550
|
+
recur(node.right, `${path14}.right`);
|
|
76178
76551
|
return;
|
|
76179
76552
|
case "union":
|
|
76180
|
-
node.sources.forEach((source, i2) => recur(source, `${
|
|
76553
|
+
node.sources.forEach((source, i2) => recur(source, `${path14}.sources[${i2}]`));
|
|
76181
76554
|
return;
|
|
76182
76555
|
case "project":
|
|
76183
76556
|
node.columns.forEach((column, i2) => {
|
|
76184
76557
|
if (typeof column === "string") return;
|
|
76185
|
-
checkSourceLinks(column.source, `${
|
|
76558
|
+
checkSourceLinks(column.source, `${path14}.columns[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
|
|
76186
76559
|
});
|
|
76187
|
-
recur(node.from, `${
|
|
76560
|
+
recur(node.from, `${path14}.from`);
|
|
76188
76561
|
return;
|
|
76189
76562
|
case "unpivot":
|
|
76190
76563
|
node.passthrough.forEach((column, i2) => {
|
|
76191
|
-
checkSourceLinks(column.source, `${
|
|
76564
|
+
checkSourceLinks(column.source, `${path14}.passthrough[${i2}]`, linkParent(node.from), fieldByEntity, errors2);
|
|
76192
76565
|
});
|
|
76193
76566
|
node.rows.forEach((row, i2) => {
|
|
76194
76567
|
for (const [key, source] of Object.entries(row)) {
|
|
76195
|
-
checkSourceLinks(source, `${
|
|
76568
|
+
checkSourceLinks(source, `${path14}.rows[${i2}].${key}`, linkParent(node.from), fieldByEntity, errors2);
|
|
76196
76569
|
}
|
|
76197
76570
|
});
|
|
76198
|
-
recur(node.from, `${
|
|
76571
|
+
recur(node.from, `${path14}.from`);
|
|
76199
76572
|
return;
|
|
76200
76573
|
default:
|
|
76201
|
-
recur(node.from, `${
|
|
76574
|
+
recur(node.from, `${path14}.from`);
|
|
76202
76575
|
}
|
|
76203
76576
|
}
|
|
76204
76577
|
var WORKFLOW_BODY_TOKEN_PATTERN = /@@(entity|field|option|role|template):([^@]+)@@/g;
|
|
@@ -76702,18 +77075,371 @@ var bindingDriftSchema = zod_default.object({
|
|
|
76702
77075
|
id: zod_default.string().describe("The stale concrete id the alias was bound to")
|
|
76703
77076
|
});
|
|
76704
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
|
+
|
|
76705
77429
|
// ../shared/src/schemas/workspace_model.ts
|
|
76706
77430
|
var workspaceModelContractSchema = packageContractSchema.omit({ apps: true, fixtures: true, knowledge: true, knowledge_expects: true }).extend({
|
|
76707
77431
|
entities: zod_default.array(contractEntitySchema).describe("The tables this model creates, with their fields, options and views"),
|
|
76708
77432
|
templates: zod_default.array(contractInlineTemplateSchema).default([]).describe("Document templates whose content travels inline (html or email)")
|
|
76709
77433
|
}).strict();
|
|
76710
77434
|
var modelApplyEntrySchema = zod_default.object({
|
|
76711
|
-
|
|
77435
|
+
package: zod_default.string().min(1).describe("The package id to copy in"),
|
|
76712
77436
|
bind: packageBindSchema.optional().describe("Entity alias \u2192 the labels this workspace calls that entity and its fields"),
|
|
76713
77437
|
no_sample_data: zod_default.boolean().optional().describe("Decline the package's sample records")
|
|
76714
77438
|
}).strict();
|
|
76715
77439
|
var workspaceModelFileSchema = workspaceModelContractSchema.extend({
|
|
76716
77440
|
rows: contractRowsSchema.optional(),
|
|
77441
|
+
field_roles: fieldRolesSchema.optional(),
|
|
77442
|
+
apps: zod_default.array(modelAppSchema).optional(),
|
|
76717
77443
|
preset: modelPresetSchema.optional(),
|
|
76718
77444
|
apply: zod_default.array(modelApplyEntrySchema).optional()
|
|
76719
77445
|
}).strict();
|
|
@@ -76723,6 +77449,8 @@ var workspaceModelOverlaySchema = zod_default.object({
|
|
|
76723
77449
|
rename: packageBindSchema.optional().describe("Entity alias \u2192 what this business calls that table and its fields"),
|
|
76724
77450
|
entities: zod_default.array(contractEntitySchema).optional().describe("Tables this business has that the preset does not declare"),
|
|
76725
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(),
|
|
76726
77454
|
apply: zod_default.array(modelApplyEntrySchema).optional()
|
|
76727
77455
|
}).strict();
|
|
76728
77456
|
function parseModelFile(raw) {
|
|
@@ -76781,13 +77509,25 @@ function resolveModelFrom(preset, overlay) {
|
|
|
76781
77509
|
}))
|
|
76782
77510
|
};
|
|
76783
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
|
+
}
|
|
76784
77522
|
return {
|
|
76785
77523
|
type: "resolved",
|
|
76786
77524
|
file: {
|
|
76787
77525
|
entities: [...bound.contract.entities, ...overlay.entities ?? []],
|
|
76788
77526
|
roles: contract.roles,
|
|
76789
77527
|
templates: contract.templates,
|
|
77528
|
+
...Object.keys(field_roles).length > 0 ? { field_roles } : {},
|
|
76790
77529
|
...overlay.rows !== void 0 ? { rows: overlay.rows } : {},
|
|
77530
|
+
...overlay.apps !== void 0 ? { apps: overlay.apps } : {},
|
|
76791
77531
|
...overlay.apply !== void 0 ? { apply: overlay.apply } : {}
|
|
76792
77532
|
}
|
|
76793
77533
|
};
|
|
@@ -76797,9 +77537,29 @@ function toPackageContract(model) {
|
|
|
76797
77537
|
}
|
|
76798
77538
|
var ENTITIES_MAX = 50;
|
|
76799
77539
|
var MODEL_ROWS_MAX = 2e3;
|
|
77540
|
+
var MODEL_DOCUMENTS_MAX = 2e3;
|
|
76800
77541
|
function asList(value2) {
|
|
76801
77542
|
return Array.isArray(value2) ? value2 : [value2];
|
|
76802
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
|
+
}
|
|
76803
77563
|
function quote(value2) {
|
|
76804
77564
|
return typeof value2 === "string" ? `"${value2}"` : `a ${typeof value2}`;
|
|
76805
77565
|
}
|
|
@@ -76830,29 +77590,29 @@ function validateModelRows(model, rows) {
|
|
|
76830
77590
|
}
|
|
76831
77591
|
seenRefs.add(row.ref);
|
|
76832
77592
|
for (const [fieldAlias, value2] of Object.entries(row.fields)) {
|
|
76833
|
-
const
|
|
77593
|
+
const path14 = `rows.${alias}.${row.ref}.${fieldAlias}`;
|
|
76834
77594
|
const field = fieldByAlias.get(fieldAlias);
|
|
76835
77595
|
if (field === void 0) {
|
|
76836
77596
|
errors2.push({
|
|
76837
|
-
path:
|
|
77597
|
+
path: path14,
|
|
76838
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"})`
|
|
76839
77599
|
});
|
|
76840
77600
|
continue;
|
|
76841
77601
|
}
|
|
76842
|
-
checkValue(field, value2,
|
|
77602
|
+
checkValue(field, value2, path14, refs, errors2);
|
|
76843
77603
|
}
|
|
76844
77604
|
}
|
|
76845
77605
|
}
|
|
76846
77606
|
return errors2;
|
|
76847
77607
|
}
|
|
76848
|
-
function checkValue(field, value2,
|
|
77608
|
+
function checkValue(field, value2, path14, refs, errors2) {
|
|
76849
77609
|
switch (field.type) {
|
|
76850
77610
|
case "select": {
|
|
76851
77611
|
const declared = new Set(field.options.map((option) => option.alias));
|
|
76852
77612
|
for (const raw of asList(value2)) {
|
|
76853
77613
|
if (typeof raw !== "string" || !declared.has(raw)) {
|
|
76854
77614
|
errors2.push({
|
|
76855
|
-
path:
|
|
77615
|
+
path: path14,
|
|
76856
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"})`
|
|
76857
77617
|
});
|
|
76858
77618
|
}
|
|
@@ -76863,7 +77623,7 @@ function checkValue(field, value2, path13, refs, errors2) {
|
|
|
76863
77623
|
for (const raw of asList(value2)) {
|
|
76864
77624
|
if (typeof raw !== "string" || !refs.has(raw)) {
|
|
76865
77625
|
errors2.push({
|
|
76866
|
-
path:
|
|
77626
|
+
path: path14,
|
|
76867
77627
|
message: `${quote(raw)} names no row in this request \u2014 use "<entity-alias>:<ref>"`
|
|
76868
77628
|
});
|
|
76869
77629
|
continue;
|
|
@@ -76871,7 +77631,7 @@ function checkValue(field, value2, path13, refs, errors2) {
|
|
|
76871
77631
|
const named = raw.slice(0, raw.indexOf(":"));
|
|
76872
77632
|
if (named === field.target_entity) continue;
|
|
76873
77633
|
errors2.push({
|
|
76874
|
-
path:
|
|
77634
|
+
path: path14,
|
|
76875
77635
|
message: `names a row of entity "${named}", but this field links to "${field.target_entity}"`
|
|
76876
77636
|
});
|
|
76877
77637
|
}
|
|
@@ -76880,17 +77640,23 @@ function checkValue(field, value2, path13, refs, errors2) {
|
|
|
76880
77640
|
case "select_member": {
|
|
76881
77641
|
if (value2 !== "self") {
|
|
76882
77642
|
errors2.push({
|
|
76883
|
-
path:
|
|
77643
|
+
path: path14,
|
|
76884
77644
|
message: `${quote(value2)} is not a member this model can name \u2014 use "self"`
|
|
76885
77645
|
});
|
|
76886
77646
|
}
|
|
76887
77647
|
return;
|
|
76888
77648
|
}
|
|
76889
77649
|
case "files": {
|
|
76890
|
-
|
|
76891
|
-
|
|
76892
|
-
|
|
76893
|
-
|
|
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
|
+
}
|
|
76894
77660
|
return;
|
|
76895
77661
|
}
|
|
76896
77662
|
case "formula":
|
|
@@ -76898,7 +77664,7 @@ function checkValue(field, value2, path13, refs, errors2) {
|
|
|
76898
77664
|
case "lookup":
|
|
76899
77665
|
case "autonumber": {
|
|
76900
77666
|
errors2.push({
|
|
76901
|
-
path:
|
|
77667
|
+
path: path14,
|
|
76902
77668
|
message: `is a ${field.type} field, which the platform computes \u2014 drop the value`
|
|
76903
77669
|
});
|
|
76904
77670
|
return;
|
|
@@ -76906,13 +77672,13 @@ function checkValue(field, value2, path13, refs, errors2) {
|
|
|
76906
77672
|
default: {
|
|
76907
77673
|
if (!isFixtureScalarValue(value2)) {
|
|
76908
77674
|
errors2.push({
|
|
76909
|
-
path:
|
|
77675
|
+
path: path14,
|
|
76910
77676
|
message: `must be a string, number, boolean or null on a ${field.type} field`
|
|
76911
77677
|
});
|
|
76912
77678
|
return;
|
|
76913
77679
|
}
|
|
76914
77680
|
if (field.type === "date" && parseFixtureDateExpression(value2).kind === "invalid") {
|
|
76915
|
-
errors2.push({ path:
|
|
77681
|
+
errors2.push({ path: path14, message: `${quote(value2)} is not a date \u2014 use ${FIXTURE_DATE_GRAMMAR}` });
|
|
76916
77682
|
}
|
|
76917
77683
|
}
|
|
76918
77684
|
}
|
|
@@ -76946,6 +77712,13 @@ function validateWorkspaceModel(model, rows) {
|
|
|
76946
77712
|
message: `carries ${total} rows, above the ${MODEL_ROWS_MAX}-row cap for one model \u2014 a real data set belongs in an import`
|
|
76947
77713
|
});
|
|
76948
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
|
+
}
|
|
76949
77722
|
errors2.push(...validateModelRows(model, rows));
|
|
76950
77723
|
return errors2;
|
|
76951
77724
|
}
|
|
@@ -76986,14 +77759,14 @@ function validateModelPreset(model, preset, rows) {
|
|
|
76986
77759
|
}
|
|
76987
77760
|
return errors2;
|
|
76988
77761
|
}
|
|
76989
|
-
function unknownModelKeys(raw, kept,
|
|
77762
|
+
function unknownModelKeys(raw, kept, path14 = "") {
|
|
76990
77763
|
if (Array.isArray(raw)) {
|
|
76991
77764
|
if (!Array.isArray(kept)) return [];
|
|
76992
|
-
return raw.flatMap((item, i2) => unknownModelKeys(item, kept[i2],
|
|
77765
|
+
return raw.flatMap((item, i2) => unknownModelKeys(item, kept[i2], path14 === "" ? String(i2) : `${path14}.${i2}`));
|
|
76993
77766
|
}
|
|
76994
77767
|
if (!isPlainObject2(raw) || !isPlainObject2(kept)) return [];
|
|
76995
77768
|
return Object.keys(raw).flatMap((key) => {
|
|
76996
|
-
const here =
|
|
77769
|
+
const here = path14 === "" ? key : `${path14}.${key}`;
|
|
76997
77770
|
if (!(key in kept)) return [{ path: here, message: "unknown key \u2014 not part of the model" }];
|
|
76998
77771
|
return unknownModelKeys(raw[key], kept[key], here);
|
|
76999
77772
|
});
|
|
@@ -77010,16 +77783,18 @@ function readPresetModel(raw) {
|
|
|
77010
77783
|
findings: [{ path: "from", message: "a preset IS the model it publishes, and this file names `from`" }]
|
|
77011
77784
|
};
|
|
77012
77785
|
}
|
|
77013
|
-
const { preset, rows, apply, ...model } = parsed.parsed.file;
|
|
77786
|
+
const { preset, rows, apps, apply, field_roles = {}, ...model } = parsed.parsed.file;
|
|
77014
77787
|
const shape = [
|
|
77015
77788
|
...preset === void 0 ? [{ path: "preset", message: "a preset file carries a `preset` block \u2014 name, description, questions, variants" }] : [],
|
|
77016
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" }],
|
|
77017
77791
|
...apply === void 0 ? [] : [{ path: "apply", message: "a preset carries no `apply` \u2014 the packages to copy in are that reader's to name" }]
|
|
77018
77792
|
];
|
|
77019
77793
|
if (preset === void 0 || shape.length > 0) return { type: "invalid", findings: shape };
|
|
77020
77794
|
const findings = [
|
|
77021
77795
|
...unknownModelKeys(raw, parsed.parsed.file),
|
|
77022
77796
|
...validateWorkspaceModel(model, {}),
|
|
77797
|
+
...checkFieldRoles(model.entities, field_roles),
|
|
77023
77798
|
...validateModelPreset(model, preset, {})
|
|
77024
77799
|
];
|
|
77025
77800
|
if (findings.length > 0) return { type: "invalid", findings };
|
|
@@ -77476,9 +78251,10 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
77476
78251
|
|
|
77477
78252
|
// src/scaffold_commands.ts
|
|
77478
78253
|
import fs10 from "node:fs";
|
|
78254
|
+
import path11 from "node:path";
|
|
77479
78255
|
|
|
77480
78256
|
// src/model_reference.md
|
|
77481
|
-
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 "starter": "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## 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';
|
|
77482
78258
|
|
|
77483
78259
|
// src/scaffold_commands.ts
|
|
77484
78260
|
function printModelReference() {
|
|
@@ -77487,10 +78263,10 @@ function printModelReference() {
|
|
|
77487
78263
|
function count(n, noun) {
|
|
77488
78264
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
77489
78265
|
}
|
|
77490
|
-
function issuePath(
|
|
77491
|
-
return
|
|
78266
|
+
function issuePath(path14) {
|
|
78267
|
+
return path14.length === 0 ? "the file" : path14.map((segment) => String(segment)).join(".");
|
|
77492
78268
|
}
|
|
77493
|
-
function checkWorkspaceModel(raw) {
|
|
78269
|
+
function checkWorkspaceModel(raw, options = {}) {
|
|
77494
78270
|
const parsed = workspaceModelFileSchema.safeParse(raw);
|
|
77495
78271
|
if (!parsed.success) {
|
|
77496
78272
|
return {
|
|
@@ -77501,28 +78277,87 @@ function checkWorkspaceModel(raw) {
|
|
|
77501
78277
|
model: null
|
|
77502
78278
|
};
|
|
77503
78279
|
}
|
|
77504
|
-
const { rows = {}, apply = [], preset, ...contract } = parsed.data;
|
|
78280
|
+
const { rows = {}, field_roles = {}, apps = [], apply = [], preset, ...contract } = parsed.data;
|
|
77505
78281
|
const findings = [
|
|
77506
78282
|
// What the parse dropped is a mistake the author cannot otherwise see.
|
|
77507
78283
|
...unknownModelKeys(raw, parsed.data),
|
|
77508
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),
|
|
77509
78289
|
// Every BRANCH of a preset, applied onto the base and checked as a model of
|
|
77510
78290
|
// its own — the branch nobody took is the one that fails in the workspace of
|
|
77511
78291
|
// whoever takes it, who is the one reader who cannot fix it.
|
|
77512
|
-
...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)
|
|
77513
78298
|
];
|
|
77514
78299
|
if (findings.length > 0) return { findings, model: null };
|
|
77515
|
-
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
|
+
);
|
|
77516
78347
|
}
|
|
77517
78348
|
function countModel(model) {
|
|
77518
78349
|
const fields = model.contract.entities.flatMap((entity) => entity.fields);
|
|
78350
|
+
const screens = model.apps.flatMap((app) => app.screens);
|
|
77519
78351
|
return {
|
|
77520
78352
|
tables: model.contract.entities.length,
|
|
77521
78353
|
fields: fields.length,
|
|
77522
78354
|
links: fields.filter((field) => field.type === "select_record_link").length,
|
|
77523
78355
|
views: model.contract.entities.reduce((n, entity) => n + entity.views.length, 0),
|
|
77524
78356
|
roles: model.contract.roles.length,
|
|
77525
|
-
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
|
|
77526
78361
|
};
|
|
77527
78362
|
}
|
|
77528
78363
|
function summarizeModel(model) {
|
|
@@ -77533,13 +78368,65 @@ function summarizeModel(model) {
|
|
|
77533
78368
|
count(counts.links, "link"),
|
|
77534
78369
|
count(counts.views, "view"),
|
|
77535
78370
|
count(counts.roles, "role"),
|
|
77536
|
-
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`] : []
|
|
77537
78374
|
].join(", ");
|
|
77538
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
|
+
}
|
|
77539
78425
|
var presetSourceSchema = zod_default.object({
|
|
77540
78426
|
entities: zod_default.array(contractEntitySchema),
|
|
77541
78427
|
roles: zod_default.array(contractRoleSchema),
|
|
77542
78428
|
templates: zod_default.array(contractInlineTemplateSchema),
|
|
78429
|
+
field_roles: fieldRolesSchema.default({}),
|
|
77543
78430
|
preset: modelPresetSchema
|
|
77544
78431
|
});
|
|
77545
78432
|
async function resolveOverlay(file2, raw, overlay) {
|
|
@@ -77571,7 +78458,7 @@ async function resolveOverlay(file2, raw, overlay) {
|
|
|
77571
78458
|
const { preset, ...contract } = source.data;
|
|
77572
78459
|
const resolved = resolveModelFrom({ ...contract, variants: preset.variants }, overlay);
|
|
77573
78460
|
if (resolved.type === "invalid") return { kind: "error", findings: resolved.findings };
|
|
77574
|
-
const { findings, model } = checkWorkspaceModel(resolved.file);
|
|
78461
|
+
const { findings, model } = checkWorkspaceModel(resolved.file, { baseDir: path11.dirname(path11.resolve(file2)) });
|
|
77575
78462
|
const dropped = unknownModelKeys(raw, overlay);
|
|
77576
78463
|
if (model === null || dropped.length > 0) {
|
|
77577
78464
|
return { kind: "error", findings: [...dropped, ...findings] };
|
|
@@ -77623,7 +78510,7 @@ async function checkModelFile(file2) {
|
|
|
77623
78510
|
if (parsed.parsed.form === "from") {
|
|
77624
78511
|
return await resolveOverlay(file2, raw, parsed.parsed.overlay);
|
|
77625
78512
|
}
|
|
77626
|
-
const { findings, model } = checkWorkspaceModel(raw);
|
|
78513
|
+
const { findings, model } = checkWorkspaceModel(raw, { baseDir: path11.dirname(path11.resolve(file2)) });
|
|
77627
78514
|
if (model === null) return { kind: "error", findings };
|
|
77628
78515
|
return { kind: "ok", model };
|
|
77629
78516
|
}
|
|
@@ -77635,6 +78522,23 @@ function reportModelFindings(file2, findings) {
|
|
|
77635
78522
|
The model's shape, every field type and a worked example: lotics scaffold docs`
|
|
77636
78523
|
);
|
|
77637
78524
|
}
|
|
78525
|
+
async function scaffoldExport(client, opts = {}) {
|
|
78526
|
+
const { contract, findings } = await client.exportWorkspaceModel(opts);
|
|
78527
|
+
const file2 = { entities: contract.entities };
|
|
78528
|
+
if (contract.roles.length > 0) file2.roles = contract.roles;
|
|
78529
|
+
if (contract.templates.length > 0) file2.templates = contract.templates;
|
|
78530
|
+
process.stdout.write(`${JSON.stringify(file2, null, 2)}
|
|
78531
|
+
`);
|
|
78532
|
+
for (const finding of findings) {
|
|
78533
|
+
console.error(` \u2022 ${finding.severity} ${finding.area}: ${finding.message}`);
|
|
78534
|
+
}
|
|
78535
|
+
const errors2 = findings.filter((finding) => finding.severity === "error").length;
|
|
78536
|
+
if (errors2 > 0) {
|
|
78537
|
+
console.error(`
|
|
78538
|
+
${count(errors2, "error")} \u2014 the file above does not describe this workspace in full.`);
|
|
78539
|
+
process.exitCode = 1;
|
|
78540
|
+
}
|
|
78541
|
+
}
|
|
77638
78542
|
function scaffoldJson(target, result, applied, signinUrl) {
|
|
77639
78543
|
return {
|
|
77640
78544
|
...target,
|
|
@@ -77642,7 +78546,7 @@ function scaffoldJson(target, result, applied, signinUrl) {
|
|
|
77642
78546
|
// Always present, empty included: a caller reading this object indexes
|
|
77643
78547
|
// `applied` to find the apps, and a key that appears only sometimes makes
|
|
77644
78548
|
// "this model applied nothing" and "this CLI is too old" the same value.
|
|
77645
|
-
applied: applied.map((entry) => ({
|
|
78549
|
+
applied: applied.map((entry) => ({ package: entry.package, apps: entry.apps })),
|
|
77646
78550
|
...signinUrl === void 0 ? {} : { signin_url: signinUrl }
|
|
77647
78551
|
};
|
|
77648
78552
|
}
|
|
@@ -77656,22 +78560,22 @@ async function applyModelPackages(client, entries2) {
|
|
|
77656
78560
|
const applied = [];
|
|
77657
78561
|
for (const entry of entries2) {
|
|
77658
78562
|
note(`
|
|
77659
|
-
Applying ${entry.
|
|
78563
|
+
Applying ${entry.package}\u2026`);
|
|
77660
78564
|
try {
|
|
77661
78565
|
const copy = await copyPackage(client, {
|
|
77662
|
-
starter_id: entry.
|
|
78566
|
+
starter_id: entry.package,
|
|
77663
78567
|
adopt: true,
|
|
77664
78568
|
...entry.no_sample_data === true ? { noSampleData: true } : {},
|
|
77665
78569
|
...entry.bind !== void 0 ? { bind: entry.bind } : {}
|
|
77666
78570
|
});
|
|
77667
|
-
applied.push({
|
|
78571
|
+
applied.push({ package: entry.package, apps: copy.apps });
|
|
77668
78572
|
} catch (error52) {
|
|
77669
78573
|
throw new Error(
|
|
77670
78574
|
`${error52 instanceof Error ? error52.message : String(error52)}
|
|
77671
78575
|
|
|
77672
|
-
${entry.
|
|
77673
|
-
The tables${applied.length > 0 ? ` and ${applied.map((one) => one.
|
|
77674
|
-
and copy it on its own: lotics library init ${entry.
|
|
78576
|
+
${entry.package} was refused, so the rest of this model's apply list did not run.
|
|
78577
|
+
The tables${applied.length > 0 ? ` and ${applied.map((one) => one.package).join(", ")}` : ""} already landed and must not be applied again \u2014 fix this entry
|
|
78578
|
+
and copy it on its own: lotics library init ${entry.package}`
|
|
77675
78579
|
);
|
|
77676
78580
|
}
|
|
77677
78581
|
}
|
|
@@ -77680,7 +78584,8 @@ Applying ${entry.starter}\u2026`);
|
|
|
77680
78584
|
async function scaffoldApply(client, model, options) {
|
|
77681
78585
|
const labels = new Map(model.contract.entities.map((entity) => [entity.alias, entity.label]));
|
|
77682
78586
|
note(`Applying the model \u2014 ${summarizeModel(model)}\u2026`);
|
|
77683
|
-
const
|
|
78587
|
+
const rows = await uploadModelDocuments(client, model, path11.dirname(path11.resolve(options.file)));
|
|
78588
|
+
const result = await postModel(client, { ...model, rows }, options);
|
|
77684
78589
|
const width = Math.max(...result.entities.map((entity) => entity.alias.length), 0);
|
|
77685
78590
|
for (const entity of result.entities) {
|
|
77686
78591
|
note(
|
|
@@ -77703,6 +78608,16 @@ No rows were written \u2014 a table this model bound already holds records${adop
|
|
|
77703
78608
|
the sample data, or add the rows yourself.`
|
|
77704
78609
|
);
|
|
77705
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
|
+
}
|
|
77706
78621
|
const applied = await applyModelPackages(client, model.apply);
|
|
77707
78622
|
const created = result.entities.filter((entity) => entity.created).length;
|
|
77708
78623
|
const apps = applied.flatMap((entry) => entry.apps);
|
|
@@ -77741,39 +78656,93 @@ function requireNonEmpty(value2, field) {
|
|
|
77741
78656
|
);
|
|
77742
78657
|
}
|
|
77743
78658
|
}
|
|
77744
|
-
|
|
78659
|
+
function signInPageUrl(pending) {
|
|
78660
|
+
return `${WEB_APP_URL}/cli_login/${pending.request_id}`;
|
|
78661
|
+
}
|
|
78662
|
+
function expirySentence(pending) {
|
|
78663
|
+
const remaining = Date.parse(pending.expires_at) - Date.now();
|
|
78664
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "";
|
|
78665
|
+
return ` The request expires in ${Math.max(1, Math.round(remaining / 6e4))} minutes.`;
|
|
78666
|
+
}
|
|
78667
|
+
function signInInstructions(pending, options) {
|
|
78668
|
+
const ask = `Sign in from your browser: open ${signInPageUrl(pending)} (also in a mail to ${pending.email}), sign in if asked, check that the page shows code ${pending.code}, and press Confirm.`;
|
|
78669
|
+
const then = options.waiting ? `Waiting here until you press Confirm.${expirySentence(pending)}` : `Then run your command again \u2014 this terminal picks up the key on its next command.${expirySentence(pending)}`;
|
|
78670
|
+
return `${ask}
|
|
78671
|
+
${then}`;
|
|
78672
|
+
}
|
|
78673
|
+
async function startPendingLogin(email3) {
|
|
77745
78674
|
const request = await startCliLogin(email3);
|
|
77746
|
-
|
|
77747
|
-
|
|
77748
|
-
|
|
77749
|
-
|
|
78675
|
+
const pending = {
|
|
78676
|
+
request_id: request.request_id,
|
|
78677
|
+
secret: request.secret,
|
|
78678
|
+
code: request.code,
|
|
78679
|
+
expires_at: request.expires_at,
|
|
78680
|
+
email: email3,
|
|
78681
|
+
api_url: API_BASE_URL
|
|
78682
|
+
};
|
|
78683
|
+
savePendingLogin(pending);
|
|
78684
|
+
return pending;
|
|
78685
|
+
}
|
|
78686
|
+
function saveApproval(state, email3, options) {
|
|
78687
|
+
requireNonEmpty(state.api_key, "api_key");
|
|
78688
|
+
requireNonEmpty(state.organization_id, "organization_id");
|
|
78689
|
+
requireNonEmpty(state.workspace_id, "workspace_id");
|
|
78690
|
+
upsertProfile(state.organization_id, {
|
|
78691
|
+
api_key: state.api_key,
|
|
78692
|
+
org_name: state.organization_name,
|
|
78693
|
+
workspace_id: state.workspace_id
|
|
78694
|
+
});
|
|
78695
|
+
const existing = loadGlobalConfig() ?? {};
|
|
78696
|
+
saveGlobalConfig({ ...existing, email: email3 });
|
|
78697
|
+
setActiveOrg(state.organization_id, options?.local === true ? "local" : "global");
|
|
78698
|
+
clearPendingLogin();
|
|
78699
|
+
return {
|
|
78700
|
+
email: email3,
|
|
78701
|
+
orgId: state.organization_id,
|
|
78702
|
+
orgName: state.organization_name,
|
|
78703
|
+
workspaceId: state.workspace_id
|
|
78704
|
+
};
|
|
78705
|
+
}
|
|
78706
|
+
async function claimPendingLogin() {
|
|
78707
|
+
const pending = loadPendingLogin();
|
|
78708
|
+
if (!pending) return { status: "none" };
|
|
78709
|
+
const expired = `The sign-in request expired. Run again: lotics auth login ${pending.email}`;
|
|
78710
|
+
if (Date.parse(pending.expires_at) <= Date.now()) {
|
|
78711
|
+
clearPendingLogin();
|
|
78712
|
+
return pending.api_url === API_BASE_URL ? { status: "gone", message: expired } : { status: "none" };
|
|
78713
|
+
}
|
|
78714
|
+
if (pending.api_url !== API_BASE_URL) return { status: "none" };
|
|
78715
|
+
const state = await pollCliLogin(pending.request_id, pending.secret);
|
|
78716
|
+
if (state.status === "approved") {
|
|
78717
|
+
return { status: "signed_in", account: saveApproval(state, pending.email) };
|
|
78718
|
+
}
|
|
78719
|
+
if (state.status === "pending") {
|
|
78720
|
+
return {
|
|
78721
|
+
status: "waiting",
|
|
78722
|
+
message: `Still waiting for Confirm on ${signInPageUrl(pending)} (code ${pending.code}). Press it, then run this again \u2014 or ask again for a new one: lotics auth login ${pending.email}`
|
|
78723
|
+
};
|
|
78724
|
+
}
|
|
78725
|
+
clearPendingLogin();
|
|
78726
|
+
return {
|
|
78727
|
+
status: "gone",
|
|
78728
|
+
message: state.status === "claimed" ? `The sign-in request was already used. Run again: lotics auth login ${pending.email}` : expired
|
|
78729
|
+
};
|
|
78730
|
+
}
|
|
78731
|
+
async function awaitCliLogin(email3, options) {
|
|
78732
|
+
const pending = await startPendingLogin(email3);
|
|
78733
|
+
console.error(signInInstructions(pending, { waiting: true }));
|
|
78734
|
+
const expiresAt = Date.parse(pending.expires_at);
|
|
77750
78735
|
for (; ; ) {
|
|
77751
78736
|
let state;
|
|
77752
78737
|
try {
|
|
77753
|
-
state = await pollCliLogin(
|
|
78738
|
+
state = await pollCliLogin(pending.request_id, pending.secret);
|
|
77754
78739
|
} catch (error52) {
|
|
77755
78740
|
if (!(Date.now() < expiresAt)) throw error52;
|
|
77756
78741
|
await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
|
|
77757
78742
|
continue;
|
|
77758
78743
|
}
|
|
77759
78744
|
if (state.status === "approved") {
|
|
77760
|
-
|
|
77761
|
-
requireNonEmpty(state.organization_id, "organization_id");
|
|
77762
|
-
requireNonEmpty(state.workspace_id, "workspace_id");
|
|
77763
|
-
upsertProfile(state.organization_id, {
|
|
77764
|
-
api_key: state.api_key,
|
|
77765
|
-
org_name: state.organization_name,
|
|
77766
|
-
workspace_id: state.workspace_id
|
|
77767
|
-
});
|
|
77768
|
-
const existing = loadGlobalConfig() ?? {};
|
|
77769
|
-
saveGlobalConfig({ ...existing, email: email3 });
|
|
77770
|
-
setActiveOrg(state.organization_id, options?.local === true ? "local" : "global");
|
|
77771
|
-
return {
|
|
77772
|
-
email: email3,
|
|
77773
|
-
orgId: state.organization_id,
|
|
77774
|
-
orgName: state.organization_name,
|
|
77775
|
-
workspaceId: state.workspace_id
|
|
77776
|
-
};
|
|
78745
|
+
return saveApproval(state, email3, options);
|
|
77777
78746
|
}
|
|
77778
78747
|
if (state.status === "expired") {
|
|
77779
78748
|
throw new Error(`That sign-in link expired. Run "lotics auth login ${email3}" again.`);
|
|
@@ -77822,7 +78791,7 @@ import fs12 from "node:fs";
|
|
|
77822
78791
|
|
|
77823
78792
|
// src/file_command_io.ts
|
|
77824
78793
|
import fs11 from "node:fs";
|
|
77825
|
-
import
|
|
78794
|
+
import path12 from "node:path";
|
|
77826
78795
|
var CliError = class extends Error {
|
|
77827
78796
|
constructor(message2) {
|
|
77828
78797
|
super(message2);
|
|
@@ -77845,8 +78814,8 @@ function rejectExtraArgs(rest2, arity, usage) {
|
|
|
77845
78814
|
fail2(`${usage} takes ${arity} argument${arity === 1 ? "" : "s"} after <file>, got ${rest2.length}. Unused: ${rest2.slice(arity).join(" ")}`);
|
|
77846
78815
|
}
|
|
77847
78816
|
function writeFileAtomic(filePath, bytes) {
|
|
77848
|
-
const dir =
|
|
77849
|
-
const tmp =
|
|
78817
|
+
const dir = path12.dirname(path12.resolve(filePath));
|
|
78818
|
+
const tmp = path12.join(dir, `.${path12.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
77850
78819
|
fs11.writeFileSync(tmp, bytes);
|
|
77851
78820
|
try {
|
|
77852
78821
|
fs11.renameSync(tmp, filePath);
|
|
@@ -80649,16 +81618,16 @@ var MatcherView = class {
|
|
|
80649
81618
|
* @returns {string|undefined}
|
|
80650
81619
|
*/
|
|
80651
81620
|
getCurrentTag() {
|
|
80652
|
-
const
|
|
80653
|
-
return
|
|
81621
|
+
const path14 = this._matcher.path;
|
|
81622
|
+
return path14.length > 0 ? path14[path14.length - 1].tag : void 0;
|
|
80654
81623
|
}
|
|
80655
81624
|
/**
|
|
80656
81625
|
* Get current namespace.
|
|
80657
81626
|
* @returns {string|undefined}
|
|
80658
81627
|
*/
|
|
80659
81628
|
getCurrentNamespace() {
|
|
80660
|
-
const
|
|
80661
|
-
return
|
|
81629
|
+
const path14 = this._matcher.path;
|
|
81630
|
+
return path14.length > 0 ? path14[path14.length - 1].namespace : void 0;
|
|
80662
81631
|
}
|
|
80663
81632
|
/**
|
|
80664
81633
|
* Get current node's attribute value.
|
|
@@ -80666,9 +81635,9 @@ var MatcherView = class {
|
|
|
80666
81635
|
* @returns {*}
|
|
80667
81636
|
*/
|
|
80668
81637
|
getAttrValue(attrName) {
|
|
80669
|
-
const
|
|
80670
|
-
if (
|
|
80671
|
-
return
|
|
81638
|
+
const path14 = this._matcher.path;
|
|
81639
|
+
if (path14.length === 0) return void 0;
|
|
81640
|
+
return path14[path14.length - 1].values?.[attrName];
|
|
80672
81641
|
}
|
|
80673
81642
|
/**
|
|
80674
81643
|
* Check if current node has an attribute.
|
|
@@ -80676,9 +81645,9 @@ var MatcherView = class {
|
|
|
80676
81645
|
* @returns {boolean}
|
|
80677
81646
|
*/
|
|
80678
81647
|
hasAttr(attrName) {
|
|
80679
|
-
const
|
|
80680
|
-
if (
|
|
80681
|
-
const current =
|
|
81648
|
+
const path14 = this._matcher.path;
|
|
81649
|
+
if (path14.length === 0) return false;
|
|
81650
|
+
const current = path14[path14.length - 1];
|
|
80682
81651
|
return current.values !== void 0 && attrName in current.values;
|
|
80683
81652
|
}
|
|
80684
81653
|
/**
|
|
@@ -80704,18 +81673,18 @@ var MatcherView = class {
|
|
|
80704
81673
|
* @returns {number}
|
|
80705
81674
|
*/
|
|
80706
81675
|
getPosition() {
|
|
80707
|
-
const
|
|
80708
|
-
if (
|
|
80709
|
-
return
|
|
81676
|
+
const path14 = this._matcher.path;
|
|
81677
|
+
if (path14.length === 0) return -1;
|
|
81678
|
+
return path14[path14.length - 1].position ?? 0;
|
|
80710
81679
|
}
|
|
80711
81680
|
/**
|
|
80712
81681
|
* Get current node's repeat counter (occurrence count of this tag name).
|
|
80713
81682
|
* @returns {number}
|
|
80714
81683
|
*/
|
|
80715
81684
|
getCounter() {
|
|
80716
|
-
const
|
|
80717
|
-
if (
|
|
80718
|
-
return
|
|
81685
|
+
const path14 = this._matcher.path;
|
|
81686
|
+
if (path14.length === 0) return -1;
|
|
81687
|
+
return path14[path14.length - 1].counter ?? 0;
|
|
80719
81688
|
}
|
|
80720
81689
|
/**
|
|
80721
81690
|
* Get current node's sibling index (alias for getPosition).
|
|
@@ -86373,10 +87342,10 @@ function resolveRelativePath(basePath, relativePath) {
|
|
|
86373
87342
|
}
|
|
86374
87343
|
return resolved.join("/");
|
|
86375
87344
|
}
|
|
86376
|
-
function findZipEntry(zipEntries,
|
|
86377
|
-
if (zipEntries[
|
|
86378
|
-
if (zipEntries[`xl/${
|
|
86379
|
-
const noSlash =
|
|
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(/^\//, "");
|
|
86380
87349
|
if (zipEntries[noSlash]) return zipEntries[noSlash];
|
|
86381
87350
|
return void 0;
|
|
86382
87351
|
}
|
|
@@ -88277,9 +89246,9 @@ function parseExcelFromZip(zip, options) {
|
|
|
88277
89246
|
}
|
|
88278
89247
|
return result;
|
|
88279
89248
|
}
|
|
88280
|
-
function findEntry(zip,
|
|
88281
|
-
if (zip[
|
|
88282
|
-
const lower2 =
|
|
89249
|
+
function findEntry(zip, path14) {
|
|
89250
|
+
if (zip[path14]) return zip[path14];
|
|
89251
|
+
const lower2 = path14.toLowerCase();
|
|
88283
89252
|
for (const key of Object.keys(zip)) {
|
|
88284
89253
|
if (key.toLowerCase() === lower2) return zip[key];
|
|
88285
89254
|
}
|
|
@@ -88916,12 +89885,12 @@ function extractPivotRoundTripInfo(originalZip) {
|
|
|
88916
89885
|
if (paths.length > 0) pivotTablesBySheetIndex.set(i2, paths);
|
|
88917
89886
|
}
|
|
88918
89887
|
const pivotXmlPaths = [];
|
|
88919
|
-
for (const
|
|
88920
|
-
if (
|
|
88921
|
-
pivotXmlPaths.push(
|
|
89888
|
+
for (const path14 of Object.keys(originalZip)) {
|
|
89889
|
+
if (path14.startsWith("xl/pivotTables/") && path14.endsWith(".xml")) {
|
|
89890
|
+
pivotXmlPaths.push(path14);
|
|
88922
89891
|
}
|
|
88923
|
-
if (
|
|
88924
|
-
pivotXmlPaths.push(
|
|
89892
|
+
if (path14.startsWith("xl/pivotCache/") && path14.endsWith(".xml")) {
|
|
89893
|
+
pivotXmlPaths.push(path14);
|
|
88925
89894
|
}
|
|
88926
89895
|
}
|
|
88927
89896
|
return { cachesByWorkbook, pivotTablesBySheetIndex, pivotXmlPaths };
|
|
@@ -88929,15 +89898,15 @@ function extractPivotRoundTripInfo(originalZip) {
|
|
|
88929
89898
|
function decode3(bytes) {
|
|
88930
89899
|
return new TextDecoder().decode(bytes);
|
|
88931
89900
|
}
|
|
88932
|
-
function pivotContentTypeFor(
|
|
88933
|
-
if (
|
|
88934
|
-
return `<Override PartName="/${
|
|
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"/>`;
|
|
88935
89904
|
}
|
|
88936
|
-
if (
|
|
88937
|
-
return `<Override PartName="/${
|
|
89905
|
+
if (path14.includes("/pivotCacheDefinition") && path14.endsWith(".xml")) {
|
|
89906
|
+
return `<Override PartName="/${path14}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"/>`;
|
|
88938
89907
|
}
|
|
88939
|
-
if (
|
|
88940
|
-
return `<Override PartName="/${
|
|
89908
|
+
if (path14.includes("/pivotCacheRecords") && path14.endsWith(".xml")) {
|
|
89909
|
+
return `<Override PartName="/${path14}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"/>`;
|
|
88941
89910
|
}
|
|
88942
89911
|
return void 0;
|
|
88943
89912
|
}
|
|
@@ -89010,16 +89979,16 @@ function exportWorkbook(workbook, originalZip) {
|
|
|
89010
89979
|
regeneratedPaths.add(`xl/worksheets/sheet${i2 + 1}.xml`);
|
|
89011
89980
|
regeneratedPaths.add(`xl/worksheets/_rels/sheet${i2 + 1}.xml.rels`);
|
|
89012
89981
|
}
|
|
89013
|
-
for (const
|
|
89014
|
-
if (
|
|
89015
|
-
regeneratedPaths.add(
|
|
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);
|
|
89016
89985
|
}
|
|
89017
89986
|
}
|
|
89018
|
-
for (const [
|
|
89019
|
-
if (regeneratedPaths.has(
|
|
89020
|
-
const ext =
|
|
89987
|
+
for (const [path14, data2] of Object.entries(originalZip)) {
|
|
89988
|
+
if (regeneratedPaths.has(path14)) continue;
|
|
89989
|
+
const ext = path14.slice(path14.lastIndexOf(".") + 1).toLowerCase();
|
|
89021
89990
|
if (!DECLARED_EXTENSIONS.includes(ext)) continue;
|
|
89022
|
-
entries2[
|
|
89991
|
+
entries2[path14] = data2;
|
|
89023
89992
|
}
|
|
89024
89993
|
}
|
|
89025
89994
|
const sharedStrings = buildSharedStrings(workbook);
|
|
@@ -89035,8 +90004,8 @@ function exportWorkbook(workbook, originalZip) {
|
|
|
89035
90004
|
buildWorkbookRels(workbook, pivotInfo)
|
|
89036
90005
|
);
|
|
89037
90006
|
const extraContentTypes = [];
|
|
89038
|
-
for (const
|
|
89039
|
-
const ct = pivotContentTypeFor(
|
|
90007
|
+
for (const path14 of pivotInfo.pivotXmlPaths) {
|
|
90008
|
+
const ct = pivotContentTypeFor(path14);
|
|
89040
90009
|
if (ct) extraContentTypes.push(ct);
|
|
89041
90010
|
}
|
|
89042
90011
|
let globalChartIndex = 1;
|
|
@@ -91399,9 +92368,9 @@ function computeExpression(tokens) {
|
|
|
91399
92368
|
break;
|
|
91400
92369
|
}
|
|
91401
92370
|
}
|
|
91402
|
-
return
|
|
92371
|
+
return evaluate2(values3, operator);
|
|
91403
92372
|
}
|
|
91404
|
-
function
|
|
92373
|
+
function evaluate2(values3, operator) {
|
|
91405
92374
|
let result = false;
|
|
91406
92375
|
switch (operator) {
|
|
91407
92376
|
case ">":
|
|
@@ -98222,14 +99191,14 @@ var DependencyGraph = class {
|
|
|
98222
99191
|
stack.push({ cell: p, path: [cell, p] });
|
|
98223
99192
|
}
|
|
98224
99193
|
while (stack.length > 0) {
|
|
98225
|
-
const { cell: current, path:
|
|
98226
|
-
if (current === cell || originCoarse.has(current)) return
|
|
99194
|
+
const { cell: current, path: path14 } = stack.pop();
|
|
99195
|
+
if (current === cell || originCoarse.has(current)) return path14;
|
|
98227
99196
|
if (visited.has(current)) continue;
|
|
98228
99197
|
visited.add(current);
|
|
98229
99198
|
const nextPrec = this.precedents.get(current);
|
|
98230
99199
|
if (!nextPrec) continue;
|
|
98231
99200
|
for (const p of nextPrec) {
|
|
98232
|
-
stack.push({ cell: p, path: [...
|
|
99201
|
+
stack.push({ cell: p, path: [...path14, p] });
|
|
98233
99202
|
}
|
|
98234
99203
|
}
|
|
98235
99204
|
return null;
|
|
@@ -104747,8 +105716,8 @@ function buildParagraphElement(para, ctx, bookmarks) {
|
|
|
104747
105716
|
for (let i2 = 0; i2 <= runElements.length; i2++) slots.push([]);
|
|
104748
105717
|
if (bookmarks) {
|
|
104749
105718
|
for (const bm of bookmarks) {
|
|
104750
|
-
const
|
|
104751
|
-
slots[
|
|
105719
|
+
const slot2 = Math.max(0, Math.min(runElements.length, bm.beforeRun));
|
|
105720
|
+
slots[slot2].push(bm.element);
|
|
104752
105721
|
}
|
|
104753
105722
|
}
|
|
104754
105723
|
const children = [];
|
|
@@ -105188,8 +106157,8 @@ async function docModelToDocx(model) {
|
|
|
105188
106157
|
// ../ooxml/src/opc.ts
|
|
105189
106158
|
var import_jszip = __toESM(require_lib4(), 1);
|
|
105190
106159
|
function dropFolderEntries(zip) {
|
|
105191
|
-
for (const [
|
|
105192
|
-
if (entry.dir) delete zip.files[
|
|
106160
|
+
for (const [path14, entry] of Object.entries(zip.files)) {
|
|
106161
|
+
if (entry.dir) delete zip.files[path14];
|
|
105193
106162
|
}
|
|
105194
106163
|
}
|
|
105195
106164
|
|
|
@@ -105444,11 +106413,11 @@ async function parseDocx(buffer) {
|
|
|
105444
106413
|
const zip = await import_jszip3.default.loadAsync(buffer);
|
|
105445
106414
|
const parts = /* @__PURE__ */ new Map();
|
|
105446
106415
|
const filePromises = [];
|
|
105447
|
-
zip.forEach((
|
|
106416
|
+
zip.forEach((path14, file2) => {
|
|
105448
106417
|
if (file2.dir) return;
|
|
105449
106418
|
filePromises.push(
|
|
105450
106419
|
file2.async("uint8array").then((bytes) => {
|
|
105451
|
-
parts.set(
|
|
106420
|
+
parts.set(path14, bytes);
|
|
105452
106421
|
})
|
|
105453
106422
|
);
|
|
105454
106423
|
});
|
|
@@ -105680,9 +106649,9 @@ var DEFAULT_PARTS = [
|
|
|
105680
106649
|
["word/theme/theme1.xml", () => encoder.encode(THEME_XML)]
|
|
105681
106650
|
];
|
|
105682
106651
|
function fillDefaultParts(parts) {
|
|
105683
|
-
for (const [
|
|
105684
|
-
if (!parts.has(
|
|
105685
|
-
parts.set(
|
|
106652
|
+
for (const [path14, makeBytes] of DEFAULT_PARTS) {
|
|
106653
|
+
if (!parts.has(path14)) {
|
|
106654
|
+
parts.set(path14, makeBytes());
|
|
105686
106655
|
}
|
|
105687
106656
|
}
|
|
105688
106657
|
return parts;
|
|
@@ -105692,9 +106661,9 @@ function fillDefaultParts(parts) {
|
|
|
105692
106661
|
async function serializeDocx(doc) {
|
|
105693
106662
|
const zip = new import_jszip4.default();
|
|
105694
106663
|
const parts = fillDefaultParts(new Map(doc.parts));
|
|
105695
|
-
for (const [
|
|
105696
|
-
if (
|
|
105697
|
-
zip.file(
|
|
106664
|
+
for (const [path14, bytes] of parts) {
|
|
106665
|
+
if (path14 === "word/document.xml") continue;
|
|
106666
|
+
zip.file(path14, bytes);
|
|
105698
106667
|
}
|
|
105699
106668
|
const documentXml = serializeDocumentXml(doc);
|
|
105700
106669
|
zip.file("word/document.xml", documentXml);
|
|
@@ -106033,18 +107002,18 @@ function replaceInParagraph(p, search, replace2) {
|
|
|
106033
107002
|
}
|
|
106034
107003
|
if (matches.length === 0) continue;
|
|
106035
107004
|
count2 += matches.length;
|
|
106036
|
-
for (const
|
|
107005
|
+
for (const slot2 of segment) {
|
|
106037
107006
|
let out = "";
|
|
106038
|
-
let cursor =
|
|
107007
|
+
let cursor = slot2.start;
|
|
106039
107008
|
for (const m of matches) {
|
|
106040
|
-
if (m.end <=
|
|
106041
|
-
const keepUntil = Math.max(cursor, Math.min(m.start,
|
|
107009
|
+
if (m.end <= slot2.start || m.start >= slot2.end) continue;
|
|
107010
|
+
const keepUntil = Math.max(cursor, Math.min(m.start, slot2.end));
|
|
106042
107011
|
if (keepUntil > cursor) out += joined.slice(cursor, keepUntil);
|
|
106043
|
-
if (m.start >=
|
|
106044
|
-
cursor = Math.max(cursor, Math.min(m.end,
|
|
107012
|
+
if (m.start >= slot2.start && m.start < slot2.end) out += replace2;
|
|
107013
|
+
cursor = Math.max(cursor, Math.min(m.end, slot2.end));
|
|
106045
107014
|
}
|
|
106046
|
-
if (cursor <
|
|
106047
|
-
rewritten.set(`${
|
|
107015
|
+
if (cursor < slot2.end) out += joined.slice(cursor, slot2.end);
|
|
107016
|
+
rewritten.set(`${slot2.inlineIndex}:${slot2.childIndex}`, out);
|
|
106048
107017
|
}
|
|
106049
107018
|
}
|
|
106050
107019
|
if (count2 === 0) return [p, 0];
|
|
@@ -106486,167 +107455,113 @@ async function runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs
|
|
|
106486
107455
|
}
|
|
106487
107456
|
|
|
106488
107457
|
// src/preview.ts
|
|
106489
|
-
import { spawn as spawn3 } from "node:child_process";
|
|
106490
107458
|
import { createServer } from "node:http";
|
|
106491
|
-
import { readFileSync, writeFileSync, existsSync as
|
|
106492
|
-
import {
|
|
106493
|
-
import {
|
|
106494
|
-
import {
|
|
106495
|
-
|
|
106496
|
-
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));
|
|
106497
107464
|
function fail3(msg) {
|
|
106498
107465
|
console.error(msg);
|
|
106499
107466
|
process.exit(1);
|
|
106500
107467
|
}
|
|
106501
|
-
function findChrome() {
|
|
106502
|
-
const env = process.env.LOTICS_CHROME || process.env.CHROME_PATH;
|
|
106503
|
-
if (env && existsSync2(env)) return env;
|
|
106504
|
-
const home = process.env.HOME || "";
|
|
106505
|
-
const pwDir = process.platform === "darwin" ? `${home}/Library/Caches/ms-playwright` : `${home}/.cache/ms-playwright`;
|
|
106506
|
-
if (existsSync2(pwDir)) {
|
|
106507
|
-
const rel = process.platform === "darwin" ? ["chrome-mac/Chromium.app/Contents/MacOS/Chromium"] : ["chrome-linux/chrome", "chrome-linux/headless_shell"];
|
|
106508
|
-
const revs = readdirSync(pwDir).filter((n) => n.startsWith("chromium-") || n.startsWith("chromium_headless_shell-")).sort().reverse();
|
|
106509
|
-
for (const rev2 of revs) {
|
|
106510
|
-
for (const r of rel) {
|
|
106511
|
-
const bin = join2(pwDir, rev2, r);
|
|
106512
|
-
if (existsSync2(bin)) return bin;
|
|
106513
|
-
}
|
|
106514
|
-
}
|
|
106515
|
-
}
|
|
106516
|
-
const systemPaths = [
|
|
106517
|
-
"/usr/bin/google-chrome",
|
|
106518
|
-
"/usr/bin/google-chrome-stable",
|
|
106519
|
-
"/usr/bin/chromium",
|
|
106520
|
-
"/usr/bin/chromium-browser",
|
|
106521
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
106522
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
106523
|
-
];
|
|
106524
|
-
return systemPaths.find((p) => existsSync2(p)) ?? null;
|
|
106525
|
-
}
|
|
106526
|
-
async function cdpConnect(wsUrl) {
|
|
106527
|
-
const ws = new WebSocket(wsUrl);
|
|
106528
|
-
await new Promise((res, rej) => {
|
|
106529
|
-
ws.onopen = () => res();
|
|
106530
|
-
ws.onerror = () => rej(new Error("CDP websocket failed to open"));
|
|
106531
|
-
});
|
|
106532
|
-
let id = 0;
|
|
106533
|
-
const pending = /* @__PURE__ */ new Map();
|
|
106534
|
-
ws.onclose = () => {
|
|
106535
|
-
for (const done of pending.values()) done(Promise.reject(new Error("CDP connection closed (Chrome exited?)")));
|
|
106536
|
-
pending.clear();
|
|
106537
|
-
};
|
|
106538
|
-
ws.onmessage = (e) => {
|
|
106539
|
-
const m = JSON.parse(String(e.data));
|
|
106540
|
-
if (m.id != null && pending.has(m.id)) {
|
|
106541
|
-
const done = pending.get(m.id);
|
|
106542
|
-
pending.delete(m.id);
|
|
106543
|
-
done(m.error ? Promise.reject(new Error(m.error.message)) : m.result);
|
|
106544
|
-
}
|
|
106545
|
-
};
|
|
106546
|
-
const send = (method, params = {}) => new Promise((res) => {
|
|
106547
|
-
const i2 = ++id;
|
|
106548
|
-
pending.set(i2, (r) => res(r));
|
|
106549
|
-
ws.send(JSON.stringify({ id: i2, method, params }));
|
|
106550
|
-
});
|
|
106551
|
-
return { send, close: () => ws.close() };
|
|
106552
|
-
}
|
|
106553
|
-
function isStoredFileId(target) {
|
|
106554
|
-
return /^fil_[A-Za-z0-9]+$/.test(target);
|
|
106555
|
-
}
|
|
106556
107468
|
function defaultPreviewOutputPath(filename, cwd) {
|
|
106557
|
-
return
|
|
107469
|
+
return join4(cwd, basename(filename, extname(filename)) + ".png");
|
|
106558
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
|
+
};
|
|
106559
107486
|
async function runPreviewCommand(filePath, flags) {
|
|
106560
|
-
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>]");
|
|
106561
107488
|
const abs2 = resolve(filePath);
|
|
106562
|
-
if (!
|
|
107489
|
+
if (!existsSync4(abs2)) fail3(`File not found: ${abs2}`);
|
|
106563
107490
|
const ext = extname(abs2).toLowerCase();
|
|
106564
|
-
const type = ext === ".docx" ? "docx" : ext === ".xlsx" || ext === ".xls" || ext === ".csv" ? "xlsx" : null;
|
|
106565
|
-
if (!type) fail3(`Unsupported file type "${ext}" \u2014 preview supports .docx
|
|
106566
|
-
|
|
106567
|
-
|
|
106568
|
-
|
|
106569
|
-
|
|
106570
|
-
|
|
106571
|
-
|
|
106572
|
-
|
|
106573
|
-
|
|
106574
|
-
|
|
106575
|
-
|
|
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}/`;
|
|
106576
107538
|
}
|
|
106577
|
-
const b64 = readFileSync(abs2).toString("base64");
|
|
106578
|
-
const bundle = readFileSync(bundlePath, "utf8");
|
|
106579
|
-
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>`;
|
|
106580
|
-
const server = createServer((req, res) => {
|
|
106581
|
-
if (req.url === "/render_page.js") {
|
|
106582
|
-
res.writeHead(200, { "content-type": "application/javascript" });
|
|
106583
|
-
res.end(bundle);
|
|
106584
|
-
} else {
|
|
106585
|
-
res.writeHead(200, { "content-type": "text/html" });
|
|
106586
|
-
res.end(html);
|
|
106587
|
-
}
|
|
106588
|
-
});
|
|
106589
107539
|
await new Promise((r) => server.listen(0, "127.0.0.1", () => r()));
|
|
106590
107540
|
const httpPort = server.address().port;
|
|
106591
|
-
|
|
106592
|
-
|
|
106593
|
-
|
|
106594
|
-
|
|
106595
|
-
|
|
106596
|
-
|
|
106597
|
-
|
|
106598
|
-
|
|
106599
|
-
"--disable-dev-shm-usage",
|
|
106600
|
-
"--force-device-scale-factor=2",
|
|
106601
|
-
"--remote-debugging-port=0",
|
|
106602
|
-
`--user-data-dir=${udd}`,
|
|
106603
|
-
"about:blank"
|
|
106604
|
-
], { 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;
|
|
106605
107549
|
const cleanup = () => {
|
|
106606
|
-
|
|
106607
|
-
child.kill();
|
|
106608
|
-
} catch {
|
|
106609
|
-
}
|
|
107550
|
+
session.close();
|
|
106610
107551
|
try {
|
|
106611
107552
|
server.close();
|
|
106612
107553
|
} catch {
|
|
106613
107554
|
}
|
|
106614
|
-
try {
|
|
106615
|
-
rmSync(udd, { recursive: true, force: true });
|
|
106616
|
-
} catch {
|
|
106617
|
-
}
|
|
106618
107555
|
};
|
|
106619
107556
|
try {
|
|
106620
|
-
|
|
106621
|
-
const
|
|
106622
|
-
for (let i2 = 0; i2 < 100 && !cdpPort; i2++) {
|
|
106623
|
-
if (existsSync2(portFile)) {
|
|
106624
|
-
const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
|
|
106625
|
-
if (p) cdpPort = p;
|
|
106626
|
-
}
|
|
106627
|
-
if (!cdpPort) await sleep2(100);
|
|
106628
|
-
}
|
|
106629
|
-
if (!cdpPort) throw new Error("Chrome did not expose a debugging port (launch failed?).");
|
|
106630
|
-
let target;
|
|
106631
|
-
for (let i2 = 0; i2 < 60 && !target?.webSocketDebuggerUrl; i2++) {
|
|
106632
|
-
try {
|
|
106633
|
-
const list2 = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
|
|
106634
|
-
target = list2.find((t) => t.type === "page");
|
|
106635
|
-
} catch {
|
|
106636
|
-
}
|
|
106637
|
-
if (!target?.webSocketDebuggerUrl) await sleep2(100);
|
|
106638
|
-
}
|
|
106639
|
-
if (!target?.webSocketDebuggerUrl) throw new Error("No Chrome page target available.");
|
|
106640
|
-
const cdp = await cdpConnect(target.webSocketDebuggerUrl);
|
|
106641
|
-
await cdp.send("Page.enable");
|
|
106642
|
-
await cdp.send("Runtime.enable");
|
|
106643
|
-
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 || []})";
|
|
106644
107559
|
let err2;
|
|
106645
107560
|
let done = false;
|
|
106646
107561
|
const warnings = [];
|
|
106647
107562
|
for (let i2 = 0; i2 < 200; i2++) {
|
|
106648
107563
|
const r = await cdp.send("Runtime.evaluate", {
|
|
106649
|
-
expression:
|
|
107564
|
+
expression: doneExpression,
|
|
106650
107565
|
returnByValue: true
|
|
106651
107566
|
});
|
|
106652
107567
|
const v = r.result?.value;
|
|
@@ -106656,15 +107571,24 @@ async function runPreviewCommand(filePath, flags) {
|
|
|
106656
107571
|
if (v.warnings?.length) warnings.push(...v.warnings);
|
|
106657
107572
|
break;
|
|
106658
107573
|
}
|
|
106659
|
-
await
|
|
107574
|
+
await sleep4(75);
|
|
106660
107575
|
}
|
|
106661
107576
|
if (!done) throw new Error("Render timed out (page never signaled completion).");
|
|
106662
107577
|
if (err2) throw new Error(`Render engine error: ${err2}`);
|
|
106663
|
-
const
|
|
106664
|
-
|
|
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();
|
|
106665
107591
|
const MAX_CAPTURE = 15e3;
|
|
106666
|
-
const w = Math.ceil(size2.width);
|
|
106667
|
-
const h = Math.ceil(size2.height);
|
|
106668
107592
|
if (w > MAX_CAPTURE || h > MAX_CAPTURE) {
|
|
106669
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)`);
|
|
106670
107594
|
}
|
|
@@ -106673,8 +107597,7 @@ async function runPreviewCommand(filePath, flags) {
|
|
|
106673
107597
|
captureBeyondViewport: true,
|
|
106674
107598
|
clip: { x: 0, y: 0, width: Math.min(w, MAX_CAPTURE), height: Math.min(h, MAX_CAPTURE), scale: 1 }
|
|
106675
107599
|
});
|
|
106676
|
-
|
|
106677
|
-
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");
|
|
106678
107601
|
writeFileSync(outPath, Buffer.from(shot.data, "base64"));
|
|
106679
107602
|
console.log(`Rendered ${basename(abs2)} \u2192 ${outPath} (${Math.min(w, MAX_CAPTURE)}\xD7${Math.min(h, MAX_CAPTURE)}, via ${basename(chrome)})`);
|
|
106680
107603
|
for (const warn2 of warnings) console.error(` \u26A0 ${warn2}`);
|
|
@@ -106702,8 +107625,8 @@ Lotics is an AI-powered operations platform. Through this CLI you can:
|
|
|
106702
107625
|
AUTHENTICATION
|
|
106703
107626
|
lotics auth Show auth help
|
|
106704
107627
|
lotics auth signup <email> Create account (run "lotics auth" for details)
|
|
106705
|
-
lotics auth login <email> Sign in on this machine \u2014
|
|
106706
|
-
|
|
107628
|
+
lotics auth login <email> Sign in on this machine \u2014 open the page it names,
|
|
107629
|
+
press Confirm, then run your command again
|
|
106707
107630
|
lotics auth api-key <key> Save a key \u2014 registers its org as a profile
|
|
106708
107631
|
|
|
106709
107632
|
ORGANIZATIONS
|
|
@@ -106745,7 +107668,11 @@ FLAGS
|
|
|
106745
107668
|
--view-as <id> Admin "View as": run every request as this member, so
|
|
106746
107669
|
is_current_member / row-scoping resolve to them (also
|
|
106747
107670
|
LOTICS_VIEW_AS env; admin key only; writes stay yours)
|
|
106748
|
-
--local Pin the current directory (lotics org use / auth login / auth api-key)
|
|
107671
|
+
--local Pin the current directory (lotics org use / auth login / auth api-key).
|
|
107672
|
+
On auth login / setup it implies --wait: only the terminal that
|
|
107673
|
+
stays in this directory can write its pin
|
|
107674
|
+
--wait (auth login / setup) Hold this terminal until Confirm is pressed,
|
|
107675
|
+
instead of picking the key up on the next command
|
|
106749
107676
|
--all (lotics auth logout) Remove every saved credential
|
|
106750
107677
|
--version Show version
|
|
106751
107678
|
|
|
@@ -106788,10 +107715,12 @@ function printAuthHelp() {
|
|
|
106788
107715
|
console.log(`Authentication commands:
|
|
106789
107716
|
|
|
106790
107717
|
lotics auth signup <email> Create account, org, workspace, and API key
|
|
106791
|
-
lotics auth login <email> Sign in an account that already exists
|
|
106792
|
-
|
|
106793
|
-
|
|
106794
|
-
|
|
107718
|
+
lotics auth login <email> Sign in an account that already exists. It does NOT
|
|
107719
|
+
wait: it prints the page to open (also mailed) and the
|
|
107720
|
+
code that page must show, and exits. Press Confirm,
|
|
107721
|
+
then run your command again \u2014 this terminal picks the
|
|
107722
|
+
key up on its next command. --wait holds the terminal
|
|
107723
|
+
instead. --json prints one object and nothing else
|
|
106795
107724
|
lotics auth api-key [key] Save an API key \u2014 registers its org as a profile
|
|
106796
107725
|
lotics auth web Send a magic link email to access the web app
|
|
106797
107726
|
lotics auth whoami Show the active account, org, workspace, and source
|
|
@@ -106802,7 +107731,11 @@ function printAuthHelp() {
|
|
|
106802
107731
|
Auth flags:
|
|
106803
107732
|
--local Pin the current directory instead of the global store.
|
|
106804
107733
|
signup/login/api-key write a ./.lotics/config.json pointer
|
|
106805
|
-
\u2014 the key itself stays in the global store.
|
|
107734
|
+
\u2014 the key itself stays in the global store. On login it
|
|
107735
|
+
implies --wait: a pin names THIS directory, and only the
|
|
107736
|
+
terminal that stays in it can write one.
|
|
107737
|
+
--wait (login) Hold this terminal until Confirm is pressed, rather
|
|
107738
|
+
than picking the key up on the next command.
|
|
106806
107739
|
--name <name> (signup) Display name (defaults to email prefix)
|
|
106807
107740
|
--timezone <timezone> (signup) Workspace timezone (defaults to this machine's, e.g. Asia/Ho_Chi_Minh)
|
|
106808
107741
|
|
|
@@ -106826,8 +107759,8 @@ function prompt(question) {
|
|
|
106826
107759
|
});
|
|
106827
107760
|
});
|
|
106828
107761
|
}
|
|
106829
|
-
async function publicPost(
|
|
106830
|
-
const response = await fetch(`${API_BASE_URL}${
|
|
107762
|
+
async function publicPost(path14, body) {
|
|
107763
|
+
const response = await fetch(`${API_BASE_URL}${path14}`, {
|
|
106831
107764
|
method: "POST",
|
|
106832
107765
|
headers: { "Content-Type": "application/json" },
|
|
106833
107766
|
body: JSON.stringify(body)
|
|
@@ -106939,15 +107872,38 @@ async function handleSignup(positionalEmail, flags) {
|
|
|
106939
107872
|
Check your email for a magic link to access the Lotics web app.`);
|
|
106940
107873
|
console.error(`Run "lotics auth web" to request a new link at any time.`);
|
|
106941
107874
|
}
|
|
107875
|
+
function waitsForConfirm(flags) {
|
|
107876
|
+
return flags.wait || flags.local;
|
|
107877
|
+
}
|
|
106942
107878
|
async function handleLogin(positionalEmail, flags) {
|
|
106943
107879
|
const email3 = positionalEmail ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
|
|
106944
107880
|
if (!email3) {
|
|
106945
107881
|
console.error("Email is required. Usage: lotics auth login <email>");
|
|
106946
107882
|
process.exit(1);
|
|
106947
107883
|
}
|
|
107884
|
+
if (!waitsForConfirm(flags)) {
|
|
107885
|
+
const pending = await startPendingLogin(email3);
|
|
107886
|
+
console.error(signInInstructions(pending, { waiting: false }));
|
|
107887
|
+
if (flags.json) {
|
|
107888
|
+
console.log(
|
|
107889
|
+
JSON.stringify(
|
|
107890
|
+
{
|
|
107891
|
+
request_id: pending.request_id,
|
|
107892
|
+
confirm_url: signInPageUrl(pending),
|
|
107893
|
+
code: pending.code,
|
|
107894
|
+
email: pending.email,
|
|
107895
|
+
expires_at: pending.expires_at
|
|
107896
|
+
},
|
|
107897
|
+
null,
|
|
107898
|
+
2
|
|
107899
|
+
)
|
|
107900
|
+
);
|
|
107901
|
+
}
|
|
107902
|
+
return;
|
|
107903
|
+
}
|
|
106948
107904
|
let account;
|
|
106949
107905
|
try {
|
|
106950
|
-
account = await awaitCliLogin(email3, flags.local
|
|
107906
|
+
account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
|
|
106951
107907
|
} catch (error52) {
|
|
106952
107908
|
console.error(error52 instanceof Error ? error52.message : String(error52));
|
|
106953
107909
|
process.exit(1);
|
|
@@ -107004,6 +107960,7 @@ Multiple workspaces in ${info.organization_name}. Run "lotics workspace select <
|
|
|
107004
107960
|
const existing = loadGlobalConfig() ?? {};
|
|
107005
107961
|
saveGlobalConfig({ ...existing, email: info.email });
|
|
107006
107962
|
setActiveOrg(info.organization_id, local ? "local" : "global");
|
|
107963
|
+
clearPendingLogin();
|
|
107007
107964
|
if (local) {
|
|
107008
107965
|
console.error(`Authenticated as ${info.email} in ${info.organization_name}.`);
|
|
107009
107966
|
console.error(` Pinned this directory: ${getConfigPath("local")}`);
|
|
@@ -107012,8 +107969,23 @@ Multiple workspaces in ${info.organization_name}. Run "lotics workspace select <
|
|
|
107012
107969
|
console.error(` Saved to the "${info.organization_name}" profile in ~/.lotics`);
|
|
107013
107970
|
}
|
|
107014
107971
|
}
|
|
107015
|
-
function
|
|
107016
|
-
const
|
|
107972
|
+
async function resolveContextOrClaim(flags, options) {
|
|
107973
|
+
const appWorkspaceId = options?.appWorkspaceId;
|
|
107974
|
+
const resolved = resolveContext(flags, appWorkspaceId);
|
|
107975
|
+
if (resolved) return { ctx: resolved, claimed: null };
|
|
107976
|
+
const claim = await claimPendingLogin();
|
|
107977
|
+
if (claim.status === "none") return { ctx: null, claimed: null };
|
|
107978
|
+
if (claim.status !== "signed_in") return { ctx: null, claimed: null, refusal: claim.message };
|
|
107979
|
+
const { account } = claim;
|
|
107980
|
+
note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
107981
|
+
return { ctx: resolveContext(flags, appWorkspaceId), claimed: account };
|
|
107982
|
+
}
|
|
107983
|
+
async function requireClient(flags, appWorkspaceId) {
|
|
107984
|
+
const { ctx, refusal } = await resolveContextOrClaim(flags, { appWorkspaceId });
|
|
107985
|
+
if (refusal !== void 0) {
|
|
107986
|
+
console.error(refusal);
|
|
107987
|
+
process.exit(1);
|
|
107988
|
+
}
|
|
107017
107989
|
if (!ctx) {
|
|
107018
107990
|
console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
|
|
107019
107991
|
process.exit(1);
|
|
@@ -107091,13 +108063,13 @@ async function resolveWorkspace(client, ctx) {
|
|
|
107091
108063
|
function resolveUploadPaths(rawPaths) {
|
|
107092
108064
|
const result = [];
|
|
107093
108065
|
for (const p of rawPaths) {
|
|
107094
|
-
const resolved =
|
|
108066
|
+
const resolved = path13.resolve(p);
|
|
107095
108067
|
const stat = fs15.statSync(resolved);
|
|
107096
108068
|
if (stat.isDirectory()) {
|
|
107097
108069
|
const entries2 = fs15.readdirSync(resolved, { withFileTypes: true });
|
|
107098
108070
|
for (const entry of entries2) {
|
|
107099
108071
|
if (entry.isFile()) {
|
|
107100
|
-
result.push(
|
|
108072
|
+
result.push(path13.join(resolved, entry.name));
|
|
107101
108073
|
}
|
|
107102
108074
|
}
|
|
107103
108075
|
} else {
|
|
@@ -107157,9 +108129,10 @@ async function main() {
|
|
|
107157
108129
|
if (command === "setup") {
|
|
107158
108130
|
const target = subcommand;
|
|
107159
108131
|
if (!target) {
|
|
107160
|
-
console.error("Usage: lotics setup <
|
|
107161
|
-
console.error(" Creates an account if this machine has none \u2014 or
|
|
107162
|
-
console.error("
|
|
108132
|
+
console.error("Usage: lotics setup <apg_id | model.json> [--email <you@co.com>] [--json]");
|
|
108133
|
+
console.error(" Creates an account if this machine has none \u2014 or, when that email");
|
|
108134
|
+
console.error(" already has one, prints a sign-in page and stops with nothing created;");
|
|
108135
|
+
console.error(" press Confirm and run the same command again. Then fills its workspace.");
|
|
107163
108136
|
console.error(" A STARTER id copies a published setup whole: schema, templates,");
|
|
107164
108137
|
console.error(" knowledge docs, sample records and its apps, deployed.");
|
|
107165
108138
|
console.error(" A model \u2014 any *.json file \u2014 creates the tables, fields, views, roles");
|
|
@@ -107180,7 +108153,11 @@ async function main() {
|
|
|
107180
108153
|
if (toolArgs !== void 0) {
|
|
107181
108154
|
warn(`A path is no longer needed \u2014 setup writes nothing to disk. Ignoring "${toolArgs}".`);
|
|
107182
108155
|
}
|
|
107183
|
-
const existing =
|
|
108156
|
+
const { ctx: existing, claimed, refusal } = await resolveContextOrClaim(flags);
|
|
108157
|
+
if (refusal !== void 0) {
|
|
108158
|
+
console.error(refusal);
|
|
108159
|
+
process.exit(1);
|
|
108160
|
+
}
|
|
107184
108161
|
if (existing === null) {
|
|
107185
108162
|
const email3 = flags.email ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
|
|
107186
108163
|
if (!email3) {
|
|
@@ -107197,10 +108174,13 @@ async function main() {
|
|
|
107197
108174
|
...flags.local === true ? { local: true } : {}
|
|
107198
108175
|
});
|
|
107199
108176
|
if (outcome.kind === "email_taken") {
|
|
107200
|
-
|
|
107201
|
-
email3
|
|
107202
|
-
|
|
107203
|
-
|
|
108177
|
+
if (!waitsForConfirm(flags)) {
|
|
108178
|
+
const pending = await startPendingLogin(email3);
|
|
108179
|
+
console.error(signInInstructions(pending, { waiting: false }));
|
|
108180
|
+
console.error("Then run this same command again.");
|
|
108181
|
+
process.exit(1);
|
|
108182
|
+
}
|
|
108183
|
+
const account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
|
|
107204
108184
|
note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
107205
108185
|
} else {
|
|
107206
108186
|
const { account } = outcome;
|
|
@@ -107210,7 +108190,7 @@ async function main() {
|
|
|
107210
108190
|
console.error(error52 instanceof Error ? error52.message : String(error52));
|
|
107211
108191
|
process.exit(1);
|
|
107212
108192
|
}
|
|
107213
|
-
} else if (flags.email !== void 0) {
|
|
108193
|
+
} else if (flags.email !== void 0 && claimed?.email.toLowerCase() !== flags.email.toLowerCase()) {
|
|
107214
108194
|
console.error(
|
|
107215
108195
|
`Already authenticated (${SOURCE_LABELS[existing.source]}), so --email would create a SECOND account and set that one up instead.
|
|
107216
108196
|
Use the account you have: lotics setup ${target}
|
|
@@ -107218,7 +108198,7 @@ async function main() {
|
|
|
107218
108198
|
);
|
|
107219
108199
|
process.exit(1);
|
|
107220
108200
|
}
|
|
107221
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108201
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107222
108202
|
await resolveWorkspace(client2, ctx2);
|
|
107223
108203
|
if (model !== null) {
|
|
107224
108204
|
const run = await scaffoldApply(client2, model.model, { file: target, adopt: false });
|
|
@@ -107250,11 +108230,17 @@ async function main() {
|
|
|
107250
108230
|
printModelReference();
|
|
107251
108231
|
return;
|
|
107252
108232
|
}
|
|
108233
|
+
if (subcommand === "export") {
|
|
108234
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
108235
|
+
await resolveWorkspace(client2, ctx2);
|
|
108236
|
+
await scaffoldExport(client2, flags.tables.length > 0 ? { tables: flags.tables } : {});
|
|
108237
|
+
return;
|
|
108238
|
+
}
|
|
107253
108239
|
if (subcommand === "check" || subcommand === "apply") {
|
|
107254
108240
|
if (!toolArgs) {
|
|
107255
108241
|
console.error(`Usage: lotics scaffold ${subcommand} <model.json>`);
|
|
107256
108242
|
console.error(
|
|
107257
|
-
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."
|
|
107258
108244
|
);
|
|
107259
108245
|
console.error(" How to write one: lotics scaffold docs");
|
|
107260
108246
|
process.exit(1);
|
|
@@ -107262,16 +108248,33 @@ async function main() {
|
|
|
107262
108248
|
if (flags.json) setMachineOutput(true);
|
|
107263
108249
|
const checked = await checkModelFile(toolArgs);
|
|
107264
108250
|
if (checked.kind === "error") {
|
|
108251
|
+
noteScope({ plan_findings: checked.findings.length });
|
|
107265
108252
|
if (subcommand === "check" && flags.json) emitJson({ ok: false, findings: checked.findings });
|
|
107266
108253
|
else reportModelFindings(toolArgs, checked.findings);
|
|
107267
108254
|
process.exit(1);
|
|
107268
108255
|
}
|
|
108256
|
+
const counts = countModel(checked.model);
|
|
108257
|
+
noteScope({ plan_screens: counts.screens, plan_custom: counts.custom, plan_findings: 0 });
|
|
107269
108258
|
if (subcommand === "check") {
|
|
107270
|
-
|
|
107271
|
-
|
|
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
|
+
}
|
|
107272
108275
|
return;
|
|
107273
108276
|
}
|
|
107274
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108277
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107275
108278
|
await resolveWorkspace(client2, ctx2);
|
|
107276
108279
|
const run = await scaffoldApply(client2, checked.model, { file: toolArgs, adopt: true });
|
|
107277
108280
|
if (flags.json) {
|
|
@@ -107285,12 +108288,16 @@ async function main() {
|
|
|
107285
108288
|
}
|
|
107286
108289
|
return;
|
|
107287
108290
|
}
|
|
107288
|
-
console.error(
|
|
108291
|
+
console.error(
|
|
108292
|
+
"Usage: lotics scaffold docs | check <model.json> | apply <model.json> | export [--tables <ids>]"
|
|
108293
|
+
);
|
|
107289
108294
|
console.error(" docs \u2014 how to write a model, with a worked example (offline)");
|
|
107290
108295
|
console.error(" check \u2014 prove one, offline, before anyone has an account");
|
|
107291
108296
|
console.error(" apply \u2014 create it here; a table of the same name is ADOPTED and");
|
|
107292
108297
|
console.error(" added to, never modified, and rows land only where every");
|
|
107293
108298
|
console.error(" bound table is empty");
|
|
108299
|
+
console.error(" export \u2014 print this workspace's tables as a model file, to edit into");
|
|
108300
|
+
console.error(" a preset or the starting model for another business");
|
|
107294
108301
|
process.exit(1);
|
|
107295
108302
|
}
|
|
107296
108303
|
if (command === "library") {
|
|
@@ -107299,7 +108306,7 @@ async function main() {
|
|
|
107299
108306
|
await libraryListPublic();
|
|
107300
108307
|
return;
|
|
107301
108308
|
}
|
|
107302
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108309
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107303
108310
|
await resolveWorkspace(client2, ctx2);
|
|
107304
108311
|
await libraryList(client2);
|
|
107305
108312
|
return;
|
|
@@ -107336,7 +108343,7 @@ async function main() {
|
|
|
107336
108343
|
emitJson(published.package);
|
|
107337
108344
|
return;
|
|
107338
108345
|
}
|
|
107339
|
-
const { client: client3 } = requireClient(flags);
|
|
108346
|
+
const { client: client3 } = await requireClient(flags);
|
|
107340
108347
|
const pkg = await client3.getStarter(toolArgs);
|
|
107341
108348
|
if (pkg.latest_version === 0) {
|
|
107342
108349
|
console.error(`${toolArgs} has released no version yet, so it declares no contract.`);
|
|
@@ -107356,7 +108363,7 @@ async function main() {
|
|
|
107356
108363
|
await libraryShowPublic(toolArgs);
|
|
107357
108364
|
return;
|
|
107358
108365
|
}
|
|
107359
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108366
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107360
108367
|
await resolveWorkspace(client2, ctx2);
|
|
107361
108368
|
await libraryShow(client2, toolArgs);
|
|
107362
108369
|
return;
|
|
@@ -107382,7 +108389,7 @@ async function main() {
|
|
|
107382
108389
|
console.error(parsedBind.error);
|
|
107383
108390
|
process.exit(1);
|
|
107384
108391
|
}
|
|
107385
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108392
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107386
108393
|
await resolveWorkspace(client2, ctx2);
|
|
107387
108394
|
if (restArgs[0] !== void 0) warn(`A path is no longer needed \u2014 a copy writes nothing to disk. Ignoring "${restArgs[0]}".`);
|
|
107388
108395
|
const result = await libraryInit(client2, {
|
|
@@ -107412,7 +108419,7 @@ async function main() {
|
|
|
107412
108419
|
process.exit(1);
|
|
107413
108420
|
}
|
|
107414
108421
|
}
|
|
107415
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
108422
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107416
108423
|
await resolveWorkspace(client2, ctx2);
|
|
107417
108424
|
await libraryFixturesCapture(client2, {
|
|
107418
108425
|
entities: flags.entity,
|
|
@@ -107459,7 +108466,12 @@ async function main() {
|
|
|
107459
108466
|
console.error(REPORT_USAGE);
|
|
107460
108467
|
process.exit(1);
|
|
107461
108468
|
}
|
|
107462
|
-
const reportCtx =
|
|
108469
|
+
const { ctx: reportCtx, refusal } = await resolveContextOrClaim(flags);
|
|
108470
|
+
if (refusal !== void 0) {
|
|
108471
|
+
console.error(refusal);
|
|
108472
|
+
echoReport(parsed2.report);
|
|
108473
|
+
process.exit(1);
|
|
108474
|
+
}
|
|
107463
108475
|
if (!reportCtx) {
|
|
107464
108476
|
console.error(
|
|
107465
108477
|
'Not authenticated, so this could not be sent. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.'
|
|
@@ -107492,7 +108504,11 @@ async function main() {
|
|
|
107492
108504
|
return;
|
|
107493
108505
|
}
|
|
107494
108506
|
if (subcommand === "whoami") {
|
|
107495
|
-
const ctx2 =
|
|
108507
|
+
const { ctx: ctx2, refusal } = await resolveContextOrClaim(flags);
|
|
108508
|
+
if (refusal !== void 0) {
|
|
108509
|
+
console.error(refusal);
|
|
108510
|
+
process.exit(1);
|
|
108511
|
+
}
|
|
107496
108512
|
if (!ctx2) {
|
|
107497
108513
|
console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
|
|
107498
108514
|
process.exit(1);
|
|
@@ -107548,11 +108564,12 @@ async function main() {
|
|
|
107548
108564
|
}
|
|
107549
108565
|
const [orgId, profile] = resolved;
|
|
107550
108566
|
removeProfile(orgId);
|
|
108567
|
+
clearPendingLogin();
|
|
107551
108568
|
console.error(`Removed the "${profile.org_name}" credential (${orgId}).`);
|
|
107552
108569
|
return;
|
|
107553
108570
|
}
|
|
107554
108571
|
if (subcommand === "web") {
|
|
107555
|
-
const { client: client2 } = requireClient(flags);
|
|
108572
|
+
const { client: client2 } = await requireClient(flags);
|
|
107556
108573
|
const { email: email3 } = await client2.login();
|
|
107557
108574
|
console.error(`Magic link sent to ${email3}. Check your email to access the Lotics web app.`);
|
|
107558
108575
|
return;
|
|
@@ -107574,8 +108591,8 @@ async function main() {
|
|
|
107574
108591
|
}
|
|
107575
108592
|
if (command === "preview") {
|
|
107576
108593
|
if (subcommand && isStoredFileId(subcommand)) {
|
|
107577
|
-
const { client: client2 } = requireClient(flags);
|
|
107578
|
-
const tmpDir = fs15.mkdtempSync(
|
|
108594
|
+
const { client: client2 } = await requireClient(flags);
|
|
108595
|
+
const tmpDir = fs15.mkdtempSync(path13.join(os4.tmpdir(), "lotics-preview-"));
|
|
107579
108596
|
try {
|
|
107580
108597
|
const { path: localPath, filename } = await client2.downloadFileById(subcommand, tmpDir);
|
|
107581
108598
|
const output = flags.output ?? defaultPreviewOutputPath(filename, process.cwd());
|
|
@@ -107739,7 +108756,7 @@ async function main() {
|
|
|
107739
108756
|
process.exit(1);
|
|
107740
108757
|
}
|
|
107741
108758
|
const appPathArg = subcommand === "dev" ? toolArgs : void 0;
|
|
107742
|
-
const { client, ctx } = requireClient(
|
|
108759
|
+
const { client, ctx } = await requireClient(
|
|
107743
108760
|
flags,
|
|
107744
108761
|
appManifestWorkspaceId(command, subcommand, appPathArg, flags)
|
|
107745
108762
|
);
|
|
@@ -107951,7 +108968,7 @@ Available workspaces:`);
|
|
|
107951
108968
|
return;
|
|
107952
108969
|
}
|
|
107953
108970
|
if (subcommand === "check") {
|
|
107954
|
-
await appCheck(client, {});
|
|
108971
|
+
await appCheck(client, { screens: flags.screens });
|
|
107955
108972
|
return;
|
|
107956
108973
|
}
|
|
107957
108974
|
if (subcommand === "workflow") {
|