@lotics/cli 0.195.0 → 0.197.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/dist/render_page.js +152 -41
- package/dist/src/cli.js +940 -299
- package/dist/src/client.d.ts +58 -12
- package/dist/src/client.js +24 -0
- package/docs/building_an_app.md +6 -0
- package/docs/cli_reference.md +14 -10
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -15464,6 +15464,15 @@ var LoticsClient = class {
|
|
|
15464
15464
|
async getAppWorkflow(app_id, alias) {
|
|
15465
15465
|
return this.execute("get_app_workflow", { app_id, alias });
|
|
15466
15466
|
}
|
|
15467
|
+
/**
|
|
15468
|
+
* The app's capability catalog exactly as a chat or MCP caller reads it —
|
|
15469
|
+
* every query, workflow and agent alias the caller's scope reaches, with the
|
|
15470
|
+
* description each is chosen BY. One read for the whole app, so a check over
|
|
15471
|
+
* what those readers see costs one request rather than one per alias.
|
|
15472
|
+
*/
|
|
15473
|
+
async getAppCapabilities(app_id) {
|
|
15474
|
+
return this.execute("get_app_capabilities", { app_id });
|
|
15475
|
+
}
|
|
15467
15476
|
/**
|
|
15468
15477
|
* Fetch the server-generated workspace `.d.ts` + the wrapper envelope that
|
|
15469
15478
|
* make a `src/workflows/<alias>.ts` body locally typecheckable (GAP-59).
|
|
@@ -15657,6 +15666,21 @@ var LoticsClient = class {
|
|
|
15657
15666
|
await fs2.promises.writeFile(absolutePath, buffer);
|
|
15658
15667
|
return absolutePath;
|
|
15659
15668
|
}
|
|
15669
|
+
/**
|
|
15670
|
+
* One page of this workspace's stored files, newest first — `GET /v1/files`.
|
|
15671
|
+
*
|
|
15672
|
+
* The only enumeration there is: every other file surface takes an id, so
|
|
15673
|
+
* without this "what is in the store" is answerable from Postgres alone, which
|
|
15674
|
+
* is exactly the escape hatch a CLI-first workflow exists to remove. Paged by a
|
|
15675
|
+
* keyset cursor the server mints; a caller walks until `next_cursor` is null.
|
|
15676
|
+
*/
|
|
15677
|
+
async listFiles(opts) {
|
|
15678
|
+
const params = new URLSearchParams();
|
|
15679
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
15680
|
+
if (opts?.cursor !== void 0) params.set("cursor", opts.cursor);
|
|
15681
|
+
const query = params.toString();
|
|
15682
|
+
return this.request("GET", `/v1/files${query ? `?${query}` : ""}`);
|
|
15683
|
+
}
|
|
15660
15684
|
async downloadFileById(fileId, destination, options) {
|
|
15661
15685
|
const signedHeaders = this.buildHeaders();
|
|
15662
15686
|
const signedUrlRes = await fetch(
|
|
@@ -33628,19 +33652,7 @@ var queryWindowFrameSchema = zod_default.object({
|
|
|
33628
33652
|
following: zod_default.number().int().nonnegative().optional().describe("Rows following current. Omit for unbounded.")
|
|
33629
33653
|
});
|
|
33630
33654
|
var queryNodeSchema = zod_default.lazy(
|
|
33631
|
-
() => zod_default.discriminatedUnion("kind",
|
|
33632
|
-
queryFromTableNodeSchema,
|
|
33633
|
-
queryProjectNodeSchema,
|
|
33634
|
-
queryFilterNodeSchema,
|
|
33635
|
-
queryJoinNodeSchema,
|
|
33636
|
-
queryUnionNodeSchema,
|
|
33637
|
-
queryGroupNodeSchema,
|
|
33638
|
-
queryWindowNodeSchema,
|
|
33639
|
-
querySortNodeSchema,
|
|
33640
|
-
queryLimitNodeSchema,
|
|
33641
|
-
queryUnpivotNodeSchema,
|
|
33642
|
-
queryUnnestNodeSchema
|
|
33643
|
-
])
|
|
33655
|
+
() => zod_default.discriminatedUnion("kind", queryNodeSchemas)
|
|
33644
33656
|
);
|
|
33645
33657
|
var queryFromTableNodeSchema = zod_default.object({
|
|
33646
33658
|
kind: zod_default.literal("from_table"),
|
|
@@ -33770,6 +33782,25 @@ var queryUnnestNodeSchema = zod_default.object({
|
|
|
33770
33782
|
"Keep source rows whose cell is NULL / empty as one output row with NULL element columns. Defaults to false (such rows are dropped)."
|
|
33771
33783
|
)
|
|
33772
33784
|
});
|
|
33785
|
+
var queryNodeSchemas = [
|
|
33786
|
+
queryFromTableNodeSchema,
|
|
33787
|
+
queryProjectNodeSchema,
|
|
33788
|
+
queryFilterNodeSchema,
|
|
33789
|
+
queryJoinNodeSchema,
|
|
33790
|
+
queryUnionNodeSchema,
|
|
33791
|
+
queryGroupNodeSchema,
|
|
33792
|
+
queryWindowNodeSchema,
|
|
33793
|
+
querySortNodeSchema,
|
|
33794
|
+
queryLimitNodeSchema,
|
|
33795
|
+
queryUnpivotNodeSchema,
|
|
33796
|
+
queryUnnestNodeSchema
|
|
33797
|
+
];
|
|
33798
|
+
var queryNodeKeysByKind = new Map(
|
|
33799
|
+
queryNodeSchemas.map((schema) => [
|
|
33800
|
+
schema.shape.kind.value,
|
|
33801
|
+
new Set(Object.keys(schema.shape))
|
|
33802
|
+
])
|
|
33803
|
+
);
|
|
33773
33804
|
var appWorkflowInputBaseSchema = zod_default.object({
|
|
33774
33805
|
description: zod_default.string().optional().describe("Human-readable description of this input \u2014 surfaces in agent + CLI tooling"),
|
|
33775
33806
|
required: zod_default.boolean().optional().describe("Whether the input must be provided. Defaults to true.")
|
|
@@ -36006,6 +36037,7 @@ function resolveScreen(app, screen, entityByAlias, roles) {
|
|
|
36006
36037
|
if (findings.length > 0) return { type: "invalid", findings };
|
|
36007
36038
|
return { type: "resolved", screen: { app, screen, entity, shapeLabel, record: record2, tabs, slots } };
|
|
36008
36039
|
}
|
|
36040
|
+
var RECORD_CHILD_VIA = ["parent", "party"];
|
|
36009
36041
|
var CHILD_COLUMN_ROLES = [
|
|
36010
36042
|
"identity",
|
|
36011
36043
|
"mark",
|
|
@@ -36018,9 +36050,9 @@ var CHILD_COLUMN_ROLES = [
|
|
|
36018
36050
|
"lifecycle",
|
|
36019
36051
|
"verdict"
|
|
36020
36052
|
];
|
|
36021
|
-
function
|
|
36053
|
+
function childLink(child, entity, roles, via) {
|
|
36022
36054
|
return child.fields.find(
|
|
36023
|
-
(field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role ===
|
|
36055
|
+
(field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role === via
|
|
36024
36056
|
);
|
|
36025
36057
|
}
|
|
36026
36058
|
function fieldsWithRole(entity, roles, role) {
|
|
@@ -36077,16 +36109,25 @@ function recordSections(entity, entities, roles, header) {
|
|
|
36077
36109
|
const shown = facts.filter((field) => !folded.has(field.alias));
|
|
36078
36110
|
const sets = ownSets.map((field) => ({ kind: "expected_set", source: "own", field }));
|
|
36079
36111
|
const children = [];
|
|
36080
|
-
|
|
36081
|
-
|
|
36082
|
-
|
|
36083
|
-
|
|
36084
|
-
|
|
36085
|
-
|
|
36086
|
-
|
|
36112
|
+
const drawn = /* @__PURE__ */ new Set();
|
|
36113
|
+
for (const via of RECORD_CHILD_VIA) {
|
|
36114
|
+
for (const child of entities) {
|
|
36115
|
+
if (drawn.has(child.alias)) continue;
|
|
36116
|
+
const link = childLink(child, entity, roles, via);
|
|
36117
|
+
if (link === void 0) continue;
|
|
36118
|
+
drawn.add(child.alias);
|
|
36119
|
+
const setField = fieldsWithRole(child, roles, "expected_set").find(
|
|
36120
|
+
(field) => field.type === "select" && field.multi !== true
|
|
36121
|
+
);
|
|
36122
|
+
if (setField !== void 0) {
|
|
36123
|
+
sets.push({ kind: "expected_set", source: "child", child, via, link, setField, filesField: fileFields(child, roles)[0] });
|
|
36124
|
+
continue;
|
|
36125
|
+
}
|
|
36126
|
+
const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
|
|
36127
|
+
(field) => field.alias !== link.alias
|
|
36128
|
+
);
|
|
36129
|
+
children.push({ kind: "children", child, via, link, fields: byRole });
|
|
36087
36130
|
}
|
|
36088
|
-
const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role));
|
|
36089
|
-
children.push({ kind: "children", child, parentField, fields: byRole });
|
|
36090
36131
|
}
|
|
36091
36132
|
return [
|
|
36092
36133
|
...shown.length > 0 ? [{ kind: "facts", fields: shown, levels }] : [],
|
|
@@ -36674,7 +36715,7 @@ function resultSideEffects(result) {
|
|
|
36674
36715
|
}
|
|
36675
36716
|
|
|
36676
36717
|
// src/version.ts
|
|
36677
|
-
var VERSION = "0.
|
|
36718
|
+
var VERSION = "0.197.0";
|
|
36678
36719
|
|
|
36679
36720
|
// src/timezone.ts
|
|
36680
36721
|
function machineTimezone() {
|
|
@@ -37103,8 +37144,15 @@ var COMMANDS = [
|
|
|
37103
37144
|
help: [
|
|
37104
37145
|
" lotics workspace List workspaces in the active org (marks current)",
|
|
37105
37146
|
" lotics workspace select <id> Switch to a different workspace",
|
|
37106
|
-
" lotics workspace create <name>
|
|
37107
|
-
"
|
|
37147
|
+
" lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]",
|
|
37148
|
+
" Create a new workspace (admin only). Without them it",
|
|
37149
|
+
" takes the org's oldest workspace's zone and currency \u2014",
|
|
37150
|
+
" which decide how every date buckets and every money",
|
|
37151
|
+
" field renders, and are invisible once wrong",
|
|
37152
|
+
" lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]",
|
|
37153
|
+
" Change the current workspace's name, currency or zone",
|
|
37154
|
+
" (admin only). Only what you name changes",
|
|
37155
|
+
" lotics workspace rename <name> Rename the current workspace \u2014 settings, by name alone",
|
|
37108
37156
|
" lotics workspace delete <id> --yes Delete a workspace (admin only; soft delete, recoverable)",
|
|
37109
37157
|
" lotics workspace doctor Report dangling schema references (admin only)"
|
|
37110
37158
|
]
|
|
@@ -37184,7 +37232,9 @@ var COMMANDS = [
|
|
|
37184
37232
|
help: [
|
|
37185
37233
|
" lotics run <tool> '<json>' Execute a tool",
|
|
37186
37234
|
" lotics run <tool> @args.json Read JSON args from a file (large payloads)",
|
|
37187
|
-
" cat args.json | lotics run <tool>
|
|
37235
|
+
" cat args.json | lotics run <tool> - Read JSON args from stdin (large payloads).",
|
|
37236
|
+
" The trailing - is required: without it the tool",
|
|
37237
|
+
" runs with no arguments and never waits.",
|
|
37188
37238
|
" Exits non-zero when the RESULT reports the work",
|
|
37189
37239
|
" failed (a refused workflow, a failed agent run) \u2014",
|
|
37190
37240
|
" not only when the call did.",
|
|
@@ -37205,9 +37255,14 @@ var COMMANDS = [
|
|
|
37205
37255
|
" (-m is required \u2014 it's the version's audit trail;",
|
|
37206
37256
|
" carries code + queries only \u2014 workflow bindings are",
|
|
37207
37257
|
" managed by set_app_workflow / remove_app_workflow.",
|
|
37208
|
-
" --prune also UNBINDS
|
|
37209
|
-
"
|
|
37210
|
-
"
|
|
37258
|
+
" --prune also UNBINDS what this project retired \u2014 an",
|
|
37259
|
+
" alias this bundle stopped calling, and one whose",
|
|
37260
|
+
" declaration you deleted; off by default because",
|
|
37261
|
+
" `run_app_workflow` and chat reach a binding the",
|
|
37262
|
+
" bundle never calls.",
|
|
37263
|
+
" --prune-invoked <alias> unbinds a workflow the",
|
|
37264
|
+
" workspace has already RUN \u2014 named per alias, only",
|
|
37265
|
+
" when you know that caller is gone)",
|
|
37211
37266
|
" lotics app versions [app_id] Show deploy history newest-first (version,",
|
|
37212
37267
|
" timestamp, deployer, build status, -m message;",
|
|
37213
37268
|
" * marks the currently served version)",
|
|
@@ -37239,8 +37294,13 @@ var COMMANDS = [
|
|
|
37239
37294
|
" lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
|
|
37240
37295
|
" through set_app_workflow (server verifies)",
|
|
37241
37296
|
" lotics app workflow pull Rewrite src/workflows/*.ts from the server",
|
|
37242
|
-
" lotics app workflow check [alias]
|
|
37243
|
-
" isolated program per alias; the app's own tsc)",
|
|
37297
|
+
" lotics app workflow check [alias...] Typecheck src/workflows bodies locally (one",
|
|
37298
|
+
" isolated program per alias; the app's own tsc).",
|
|
37299
|
+
" Name several to check several; none checks all",
|
|
37300
|
+
" lotics app workflow diff [alias...] Show how src/workflows/<alias>.ts differs from",
|
|
37301
|
+
" the body the server is running, line by line.",
|
|
37302
|
+
" None named diffs every alias that reads as",
|
|
37303
|
+
" drifted. Exits 1 when anything differs",
|
|
37244
37304
|
" lotics app query set <alias> Push package.json#lotics.queries.<alias> to",
|
|
37245
37305
|
" apps.queries via set_app_query and regenerate",
|
|
37246
37306
|
" .lotics/app_queries.d.ts (no deploy; re-synced by",
|
|
@@ -37252,7 +37312,12 @@ var COMMANDS = [
|
|
|
37252
37312
|
" those with set_app_agent, then pull.",
|
|
37253
37313
|
" lotics app subdomain <new-subdomain> Rename the app's public address (its subdomain)",
|
|
37254
37314
|
` lotics app rename "<new name>" Rename the app's display name (launcher title)`,
|
|
37255
|
-
" lotics app dev [path]
|
|
37315
|
+
" lotics app dev [path] [--port=<n>] [--vite-port=<n>]",
|
|
37316
|
+
" Run the app locally with HMR (RPC forwarded to prod).",
|
|
37317
|
+
" --port is the wrapper you open, --vite-port the module",
|
|
37318
|
+
" server. Pin both to run several apps at once; with no",
|
|
37319
|
+
" --vite-port, vite.config's server.port is used",
|
|
37320
|
+
" lotics app dev --view-as <member_id> \u2026 as that member sees it (admin only, reads only)"
|
|
37256
37321
|
]
|
|
37257
37322
|
},
|
|
37258
37323
|
{
|
|
@@ -37284,6 +37349,13 @@ var COMMANDS = [
|
|
|
37284
37349
|
verbs: ["file"],
|
|
37285
37350
|
aliases: ["upload", "download", "preview"],
|
|
37286
37351
|
help: [
|
|
37352
|
+
" lotics file list [--limit <n>] [--cursor <token>]",
|
|
37353
|
+
" List this workspace's files newest-first \u2014",
|
|
37354
|
+
" id, date, bytes, type, name. One page; the last",
|
|
37355
|
+
" line prints the command for the next one",
|
|
37356
|
+
" lotics file delete <file_id> Archive a stored file. Refused while a record,",
|
|
37357
|
+
" comment, knowledge doc, document template or",
|
|
37358
|
+
" voice session still references it \u2014 it names them",
|
|
37287
37359
|
" lotics file upload <file|dir...> Upload files (alias: lotics upload)",
|
|
37288
37360
|
" lotics file upload --stdin --as <name>",
|
|
37289
37361
|
" Upload bytes you already hold, piped on stdin",
|
|
@@ -37305,9 +37377,33 @@ var COMMANDS = [
|
|
|
37305
37377
|
function renderCommandsHelp() {
|
|
37306
37378
|
return COMMANDS.flatMap((group) => group.help).join("\n");
|
|
37307
37379
|
}
|
|
37380
|
+
function helpEntries(help) {
|
|
37381
|
+
const entries2 = [];
|
|
37382
|
+
for (const line of help) {
|
|
37383
|
+
if (line.startsWith(" lotics ") || entries2.length === 0) entries2.push([line]);
|
|
37384
|
+
else entries2[entries2.length - 1].push(line);
|
|
37385
|
+
}
|
|
37386
|
+
return entries2;
|
|
37387
|
+
}
|
|
37388
|
+
function commandHelp(path15) {
|
|
37389
|
+
const words = path15.filter((word) => word !== "");
|
|
37390
|
+
if (words.length === 0) return null;
|
|
37391
|
+
const group = COMMANDS.find(
|
|
37392
|
+
(g) => g.verbs.includes(words[0]) || (g.aliases ?? []).includes(words[0])
|
|
37393
|
+
);
|
|
37394
|
+
if (!group) return null;
|
|
37395
|
+
if (words.length === 1) return group.help.join("\n");
|
|
37396
|
+
const prefix = ` lotics ${words.join(" ")}`;
|
|
37397
|
+
const matched = helpEntries(group.help).filter((entry) => entry[0].startsWith(prefix));
|
|
37398
|
+
return (matched.length > 0 ? matched.flat() : group.help).join("\n");
|
|
37399
|
+
}
|
|
37308
37400
|
function knownVerbs() {
|
|
37309
37401
|
return new Set(COMMANDS.flatMap((group) => [...group.verbs, ...group.aliases ?? []]));
|
|
37310
37402
|
}
|
|
37403
|
+
function commandAliases(verb) {
|
|
37404
|
+
const group = COMMANDS.find((g) => g.verbs.includes(verb));
|
|
37405
|
+
return new Set(group?.aliases ?? []);
|
|
37406
|
+
}
|
|
37311
37407
|
|
|
37312
37408
|
// src/inputs.ts
|
|
37313
37409
|
function shellQuotingHint(raw, source) {
|
|
@@ -37326,7 +37422,10 @@ function readStdin() {
|
|
|
37326
37422
|
async function ingestJsonArgs(opts) {
|
|
37327
37423
|
let raw = opts.rawArg;
|
|
37328
37424
|
let source = "inline";
|
|
37329
|
-
if (raw
|
|
37425
|
+
if (raw === "-") {
|
|
37426
|
+
source = "stdin";
|
|
37427
|
+
raw = await opts.readStdin();
|
|
37428
|
+
} else if (raw && raw.startsWith("@")) {
|
|
37330
37429
|
source = "file";
|
|
37331
37430
|
const argsPath = raw.slice(1);
|
|
37332
37431
|
try {
|
|
@@ -37337,9 +37436,6 @@ async function ingestJsonArgs(opts) {
|
|
|
37337
37436
|
message: `Cannot read args file "${argsPath}": ${err instanceof Error ? err.message : String(err)}`
|
|
37338
37437
|
};
|
|
37339
37438
|
}
|
|
37340
|
-
} else if (!raw && !opts.stdinIsTTY) {
|
|
37341
|
-
source = "stdin";
|
|
37342
|
-
raw = await opts.readStdin();
|
|
37343
37439
|
}
|
|
37344
37440
|
if (!raw) return { kind: "ok", args: {} };
|
|
37345
37441
|
try {
|
|
@@ -38511,6 +38607,13 @@ function queryOutputNames(node) {
|
|
|
38511
38607
|
}
|
|
38512
38608
|
|
|
38513
38609
|
// ../shared/src/app_dts.ts
|
|
38610
|
+
function selectOptionValues(decl, optionsByFieldKey) {
|
|
38611
|
+
const inline = Array.isArray(decl.options) ? decl.options : null;
|
|
38612
|
+
const source = inline ?? (typeof decl.field === "string" ? optionsByFieldKey?.get(decl.field) : void 0) ?? [];
|
|
38613
|
+
return [...source].map(
|
|
38614
|
+
(o) => o !== null && typeof o === "object" && "value" in o && typeof o.value === "string" ? o.value : null
|
|
38615
|
+
).filter((v) => v !== null);
|
|
38616
|
+
}
|
|
38514
38617
|
var IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
38515
38618
|
function isValidIdentifier(name) {
|
|
38516
38619
|
return IDENTIFIER_REGEX.test(name);
|
|
@@ -38521,7 +38624,7 @@ function inputsToType(inputs, opts) {
|
|
|
38521
38624
|
for (const [key, decl] of Object.entries(inputs)) {
|
|
38522
38625
|
if (decl === null || typeof decl !== "object") continue;
|
|
38523
38626
|
const d = decl;
|
|
38524
|
-
const tsType = inputDeclToTsType(d);
|
|
38627
|
+
const tsType = inputDeclToTsType(d, opts?.optionsByFieldKey);
|
|
38525
38628
|
const isOptional = d.required === false;
|
|
38526
38629
|
const fieldType = isOptional && nullableOptional ? `${tsType} | null` : tsType;
|
|
38527
38630
|
const optional2 = isOptional ? "?" : "";
|
|
@@ -38533,7 +38636,7 @@ function inputsToType(inputs, opts) {
|
|
|
38533
38636
|
${fields.join("\n")}
|
|
38534
38637
|
}`;
|
|
38535
38638
|
}
|
|
38536
|
-
function inputDeclToTsType(decl) {
|
|
38639
|
+
function inputDeclToTsType(decl, optionsByFieldKey) {
|
|
38537
38640
|
const type = decl.type;
|
|
38538
38641
|
switch (type) {
|
|
38539
38642
|
case "text":
|
|
@@ -38551,13 +38654,7 @@ function inputDeclToTsType(decl) {
|
|
|
38551
38654
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
38552
38655
|
}
|
|
38553
38656
|
case "select": {
|
|
38554
|
-
const
|
|
38555
|
-
const literals = options.map((o) => {
|
|
38556
|
-
if (o !== null && typeof o === "object" && "value" in o && typeof o.value === "string") {
|
|
38557
|
-
return JSON.stringify(o.value);
|
|
38558
|
-
}
|
|
38559
|
-
return null;
|
|
38560
|
-
}).filter((v) => v !== null);
|
|
38657
|
+
const literals = selectOptionValues(decl, optionsByFieldKey).map((v) => JSON.stringify(v));
|
|
38561
38658
|
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
38562
38659
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
38563
38660
|
}
|
|
@@ -38567,11 +38664,11 @@ function inputDeclToTsType(decl) {
|
|
|
38567
38664
|
return decl.multi === true ? "ReadonlyArray<string>" : "string";
|
|
38568
38665
|
case "object": {
|
|
38569
38666
|
const fields = decl.fields !== null && typeof decl.fields === "object" ? decl.fields : {};
|
|
38570
|
-
return inputsToType(fields);
|
|
38667
|
+
return inputsToType(fields, { optionsByFieldKey });
|
|
38571
38668
|
}
|
|
38572
38669
|
case "array": {
|
|
38573
38670
|
const items = decl.items !== null && typeof decl.items === "object" ? decl.items : null;
|
|
38574
|
-
return items ? `ReadonlyArray<${inputDeclToTsType(items)}>` : "ReadonlyArray<unknown>";
|
|
38671
|
+
return items ? `ReadonlyArray<${inputDeclToTsType(items, optionsByFieldKey)}>` : "ReadonlyArray<unknown>";
|
|
38575
38672
|
}
|
|
38576
38673
|
case "json":
|
|
38577
38674
|
return "unknown";
|
|
@@ -38579,19 +38676,20 @@ function inputDeclToTsType(decl) {
|
|
|
38579
38676
|
return "unknown";
|
|
38580
38677
|
}
|
|
38581
38678
|
}
|
|
38582
|
-
function objectFieldsToType(fields) {
|
|
38679
|
+
function objectFieldsToType(fields, optionsByFieldKey) {
|
|
38583
38680
|
const parts = [];
|
|
38584
38681
|
for (const [key, decl] of Object.entries(fields)) {
|
|
38585
38682
|
if (decl === null || typeof decl !== "object") continue;
|
|
38586
38683
|
const d = decl;
|
|
38587
|
-
const
|
|
38684
|
+
const isOptional = d.required === false;
|
|
38685
|
+
const tsType = outputDeclToTsType(d, optionsByFieldKey);
|
|
38588
38686
|
const fieldKey = isValidIdentifier(key) ? key : JSON.stringify(key);
|
|
38589
|
-
parts.push(`${fieldKey}${
|
|
38687
|
+
parts.push(`${fieldKey}${isOptional ? "?" : ""}: ${isOptional ? `${tsType} | null` : tsType}`);
|
|
38590
38688
|
}
|
|
38591
38689
|
if (parts.length === 0) return "Record<string, never>";
|
|
38592
38690
|
return `{ ${parts.join("; ")} }`;
|
|
38593
38691
|
}
|
|
38594
|
-
function outputDeclToTsType(decl) {
|
|
38692
|
+
function outputDeclToTsType(decl, optionsByFieldKey) {
|
|
38595
38693
|
const type = decl.type;
|
|
38596
38694
|
switch (type) {
|
|
38597
38695
|
case "text":
|
|
@@ -38606,20 +38704,17 @@ function outputDeclToTsType(decl) {
|
|
|
38606
38704
|
case "record_link":
|
|
38607
38705
|
return decl.multi === true ? "ReadonlyArray<string>" : "string";
|
|
38608
38706
|
case "select": {
|
|
38609
|
-
const
|
|
38610
|
-
const literals = options.map(
|
|
38611
|
-
(o) => o !== null && typeof o === "object" && "value" in o && typeof o.value === "string" ? JSON.stringify(o.value) : null
|
|
38612
|
-
).filter((v) => v !== null);
|
|
38707
|
+
const literals = selectOptionValues(decl, optionsByFieldKey).map((v) => JSON.stringify(v));
|
|
38613
38708
|
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
38614
38709
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
38615
38710
|
}
|
|
38616
38711
|
case "object": {
|
|
38617
38712
|
const fields = decl.fields !== null && typeof decl.fields === "object" ? decl.fields : {};
|
|
38618
|
-
return objectFieldsToType(fields);
|
|
38713
|
+
return objectFieldsToType(fields, optionsByFieldKey);
|
|
38619
38714
|
}
|
|
38620
38715
|
case "array": {
|
|
38621
38716
|
const items = decl.items !== null && typeof decl.items === "object" ? decl.items : null;
|
|
38622
|
-
return items ? `ReadonlyArray<${outputDeclToTsType(items)}>` : "ReadonlyArray<unknown>";
|
|
38717
|
+
return items ? `ReadonlyArray<${outputDeclToTsType(items, optionsByFieldKey)}>` : "ReadonlyArray<unknown>";
|
|
38623
38718
|
}
|
|
38624
38719
|
case "json":
|
|
38625
38720
|
return "unknown";
|
|
@@ -38635,7 +38730,7 @@ var QUERIES_HEADER = `// Auto-generated by 'lotics app pull/dev/deploy'.
|
|
|
38635
38730
|
|
|
38636
38731
|
import "@lotics/app-sdk";
|
|
38637
38732
|
`;
|
|
38638
|
-
function generateAppQueriesDts(queries) {
|
|
38733
|
+
function generateAppQueriesDts(queries, optionsByFieldKey) {
|
|
38639
38734
|
const entries2 = Object.entries(queries ?? {});
|
|
38640
38735
|
if (entries2.length === 0) {
|
|
38641
38736
|
return `${QUERIES_HEADER}
|
|
@@ -38650,7 +38745,7 @@ declare module "@lotics/app-sdk" {
|
|
|
38650
38745
|
const lines = [];
|
|
38651
38746
|
const columnLines = [];
|
|
38652
38747
|
for (const [alias, declaration] of entries2) {
|
|
38653
|
-
const valueType = inputsToType(declaration.params ?? {});
|
|
38748
|
+
const valueType = inputsToType(declaration.params ?? {}, { optionsByFieldKey });
|
|
38654
38749
|
const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
|
|
38655
38750
|
lines.push(` ${aliasKey}: ${valueType};`);
|
|
38656
38751
|
const parsed = declaration.ast === void 0 ? void 0 : queryNodeSchema.safeParse(declaration.ast);
|
|
@@ -38682,7 +38777,7 @@ var WORKFLOWS_HEADER = `// Auto-generated by 'lotics app pull/dev/deploy'.
|
|
|
38682
38777
|
|
|
38683
38778
|
import "@lotics/app-sdk";
|
|
38684
38779
|
`;
|
|
38685
|
-
function generateAppWorkflowsDts(workflows) {
|
|
38780
|
+
function generateAppWorkflowsDts(workflows, optionsByFieldKey) {
|
|
38686
38781
|
const entries2 = Object.entries(workflows ?? {});
|
|
38687
38782
|
if (entries2.length === 0) {
|
|
38688
38783
|
return `${WORKFLOWS_HEADER}
|
|
@@ -38697,11 +38792,13 @@ declare module "@lotics/app-sdk" {
|
|
|
38697
38792
|
const inputLines = [];
|
|
38698
38793
|
const resultLines = [];
|
|
38699
38794
|
for (const [alias, declaration] of entries2) {
|
|
38700
|
-
const valueType = declaration.inputs ? inputsToType(declaration.inputs, { nullableOptional: true }) : "Record<string, unknown>";
|
|
38795
|
+
const valueType = declaration.inputs ? inputsToType(declaration.inputs, { nullableOptional: true, optionsByFieldKey }) : "Record<string, unknown>";
|
|
38701
38796
|
const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
|
|
38702
38797
|
inputLines.push(` ${aliasKey}: ${valueType};`);
|
|
38703
38798
|
if (declaration.outputs) {
|
|
38704
|
-
resultLines.push(
|
|
38799
|
+
resultLines.push(
|
|
38800
|
+
` ${aliasKey}: ${objectFieldsToType(declaration.outputs, optionsByFieldKey)};`
|
|
38801
|
+
);
|
|
38705
38802
|
}
|
|
38706
38803
|
}
|
|
38707
38804
|
const resultsBlock = resultLines.length > 0 ? `
|
|
@@ -38724,7 +38821,7 @@ var AGENTS_HEADER = `// Auto-generated by 'lotics app pull/dev/deploy'.
|
|
|
38724
38821
|
|
|
38725
38822
|
import "@lotics/app-sdk";
|
|
38726
38823
|
`;
|
|
38727
|
-
function generateAppAgentsDts(agents) {
|
|
38824
|
+
function generateAppAgentsDts(agents, optionsByFieldKey) {
|
|
38728
38825
|
const entries2 = Object.entries(agents ?? {});
|
|
38729
38826
|
if (entries2.length === 0) {
|
|
38730
38827
|
return `${AGENTS_HEADER}
|
|
@@ -38739,11 +38836,13 @@ declare module "@lotics/app-sdk" {
|
|
|
38739
38836
|
const inputLines = [];
|
|
38740
38837
|
const resultLines = [];
|
|
38741
38838
|
for (const [alias, declaration] of entries2) {
|
|
38742
|
-
const valueType = declaration.inputs ? inputsToType(declaration.inputs) : "Record<string, unknown>";
|
|
38839
|
+
const valueType = declaration.inputs ? inputsToType(declaration.inputs, { optionsByFieldKey }) : "Record<string, unknown>";
|
|
38743
38840
|
const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
|
|
38744
38841
|
inputLines.push(` ${aliasKey}: ${valueType};`);
|
|
38745
38842
|
if (declaration.outputs) {
|
|
38746
|
-
resultLines.push(
|
|
38843
|
+
resultLines.push(
|
|
38844
|
+
` ${aliasKey}: ${objectFieldsToType(declaration.outputs, optionsByFieldKey)};`
|
|
38845
|
+
);
|
|
38747
38846
|
}
|
|
38748
38847
|
}
|
|
38749
38848
|
const resultsBlock = resultLines.length > 0 ? `
|
|
@@ -39064,6 +39163,20 @@ export type AppFields = typeof F;
|
|
|
39064
39163
|
export type AppOptions = typeof OPT;
|
|
39065
39164
|
`;
|
|
39066
39165
|
}
|
|
39166
|
+
function selectOptionsByFieldKey(tables) {
|
|
39167
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
39168
|
+
for (const table of tables) {
|
|
39169
|
+
for (const field of table.fields) {
|
|
39170
|
+
if (field.options && field.options.length > 0) {
|
|
39171
|
+
byKey.set(
|
|
39172
|
+
field.id,
|
|
39173
|
+
field.options.map((option) => ({ value: option.id }))
|
|
39174
|
+
);
|
|
39175
|
+
}
|
|
39176
|
+
}
|
|
39177
|
+
}
|
|
39178
|
+
return byKey;
|
|
39179
|
+
}
|
|
39067
39180
|
function codegenTableIds(queries, allowlist) {
|
|
39068
39181
|
const ids = new Set(allowlist);
|
|
39069
39182
|
for (const declaration of Object.values(queries)) {
|
|
@@ -39153,11 +39266,11 @@ function bindScreens(screens, live, roles, entities) {
|
|
|
39153
39266
|
if (own === void 0) continue;
|
|
39154
39267
|
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen));
|
|
39155
39268
|
const children = /* @__PURE__ */ new Map();
|
|
39156
|
-
const bindChild = (section, child,
|
|
39157
|
-
const found = bindTable(`${at2}.${child.alias}`, child, [
|
|
39269
|
+
const bindChild = (section, child, via, linkField, drawn, set2, files) => {
|
|
39270
|
+
const found = bindTable(`${at2}.${child.alias}`, child, [linkField, ...drawn], aliased, live, missing);
|
|
39158
39271
|
if (found === void 0) return;
|
|
39159
|
-
const
|
|
39160
|
-
if (
|
|
39272
|
+
const link = found.fields.get(linkField.alias);
|
|
39273
|
+
if (link === void 0) return;
|
|
39161
39274
|
const drawnRoles = /* @__PURE__ */ new Map();
|
|
39162
39275
|
const fields = /* @__PURE__ */ new Map();
|
|
39163
39276
|
for (const field of drawn) {
|
|
@@ -39169,6 +39282,7 @@ function bindScreens(screens, live, roles, entities) {
|
|
|
39169
39282
|
}
|
|
39170
39283
|
children.set(child.alias, {
|
|
39171
39284
|
section,
|
|
39285
|
+
via,
|
|
39172
39286
|
// Keyed by the ENTITY, not the screen: two screens over one entity read
|
|
39173
39287
|
// the same rows through the same filter, and a second alias for them is
|
|
39174
39288
|
// a second manifest entry, a second `.d.ts` entry and a second cache key
|
|
@@ -39178,7 +39292,7 @@ function bindScreens(screens, live, roles, entities) {
|
|
|
39178
39292
|
entity: child,
|
|
39179
39293
|
table: found.table,
|
|
39180
39294
|
tableAlias: found.tableAlias,
|
|
39181
|
-
|
|
39295
|
+
link,
|
|
39182
39296
|
fields,
|
|
39183
39297
|
roles: drawnRoles,
|
|
39184
39298
|
identity: child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
|
|
@@ -39192,7 +39306,7 @@ function bindScreens(screens, live, roles, entities) {
|
|
|
39192
39306
|
const role = roleOf(roles, section.child.alias, field.alias)?.role;
|
|
39193
39307
|
return role !== void 0 && DRAWN_ROLES.includes(role);
|
|
39194
39308
|
});
|
|
39195
|
-
bindChild("children", section.child, section.
|
|
39309
|
+
bindChild("children", section.child, section.via, section.link, drawn);
|
|
39196
39310
|
continue;
|
|
39197
39311
|
}
|
|
39198
39312
|
if (section.kind !== "expected_set" || section.source !== "child") continue;
|
|
@@ -39200,7 +39314,8 @@ function bindScreens(screens, live, roles, entities) {
|
|
|
39200
39314
|
bindChild(
|
|
39201
39315
|
"expected_set",
|
|
39202
39316
|
section.child,
|
|
39203
|
-
section.
|
|
39317
|
+
section.via,
|
|
39318
|
+
section.link,
|
|
39204
39319
|
[...name, section.setField, ...section.filesField === void 0 ? [] : [section.filesField]],
|
|
39205
39320
|
section.setField,
|
|
39206
39321
|
section.filesField
|
|
@@ -39246,7 +39361,7 @@ function planQueries(bound) {
|
|
|
39246
39361
|
filter: {
|
|
39247
39362
|
node_type: "condition",
|
|
39248
39363
|
type: "select_record_link",
|
|
39249
|
-
field_key: child.
|
|
39364
|
+
field_key: child.link.id,
|
|
39250
39365
|
operator: "has_any_of",
|
|
39251
39366
|
value: [`{{params.${child.param}}}`]
|
|
39252
39367
|
}
|
|
@@ -39254,7 +39369,7 @@ function planQueries(bound) {
|
|
|
39254
39369
|
columns: [...child.fields.values()].map((field) => field.id)
|
|
39255
39370
|
},
|
|
39256
39371
|
params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
|
|
39257
|
-
description: `${child.entity.label} \u2014 the rows under one ${entry.screen.entity.label}`
|
|
39372
|
+
description: `${child.entity.label} \u2014 the rows ${child.via === "party" ? "naming" : "under"} one ${entry.screen.entity.label}`
|
|
39258
39373
|
};
|
|
39259
39374
|
}
|
|
39260
39375
|
}
|
|
@@ -39568,8 +39683,12 @@ function roleColumn(spec) {
|
|
|
39568
39683
|
// the same value reads "—" in the facts beside it.
|
|
39569
39684
|
case "measure":
|
|
39570
39685
|
return `${head}, width: 96, align: "right", cell: (${bind}) => { const level = amount(${ref}); return level === null ? null : <NumberCell value={level} />; } }`;
|
|
39686
|
+
// ONE VALUE, ONE READING: the currency is the FIELD's, so a total reads the
|
|
39687
|
+
// same money in the band, in this column and in the facts behind it. Stated
|
|
39688
|
+
// nowhere, the cell falls back to the reader's locale, which is the only
|
|
39689
|
+
// answer a field that names no currency has.
|
|
39571
39690
|
case "amount":
|
|
39572
|
-
return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref}); return sum === null ? null : <MoneyCell value={sum} />; } }`;
|
|
39691
|
+
return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref}); return sum === null ? null : <MoneyCell value={sum}${spec.currency === void 0 ? "" : ` currency=${str(spec.currency)}`} />; } }`;
|
|
39573
39692
|
case "lifecycle":
|
|
39574
39693
|
return `${head}, width: 120, cell: (${bind}) => { const held = stageOf(${options}, ${ref}); return held === null ? null : <StageCell stage={held} />; } }`;
|
|
39575
39694
|
case "verdict":
|
|
@@ -39582,11 +39701,13 @@ function childColumn(child, alias) {
|
|
|
39582
39701
|
const field = child.fields.get(alias);
|
|
39583
39702
|
const role = child.roles.get(alias);
|
|
39584
39703
|
if (field === void 0 || role === void 0) return null;
|
|
39704
|
+
const declared = child.entity.fields.find((candidate) => candidate.alias === alias);
|
|
39585
39705
|
return roleColumn({
|
|
39586
39706
|
role,
|
|
39587
39707
|
key: field.alias,
|
|
39588
39708
|
label: field.label,
|
|
39589
39709
|
type: field.type,
|
|
39710
|
+
currency: declared === void 0 ? void 0 : currencyOf(declared),
|
|
39590
39711
|
bind: "child",
|
|
39591
39712
|
ref: `child[F.${child.tableAlias}.${field.alias}]`,
|
|
39592
39713
|
options: `${camel(child.alias)}Fields[F.${child.tableAlias}.${field.alias}]`
|
|
@@ -39906,6 +40027,7 @@ ${pageBody}
|
|
|
39906
40027
|
key: field.alias,
|
|
39907
40028
|
label: field.label,
|
|
39908
40029
|
type: field.type,
|
|
40030
|
+
currency: currencyOf(slot2.field),
|
|
39909
40031
|
bind: "r",
|
|
39910
40032
|
ref: `r[T.${field.alias}]`,
|
|
39911
40033
|
options: `fields[T.${field.alias}]`
|
|
@@ -40618,7 +40740,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
40618
40740
|
}
|
|
40619
40741
|
|
|
40620
40742
|
// src/model_reference.md
|
|
40621
|
-
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| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\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 "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "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": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "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` | `mark`, `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) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` links here. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet` and `lines:` a `RecordChildren`; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\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; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "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 record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered)\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 record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\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. Each\nrecord is a header and facts here: neither entity carries a stage, a required\nset, files, or rows of its own. The Customers page states its name in the header\nand nowhere else; the Orders drawer has no `identity` to state, so every field\nit reads is a fact.\n';
|
|
40743
|
+
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| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\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 "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "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": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "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` | `mark`, `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) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` OR `party` links here \u2014 so a party\'s record is its\nhistory, the rows that name it. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\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; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "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 record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount)\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 record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\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. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe customer\'s `history:`, which is the orders that name it as their party, a\nsection nothing in the file declares. The Customers page states its name in the\nheader and nowhere else; the Orders drawer has no `identity` to state, so every\nfield it reads is a fact.\n';
|
|
40622
40744
|
|
|
40623
40745
|
// src/scaffold_commands.ts
|
|
40624
40746
|
function printModelReference() {
|
|
@@ -40774,8 +40896,10 @@ function describeRecord(model, resolved) {
|
|
|
40774
40896
|
const set2 = `${required2.options.length} required`;
|
|
40775
40897
|
return section.source === "own" ? `documents: ${name} (${set2})` : `documents: ${name} (${required2.label}: ${set2})`;
|
|
40776
40898
|
}
|
|
40777
|
-
case "children":
|
|
40778
|
-
|
|
40899
|
+
case "children": {
|
|
40900
|
+
const heading = section.via === "party" ? "history" : "lines";
|
|
40901
|
+
return `${heading}: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
|
|
40902
|
+
}
|
|
40779
40903
|
case "files":
|
|
40780
40904
|
return `files: ${labels(section.fields)}`;
|
|
40781
40905
|
}
|
|
@@ -41102,15 +41226,7 @@ async function dispatchRpc(client, body, opts) {
|
|
|
41102
41226
|
if (!p || typeof p.alias !== "string") {
|
|
41103
41227
|
throw new Error("query payload must include `alias`");
|
|
41104
41228
|
}
|
|
41105
|
-
return client.appQuery(body.app_id, {
|
|
41106
|
-
alias: p.alias,
|
|
41107
|
-
params: p.params,
|
|
41108
|
-
limit: p.limit,
|
|
41109
|
-
offset: p.offset,
|
|
41110
|
-
sort: p.sort,
|
|
41111
|
-
filter: p.filter,
|
|
41112
|
-
count: p.count
|
|
41113
|
-
});
|
|
41229
|
+
return client.appQuery(body.app_id, { ...p, alias: p.alias });
|
|
41114
41230
|
}
|
|
41115
41231
|
case "field_options": {
|
|
41116
41232
|
const p = body.payload;
|
|
@@ -41244,6 +41360,12 @@ function openAppTarget(payload) {
|
|
|
41244
41360
|
}
|
|
41245
41361
|
|
|
41246
41362
|
// src/dev/wrapper_page.ts
|
|
41363
|
+
function deepLinkLoc(pathname, search) {
|
|
41364
|
+
var explicit = new URLSearchParams(search).get("_loc");
|
|
41365
|
+
if (explicit) return explicit;
|
|
41366
|
+
if (pathname === "/" || pathname === "/index.html") return null;
|
|
41367
|
+
return pathname + search;
|
|
41368
|
+
}
|
|
41247
41369
|
function buildWrapperPage(args) {
|
|
41248
41370
|
const { app_name, app_id, workspace_id, vite_url, api_url, web_app_url } = args;
|
|
41249
41371
|
return `<!doctype html>
|
|
@@ -41301,7 +41423,8 @@ function buildWrapperPage(args) {
|
|
|
41301
41423
|
// server (a url param flowing into an iframe src is a redirect vector);
|
|
41302
41424
|
// anything else falls back to the app root.
|
|
41303
41425
|
const appSrc = new URL(VITE_URL);
|
|
41304
|
-
|
|
41426
|
+
var deepLinkLoc = (${deepLinkLoc.toString()});
|
|
41427
|
+
const savedLoc = deepLinkLoc(window.location.pathname, window.location.search);
|
|
41305
41428
|
if (savedLoc) {
|
|
41306
41429
|
try {
|
|
41307
41430
|
const resolved = new URL(savedLoc, VITE_ORIGIN);
|
|
@@ -41661,6 +41784,18 @@ var STOP_DEADLINE_MS = 2e3;
|
|
|
41661
41784
|
function serializeRpcResult(result) {
|
|
41662
41785
|
return JSON.stringify(result ?? null);
|
|
41663
41786
|
}
|
|
41787
|
+
function servesWrapperPage(method, pathname) {
|
|
41788
|
+
return method === "GET" && !pathname.startsWith("/_");
|
|
41789
|
+
}
|
|
41790
|
+
function viteChildArgs(viteEntry, vitePort, uiSrc) {
|
|
41791
|
+
return [
|
|
41792
|
+
viteEntry,
|
|
41793
|
+
"--port",
|
|
41794
|
+
String(vitePort),
|
|
41795
|
+
"--strictPort",
|
|
41796
|
+
...uiSrc ? ["--force"] : []
|
|
41797
|
+
];
|
|
41798
|
+
}
|
|
41664
41799
|
function nodeForChildProcess() {
|
|
41665
41800
|
return process.versions.bun === void 0 ? process.execPath : "node";
|
|
41666
41801
|
}
|
|
@@ -41684,7 +41819,7 @@ async function startDevServer(args) {
|
|
|
41684
41819
|
}
|
|
41685
41820
|
const viteChild = spawn(
|
|
41686
41821
|
nodeForChildProcess(),
|
|
41687
|
-
|
|
41822
|
+
viteChildArgs(viteEntry, vitePort, process.env.LOTICS_UI_SRC),
|
|
41688
41823
|
{
|
|
41689
41824
|
cwd: args.projectDir,
|
|
41690
41825
|
stdio: [process.stdin.isTTY ? "inherit" : "ignore", "inherit", "inherit"],
|
|
@@ -41733,7 +41868,7 @@ async function startDevServer(args) {
|
|
|
41733
41868
|
const server = http.createServer(async (req, res) => {
|
|
41734
41869
|
const url2 = req.url ?? "/";
|
|
41735
41870
|
const pathname = url2.split("?")[0];
|
|
41736
|
-
if (req.method
|
|
41871
|
+
if (servesWrapperPage(req.method, pathname)) {
|
|
41737
41872
|
res.writeHead(200, {
|
|
41738
41873
|
"Content-Type": "text/html; charset=utf-8",
|
|
41739
41874
|
"Cache-Control": "no-cache, no-store, must-revalidate"
|
|
@@ -43390,6 +43525,7 @@ __export(expression_string_exports, {
|
|
|
43390
43525
|
capitalize: () => capitalize,
|
|
43391
43526
|
contains: () => contains,
|
|
43392
43527
|
endsWith: () => endsWith,
|
|
43528
|
+
formatDecimal: () => formatDecimal,
|
|
43393
43529
|
formatNumber: () => formatNumber,
|
|
43394
43530
|
join: () => join3,
|
|
43395
43531
|
length: () => length,
|
|
@@ -43405,6 +43541,18 @@ __export(expression_string_exports, {
|
|
|
43405
43541
|
trim: () => trim,
|
|
43406
43542
|
upper: () => upper
|
|
43407
43543
|
});
|
|
43544
|
+
|
|
43545
|
+
// ../shared/src/expression_locale.ts
|
|
43546
|
+
function isUsableLocale(locale) {
|
|
43547
|
+
try {
|
|
43548
|
+
Intl.NumberFormat.supportedLocalesOf(locale);
|
|
43549
|
+
return true;
|
|
43550
|
+
} catch {
|
|
43551
|
+
return false;
|
|
43552
|
+
}
|
|
43553
|
+
}
|
|
43554
|
+
|
|
43555
|
+
// ../shared/src/expression_string.ts
|
|
43408
43556
|
function join3(arr, separator) {
|
|
43409
43557
|
if (arr == null) return "";
|
|
43410
43558
|
if (!Array.isArray(arr)) {
|
|
@@ -43637,10 +43785,29 @@ function formatNumber(value, decimals) {
|
|
|
43637
43785
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
43638
43786
|
throw new Error("formatNumber: expected number, got " + typeof value);
|
|
43639
43787
|
}
|
|
43788
|
+
assertDecimals(decimals, "formatNumber");
|
|
43789
|
+
return value.toFixed(decimals);
|
|
43790
|
+
}
|
|
43791
|
+
function formatDecimal(value, decimals, locale) {
|
|
43792
|
+
if (value == null) return "";
|
|
43793
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
43794
|
+
throw new Error("formatDecimal: expected number, got " + typeof value);
|
|
43795
|
+
}
|
|
43796
|
+
assertDecimals(decimals, "formatDecimal");
|
|
43797
|
+
if (typeof locale !== "string" || locale === "" || !isUsableLocale(locale)) {
|
|
43798
|
+
throw new Error(
|
|
43799
|
+
`formatDecimal(value, decimals, locale) requires a locale tag. Got ${JSON.stringify(locale)}. Example: formatDecimal(151000, 0, "vi-VN").`
|
|
43800
|
+
);
|
|
43801
|
+
}
|
|
43802
|
+
return new Intl.NumberFormat(locale, {
|
|
43803
|
+
minimumFractionDigits: decimals,
|
|
43804
|
+
maximumFractionDigits: decimals
|
|
43805
|
+
}).format(value);
|
|
43806
|
+
}
|
|
43807
|
+
function assertDecimals(decimals, funcName) {
|
|
43640
43808
|
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 100) {
|
|
43641
|
-
throw new Error(
|
|
43809
|
+
throw new Error(`${funcName}: decimals must be an integer between 0 and 100`);
|
|
43642
43810
|
}
|
|
43643
|
-
return value.toFixed(decimals);
|
|
43644
43811
|
}
|
|
43645
43812
|
|
|
43646
43813
|
// ../shared/src/expression_math.ts
|
|
@@ -44053,7 +44220,7 @@ function coalesce(...values2) {
|
|
|
44053
44220
|
for (const val of values2) {
|
|
44054
44221
|
if (val != null) return val;
|
|
44055
44222
|
}
|
|
44056
|
-
return
|
|
44223
|
+
return null;
|
|
44057
44224
|
}
|
|
44058
44225
|
function toNumber(val) {
|
|
44059
44226
|
if (val == null) return null;
|
|
@@ -44122,6 +44289,15 @@ function parseDateStringInTimezone(dateStr, timezone) {
|
|
|
44122
44289
|
const zonedDate = new TZDate(year, month, day, hour, minute, second, ms, timezone);
|
|
44123
44290
|
return new Date(zonedDate.getTime());
|
|
44124
44291
|
}
|
|
44292
|
+
function isDayOnly(date5) {
|
|
44293
|
+
return typeof date5 === "string" && /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(date5);
|
|
44294
|
+
}
|
|
44295
|
+
function compareCalendarDays(left, right, timezone) {
|
|
44296
|
+
if (timezone) {
|
|
44297
|
+
return differenceInCalendarDays(new TZDate(left, timezone), new TZDate(right, timezone));
|
|
44298
|
+
}
|
|
44299
|
+
return differenceInCalendarDays(left, right);
|
|
44300
|
+
}
|
|
44125
44301
|
function normalizeDate(date5, timezone) {
|
|
44126
44302
|
if (date5 instanceof Date) {
|
|
44127
44303
|
return date5;
|
|
@@ -44403,6 +44579,13 @@ function getDateExpressionFunctions(defaultTimezone) {
|
|
|
44403
44579
|
// ========================================================================
|
|
44404
44580
|
/**
|
|
44405
44581
|
* Check if dateLeft is before dateRight. Returns false if either is null.
|
|
44582
|
+
*
|
|
44583
|
+
* A DAY-precision operand (a `date` field, which holds no time) compares at
|
|
44584
|
+
* DAY granularity in the workspace timezone. Resolving the day to midnight
|
|
44585
|
+
* and comparing instants made `isBefore({due}, now())` answer by the
|
|
44586
|
+
* reader's offset: the deadline "had passed" from the first hour of its own
|
|
44587
|
+
* day, and a countdown rendered from the same cell disagreed with the flag
|
|
44588
|
+
* beside it. Two instants still compare as instants.
|
|
44406
44589
|
*/
|
|
44407
44590
|
isBefore: (dateLeft, dateRight, timezone) => {
|
|
44408
44591
|
if (dateLeft == null || dateLeft === "" || dateRight == null || dateRight === "") return false;
|
|
@@ -44412,10 +44595,14 @@ function getDateExpressionFunctions(defaultTimezone) {
|
|
|
44412
44595
|
const tz = getTimezone(timezone);
|
|
44413
44596
|
const left = normalizeDateWithValidation(dateLeft, tz, "isBefore");
|
|
44414
44597
|
const right = normalizeDateWithValidation(dateRight, tz, "isBefore");
|
|
44598
|
+
if (isDayOnly(dateLeft) || isDayOnly(dateRight)) {
|
|
44599
|
+
return compareCalendarDays(left, right, tz) < 0;
|
|
44600
|
+
}
|
|
44415
44601
|
return isBefore(left, right);
|
|
44416
44602
|
},
|
|
44417
44603
|
/**
|
|
44418
44604
|
* Check if dateLeft is after dateRight. Returns false if either is null.
|
|
44605
|
+
* Day-precision operands compare at day granularity — see isBefore.
|
|
44419
44606
|
*/
|
|
44420
44607
|
isAfter: (dateLeft, dateRight, timezone) => {
|
|
44421
44608
|
if (dateLeft == null || dateLeft === "" || dateRight == null || dateRight === "") return false;
|
|
@@ -44425,6 +44612,9 @@ function getDateExpressionFunctions(defaultTimezone) {
|
|
|
44425
44612
|
const tz = getTimezone(timezone);
|
|
44426
44613
|
const left = normalizeDateWithValidation(dateLeft, tz, "isAfter");
|
|
44427
44614
|
const right = normalizeDateWithValidation(dateRight, tz, "isAfter");
|
|
44615
|
+
if (isDayOnly(dateLeft) || isDayOnly(dateRight)) {
|
|
44616
|
+
return compareCalendarDays(left, right, tz) > 0;
|
|
44617
|
+
}
|
|
44428
44618
|
return isAfter(left, right);
|
|
44429
44619
|
},
|
|
44430
44620
|
/**
|
|
@@ -44465,6 +44655,9 @@ function getDateExpressionFunctions(defaultTimezone) {
|
|
|
44465
44655
|
},
|
|
44466
44656
|
/**
|
|
44467
44657
|
* Check if a date is within a range (inclusive). Returns false if any input is null.
|
|
44658
|
+
* It is the two comparisons above, so a day-precision operand anywhere in it
|
|
44659
|
+
* puts the whole check at day granularity — otherwise `isWithinRange(d, a, b)`
|
|
44660
|
+
* and `!isBefore(d, a) && !isAfter(d, b)` would answer differently.
|
|
44468
44661
|
*/
|
|
44469
44662
|
isWithinRange: (date5, startDate, endDate, timezone) => {
|
|
44470
44663
|
if (date5 == null || date5 === "" || startDate == null || startDate === "" || endDate == null || endDate === "") return false;
|
|
@@ -44476,6 +44669,9 @@ function getDateExpressionFunctions(defaultTimezone) {
|
|
|
44476
44669
|
const normalizedDate = normalizeDateWithValidation(date5, tz, "isWithinRange");
|
|
44477
44670
|
const normalizedStart = normalizeDateWithValidation(startDate, tz, "isWithinRange");
|
|
44478
44671
|
const normalizedEnd = normalizeDateWithValidation(endDate, tz, "isWithinRange");
|
|
44672
|
+
if (isDayOnly(date5) || isDayOnly(startDate) || isDayOnly(endDate)) {
|
|
44673
|
+
return compareCalendarDays(normalizedDate, normalizedStart, tz) >= 0 && compareCalendarDays(normalizedDate, normalizedEnd, tz) <= 0;
|
|
44674
|
+
}
|
|
44479
44675
|
return isWithinInterval(normalizedDate, {
|
|
44480
44676
|
start: normalizedStart,
|
|
44481
44677
|
end: normalizedEnd
|
|
@@ -46967,9 +47163,12 @@ function walkToolInvocation(call, scope, opts, allowWaits) {
|
|
|
46967
47163
|
const input = walkToolInputObject(arg, scope);
|
|
46968
47164
|
return { tool_name: name, input };
|
|
46969
47165
|
}
|
|
47166
|
+
var TOOL_INPUT_ARRAY_SPREAD = "Spread is not supported inside a tool input. Write concat(<array>, <array>) here, or bind the merged array first (const xs = [...a, ...b];) and pass xs.";
|
|
47167
|
+
var TOOL_INPUT_OBJECT_SPREAD = "Spread is not supported inside a tool input. Bind the merged object first (const x = { ...a, b: 1 };) and pass x.";
|
|
46970
47168
|
function walkToolInputObject(obj, scope) {
|
|
46971
47169
|
const out = {};
|
|
46972
47170
|
for (const p of obj.properties) {
|
|
47171
|
+
if (p.type === "SpreadElement") fail2(p, TOOL_INPUT_OBJECT_SPREAD, "spread_element");
|
|
46973
47172
|
if (p.type !== "ObjectProperty") fail2(p, "Tool kwargs must be plain key: value pairs.");
|
|
46974
47173
|
if (p.computed) fail2(p, "Computed property keys are not supported.", "computed_key");
|
|
46975
47174
|
const key = p.key.type === "StringLiteral" ? p.key.value : p.key.type === "Identifier" ? p.key.name : null;
|
|
@@ -46982,6 +47181,7 @@ function walkToolInputValue(node, scope) {
|
|
|
46982
47181
|
if (node.type === "ObjectExpression") {
|
|
46983
47182
|
const inner = {};
|
|
46984
47183
|
for (const p of node.properties) {
|
|
47184
|
+
if (p.type === "SpreadElement") fail2(p, TOOL_INPUT_OBJECT_SPREAD, "spread_element");
|
|
46985
47185
|
if (p.type !== "ObjectProperty") fail2(p, "Tool kwargs must be plain key: value pairs.");
|
|
46986
47186
|
if (p.computed) fail2(p, "Computed property keys are not supported.");
|
|
46987
47187
|
const key = p.key.type === "StringLiteral" ? p.key.value : p.key.type === "Identifier" ? p.key.name : null;
|
|
@@ -46994,7 +47194,7 @@ function walkToolInputValue(node, scope) {
|
|
|
46994
47194
|
const arr = [];
|
|
46995
47195
|
for (const elem of node.elements) {
|
|
46996
47196
|
if (elem === null) fail2(node, "Sparse arrays are not supported.", "sparse_array");
|
|
46997
|
-
if (elem.type === "SpreadElement") fail2(elem,
|
|
47197
|
+
if (elem.type === "SpreadElement") fail2(elem, TOOL_INPUT_ARRAY_SPREAD, "spread_element");
|
|
46998
47198
|
arr.push(walkToolInputValue(elem, scope));
|
|
46999
47199
|
}
|
|
47000
47200
|
return arr;
|
|
@@ -47504,6 +47704,7 @@ function generatePrefixedId(prefix) {
|
|
|
47504
47704
|
return `${prefix}_${nanoid3()}`;
|
|
47505
47705
|
}
|
|
47506
47706
|
var RECORD_ID_PATTERN = new RegExp(`^rec_[${ID_ALPHABET}]{${ID_LENGTH}}$`);
|
|
47707
|
+
var FILE_ID_PATTERN = new RegExp(`^fil_[${ID_ALPHABET}]{${ID_LENGTH}}$`);
|
|
47507
47708
|
function generateWorkspaceId() {
|
|
47508
47709
|
return generatePrefixedId("wsp");
|
|
47509
47710
|
}
|
|
@@ -47676,6 +47877,137 @@ async function requireLatestNpmVersion(packageName, { timeoutMs = 1e4 } = {}) {
|
|
|
47676
47877
|
return version2;
|
|
47677
47878
|
}
|
|
47678
47879
|
|
|
47880
|
+
// src/line_diff.ts
|
|
47881
|
+
function lineDiff(before, after) {
|
|
47882
|
+
const rows = before.length;
|
|
47883
|
+
const cols = after.length;
|
|
47884
|
+
const lcs = Array.from(
|
|
47885
|
+
{ length: rows + 1 },
|
|
47886
|
+
() => Array.from({ length: cols + 1 }, () => 0)
|
|
47887
|
+
);
|
|
47888
|
+
for (let i2 = rows - 1; i2 >= 0; i2--) {
|
|
47889
|
+
for (let j2 = cols - 1; j2 >= 0; j2--) {
|
|
47890
|
+
lcs[i2][j2] = before[i2] === after[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
47891
|
+
}
|
|
47892
|
+
}
|
|
47893
|
+
const out = [];
|
|
47894
|
+
let i = 0;
|
|
47895
|
+
let j = 0;
|
|
47896
|
+
while (i < rows && j < cols) {
|
|
47897
|
+
if (before[i] === after[j]) {
|
|
47898
|
+
out.push({ kind: "same", text: before[i] });
|
|
47899
|
+
i++;
|
|
47900
|
+
j++;
|
|
47901
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
47902
|
+
out.push({ kind: "removed", text: before[i] });
|
|
47903
|
+
i++;
|
|
47904
|
+
} else {
|
|
47905
|
+
out.push({ kind: "added", text: after[j] });
|
|
47906
|
+
j++;
|
|
47907
|
+
}
|
|
47908
|
+
}
|
|
47909
|
+
for (; i < rows; i++) out.push({ kind: "removed", text: before[i] });
|
|
47910
|
+
for (; j < cols; j++) out.push({ kind: "added", text: after[j] });
|
|
47911
|
+
return out;
|
|
47912
|
+
}
|
|
47913
|
+
var MARK = { same: " ", removed: "-", added: "+" };
|
|
47914
|
+
function renderLineDiff(before, after, opts = {}) {
|
|
47915
|
+
const context = opts.context ?? 3;
|
|
47916
|
+
const lines = lineDiff(before.split("\n"), after.split("\n"));
|
|
47917
|
+
const changed = lines.map((line) => line.kind !== "same");
|
|
47918
|
+
const keep = lines.map(
|
|
47919
|
+
(_, index) => changed.slice(Math.max(0, index - context), index + context + 1).some(Boolean)
|
|
47920
|
+
);
|
|
47921
|
+
const out = [];
|
|
47922
|
+
let elided = false;
|
|
47923
|
+
for (const [index, line] of lines.entries()) {
|
|
47924
|
+
if (!keep[index]) {
|
|
47925
|
+
elided = true;
|
|
47926
|
+
continue;
|
|
47927
|
+
}
|
|
47928
|
+
if (elided && out.length > 0) out.push(" \u2026");
|
|
47929
|
+
elided = false;
|
|
47930
|
+
out.push(`${MARK[line.kind]} ${line.text}`);
|
|
47931
|
+
}
|
|
47932
|
+
return out.join("\n");
|
|
47933
|
+
}
|
|
47934
|
+
|
|
47935
|
+
// src/vite_config_text.ts
|
|
47936
|
+
var VITE_CONFIG_NAMES = [
|
|
47937
|
+
"vite.config.ts",
|
|
47938
|
+
"vite.config.mts",
|
|
47939
|
+
"vite.config.cts",
|
|
47940
|
+
"vite.config.js",
|
|
47941
|
+
"vite.config.mjs",
|
|
47942
|
+
"vite.config.cjs"
|
|
47943
|
+
];
|
|
47944
|
+
function codeOnly(source) {
|
|
47945
|
+
let out = "";
|
|
47946
|
+
let i = 0;
|
|
47947
|
+
while (i < source.length) {
|
|
47948
|
+
const two = source.slice(i, i + 2);
|
|
47949
|
+
if (two === "//") {
|
|
47950
|
+
while (i < source.length && source[i] !== "\n") {
|
|
47951
|
+
out += " ";
|
|
47952
|
+
i++;
|
|
47953
|
+
}
|
|
47954
|
+
continue;
|
|
47955
|
+
}
|
|
47956
|
+
if (two === "/*") {
|
|
47957
|
+
while (i < source.length && source.slice(i, i + 2) !== "*/") {
|
|
47958
|
+
out += source[i] === "\n" ? "\n" : " ";
|
|
47959
|
+
i++;
|
|
47960
|
+
}
|
|
47961
|
+
out += " ";
|
|
47962
|
+
i += 2;
|
|
47963
|
+
continue;
|
|
47964
|
+
}
|
|
47965
|
+
const quote2 = source[i];
|
|
47966
|
+
if (quote2 === '"' || quote2 === "'" || quote2 === "`") {
|
|
47967
|
+
out += " ";
|
|
47968
|
+
i++;
|
|
47969
|
+
while (i < source.length && source[i] !== quote2) {
|
|
47970
|
+
if (source[i] === "\\") {
|
|
47971
|
+
out += " ";
|
|
47972
|
+
i += 2;
|
|
47973
|
+
continue;
|
|
47974
|
+
}
|
|
47975
|
+
out += source[i] === "\n" ? "\n" : " ";
|
|
47976
|
+
i++;
|
|
47977
|
+
}
|
|
47978
|
+
out += " ";
|
|
47979
|
+
i++;
|
|
47980
|
+
continue;
|
|
47981
|
+
}
|
|
47982
|
+
out += source[i];
|
|
47983
|
+
i++;
|
|
47984
|
+
}
|
|
47985
|
+
return out;
|
|
47986
|
+
}
|
|
47987
|
+
function objectBlocks(code, key) {
|
|
47988
|
+
const blocks = [];
|
|
47989
|
+
const literal2 = key.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
47990
|
+
for (const opener of code.matchAll(new RegExp(String.raw`\b${literal2}\s*:\s*\{`, "g"))) {
|
|
47991
|
+
let depth = 1;
|
|
47992
|
+
let i = opener.index + opener[0].length;
|
|
47993
|
+
const start = i;
|
|
47994
|
+
while (i < code.length && depth > 0) {
|
|
47995
|
+
if (code[i] === "{") depth++;
|
|
47996
|
+
else if (code[i] === "}") depth--;
|
|
47997
|
+
i++;
|
|
47998
|
+
}
|
|
47999
|
+
blocks.push(code.slice(start, i));
|
|
48000
|
+
}
|
|
48001
|
+
return blocks;
|
|
48002
|
+
}
|
|
48003
|
+
function declaredVitePort(config2) {
|
|
48004
|
+
for (const block of objectBlocks(codeOnly(config2), "server")) {
|
|
48005
|
+
const found = block.match(/(?:^|[{,;])\s*port\s*:\s*(\d+)\s*(?:[,}]|$)/);
|
|
48006
|
+
if (found) return Number(found[1]);
|
|
48007
|
+
}
|
|
48008
|
+
return null;
|
|
48009
|
+
}
|
|
48010
|
+
|
|
47679
48011
|
// ../shared/src/schemas/workflow_issues.ts
|
|
47680
48012
|
var issueSchema = external_exports.object({
|
|
47681
48013
|
source: external_exports.enum(["parse", "typecheck", "resolve", "lint", "structural"]),
|
|
@@ -48300,16 +48632,16 @@ function writeAppMeta(projectDir, meta3) {
|
|
|
48300
48632
|
pkg.lotics = { ...meta3, ...preserved };
|
|
48301
48633
|
fs11.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
48302
48634
|
}
|
|
48303
|
-
function
|
|
48635
|
+
function writeWorkflowDeclarationFields(projectDir, alias, fields) {
|
|
48304
48636
|
const pkgPath = path12.join(projectDir, "package.json");
|
|
48305
48637
|
const pkg = JSON.parse(fs11.readFileSync(pkgPath, "utf-8"));
|
|
48306
48638
|
const declaration = pkg.lotics?.workflows?.[alias];
|
|
48307
48639
|
if (!declaration) {
|
|
48308
48640
|
throw new Error(
|
|
48309
|
-
`package.json#lotics.workflows.${alias} disappeared before the
|
|
48641
|
+
`package.json#lotics.workflows.${alias} disappeared before the post-set write-back.`
|
|
48310
48642
|
);
|
|
48311
48643
|
}
|
|
48312
|
-
declaration
|
|
48644
|
+
Object.assign(declaration, fields);
|
|
48313
48645
|
fs11.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
48314
48646
|
}
|
|
48315
48647
|
function workflowBodyDrift(projectDir, workflows, synced = {}) {
|
|
@@ -48419,13 +48751,22 @@ function writeAppDtsKitLinks(projectDir) {
|
|
|
48419
48751
|
writeDevLinkTsconfig(projectDir);
|
|
48420
48752
|
writeKitTypeAugmentation(projectDir);
|
|
48421
48753
|
}
|
|
48422
|
-
function writeAppDts(projectDir, manifest) {
|
|
48754
|
+
function writeAppDts(projectDir, manifest, optionsByFieldKey) {
|
|
48423
48755
|
const dotLotics = path12.join(projectDir, ".lotics");
|
|
48424
48756
|
fs11.mkdirSync(dotLotics, { recursive: true });
|
|
48425
48757
|
const written = [
|
|
48426
|
-
[
|
|
48427
|
-
|
|
48428
|
-
|
|
48758
|
+
[
|
|
48759
|
+
path12.join(dotLotics, "app_workflows.d.ts"),
|
|
48760
|
+
generateAppWorkflowsDts(manifest.workflows, optionsByFieldKey)
|
|
48761
|
+
],
|
|
48762
|
+
[
|
|
48763
|
+
path12.join(dotLotics, "app_queries.d.ts"),
|
|
48764
|
+
generateAppQueriesDts(manifest.queries, optionsByFieldKey)
|
|
48765
|
+
],
|
|
48766
|
+
[
|
|
48767
|
+
path12.join(dotLotics, "app_agents.d.ts"),
|
|
48768
|
+
generateAppAgentsDts(manifest.agents, optionsByFieldKey)
|
|
48769
|
+
]
|
|
48429
48770
|
];
|
|
48430
48771
|
for (const [file2, content] of written) fs11.writeFileSync(file2, content);
|
|
48431
48772
|
writeAppDtsKitLinks(projectDir);
|
|
@@ -48448,34 +48789,36 @@ function writeAppFields(projectDir, tables) {
|
|
|
48448
48789
|
fs11.writeFileSync(file2, generateAppFields(tables));
|
|
48449
48790
|
return file2;
|
|
48450
48791
|
}
|
|
48451
|
-
async function writeGeneratedAppFields(client, projectDir, app,
|
|
48792
|
+
async function writeGeneratedAppFields(client, projectDir, app, manifest) {
|
|
48793
|
+
const queries = manifest.queries ?? {};
|
|
48452
48794
|
if (client.viewAsMemberId) {
|
|
48453
48795
|
console.error(
|
|
48454
48796
|
`\u26A0 Skipped .lotics/app_fields.ts \u2014 running with --view-as ${client.viewAsMemberId}. The schema is read as that member, so any table they cannot see would be silently missing from F/OPT. Re-run without --view-as to regenerate it.`
|
|
48455
48797
|
);
|
|
48456
48798
|
return;
|
|
48457
48799
|
}
|
|
48800
|
+
let tables;
|
|
48458
48801
|
try {
|
|
48459
|
-
|
|
48460
|
-
|
|
48461
|
-
|
|
48462
|
-
|
|
48463
|
-
|
|
48464
|
-
|
|
48465
|
-
`\u26A0 Skipped .lotics/app_fields.ts \u2014 ${missing.length} table(s) the app queries could not be read: ${missing.join(", ")}.
|
|
48802
|
+
const tableIds = resolveCodegenTableIds(projectDir, queries);
|
|
48803
|
+
tables = await client.getWorkspaceSchema(tableIds);
|
|
48804
|
+
const missing = tableIds.filter((id) => !tables.some((t) => t.id === id));
|
|
48805
|
+
if (missing.length > 0) {
|
|
48806
|
+
console.error(
|
|
48807
|
+
`\u26A0 Skipped .lotics/app_fields.ts \u2014 ${missing.length} table(s) the app queries could not be read: ${missing.join(", ")}.
|
|
48466
48808
|
Writing anyway would bake a narrowed F/OPT map that compiles and then throws at runtime. Check this key's access to those tables.
|
|
48467
48809
|
An existing app_fields.ts is kept and still works; a project that has none yet will fail to BUILD until this resolves \u2014 which is the loud failure, not a new problem.`
|
|
48468
|
-
);
|
|
48469
|
-
return;
|
|
48470
|
-
}
|
|
48471
|
-
const fieldsPath = writeAppFields(projectDir, tables);
|
|
48472
|
-
note(
|
|
48473
|
-
`Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"})`
|
|
48474
48810
|
);
|
|
48811
|
+
return;
|
|
48475
48812
|
}
|
|
48813
|
+
const fieldsPath = writeAppFields(projectDir, tables);
|
|
48814
|
+
note(
|
|
48815
|
+
`Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"})`
|
|
48816
|
+
);
|
|
48476
48817
|
} catch (err) {
|
|
48477
48818
|
warnAppFieldsUnwritten(projectDir, err);
|
|
48819
|
+
return;
|
|
48478
48820
|
}
|
|
48821
|
+
writeAppDts(projectDir, manifest, selectOptionsByFieldKey(tables));
|
|
48479
48822
|
}
|
|
48480
48823
|
function warnAppFieldsUnwritten(projectDir, err) {
|
|
48481
48824
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -48519,6 +48862,13 @@ function installedKitVersion(projectDir, pkg) {
|
|
|
48519
48862
|
return null;
|
|
48520
48863
|
}
|
|
48521
48864
|
}
|
|
48865
|
+
function installedKitMajorAtLeast(projectDir, pkg, major) {
|
|
48866
|
+
const version2 = installedKitVersion(projectDir, pkg);
|
|
48867
|
+
if (version2 === null) return false;
|
|
48868
|
+
const installedMajor = parseInt(version2.split(".")[0], 10);
|
|
48869
|
+
if (Number.isNaN(installedMajor)) return false;
|
|
48870
|
+
return installedMajor >= major;
|
|
48871
|
+
}
|
|
48522
48872
|
async function publishedKitVersion(pkg) {
|
|
48523
48873
|
const controller = new AbortController();
|
|
48524
48874
|
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
@@ -48674,6 +49024,7 @@ async function appCodegen(args) {
|
|
|
48674
49024
|
for (const alias of pruneOrphanWorkflowGlobals(projectDir, declared)) {
|
|
48675
49025
|
console.error(`Removed stale workflow types for ${alias} \u2014 no longer in package.json#lotics.workflows`);
|
|
48676
49026
|
}
|
|
49027
|
+
reportUndeclaredCalledAliases(calledAppAliases(readAppSourceText(projectDir)), meta3);
|
|
48677
49028
|
for (const alias of orphanWorkflowBodies(projectDir, declared)) {
|
|
48678
49029
|
console.error(
|
|
48679
49030
|
`\u26A0 ${path12.join(WORKFLOWS_DIR, `${alias}.ts`)} is not declared in package.json#lotics.workflows \u2014 'workflow check' and 'workflow set' both skip it. Declare the alias or delete the file.`
|
|
@@ -48688,12 +49039,11 @@ async function appCodegen(args) {
|
|
|
48688
49039
|
let app = null;
|
|
48689
49040
|
try {
|
|
48690
49041
|
app = await args.client.getApp(meta3.app_id);
|
|
48691
|
-
await writeGeneratedAppFields(
|
|
48692
|
-
|
|
48693
|
-
|
|
48694
|
-
|
|
48695
|
-
|
|
48696
|
-
);
|
|
49042
|
+
await writeGeneratedAppFields(args.client, projectDir, { app_id: meta3.app_id }, {
|
|
49043
|
+
workflows: meta3.workflows,
|
|
49044
|
+
queries: meta3.queries,
|
|
49045
|
+
agents: meta3.agents
|
|
49046
|
+
});
|
|
48697
49047
|
} catch (err) {
|
|
48698
49048
|
warnAppFieldsUnwritten(projectDir, err);
|
|
48699
49049
|
}
|
|
@@ -48722,17 +49072,24 @@ async function refreshWorkflowGlobals(client, projectDir, app_id, workflows) {
|
|
|
48722
49072
|
alias,
|
|
48723
49073
|
toWorkflowDtsDeclaration(declaration)
|
|
48724
49074
|
);
|
|
48725
|
-
if (refreshed
|
|
49075
|
+
if (refreshed.kind === "refreshed") {
|
|
49076
|
+
console.error(`Refreshed workflow types for ${alias}`);
|
|
49077
|
+
continue;
|
|
49078
|
+
}
|
|
49079
|
+
console.error(
|
|
49080
|
+
`Wrote workflow types for ${alias} \u2014 ${refreshed.hasFile ? "its body file is empty" : `no body at ${path12.join(WORKFLOWS_DIR, `${alias}.ts`)}`}. Write the body there, then 'lotics app workflow check ${alias}'.`
|
|
49081
|
+
);
|
|
48726
49082
|
}
|
|
48727
49083
|
}
|
|
48728
49084
|
async function refreshWorkflowTypes(client, projectDir, app_id, alias, declaration) {
|
|
48729
49085
|
const file2 = workflowFilePath(projectDir, alias);
|
|
48730
|
-
|
|
48731
|
-
const body = stripWorkflowHeader(fs11.readFileSync(file2, "utf-8"));
|
|
48732
|
-
if (body.trim() === "") return false;
|
|
49086
|
+
const body = fs11.existsSync(file2) ? stripWorkflowHeader(fs11.readFileSync(file2, "utf-8")) : "";
|
|
48733
49087
|
const envelope2 = await fetchWorkflowGlobals(client, projectDir, app_id, alias, declaration);
|
|
49088
|
+
if (body.trim() === "") {
|
|
49089
|
+
return { kind: "types_only", hasFile: fs11.existsSync(file2) };
|
|
49090
|
+
}
|
|
48734
49091
|
writeWorkflowFile(projectDir, alias, body, envelope2);
|
|
48735
|
-
return
|
|
49092
|
+
return { kind: "refreshed" };
|
|
48736
49093
|
}
|
|
48737
49094
|
function readPriorStamp(projectDir) {
|
|
48738
49095
|
const pkgPath = path12.join(projectDir, "package.json");
|
|
@@ -48840,7 +49197,7 @@ async function appCreate(client, args) {
|
|
|
48840
49197
|
await materializeScaffold(targetPath, files);
|
|
48841
49198
|
if (plan !== null) {
|
|
48842
49199
|
writeAppDts(targetPath, { queries: plan.queries });
|
|
48843
|
-
await writeGeneratedAppFields(client, targetPath, { app_id: app.id }, plan.queries);
|
|
49200
|
+
await writeGeneratedAppFields(client, targetPath, { app_id: app.id }, { queries: plan.queries });
|
|
48844
49201
|
if (!fs11.existsSync(path12.join(targetPath, ".lotics", "app_fields.ts"))) {
|
|
48845
49202
|
throw new Error(
|
|
48846
49203
|
`.lotics/app_fields.ts could not be written (the warning above says why), and every generated screen reads it \u2014 resolve that, then \`cd ${path12.relative(process.cwd(), targetPath) || "."} && lotics app codegen && lotics app deploy\`.`
|
|
@@ -49059,12 +49416,11 @@ async function hydrateAppProject(client, targetPath, app, opts) {
|
|
|
49059
49416
|
const sha = declarationSha(declaration);
|
|
49060
49417
|
writeSynced(targetPath, "queries", alias, { content: sha, live: sha });
|
|
49061
49418
|
}
|
|
49062
|
-
await writeGeneratedAppFields(
|
|
49063
|
-
|
|
49064
|
-
|
|
49065
|
-
|
|
49066
|
-
|
|
49067
|
-
);
|
|
49419
|
+
await writeGeneratedAppFields(client, targetPath, { app_id: app.id }, {
|
|
49420
|
+
workflows: toManifestWorkflows(app.workflows ?? {}),
|
|
49421
|
+
queries: app.queries ?? {},
|
|
49422
|
+
agents: toManifestAgents(app.agents ?? {})
|
|
49423
|
+
});
|
|
49068
49424
|
const workflows = app.workflows ?? {};
|
|
49069
49425
|
if (Object.keys(workflows).length > 0) {
|
|
49070
49426
|
const { written, kept, unseenLive } = await writeWorkflowFiles(
|
|
@@ -49257,12 +49613,11 @@ async function appDeploy(client, args) {
|
|
|
49257
49613
|
warnIfProjectFootguns(projectDir, sourceText);
|
|
49258
49614
|
writeAppDts(projectDir, { workflows: meta3.workflows, queries: meta3.queries, agents: meta3.agents });
|
|
49259
49615
|
try {
|
|
49260
|
-
await writeGeneratedAppFields(
|
|
49261
|
-
|
|
49262
|
-
|
|
49263
|
-
|
|
49264
|
-
|
|
49265
|
-
);
|
|
49616
|
+
await writeGeneratedAppFields(client, projectDir, { app_id: meta3.app_id }, {
|
|
49617
|
+
workflows: meta3.workflows,
|
|
49618
|
+
queries: meta3.queries,
|
|
49619
|
+
agents: meta3.agents
|
|
49620
|
+
});
|
|
49266
49621
|
} catch (err) {
|
|
49267
49622
|
warn(
|
|
49268
49623
|
`\u26A0 Could not regenerate .lotics/app_fields.ts (${err.message}).
|
|
@@ -49336,7 +49691,8 @@ async function appDeploy(client, args) {
|
|
|
49336
49691
|
try {
|
|
49337
49692
|
const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
|
|
49338
49693
|
for (const { alias, declaration } of drifted) {
|
|
49339
|
-
|
|
49694
|
+
const refreshed = await refreshWorkflowTypes(client, projectDir, meta3.app_id, alias, declaration);
|
|
49695
|
+
if (refreshed.kind === "refreshed") {
|
|
49340
49696
|
note(`Refreshed workflow types for ${alias} \u2014 this deploy moved its declaration.`);
|
|
49341
49697
|
}
|
|
49342
49698
|
}
|
|
@@ -49356,10 +49712,16 @@ async function appDeploy(client, args) {
|
|
|
49356
49712
|
warnIfInertAgentTools(liveAfter);
|
|
49357
49713
|
}
|
|
49358
49714
|
if (liveAfter) {
|
|
49359
|
-
const
|
|
49715
|
+
const undeclared = undeclaredBindings({
|
|
49716
|
+
meta: meta3,
|
|
49717
|
+
synced: readSynced(projectDir),
|
|
49718
|
+
live: liveAfter
|
|
49719
|
+
});
|
|
49720
|
+
const lostCallSite = orphanedBindings(called, liveAfter, meta3.bundle_calls);
|
|
49721
|
+
const orphans = allOrphans(lostCallSite, undeclared);
|
|
49360
49722
|
let outstanding = orphans;
|
|
49361
49723
|
if (args.prune) {
|
|
49362
|
-
if (!meta3.bundle_calls) {
|
|
49724
|
+
if (!meta3.bundle_calls && nothingOrphaned(undeclared)) {
|
|
49363
49725
|
note(
|
|
49364
49726
|
"Nothing to prune yet: this project has no record of what the previous bundle called, so there is no way to tell a retired binding from an agent-facing one. This deploy records it; the next can compare."
|
|
49365
49727
|
);
|
|
@@ -49369,12 +49731,14 @@ async function appDeploy(client, args) {
|
|
|
49369
49731
|
projectDir,
|
|
49370
49732
|
{ app_id: meta3.app_id },
|
|
49371
49733
|
orphans,
|
|
49372
|
-
|
|
49734
|
+
undeclared,
|
|
49735
|
+
called.dynamic,
|
|
49736
|
+
args.pruneInvoked ?? []
|
|
49373
49737
|
);
|
|
49374
49738
|
} else {
|
|
49375
|
-
warnAboutOrphanedBindings(called, liveAfter, meta3.bundle_calls);
|
|
49739
|
+
warnAboutOrphanedBindings(called, liveAfter, meta3.bundle_calls, undeclared);
|
|
49376
49740
|
}
|
|
49377
|
-
const settled = outstanding
|
|
49741
|
+
const settled = nothingOrphaned(bothOf(outstanding, lostCallSite));
|
|
49378
49742
|
if (settled) {
|
|
49379
49743
|
writeAppMeta(projectDir, {
|
|
49380
49744
|
...readAppMeta(projectDir),
|
|
@@ -49442,8 +49806,14 @@ async function appCheck(client, args = {}) {
|
|
|
49442
49806
|
writeAppDts(projectDir, { workflows: meta3.workflows, queries: meta3.queries, agents: meta3.agents });
|
|
49443
49807
|
const sourceText = readAppSourceText(projectDir);
|
|
49444
49808
|
const called = calledAppAliases(sourceText);
|
|
49809
|
+
const undeclaredCalls = reportUndeclaredCalledAliases(called, meta3);
|
|
49445
49810
|
warnAboutDevLink(projectDir, "deploy");
|
|
49446
|
-
warnAboutOrphanedBindings(
|
|
49811
|
+
warnAboutOrphanedBindings(
|
|
49812
|
+
called,
|
|
49813
|
+
app,
|
|
49814
|
+
meta3.bundle_calls,
|
|
49815
|
+
undeclaredBindings({ meta: meta3, synced: readSynced(projectDir), live: app })
|
|
49816
|
+
);
|
|
49447
49817
|
const pending = pendingBindings({
|
|
49448
49818
|
projectDir,
|
|
49449
49819
|
meta: meta3,
|
|
@@ -49459,6 +49829,7 @@ async function appCheck(client, args = {}) {
|
|
|
49459
49829
|
warnIfProjectFootguns(projectDir, sourceText);
|
|
49460
49830
|
warnIfUnbranded(app);
|
|
49461
49831
|
warnIfUndescribed(app);
|
|
49832
|
+
await warnIfWorkflowsUndescribed(client, meta3);
|
|
49462
49833
|
const unportable = scanProjectForIds(projectDir);
|
|
49463
49834
|
reportPortabilityIds(unportable);
|
|
49464
49835
|
const bodiesFailing = await countFailingWorkflowBodies(client, projectDir, meta3);
|
|
@@ -49485,13 +49856,43 @@ async function appCheck(client, args = {}) {
|
|
|
49485
49856
|
});
|
|
49486
49857
|
console.error(formatScreenFindings(report));
|
|
49487
49858
|
}
|
|
49488
|
-
if (!nothingPending(pending) || unportable.length > 0 || bodiesFailing > 0 || typesFailing || screenFindings > 0) {
|
|
49859
|
+
if (!nothingPending(pending) || unportable.length > 0 || undeclaredCalls > 0 || bodiesFailing > 0 || typesFailing || screenFindings > 0) {
|
|
49489
49860
|
process.exit(1);
|
|
49490
49861
|
}
|
|
49491
49862
|
console.error(
|
|
49492
49863
|
"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."
|
|
49493
49864
|
);
|
|
49494
49865
|
}
|
|
49866
|
+
var capabilityAliasesSchema = zod_default.object({
|
|
49867
|
+
aliases: zod_default.array(
|
|
49868
|
+
zod_default.object({
|
|
49869
|
+
alias: zod_default.string(),
|
|
49870
|
+
kind: zod_default.string(),
|
|
49871
|
+
description: zod_default.string().nullish()
|
|
49872
|
+
})
|
|
49873
|
+
).optional()
|
|
49874
|
+
});
|
|
49875
|
+
async function warnIfWorkflowsUndescribed(client, meta3) {
|
|
49876
|
+
let res;
|
|
49877
|
+
try {
|
|
49878
|
+
res = await client.getAppCapabilities(meta3.app_id);
|
|
49879
|
+
} catch {
|
|
49880
|
+
return;
|
|
49881
|
+
}
|
|
49882
|
+
if (res.error) return;
|
|
49883
|
+
const parsed = capabilityAliasesSchema.safeParse(res.result);
|
|
49884
|
+
if (!parsed.success || parsed.data.aliases === void 0) return;
|
|
49885
|
+
const workflows = parsed.data.aliases.filter((entry) => entry.kind === "workflow");
|
|
49886
|
+
const undescribed = workflows.filter((entry) => entry.description == null || entry.description === "").map((entry) => entry.alias);
|
|
49887
|
+
if (undescribed.length === 0) return;
|
|
49888
|
+
console.error(
|
|
49889
|
+
`
|
|
49890
|
+
\u26A0 ${undescribed.length} of ${workflows.length} workflow${workflows.length === 1 ? "" : "s"} are offered with no description: ${undescribed.join(", ")}.
|
|
49891
|
+
That text is what chat and MCP read to choose between aliases; without it they choose by
|
|
49892
|
+
the alias. Write one line per workflow in
|
|
49893
|
+
package.json#lotics.workflows.<alias>.description, then 'lotics app workflow set <alias>'.`
|
|
49894
|
+
);
|
|
49895
|
+
}
|
|
49495
49896
|
async function countFailingWorkflowBodies(client, projectDir, meta3) {
|
|
49496
49897
|
const bound = Object.keys(meta3.workflows ?? {});
|
|
49497
49898
|
const typed = bound.filter((alias) => fs11.existsSync(workflowGlobalsPath(projectDir, alias)));
|
|
@@ -49511,8 +49912,29 @@ async function countFailingWorkflowBodies(client, projectDir, meta3) {
|
|
|
49511
49912
|
}
|
|
49512
49913
|
const results = checkWorkflowBodies(await loadProjectTypescript(projectDir), toCheck);
|
|
49513
49914
|
printWorkflowCheckResults(projectDir, results);
|
|
49915
|
+
reportStoredBodiesOwedMigration(
|
|
49916
|
+
projectDir,
|
|
49917
|
+
results.filter((r) => r.issues.length > 0).map((r) => r.alias)
|
|
49918
|
+
);
|
|
49514
49919
|
return results.filter((r) => r.issues.length > 0).length;
|
|
49515
49920
|
}
|
|
49921
|
+
function reportStoredBodiesOwedMigration(projectDir, failing) {
|
|
49922
|
+
const baselines = readSynced(projectDir).workflows;
|
|
49923
|
+
const unedited = failing.filter((alias) => {
|
|
49924
|
+
const baseline = baselines[alias]?.content;
|
|
49925
|
+
if (baseline === void 0) return false;
|
|
49926
|
+
const file2 = workflowFilePath(projectDir, alias);
|
|
49927
|
+
if (!fs11.existsSync(file2)) return false;
|
|
49928
|
+
return contentSha(stripWorkflowHeader(fs11.readFileSync(file2, "utf-8"))) === baseline;
|
|
49929
|
+
});
|
|
49930
|
+
if (unedited.length === 0) return;
|
|
49931
|
+
console.error(
|
|
49932
|
+
`
|
|
49933
|
+
\u26A0 ${unedited.length} of those bod${unedited.length === 1 ? "y is" : "ies are"} unchanged since this checkout pushed or pulled ${unedited.length === 1 ? "it" : "them"} \u2014 ${unedited.join(", ")}. That is the body the server is RUNNING, so this is a migration it is owed, not a stale tree.
|
|
49934
|
+
The usual cause is a grammar move: a link field reads as an id array, so drop '.id' or descend with linked(\u2026).
|
|
49935
|
+
Edit each body, then 'lotics app workflow set <alias>'. Until then the stored body keeps running as it always has.`
|
|
49936
|
+
);
|
|
49937
|
+
}
|
|
49516
49938
|
async function pushPendingBindings(client, projectDir, pending) {
|
|
49517
49939
|
const plan = [
|
|
49518
49940
|
...pending.queries.map((alias) => `query ${alias}`),
|
|
@@ -49641,36 +50063,64 @@ function orphanedBindings(called, live, previouslyCalled) {
|
|
|
49641
50063
|
referenced
|
|
49642
50064
|
);
|
|
49643
50065
|
}
|
|
49644
|
-
function
|
|
49645
|
-
const
|
|
50066
|
+
function undeclaredBindings(args) {
|
|
50067
|
+
const { meta: meta3, synced, live } = args;
|
|
50068
|
+
const removed = (kind, declared, bound) => Object.keys(bound ?? {}).filter((alias) => synced[kind][alias] !== void 0 && (declared ?? {})[alias] === void 0).sort();
|
|
50069
|
+
return {
|
|
50070
|
+
queries: removed("queries", meta3.queries, live.queries),
|
|
50071
|
+
workflows: removed("workflows", meta3.workflows, live.workflows),
|
|
50072
|
+
agents: removed("agents", meta3.agents, live.agents)
|
|
50073
|
+
};
|
|
50074
|
+
}
|
|
50075
|
+
function nothingOrphaned(orphans) {
|
|
50076
|
+
return orphans.queries.length === 0 && orphans.workflows.length === 0 && orphans.agents.length === 0;
|
|
50077
|
+
}
|
|
50078
|
+
function allOrphans(lostCallSite, undeclared) {
|
|
50079
|
+
const merge3 = (a, b) => [.../* @__PURE__ */ new Set([...a, ...b])].sort();
|
|
50080
|
+
return {
|
|
50081
|
+
queries: merge3(lostCallSite.queries, undeclared.queries),
|
|
50082
|
+
workflows: merge3(lostCallSite.workflows, undeclared.workflows),
|
|
50083
|
+
agents: merge3(lostCallSite.agents, undeclared.agents)
|
|
50084
|
+
};
|
|
50085
|
+
}
|
|
50086
|
+
function bothOf(a, b) {
|
|
50087
|
+
const both = (x, y) => x.filter((alias) => y.includes(alias));
|
|
50088
|
+
return {
|
|
50089
|
+
queries: both(a.queries, b.queries),
|
|
50090
|
+
workflows: both(a.workflows, b.workflows),
|
|
50091
|
+
agents: both(a.agents, b.agents)
|
|
50092
|
+
};
|
|
50093
|
+
}
|
|
50094
|
+
function warnAboutOrphanedBindings(called, live, previouslyCalled, undeclared) {
|
|
50095
|
+
const lostCallSite = orphanedBindings(called, live, previouslyCalled);
|
|
50096
|
+
const label = (kind, alias, undeclaredOfKind) => ` \u2022 ${kind} ${alias}${undeclaredOfKind.includes(alias) ? " (no longer declared in package.json)" : ""}`;
|
|
50097
|
+
const merged = allOrphans(lostCallSite, undeclared);
|
|
49646
50098
|
const lines = [
|
|
49647
|
-
...
|
|
49648
|
-
...
|
|
49649
|
-
...
|
|
50099
|
+
...merged.queries.map((a) => label("query", a, undeclared.queries)),
|
|
50100
|
+
...merged.workflows.map((a) => label("workflow", a, undeclared.workflows)),
|
|
50101
|
+
...merged.agents.map((a) => label("agent", a, undeclared.agents))
|
|
49650
50102
|
];
|
|
49651
50103
|
if (lines.length === 0) return 0;
|
|
50104
|
+
const dynamicCaveat = called.dynamic.length > 0 && !nothingOrphaned(lostCallSite) ? `
|
|
50105
|
+
|
|
50106
|
+
This app computes an alias at runtime, so one of the bindings above still
|
|
50107
|
+
declared in package.json may be called after all \u2014 \`--prune\` leaves those in
|
|
50108
|
+
place. Make those call sites literal to prune them.` + (nothingOrphaned(undeclared) ? `` : ` The ones marked "no longer
|
|
50109
|
+
declared" are unbound anyway.`) : ``;
|
|
49652
50110
|
console.error(
|
|
49653
50111
|
`
|
|
49654
|
-
\u26A0 ${lines.length} binding(s) still live that this
|
|
49655
|
-
` + lines.join("\n") +
|
|
49656
|
-
|
|
49657
|
-
This app computes an alias at runtime, so one of these may be called after
|
|
49658
|
-
all \u2014 and \`--prune\` therefore leaves them ALL in place. Make those call
|
|
49659
|
-
sites literal to prune them.` : `
|
|
50112
|
+
\u26A0 ${lines.length} binding(s) still live that this project has retired:
|
|
50113
|
+
` + lines.join("\n") + `
|
|
49660
50114
|
|
|
49661
|
-
Removing the call site does not unbind it, so it keeps
|
|
49662
|
-
stays published to chat and to MCP, which reach an alias
|
|
49663
|
-
site \u2014 so an agent can still invoke what you meant to retire.
|
|
49664
|
-
To unbind: lotics app deploy --prune`
|
|
50115
|
+
Removing the call site or the declaration does not unbind it, so it keeps
|
|
50116
|
+
serving. It also stays published to chat and to MCP, which reach an alias
|
|
50117
|
+
with no call site \u2014 so an agent can still invoke what you meant to retire.
|
|
50118
|
+
To unbind: lotics app deploy --prune` + dynamicCaveat
|
|
49665
50119
|
);
|
|
49666
50120
|
return lines.length;
|
|
49667
50121
|
}
|
|
49668
|
-
async function pruneOrphanedBindings(client, projectDir, app, orphans, dynamic) {
|
|
49669
|
-
const stillBound = {
|
|
49670
|
-
queries: [],
|
|
49671
|
-
workflows: [],
|
|
49672
|
-
agents: []
|
|
49673
|
-
};
|
|
50122
|
+
async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclared, dynamic, pruneInvoked) {
|
|
50123
|
+
const stillBound = { queries: [], workflows: [], agents: [] };
|
|
49674
50124
|
const targets = [
|
|
49675
50125
|
...orphans.queries.map((alias) => ({
|
|
49676
50126
|
tool: "remove_app_query",
|
|
@@ -49695,22 +50145,45 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, dynamic)
|
|
|
49695
50145
|
}))
|
|
49696
50146
|
];
|
|
49697
50147
|
if (targets.length === 0) return stillBound;
|
|
49698
|
-
|
|
50148
|
+
const deferred = dynamic.length > 0 ? targets.filter((t) => !undeclared[t.kind].includes(t.alias)) : [];
|
|
50149
|
+
if (deferred.length > 0) {
|
|
49699
50150
|
console.error(
|
|
49700
50151
|
`
|
|
49701
|
-
\u26A0 Left ${
|
|
49702
|
-
` +
|
|
50152
|
+
\u26A0 Left ${deferred.length} unused binding(s) in place: this app calls ${dynamic.join(" / ")} with an alias it computes at runtime, and a static scan cannot tell which binding that reaches \u2014 so any of these may be live:
|
|
50153
|
+
` + deferred.map((t) => ` \u2022 ${t.label}`).join("\n") + `
|
|
49703
50154
|
Make those call sites literal and the next deploy will clean them up, or remove the ones you know are dead by hand.`
|
|
49704
50155
|
);
|
|
49705
|
-
for (const t of
|
|
49706
|
-
return stillBound;
|
|
50156
|
+
for (const t of deferred) stillBound[t.kind].push(t.alias);
|
|
49707
50157
|
}
|
|
50158
|
+
const prunable = targets.filter((t) => !deferred.includes(t));
|
|
50159
|
+
if (prunable.length === 0) return stillBound;
|
|
49708
50160
|
const tablesBefore = new Set(resolveCodegenTableIds(projectDir, readAppMeta(projectDir).queries ?? {}));
|
|
50161
|
+
const notTargeted = pruneInvoked.filter(
|
|
50162
|
+
(alias) => !prunable.some((t) => t.kind === "workflows" && t.alias === alias)
|
|
50163
|
+
);
|
|
50164
|
+
if (notTargeted.length > 0) {
|
|
50165
|
+
console.error(
|
|
50166
|
+
` \u26A0 --prune-invoked named ${notTargeted.map((a) => `"${a}"`).join(", ")}, which this prune is not removing. The flag applies to a workflow alias this prune is unbinding.`
|
|
50167
|
+
);
|
|
50168
|
+
}
|
|
49709
50169
|
let removedLocally = false;
|
|
49710
|
-
for (const target of
|
|
49711
|
-
const
|
|
50170
|
+
for (const target of prunable) {
|
|
50171
|
+
const overrideInvocationGuard = target.kind === "workflows" && pruneInvoked.includes(target.alias);
|
|
50172
|
+
const res = await client.execute(target.tool, {
|
|
50173
|
+
app_id: app.app_id,
|
|
50174
|
+
alias: target.alias,
|
|
50175
|
+
// Sent only for the aliases the operator named, so the default stays the
|
|
50176
|
+
// guard: a run recorded against an alias means something outside the
|
|
50177
|
+
// bundle reaches it.
|
|
50178
|
+
...overrideInvocationGuard ? { even_if_invoked: true } : {}
|
|
50179
|
+
});
|
|
49712
50180
|
if (res.error) {
|
|
49713
50181
|
console.error(` \u2717 could not unbind ${target.label} \u2014 ${res.error}`);
|
|
50182
|
+
if (target.kind === "workflows" && !overrideInvocationGuard) {
|
|
50183
|
+
console.error(
|
|
50184
|
+
` If nothing outside this bundle calls it: lotics app deploy --prune --prune-invoked ${target.alias} -m "<message>"`
|
|
50185
|
+
);
|
|
50186
|
+
}
|
|
49714
50187
|
stillBound[target.kind].push(target.alias);
|
|
49715
50188
|
continue;
|
|
49716
50189
|
}
|
|
@@ -49753,7 +50226,11 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, dynamic)
|
|
|
49753
50226
|
` \u26A0 .lotics/app_fields.ts no longer covers ${dropped.join(", ")} \u2014 ${dropped.length === 1 ? "that table was" : "those tables were"} named only by the pruned quer${orphans.queries.length === 1 ? "y" : "ies"}. If your source still uses F/OPT for ${dropped.length === 1 ? "it" : "them"}, add ${dropped.map((id) => `"${id}"`).join(", ")} to package.json#lotics.codegen.tables and re-run lotics app codegen.`
|
|
49754
50227
|
);
|
|
49755
50228
|
}
|
|
49756
|
-
await writeGeneratedAppFields(client, projectDir, app,
|
|
50229
|
+
await writeGeneratedAppFields(client, projectDir, app, {
|
|
50230
|
+
workflows: meta3.workflows,
|
|
50231
|
+
queries: meta3.queries,
|
|
50232
|
+
agents: meta3.agents
|
|
50233
|
+
});
|
|
49757
50234
|
}
|
|
49758
50235
|
} catch (err) {
|
|
49759
50236
|
console.error(
|
|
@@ -49859,72 +50336,10 @@ function warnIfUndeclaredCapabilities(sourceText, capabilities) {
|
|
|
49859
50336
|
Add to package.json#lotics.capabilities: ${block}`
|
|
49860
50337
|
);
|
|
49861
50338
|
}
|
|
49862
|
-
var VITE_CONFIG_NAMES = [
|
|
49863
|
-
"vite.config.ts",
|
|
49864
|
-
"vite.config.mts",
|
|
49865
|
-
"vite.config.cts",
|
|
49866
|
-
"vite.config.js",
|
|
49867
|
-
"vite.config.mjs",
|
|
49868
|
-
"vite.config.cjs"
|
|
49869
|
-
];
|
|
49870
|
-
function codeOnly(source) {
|
|
49871
|
-
let out = "";
|
|
49872
|
-
let i = 0;
|
|
49873
|
-
while (i < source.length) {
|
|
49874
|
-
const two = source.slice(i, i + 2);
|
|
49875
|
-
if (two === "//") {
|
|
49876
|
-
while (i < source.length && source[i] !== "\n") {
|
|
49877
|
-
out += " ";
|
|
49878
|
-
i++;
|
|
49879
|
-
}
|
|
49880
|
-
continue;
|
|
49881
|
-
}
|
|
49882
|
-
if (two === "/*") {
|
|
49883
|
-
while (i < source.length && source.slice(i, i + 2) !== "*/") {
|
|
49884
|
-
out += source[i] === "\n" ? "\n" : " ";
|
|
49885
|
-
i++;
|
|
49886
|
-
}
|
|
49887
|
-
out += " ";
|
|
49888
|
-
i += 2;
|
|
49889
|
-
continue;
|
|
49890
|
-
}
|
|
49891
|
-
const quote2 = source[i];
|
|
49892
|
-
if (quote2 === '"' || quote2 === "'" || quote2 === "`") {
|
|
49893
|
-
out += " ";
|
|
49894
|
-
i++;
|
|
49895
|
-
while (i < source.length && source[i] !== quote2) {
|
|
49896
|
-
if (source[i] === "\\") {
|
|
49897
|
-
out += " ";
|
|
49898
|
-
i += 2;
|
|
49899
|
-
continue;
|
|
49900
|
-
}
|
|
49901
|
-
out += source[i] === "\n" ? "\n" : " ";
|
|
49902
|
-
i++;
|
|
49903
|
-
}
|
|
49904
|
-
out += " ";
|
|
49905
|
-
i++;
|
|
49906
|
-
continue;
|
|
49907
|
-
}
|
|
49908
|
-
out += source[i];
|
|
49909
|
-
i++;
|
|
49910
|
-
}
|
|
49911
|
-
return out;
|
|
49912
|
-
}
|
|
49913
50339
|
function defineBlockKeys(config2) {
|
|
49914
|
-
const code = codeOnly(config2);
|
|
49915
50340
|
const keys2 = /* @__PURE__ */ new Set();
|
|
49916
|
-
for (const
|
|
49917
|
-
|
|
49918
|
-
let i = opener.index + opener[0].length;
|
|
49919
|
-
const start = i;
|
|
49920
|
-
while (i < code.length && depth > 0) {
|
|
49921
|
-
if (code[i] === "{") depth++;
|
|
49922
|
-
else if (code[i] === "}") depth--;
|
|
49923
|
-
i++;
|
|
49924
|
-
}
|
|
49925
|
-
for (const key of code.slice(start, i).matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:/g)) {
|
|
49926
|
-
keys2.add(key[1]);
|
|
49927
|
-
}
|
|
50341
|
+
for (const block of objectBlocks(codeOnly(config2), "define")) {
|
|
50342
|
+
for (const key of block.matchAll(/(?:^|[{,])\s*([A-Za-z_$][\w$]*)\s*:/g)) keys2.add(key[1]);
|
|
49928
50343
|
}
|
|
49929
50344
|
return keys2;
|
|
49930
50345
|
}
|
|
@@ -49974,6 +50389,22 @@ function warnIfProjectFootguns(projectDir, sourceText) {
|
|
|
49974
50389
|
);
|
|
49975
50390
|
}
|
|
49976
50391
|
}
|
|
50392
|
+
if (configName !== void 0 && installedKitMajorAtLeast(projectDir, "@lotics/ui", 48)) {
|
|
50393
|
+
const config2 = fs11.readFileSync(path12.join(projectDir, configName), "utf-8");
|
|
50394
|
+
const handWritten = objectBlocks(codeOnly(config2), "optimizeDeps").some(
|
|
50395
|
+
(block) => /(?:^|[{,;])\s*include\s*:/.test(block)
|
|
50396
|
+
);
|
|
50397
|
+
if (handWritten) {
|
|
50398
|
+
lines.push(
|
|
50399
|
+
[
|
|
50400
|
+
` \u2022 ${configName} sets optimizeDeps.include \u2014 @lotics/ui 48 ships built ESM, so Vite`,
|
|
50401
|
+
" pre-bundles it unaided. The list cannot help and a stale entry still fails the",
|
|
50402
|
+
" dev boot ('Failed to resolve dependency: \u2026, present in optimizeDeps.include').",
|
|
50403
|
+
" delete the optimizeDeps block (and any loticsOptimizeDeps import \u2014 48 has none)"
|
|
50404
|
+
].join("\n")
|
|
50405
|
+
);
|
|
50406
|
+
}
|
|
50407
|
+
}
|
|
49977
50408
|
if (/\bwindow\.open\s*\(/.test(sourceText)) {
|
|
49978
50409
|
lines.push(
|
|
49979
50410
|
[
|
|
@@ -50032,6 +50463,35 @@ function warnIfInertAgentTools(app) {
|
|
|
50032
50463
|
function anyToolReaches(tools, field) {
|
|
50033
50464
|
return DECLARATION_BOUND_TOOLS.some((g) => g.field === field && tools.includes(g.tool));
|
|
50034
50465
|
}
|
|
50466
|
+
function undeclaredCalledAliases(called, meta3) {
|
|
50467
|
+
const declared = {
|
|
50468
|
+
queries: new Set(Object.keys(meta3.queries ?? {})),
|
|
50469
|
+
workflows: new Set(Object.keys(meta3.workflows ?? {}))
|
|
50470
|
+
};
|
|
50471
|
+
return {
|
|
50472
|
+
queries: [...new Set(called.queries)].filter((a) => !declared.queries.has(a)).sort(),
|
|
50473
|
+
workflows: [...new Set(called.workflows)].filter((a) => !declared.workflows.has(a)).sort()
|
|
50474
|
+
};
|
|
50475
|
+
}
|
|
50476
|
+
function reportUndeclaredCalledAliases(called, meta3) {
|
|
50477
|
+
const undeclared = undeclaredCalledAliases(called, meta3);
|
|
50478
|
+
const lines = [
|
|
50479
|
+
...undeclared.queries.map((a) => ` \u2022 useQuery("${a}") \u2014 no package.json#lotics.queries.${a}`),
|
|
50480
|
+
...undeclared.workflows.map(
|
|
50481
|
+
(a) => ` \u2022 useWorkflow("${a}") \u2014 no package.json#lotics.workflows.${a}`
|
|
50482
|
+
)
|
|
50483
|
+
];
|
|
50484
|
+
if (lines.length === 0) return 0;
|
|
50485
|
+
console.error(
|
|
50486
|
+
`
|
|
50487
|
+
\u2717 ${lines.length} call site${lines.length === 1 ? "" : "s"} name${lines.length === 1 ? "s" : ""} an alias this project does not declare:
|
|
50488
|
+
` + lines.join("\n") + `
|
|
50489
|
+
|
|
50490
|
+
These COMPILE \u2014 the hooks keep a bare-string overload for a computed alias \u2014 and fail
|
|
50491
|
+
when the screen renders. Declare the alias, or fix the call site.`
|
|
50492
|
+
);
|
|
50493
|
+
return lines.length;
|
|
50494
|
+
}
|
|
50035
50495
|
function warnIfUnboundAliases(app, called) {
|
|
50036
50496
|
const {
|
|
50037
50497
|
queries: unboundQueries,
|
|
@@ -50055,10 +50515,23 @@ function warnIfUnboundAliases(app, called) {
|
|
|
50055
50515
|
for (const alias of unboundAgents) lines.push(` \u2022 agent "${alias}" \u2192 bind with set_app_agent (lotics run set_app_agent \u2026)`);
|
|
50056
50516
|
console.error(lines.join("\n"));
|
|
50057
50517
|
}
|
|
50518
|
+
function resolveDeclaredVitePort(projectDir, flagPort) {
|
|
50519
|
+
const configName = VITE_CONFIG_NAMES.find((name) => fs11.existsSync(path12.join(projectDir, name)));
|
|
50520
|
+
const declared = configName === void 0 ? null : declaredVitePort(fs11.readFileSync(path12.join(projectDir, configName), "utf-8"));
|
|
50521
|
+
if (declared === null) return flagPort;
|
|
50522
|
+
if (flagPort === void 0) return declared;
|
|
50523
|
+
if (flagPort !== declared) {
|
|
50524
|
+
console.error(
|
|
50525
|
+
`\u26A0 --vite-port=${flagPort} overrides ${configName}'s server.port ${declared}. Vite is bound to ${flagPort}; drop the flag to use the config's port.`
|
|
50526
|
+
);
|
|
50527
|
+
}
|
|
50528
|
+
return flagPort;
|
|
50529
|
+
}
|
|
50058
50530
|
async function appDev(client, args) {
|
|
50059
50531
|
const projectDir = path12.resolve(args.projectDir ?? process.cwd());
|
|
50060
50532
|
const meta3 = readAppMeta(projectDir);
|
|
50061
50533
|
warnAboutDevLink(projectDir, "dev");
|
|
50534
|
+
const vitePort = resolveDeclaredVitePort(projectDir, args.vitePort);
|
|
50062
50535
|
writeAppDts(projectDir, { workflows: meta3.workflows, queries: meta3.queries, agents: meta3.agents });
|
|
50063
50536
|
const app = await client.getApp(meta3.app_id);
|
|
50064
50537
|
const handle = await startDevServer({
|
|
@@ -50068,7 +50541,7 @@ async function appDev(client, args) {
|
|
|
50068
50541
|
workspace_id: meta3.workspace_id,
|
|
50069
50542
|
api_url: client.baseUrl,
|
|
50070
50543
|
port: args.port,
|
|
50071
|
-
vitePort
|
|
50544
|
+
vitePort,
|
|
50072
50545
|
client,
|
|
50073
50546
|
commentsEnabled: meta3.capabilities?.comments
|
|
50074
50547
|
});
|
|
@@ -50329,13 +50802,16 @@ async function appWorkflowSet(client, args) {
|
|
|
50329
50802
|
...typeof declaration.description === "string" ? { description: declaration.description } : {}
|
|
50330
50803
|
});
|
|
50331
50804
|
console.error(`Set workflow "${args.alias}" \u2192 ${result.workflow_id}`);
|
|
50805
|
+
if (declaration.workflow_id !== result.workflow_id) {
|
|
50806
|
+
writeWorkflowDeclarationFields(projectDir, args.alias, { workflow_id: result.workflow_id });
|
|
50807
|
+
}
|
|
50332
50808
|
for (const warning of result.warnings) {
|
|
50333
50809
|
const issue2 = issueSchema.safeParse(warning);
|
|
50334
50810
|
console.error(issue2.success ? renderWorkflowIssue(issue2.data) : ` ${JSON.stringify(warning)}`);
|
|
50335
50811
|
}
|
|
50336
50812
|
const derivedOutputs = result.outputs;
|
|
50337
50813
|
if (!declaration.outputs && derivedOutputs && Object.keys(derivedOutputs).length > 0) {
|
|
50338
|
-
|
|
50814
|
+
writeWorkflowDeclarationFields(projectDir, args.alias, { outputs: derivedOutputs });
|
|
50339
50815
|
console.error(
|
|
50340
50816
|
` Wrote the derived result.data schema into package.json#lotics.workflows.${args.alias}.outputs.`
|
|
50341
50817
|
);
|
|
@@ -50343,7 +50819,7 @@ async function appWorkflowSet(client, args) {
|
|
|
50343
50819
|
inputs: declaration.inputs,
|
|
50344
50820
|
outputs: derivedOutputs
|
|
50345
50821
|
});
|
|
50346
|
-
if (refreshed) console.error(` Refreshed workflow types for ${args.alias}.`);
|
|
50822
|
+
if (refreshed.kind === "refreshed") console.error(` Refreshed workflow types for ${args.alias}.`);
|
|
50347
50823
|
} else if (derivedOutputs) {
|
|
50348
50824
|
console.error(` result.data schema: ${JSON.stringify(derivedOutputs)}`);
|
|
50349
50825
|
}
|
|
@@ -50411,22 +50887,79 @@ async function appWorkflowPull(client, args = {}) {
|
|
|
50411
50887
|
reportUnseenLive("workflow body", unseenLive, meta3.app_id);
|
|
50412
50888
|
ensureAppTsconfig(projectDir);
|
|
50413
50889
|
}
|
|
50414
|
-
async function
|
|
50890
|
+
async function appWorkflowDiff(client, args = {}) {
|
|
50415
50891
|
const projectDir = process.cwd();
|
|
50416
50892
|
const meta3 = readAppMeta(projectDir);
|
|
50417
50893
|
const bound = Object.keys(meta3.workflows ?? {});
|
|
50418
|
-
|
|
50419
|
-
|
|
50420
|
-
|
|
50421
|
-
|
|
50422
|
-
|
|
50423
|
-
|
|
50424
|
-
|
|
50894
|
+
const requested = args.aliases ?? [];
|
|
50895
|
+
const unbound = requested.filter((alias) => !bound.includes(alias));
|
|
50896
|
+
if (unbound.length > 0) {
|
|
50897
|
+
fail(
|
|
50898
|
+
`No workflow ${unbound.map((a) => `"${a}"`).join(", ")} in package.json#lotics.workflows. Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`
|
|
50899
|
+
);
|
|
50900
|
+
}
|
|
50901
|
+
const aliases = requested.length > 0 ? [...new Set(requested)] : workflowBodyDrift(projectDir, meta3.workflows, readSynced(projectDir).workflows);
|
|
50902
|
+
if (aliases.length === 0) {
|
|
50903
|
+
console.error("No workflow body differs from the baseline this checkout last pushed or pulled.");
|
|
50904
|
+
return;
|
|
50905
|
+
}
|
|
50906
|
+
let differing = 0;
|
|
50907
|
+
for (const alias of aliases) {
|
|
50908
|
+
const file2 = workflowFilePath(projectDir, alias);
|
|
50909
|
+
if (!fs11.existsSync(file2)) {
|
|
50910
|
+
console.error(`\u26A0 Skipped "${alias}" \u2014 no body at ${path12.relative(projectDir, file2)}.`);
|
|
50911
|
+
continue;
|
|
50425
50912
|
}
|
|
50426
|
-
|
|
50427
|
-
|
|
50428
|
-
|
|
50913
|
+
const res = await client.getAppWorkflow(meta3.app_id, alias);
|
|
50914
|
+
if (res.error) {
|
|
50915
|
+
if (isMissingWorkflowBodyError(res.error)) {
|
|
50916
|
+
console.error(
|
|
50917
|
+
`\u26A0 Skipped "${alias}" \u2014 the server has no rendered body for it (never set, or legacy).`
|
|
50918
|
+
);
|
|
50919
|
+
continue;
|
|
50920
|
+
}
|
|
50921
|
+
fail(`Could not read workflow "${alias}" of ${meta3.app_id}: ${res.error}`);
|
|
50922
|
+
}
|
|
50923
|
+
const parsed = zod_default.object({ source: zod_default.string() }).safeParse(res.result);
|
|
50924
|
+
if (!parsed.success) {
|
|
50925
|
+
console.error(`\u26A0 Skipped "${alias}" \u2014 the server returned no body source.`);
|
|
50926
|
+
continue;
|
|
50927
|
+
}
|
|
50928
|
+
const live = normalizeWorkflowBody(parsed.data.source);
|
|
50929
|
+
const local = stripWorkflowHeader(fs11.readFileSync(file2, "utf-8"));
|
|
50930
|
+
if (live === local) {
|
|
50931
|
+
console.error(`\u2713 ${alias} \u2014 identical to the body the server is running.`);
|
|
50932
|
+
continue;
|
|
50933
|
+
}
|
|
50934
|
+
differing++;
|
|
50935
|
+
console.error(`
|
|
50936
|
+
${alias} (- live, + ${path12.join(WORKFLOWS_DIR, `${alias}.ts`)})`);
|
|
50937
|
+
console.log(renderLineDiff(live, local));
|
|
50938
|
+
}
|
|
50939
|
+
if (differing > 0) {
|
|
50940
|
+
console.error(
|
|
50941
|
+
`
|
|
50942
|
+
${differing} workflow ${differing === 1 ? "body differs" : "bodies differ"} from the server. 'lotics app workflow set <alias>' pushes one; 'lotics app workflow pull --force' takes the server's.`
|
|
50943
|
+
);
|
|
50944
|
+
process.exitCode = 1;
|
|
50945
|
+
}
|
|
50946
|
+
}
|
|
50947
|
+
function resolveCheckAliases(requested, bound) {
|
|
50948
|
+
const unbound = [...new Set(requested)].filter((alias) => !bound.includes(alias));
|
|
50949
|
+
if (unbound.length > 0) return { kind: "unbound", unbound };
|
|
50950
|
+
return { kind: "ok", aliases: requested.length > 0 ? [...new Set(requested)] : [...bound] };
|
|
50951
|
+
}
|
|
50952
|
+
async function appWorkflowCheck(args) {
|
|
50953
|
+
const projectDir = process.cwd();
|
|
50954
|
+
const meta3 = readAppMeta(projectDir);
|
|
50955
|
+
const bound = Object.keys(meta3.workflows ?? {});
|
|
50956
|
+
const resolved = resolveCheckAliases(args.aliases ?? [], bound);
|
|
50957
|
+
if (resolved.kind === "unbound") {
|
|
50958
|
+
fail(
|
|
50959
|
+
`No workflow ${resolved.unbound.map((a) => `"${a}"`).join(", ")} in package.json#lotics.workflows. Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`
|
|
50960
|
+
);
|
|
50429
50961
|
}
|
|
50962
|
+
const aliases = resolved.aliases;
|
|
50430
50963
|
if (aliases.length === 0) {
|
|
50431
50964
|
console.error(`App ${meta3.app_id} has no bound workflows to check.`);
|
|
50432
50965
|
return;
|
|
@@ -50439,8 +50972,7 @@ async function appWorkflowCheck(args) {
|
|
|
50439
50972
|
const tsApi = await loadProjectTypescript(projectDir);
|
|
50440
50973
|
const results = checkWorkflowBodies(tsApi, toCheck);
|
|
50441
50974
|
printWorkflowCheckResults(projectDir, results);
|
|
50442
|
-
|
|
50443
|
-
if (failed.length > 0) process.exit(1);
|
|
50975
|
+
if (results.some((r) => r.issues.length > 0)) process.exitCode = 1;
|
|
50444
50976
|
}
|
|
50445
50977
|
async function collectWorkflowBodyChecks(projectDir, meta3, aliases, client) {
|
|
50446
50978
|
const toCheck = [];
|
|
@@ -50449,15 +50981,14 @@ async function collectWorkflowBodyChecks(projectDir, meta3, aliases, client) {
|
|
|
50449
50981
|
const globalsPath = workflowGlobalsPath(projectDir, alias);
|
|
50450
50982
|
if (!fs11.existsSync(bodyPath)) {
|
|
50451
50983
|
console.error(
|
|
50452
|
-
`\u26A0 Skipped "${alias}" \u2014 no body at ${path12.relative(projectDir, bodyPath)}.
|
|
50984
|
+
`\u26A0 Skipped "${alias}" \u2014 no body at ${path12.relative(projectDir, bodyPath)}. Write it there (its types are in ${path12.relative(projectDir, globalsPath)}), or 'lotics app workflow pull' if the server already has one.`
|
|
50453
50985
|
);
|
|
50454
50986
|
continue;
|
|
50455
50987
|
}
|
|
50456
50988
|
if (!fs11.existsSync(globalsPath)) {
|
|
50457
|
-
|
|
50989
|
+
fail(
|
|
50458
50990
|
`Cannot check "${alias}" \u2014 missing types at ${path12.relative(projectDir, globalsPath)}. Run 'lotics app codegen' to fetch them (it refreshes types WITHOUT touching your body; a pull would overwrite it).`
|
|
50459
50991
|
);
|
|
50460
|
-
process.exit(1);
|
|
50461
50992
|
}
|
|
50462
50993
|
toCheck.push({ alias, input: { bodyPath, globalsPath } });
|
|
50463
50994
|
}
|
|
@@ -50535,6 +51066,7 @@ function parseArgs(argv) {
|
|
|
50535
51066
|
remove: void 0,
|
|
50536
51067
|
includeHidden: false,
|
|
50537
51068
|
timezone: void 0,
|
|
51069
|
+
currency: void 0,
|
|
50538
51070
|
email: void 0,
|
|
50539
51071
|
message: void 0,
|
|
50540
51072
|
session: void 0,
|
|
@@ -50547,11 +51079,13 @@ function parseArgs(argv) {
|
|
|
50547
51079
|
printCreated: false,
|
|
50548
51080
|
cleanup: false,
|
|
50549
51081
|
prune: false,
|
|
51082
|
+
pruneInvoked: [],
|
|
50550
51083
|
screens: false,
|
|
50551
51084
|
noSampleData: false,
|
|
50552
51085
|
adopt: false,
|
|
50553
51086
|
entity: [],
|
|
50554
51087
|
limit: void 0,
|
|
51088
|
+
cursor: void 0,
|
|
50555
51089
|
tables: [],
|
|
50556
51090
|
bind: [],
|
|
50557
51091
|
version: false,
|
|
@@ -50649,6 +51183,9 @@ function parseArgs(argv) {
|
|
|
50649
51183
|
case "--timezone":
|
|
50650
51184
|
flags.timezone = takeValue(arg, "<Area/City>");
|
|
50651
51185
|
break;
|
|
51186
|
+
case "--currency":
|
|
51187
|
+
flags.currency = takeValue(arg, "<ISO code>");
|
|
51188
|
+
break;
|
|
50652
51189
|
case "-m":
|
|
50653
51190
|
case "--message":
|
|
50654
51191
|
flags.message = takeValue(arg, "<message>");
|
|
@@ -50690,6 +51227,9 @@ function parseArgs(argv) {
|
|
|
50690
51227
|
case "--limit":
|
|
50691
51228
|
flags.limit = takeValue(arg, "<n>");
|
|
50692
51229
|
break;
|
|
51230
|
+
case "--cursor":
|
|
51231
|
+
flags.cursor = takeValue(arg, "<token>");
|
|
51232
|
+
break;
|
|
50693
51233
|
case "--tables":
|
|
50694
51234
|
flags.tables = [...flags.tables, ...takeList(arg, "<tbl_id,tbl_id>")];
|
|
50695
51235
|
break;
|
|
@@ -50698,6 +51238,9 @@ function parseArgs(argv) {
|
|
|
50698
51238
|
case "--bind":
|
|
50699
51239
|
flags.bind = [...flags.bind, takeValue(arg, "<entity>=<Label>")];
|
|
50700
51240
|
break;
|
|
51241
|
+
case "--prune-invoked":
|
|
51242
|
+
flags.pruneInvoked = [...flags.pruneInvoked, ...takeList(arg, "<alias>")];
|
|
51243
|
+
break;
|
|
50701
51244
|
case "--prune":
|
|
50702
51245
|
flags.prune = true;
|
|
50703
51246
|
break;
|
|
@@ -51154,7 +51697,14 @@ async function runPreviewCommand(filePath, flags) {
|
|
|
51154
51697
|
if (!existsSync4(abs2)) fail(`File not found: ${abs2}`);
|
|
51155
51698
|
const ext = extname(abs2).toLowerCase();
|
|
51156
51699
|
const type = ext === ".docx" ? "docx" : ext === ".xlsx" || ext === ".xls" || ext === ".csv" ? "xlsx" : ext === ".html" || ext === ".htm" ? "html" : null;
|
|
51157
|
-
if (!type)
|
|
51700
|
+
if (!type) {
|
|
51701
|
+
const route = ext === ".pdf" ? `
|
|
51702
|
+
A PDF is rasterised outside this CLI: lotics file download <fil_id>, then
|
|
51703
|
+
'pdftoppm -png -r 150 <file.pdf> <out-prefix>' (poppler-utils) for one PNG per page.` : "";
|
|
51704
|
+
fail(
|
|
51705
|
+
`Unsupported file type "${ext}" \u2014 preview supports .docx, .xlsx/.csv and .html.${route}`
|
|
51706
|
+
);
|
|
51707
|
+
}
|
|
51158
51708
|
let server;
|
|
51159
51709
|
let pageUrl;
|
|
51160
51710
|
if (type === "html") {
|
|
@@ -51766,23 +52316,32 @@ async function main() {
|
|
|
51766
52316
|
console.error(`${flag} needs a value \u2014 write it as: ${flag} ${expects}`);
|
|
51767
52317
|
process.exit(1);
|
|
51768
52318
|
}
|
|
51769
|
-
if (command === "file") {
|
|
51770
|
-
if (!subcommand) {
|
|
51771
|
-
printHelp();
|
|
51772
|
-
return;
|
|
51773
|
-
}
|
|
51774
|
-
command = subcommand;
|
|
51775
|
-
subcommand = toolArgs;
|
|
51776
|
-
toolArgs = restArgs.shift();
|
|
51777
|
-
}
|
|
51778
52319
|
if (flags.help && command === "report") {
|
|
51779
52320
|
console.error(REPORT_USAGE);
|
|
51780
52321
|
return;
|
|
51781
52322
|
}
|
|
51782
52323
|
if (flags.help || !command && !flags.version) {
|
|
52324
|
+
const scoped = flags.help ? commandHelp([parsed.command, parsed.subcommand, parsed.toolArgs].filter((w) => w !== void 0)) : null;
|
|
52325
|
+
if (scoped !== null) {
|
|
52326
|
+
console.log(scoped);
|
|
52327
|
+
return;
|
|
52328
|
+
}
|
|
51783
52329
|
printHelp();
|
|
51784
52330
|
return;
|
|
51785
52331
|
}
|
|
52332
|
+
if (command === "file") {
|
|
52333
|
+
if (!subcommand) {
|
|
52334
|
+
const help = commandHelp(["file"]);
|
|
52335
|
+
if (help !== null) console.log(help);
|
|
52336
|
+
else printHelp();
|
|
52337
|
+
return;
|
|
52338
|
+
}
|
|
52339
|
+
if (commandAliases("file").has(subcommand)) {
|
|
52340
|
+
command = subcommand;
|
|
52341
|
+
subcommand = toolArgs;
|
|
52342
|
+
toolArgs = restArgs.shift();
|
|
52343
|
+
}
|
|
52344
|
+
}
|
|
51786
52345
|
if (flags.version && command !== void 0) {
|
|
51787
52346
|
console.error(
|
|
51788
52347
|
`--version prints this CLI's version and takes no argument; it cannot be combined with a command.
|
|
@@ -52286,7 +52845,7 @@ async function main() {
|
|
|
52286
52845
|
);
|
|
52287
52846
|
client2 = void 0;
|
|
52288
52847
|
}
|
|
52289
|
-
await appWorkflowCheck({
|
|
52848
|
+
await appWorkflowCheck({ aliases: restArgs, client: client2 });
|
|
52290
52849
|
return;
|
|
52291
52850
|
}
|
|
52292
52851
|
if (command === "app" && subcommand === "codegen") {
|
|
@@ -52455,6 +53014,59 @@ async function main() {
|
|
|
52455
53014
|
}
|
|
52456
53015
|
return;
|
|
52457
53016
|
}
|
|
53017
|
+
if (command === "file") {
|
|
53018
|
+
if (subcommand !== "list" && subcommand !== "delete") {
|
|
53019
|
+
console.error(`Unknown file subcommand: ${subcommand}
|
|
53020
|
+
`);
|
|
53021
|
+
console.error(commandHelp(["file"]));
|
|
53022
|
+
process.exit(1);
|
|
53023
|
+
}
|
|
53024
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
53025
|
+
await resolveWorkspace(client2, ctx2);
|
|
53026
|
+
if (subcommand === "list") {
|
|
53027
|
+
let limit;
|
|
53028
|
+
if (flags.limit !== void 0) {
|
|
53029
|
+
limit = Number(flags.limit);
|
|
53030
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
53031
|
+
console.error(`Invalid --limit "${flags.limit}" \u2014 expected a positive integer.`);
|
|
53032
|
+
process.exit(1);
|
|
53033
|
+
}
|
|
53034
|
+
}
|
|
53035
|
+
const page = await client2.listFiles({ limit, cursor: flags.cursor });
|
|
53036
|
+
if (flags.json) {
|
|
53037
|
+
console.log(JSON.stringify(page, null, 2));
|
|
53038
|
+
} else {
|
|
53039
|
+
for (const file2 of page.files) {
|
|
53040
|
+
const size2 = file2.size == null ? "-" : String(file2.size);
|
|
53041
|
+
console.log(`${file2.id} ${file2.created_at} ${size2} ${file2.mime_type} ${file2.filename}`);
|
|
53042
|
+
}
|
|
53043
|
+
console.error(
|
|
53044
|
+
page.next_cursor === null ? `${page.files.length} file${page.files.length === 1 ? "" : "s"} \u2014 end of the store.` : `${page.files.length} files. Next page: lotics file list --cursor ${page.next_cursor}`
|
|
53045
|
+
);
|
|
53046
|
+
}
|
|
53047
|
+
return;
|
|
53048
|
+
}
|
|
53049
|
+
const fileId = toolArgs;
|
|
53050
|
+
if (!fileId || !isStoredFileId(fileId)) {
|
|
53051
|
+
console.error("Usage: lotics file delete <file_id>");
|
|
53052
|
+
console.error(
|
|
53053
|
+
"Archives a stored file. Refused while a record, comment, knowledge doc, document"
|
|
53054
|
+
);
|
|
53055
|
+
console.error("template or voice session still references it \u2014 those are named back.");
|
|
53056
|
+
process.exit(1);
|
|
53057
|
+
}
|
|
53058
|
+
const deleted = await client2.execute("delete_file", { file_id: fileId }, { format: "text" });
|
|
53059
|
+
if (deleted.error) {
|
|
53060
|
+
console.error(deleted.error);
|
|
53061
|
+
process.exit(1);
|
|
53062
|
+
}
|
|
53063
|
+
if (flags.json) {
|
|
53064
|
+
console.log(JSON.stringify(deleted.result, null, 2));
|
|
53065
|
+
} else {
|
|
53066
|
+
console.log(deleted.model_output ?? JSON.stringify(deleted.result));
|
|
53067
|
+
}
|
|
53068
|
+
return;
|
|
53069
|
+
}
|
|
52458
53070
|
const appPathArg = subcommand === "dev" ? toolArgs : void 0;
|
|
52459
53071
|
const { client, ctx } = await requireClient(
|
|
52460
53072
|
flags,
|
|
@@ -52480,6 +53092,22 @@ async function main() {
|
|
|
52480
53092
|
process.exitCode = 1;
|
|
52481
53093
|
return;
|
|
52482
53094
|
}
|
|
53095
|
+
const settingsName = subcommand === "rename" ? toolArgs : flags.name;
|
|
53096
|
+
if (subcommand === "rename" && !settingsName) {
|
|
53097
|
+
console.error("Usage: lotics workspace rename <new name>");
|
|
53098
|
+
console.error('Renames the CURRENT workspace. Switch first with "lotics workspace select <id>".');
|
|
53099
|
+
process.exit(1);
|
|
53100
|
+
}
|
|
53101
|
+
if (subcommand === "settings" && settingsName === void 0 && flags.currency === void 0 && flags.timezone === void 0) {
|
|
53102
|
+
console.error(
|
|
53103
|
+
"Usage: lotics workspace settings [--name <name>] [--currency <ISO>] [--timezone <Area/City>]"
|
|
53104
|
+
);
|
|
53105
|
+
console.error(
|
|
53106
|
+
"Changes the CURRENT workspace. The currency decides how every money field renders and the"
|
|
53107
|
+
);
|
|
53108
|
+
console.error("zone decides how every date buckets; neither is visible once it is wrong.");
|
|
53109
|
+
process.exit(1);
|
|
53110
|
+
}
|
|
52483
53111
|
const workspaces = await client.listWorkspaces();
|
|
52484
53112
|
const currentWorkspaceId = ctx.workspaceId;
|
|
52485
53113
|
if (subcommand === "select") {
|
|
@@ -52505,45 +53133,47 @@ Available workspaces:`);
|
|
|
52505
53133
|
console.error(`Switched to workspace: ${target.name} (${target.id})${where}`);
|
|
52506
53134
|
return;
|
|
52507
53135
|
}
|
|
52508
|
-
if (subcommand === "
|
|
52509
|
-
const
|
|
52510
|
-
if (!
|
|
52511
|
-
console.error("
|
|
53136
|
+
if (subcommand === "settings" || subcommand === "rename") {
|
|
53137
|
+
const current = workspaces.find((ws) => ws.id === currentWorkspaceId);
|
|
53138
|
+
if (!current) {
|
|
53139
|
+
console.error('No workspace selected. Choose one with "lotics workspace select <id>".');
|
|
52512
53140
|
process.exit(1);
|
|
52513
53141
|
}
|
|
52514
|
-
const
|
|
52515
|
-
|
|
52516
|
-
|
|
52517
|
-
|
|
53142
|
+
const updated = await client.updateWorkspace({
|
|
53143
|
+
name: settingsName ?? current.name,
|
|
53144
|
+
default_currency: flags.currency ?? current.default_currency,
|
|
53145
|
+
timezone: flags.timezone ?? current.timezone
|
|
53146
|
+
});
|
|
52518
53147
|
if (flags.json) {
|
|
52519
|
-
console.log(JSON.stringify(
|
|
53148
|
+
console.log(JSON.stringify(updated, null, 2));
|
|
52520
53149
|
} else {
|
|
52521
|
-
console.error(
|
|
52522
|
-
|
|
53150
|
+
console.error(
|
|
53151
|
+
`Workspace ${updated.id}: ${updated.name} \xB7 ${updated.default_currency} \xB7 ${updated.timezone}`
|
|
53152
|
+
);
|
|
52523
53153
|
}
|
|
52524
53154
|
return;
|
|
52525
53155
|
}
|
|
52526
|
-
if (subcommand === "
|
|
53156
|
+
if (subcommand === "create") {
|
|
52527
53157
|
const name = toolArgs;
|
|
52528
53158
|
if (!name) {
|
|
52529
|
-
console.error("Usage: lotics workspace
|
|
52530
|
-
console.error('Renames the CURRENT workspace. Switch first with "lotics workspace select <id>".');
|
|
53159
|
+
console.error("Usage: lotics workspace create <name> [--timezone <tz>] [--currency <ISO>]");
|
|
52531
53160
|
process.exit(1);
|
|
52532
53161
|
}
|
|
52533
|
-
const
|
|
52534
|
-
|
|
52535
|
-
console.error('No workspace selected. Choose one with "lotics workspace select <id>".');
|
|
52536
|
-
process.exit(1);
|
|
52537
|
-
}
|
|
52538
|
-
const renamed = await client.updateWorkspace({
|
|
53162
|
+
const timezone = flags.timezone;
|
|
53163
|
+
const created = await client.createWorkspace({
|
|
52539
53164
|
name,
|
|
52540
|
-
|
|
52541
|
-
|
|
53165
|
+
timezone,
|
|
53166
|
+
default_currency: flags.currency
|
|
52542
53167
|
});
|
|
53168
|
+
setSelectedWorkspace(created.id, ctx.orgId);
|
|
53169
|
+
client.setWorkspaceId(created.id);
|
|
52543
53170
|
if (flags.json) {
|
|
52544
|
-
console.log(JSON.stringify(
|
|
53171
|
+
console.log(JSON.stringify(created, null, 2));
|
|
52545
53172
|
} else {
|
|
52546
|
-
console.error(
|
|
53173
|
+
console.error(
|
|
53174
|
+
`Created workspace: ${created.name} (${created.id}) \xB7 ${created.default_currency} \xB7 ${created.timezone}`
|
|
53175
|
+
);
|
|
53176
|
+
console.error(`Switched to ${created.id}`);
|
|
52547
53177
|
}
|
|
52548
53178
|
return;
|
|
52549
53179
|
}
|
|
@@ -52632,9 +53262,16 @@ Available workspaces:`);
|
|
|
52632
53262
|
return;
|
|
52633
53263
|
}
|
|
52634
53264
|
if (subcommand === "deploy") {
|
|
53265
|
+
if (flags.pruneInvoked.length > 0 && !flags.prune) {
|
|
53266
|
+
console.error(
|
|
53267
|
+
"--prune-invoked lifts one guard inside --prune; it does nothing on its own. Add --prune."
|
|
53268
|
+
);
|
|
53269
|
+
process.exit(1);
|
|
53270
|
+
}
|
|
52635
53271
|
await appDeploy(client, {
|
|
52636
53272
|
message: (flags.message ?? toolArgs)?.trim() || void 0,
|
|
52637
|
-
prune: flags.prune
|
|
53273
|
+
prune: flags.prune,
|
|
53274
|
+
pruneInvoked: flags.pruneInvoked
|
|
52638
53275
|
});
|
|
52639
53276
|
return;
|
|
52640
53277
|
}
|
|
@@ -52677,7 +53314,8 @@ Available workspaces:`);
|
|
|
52677
53314
|
console.error("Usage:");
|
|
52678
53315
|
console.error(" lotics app workflow set <alias> Push src/workflows/<alias>.ts");
|
|
52679
53316
|
console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
|
|
52680
|
-
console.error(" lotics app workflow check [alias]
|
|
53317
|
+
console.error(" lotics app workflow check [alias...] Typecheck src/workflows bodies locally");
|
|
53318
|
+
console.error(" lotics app workflow diff [alias...] Show how a body differs from the server's");
|
|
52681
53319
|
process.exit(1);
|
|
52682
53320
|
};
|
|
52683
53321
|
if (action === "set") {
|
|
@@ -52694,6 +53332,10 @@ Available workspaces:`);
|
|
|
52694
53332
|
await appWorkflowPull(client, { force: flags.force === true });
|
|
52695
53333
|
return;
|
|
52696
53334
|
}
|
|
53335
|
+
if (action === "diff") {
|
|
53336
|
+
await appWorkflowDiff(client, { aliases: restArgs });
|
|
53337
|
+
return;
|
|
53338
|
+
}
|
|
52697
53339
|
workflowUsage();
|
|
52698
53340
|
}
|
|
52699
53341
|
if (subcommand === "agent") {
|
|
@@ -52838,7 +53480,6 @@ ${JSON.stringify(info.input_schema, null, 2)}`);
|
|
|
52838
53480
|
const toolName = subcommand;
|
|
52839
53481
|
const ingested = await ingestJsonArgs({
|
|
52840
53482
|
rawArg: toolArgs,
|
|
52841
|
-
stdinIsTTY: process.stdin.isTTY ?? false,
|
|
52842
53483
|
readFile: (p) => fs14.readFileSync(p, "utf-8"),
|
|
52843
53484
|
readStdin
|
|
52844
53485
|
});
|