@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/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 parentLink(child, entity, roles) {
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 === "parent"
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
- for (const child of entities) {
36081
- const parentField = parentLink(child, entity, roles);
36082
- if (parentField === void 0) continue;
36083
- const setField = fieldsWithRole(child, roles, "expected_set")[0];
36084
- if (setField !== void 0) {
36085
- sets.push({ kind: "expected_set", source: "child", child, parentField, setField, filesField: fileFields(child, roles)[0] });
36086
- continue;
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.195.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> Create a new workspace (admin only)",
37107
- " lotics workspace rename <name> Rename the current workspace (admin only)",
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> Read JSON args from stdin (large payloads)",
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 aliases this bundle no longer",
37209
- " names; off by default because `run_app_workflow` and",
37210
- " chat reach a binding the bundle never calls)",
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] Typecheck src/workflows bodies locally (one",
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] Run the app locally with HMR (RPC forwarded to prod)"
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 && raw.startsWith("@")) {
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 options = Array.isArray(decl.options) ? decl.options : [];
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 optional2 = d.required === false ? "?" : "";
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}${optional2}: ${outputDeclToTsType(d)}`);
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 options = Array.isArray(decl.options) ? decl.options : [];
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(` ${aliasKey}: ${objectFieldsToType(declaration.outputs)};`);
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(` ${aliasKey}: ${objectFieldsToType(declaration.outputs)};`);
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, parentField, drawn, set2, files) => {
39157
- const found = bindTable(`${at2}.${child.alias}`, child, [parentField, ...drawn], aliased, live, missing);
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 parent = found.fields.get(parentField.alias);
39160
- if (parent === void 0) return;
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
- parent,
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.parentField, drawn);
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.parentField,
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.parent.id,
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
- return `lines: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
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
- const savedLoc = new URLSearchParams(window.location.search).get("_loc");
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
- [viteEntry, "--port", String(vitePort), "--strictPort"],
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 === "GET" && (pathname === "/" || pathname === "/index.html")) {
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("formatNumber: decimals must be an integer between 0 and 100");
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 void 0;
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, "Spread/rest is not supported.", "spread_element");
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 writeWorkflowOutputs(projectDir, alias, outputs) {
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 derived-outputs write-back.`
48641
+ `package.json#lotics.workflows.${alias} disappeared before the post-set write-back.`
48310
48642
  );
48311
48643
  }
48312
- declaration.outputs = outputs;
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
- [path12.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
48427
- [path12.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
48428
- [path12.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)]
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, queries) {
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
- const tableIds = resolveCodegenTableIds(projectDir, queries);
48461
- const tables = await client.getWorkspaceSchema(tableIds);
48462
- const missing = tableIds.filter((id) => !tables.some((t) => t.id === id));
48463
- if (missing.length > 0) {
48464
- console.error(
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
- args.client,
48693
- projectDir,
48694
- { app_id: meta3.app_id },
48695
- meta3.queries ?? {}
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) console.error(`Refreshed workflow types for ${alias}`);
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
- if (!fs11.existsSync(file2)) return false;
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 true;
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
- client,
49064
- targetPath,
49065
- { app_id: app.id },
49066
- app.queries ?? {}
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
- client,
49262
- projectDir,
49263
- { app_id: meta3.app_id },
49264
- meta3.queries ?? {}
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
- if (await refreshWorkflowTypes(client, projectDir, meta3.app_id, alias, declaration)) {
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 orphans = orphanedBindings(called, liveAfter, meta3.bundle_calls);
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
- called.dynamic
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.queries.length === 0 && outstanding.workflows.length === 0 && outstanding.agents.length === 0;
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(called, app, meta3.bundle_calls);
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 warnAboutOrphanedBindings(called, live, previouslyCalled) {
49645
- const orphans = orphanedBindings(called, live, previouslyCalled);
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
- ...orphans.queries.map((a) => ` \u2022 query ${a}`),
49648
- ...orphans.workflows.map((a) => ` \u2022 workflow ${a}`),
49649
- ...orphans.agents.map((a) => ` \u2022 agent ${a}`)
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 bundle STOPPED calling:
49655
- ` + lines.join("\n") + (called.dynamic.length > 0 ? `
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 serving. It also
49662
- stays published to chat and to MCP, which reach an alias with no call
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
- if (dynamic.length > 0) {
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 ${targets.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:
49702
- ` + targets.map((t) => ` \u2022 ${t.label}`).join("\n") + `
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 targets) stillBound[t.kind].push(t.alias);
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 targets) {
49711
- const res = await client.execute(target.tool, { app_id: app.app_id, alias: target.alias });
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, meta3.queries ?? {});
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 opener of code.matchAll(/\bdefine\s*:\s*\{/g)) {
49917
- let depth = 1;
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: args.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
- writeWorkflowOutputs(projectDir, args.alias, derivedOutputs);
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 appWorkflowCheck(args) {
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
- let aliases;
50419
- if (args.alias) {
50420
- if (!bound.includes(args.alias)) {
50421
- console.error(
50422
- `No workflow "${args.alias}" in package.json#lotics.workflows. Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`
50423
- );
50424
- process.exit(1);
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
- aliases = [args.alias];
50427
- } else {
50428
- aliases = bound;
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
- const failed = results.filter((r) => r.issues.length > 0);
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)}. Run 'lotics app workflow pull' to write it.`
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
- console.error(
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) fail(`Unsupported file type "${ext}" \u2014 preview supports .docx, .xlsx/.csv and .html. (PDFs open directly \u2014 no preview needed.)`);
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({ alias: restArgs[0], client: client2 });
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 === "create") {
52509
- const name = toolArgs;
52510
- if (!name) {
52511
- console.error("Usage: lotics workspace create <name> [--timezone <tz>]");
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 timezone = flags.timezone;
52515
- const created = await client.createWorkspace({ name, timezone });
52516
- setSelectedWorkspace(created.id, ctx.orgId);
52517
- client.setWorkspaceId(created.id);
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(created, null, 2));
53148
+ console.log(JSON.stringify(updated, null, 2));
52520
53149
  } else {
52521
- console.error(`Created workspace: ${created.name} (${created.id})`);
52522
- console.error(`Switched to ${created.id}`);
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 === "rename") {
53156
+ if (subcommand === "create") {
52527
53157
  const name = toolArgs;
52528
53158
  if (!name) {
52529
- console.error("Usage: lotics workspace rename <new name>");
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 current = workspaces.find((ws) => ws.id === currentWorkspaceId);
52534
- if (!current) {
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
- default_currency: current.default_currency,
52541
- timezone: current.timezone
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(renamed, null, 2));
53171
+ console.log(JSON.stringify(created, null, 2));
52545
53172
  } else {
52546
- console.error(`Renamed "${current.name}" to "${renamed.name}" (${renamed.id})`);
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] Typecheck src/workflows bodies locally");
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
  });