@lotics/cli 0.260.0 → 0.261.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.
@@ -1835,7 +1835,7 @@ var __loticsProbe = (() => {
1835
1835
  partial_count: {
1836
1836
  law: "reviewing.md",
1837
1837
  section: "8o",
1838
- fix: "State the server's count of the set, never the rows a page loaded: count the read (`useCount`) and step or badge against that total."
1838
+ fix: "State the server's count of the set, never the rows a page loaded: count the read (`useQuery`'s `total`) and step or badge against that total."
1839
1839
  },
1840
1840
  record_primary: {
1841
1841
  law: "hierarchy.md",
package/dist/src/cli.js CHANGED
@@ -52,7 +52,7 @@ var __toESM = (mod2, isNodeMode, target) => (target = mod2 != null ? __create(__
52
52
  var define_LOTICS_KIT_VERSIONS_default;
53
53
  var init_define_LOTICS_KIT_VERSIONS = __esm({
54
54
  "<define:__LOTICS_KIT_VERSIONS__>"() {
55
- define_LOTICS_KIT_VERSIONS_default = { runtime: "0.42.1" };
55
+ define_LOTICS_KIT_VERSIONS_default = { runtime: "0.43.0" };
56
56
  }
57
57
  });
58
58
 
@@ -30137,6 +30137,14 @@ var LoticsClient = class {
30137
30137
  async appFieldOptions(app_id, alias2) {
30138
30138
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias: alias2 });
30139
30139
  }
30140
+ /**
30141
+ * What the app's writes will change — each bound workflow's record-write subset, each query's column
30142
+ * sources, the fields they name. Mirrors GET /v1/apps/{app_id}/write_model; the dev server hands it to
30143
+ * the app's SDK whole, so its parts stay opaque here.
30144
+ */
30145
+ async appWriteModel(app_id) {
30146
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/write_model`);
30147
+ }
30140
30148
  /**
30141
30149
  * Execute a workflow by alias declared in package.json#lotics.workflows.
30142
30150
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
@@ -64296,7 +64304,7 @@ function resultSideEffects(result) {
64296
64304
 
64297
64305
  // src/version.ts
64298
64306
  init_define_LOTICS_KIT_VERSIONS();
64299
- var VERSION = "0.260.0";
64307
+ var VERSION = "0.261.0";
64300
64308
 
64301
64309
  // src/timezone.ts
64302
64310
  init_define_LOTICS_KIT_VERSIONS();
@@ -69642,6 +69650,47 @@ function matchesTimeOfDay(recordValue, startTime, endTime) {
69642
69650
 
69643
69651
  // ../shared/src/app_query_ast.ts
69644
69652
  init_define_LOTICS_KIT_VERSIONS();
69653
+
69654
+ // ../shared/src/interpolate.ts
69655
+ init_define_LOTICS_KIT_VERSIONS();
69656
+ function interpolateObject(obj, context) {
69657
+ if (obj === null || obj === void 0) {
69658
+ return obj;
69659
+ }
69660
+ if (typeof obj === "string") {
69661
+ const expressionMatches = obj.match(/\{\{.*?\}\}/g);
69662
+ if (expressionMatches && expressionMatches.length > 0) {
69663
+ if (expressionMatches.length === 1 && obj === expressionMatches[0]) {
69664
+ const expression = obj.slice(2, -2).trim();
69665
+ return evaluateJsExpression(expression, context);
69666
+ }
69667
+ const parts = obj.split(/(\{\{.*?\}\})/g);
69668
+ const evaluatedParts = parts.map((part) => {
69669
+ if (part.startsWith("{{") && part.endsWith("}}")) {
69670
+ const expression = part.slice(2, -2).trim();
69671
+ const result = evaluateJsExpression(expression, context);
69672
+ return result != null ? String(result) : "";
69673
+ }
69674
+ return part;
69675
+ });
69676
+ return evaluatedParts.join("");
69677
+ }
69678
+ return obj;
69679
+ }
69680
+ if (Array.isArray(obj)) {
69681
+ return obj.map((item) => interpolateObject(item, context));
69682
+ }
69683
+ if (typeof obj === "object") {
69684
+ const result = {};
69685
+ for (const [key, value] of Object.entries(obj)) {
69686
+ result[key] = interpolateObject(value, context);
69687
+ }
69688
+ return result;
69689
+ }
69690
+ return obj;
69691
+ }
69692
+
69693
+ // ../shared/src/app_query_ast.ts
69645
69694
  function collectQueryTableIds(node) {
69646
69695
  const result = /* @__PURE__ */ new Set();
69647
69696
  walk(node, (n) => {
@@ -71452,7 +71501,8 @@ function codeWithoutComments(sourceText) {
71452
71501
  return out.join("");
71453
71502
  }
71454
71503
  var ALIAS_CALL_HOOKS = {
71455
- queries: ["useQuery", "usePaginatedQuery", "useInfiniteQuery", "useCount", "useFieldOptions", "useQueries", "queryAll"],
71504
+ // `usePaginatedQuery` through `useCounts`: gone from the SDK, still called by bundles on runtime 0.42 or earlier.
71505
+ queries: ["useQuery", "useFieldOptions", "useQueries", "queryAll", "usePaginatedQuery", "useInfiniteQuery", "useCount", "useCounts"],
71456
71506
  workflows: ["useWorkflow", "useWorkflows", "useRecording"],
71457
71507
  agents: ["useAgentRun"]
71458
71508
  };
@@ -76264,7 +76314,7 @@ init_define_LOTICS_KIT_VERSIONS();
76264
76314
  var TOOL_INPUT_SEMANTICS = {
76265
76315
  // ─── Bulk record mutations ─────────────────────────────────────────────
76266
76316
  update_records: {
76267
- table_id: { kind: "table_ref", removes: false },
76317
+ table_id: { kind: "table_ref", rows: "change" },
76268
76318
  record_ids: { kind: "record_refs" },
76269
76319
  filters: { kind: "filter_tree", table_from: "table_id" },
76270
76320
  set: { kind: "record_data", table_from: "table_id", writes: true },
@@ -76296,7 +76346,7 @@ var TOOL_INPUT_SEMANTICS = {
76296
76346
  field_edits: { kind: "dynamic" }
76297
76347
  },
76298
76348
  create_records: {
76299
- table_id: { kind: "table_ref", removes: false },
76349
+ table_id: { kind: "table_ref", rows: "change" },
76300
76350
  records: { kind: "record_data_array", table_from: "table_id", writes: true },
76301
76351
  field_keys: { kind: "field_key_refs", table_from: "table_id", writes: true },
76302
76352
  // Per-record cells — semantic depends on field_keys[i]; tool resolves.
@@ -76307,7 +76357,7 @@ var TOOL_INPUT_SEMANTICS = {
76307
76357
  // `rec_*` identifiers) which is all this kind asserts: normalization passes it
76308
76358
  // through raw and nothing resolves it against stored rows.
76309
76359
  ids: { kind: "record_refs" },
76310
- source_table_id: { kind: "table_ref", removes: false },
76360
+ source_table_id: { kind: "table_ref", rows: "none" },
76311
76361
  record_ids: { kind: "record_refs" },
76312
76362
  filters: { kind: "filter_tree", table_from: "table_id" },
76313
76363
  // Cross-table mapping — {source_field_key: target_field_key}. Not
@@ -76326,28 +76376,28 @@ var TOOL_INPUT_SEMANTICS = {
76326
76376
  // rows of whatever tables a single-valued link points here from, and those
76327
76377
  // are the link graph's answer rather than the source's, so a scan can no
76328
76378
  // more resolve them than it can a field key computed at runtime.
76329
- table_id: { kind: "table_ref", removes: true },
76379
+ table_id: { kind: "table_ref", rows: "remove" },
76330
76380
  record_ids: { kind: "record_refs" },
76331
76381
  filters: { kind: "filter_tree", table_from: "table_id" },
76332
76382
  cascade: { kind: "scalar" }
76333
76383
  },
76334
76384
  restore_records: {
76335
- table_id: { kind: "table_ref", removes: false },
76385
+ table_id: { kind: "table_ref", rows: "change" },
76336
76386
  record_ids: { kind: "record_refs" },
76337
76387
  filters: { kind: "filter_tree", table_from: "table_id" }
76338
76388
  },
76339
76389
  lock_records: {
76340
- table_id: { kind: "table_ref", removes: false },
76390
+ table_id: { kind: "table_ref", rows: "change" },
76341
76391
  record_ids: { kind: "record_refs" },
76342
76392
  filters: { kind: "filter_tree", table_from: "table_id" }
76343
76393
  },
76344
76394
  unlock_records: {
76345
- table_id: { kind: "table_ref", removes: false },
76395
+ table_id: { kind: "table_ref", rows: "change" },
76346
76396
  record_ids: { kind: "record_refs" },
76347
76397
  filters: { kind: "filter_tree", table_from: "table_id" }
76348
76398
  },
76349
76399
  request_locked_record_change: {
76350
- table_id: { kind: "table_ref", removes: false },
76400
+ table_id: { kind: "table_ref", rows: "none" },
76351
76401
  record_id: { kind: "record_ref" },
76352
76402
  // Map of field_key → new value; key validation and per-field value
76353
76403
  // resolution depend on the table schema, resolved at execute time.
@@ -76356,7 +76406,7 @@ var TOOL_INPUT_SEMANTICS = {
76356
76406
  },
76357
76407
  // ─── Query surfaces ────────────────────────────────────────────────────
76358
76408
  query_records: {
76359
- table_id: { kind: "table_ref", removes: false },
76409
+ table_id: { kind: "table_ref", rows: "none" },
76360
76410
  view_id: { kind: "scalar" },
76361
76411
  field_keys: { kind: "field_key_refs", table_from: "table_id", writes: false },
76362
76412
  filters: { kind: "filter_tree", table_from: "table_id" },
@@ -76397,7 +76447,7 @@ var TOOL_INPUT_SEMANTICS = {
76397
76447
  limit: { kind: "scalar" }
76398
76448
  },
76399
76449
  aggregate_records: {
76400
- table_id: { kind: "table_ref", removes: false },
76450
+ table_id: { kind: "table_ref", rows: "none" },
76401
76451
  filters: { kind: "filter_tree", table_from: "table_id" },
76402
76452
  // Nested {field_key, operation} shape — current vocabulary has no
76403
76453
  // nested-field-key kind, so the tool resolves at execute time.
@@ -76408,14 +76458,14 @@ var TOOL_INPUT_SEMANTICS = {
76408
76458
  get_record: {
76409
76459
  // Schema only takes `record_id`; table_id is optional/derived.
76410
76460
  record_id: { kind: "record_ref" },
76411
- table_id: { kind: "table_ref", removes: false },
76461
+ table_id: { kind: "table_ref", rows: "none" },
76412
76462
  // Keys on the record's own table, which `table_id` names when supplied and
76413
76463
  // the record resolves otherwise — the same position it holds on a query.
76414
76464
  field_keys: { kind: "field_key_refs", table_from: "table_id", writes: false }
76415
76465
  },
76416
76466
  // ─── View management ──────────────────────────────────────────────────
76417
76467
  create_view: {
76418
- table_id: { kind: "table_ref", removes: false },
76468
+ table_id: { kind: "table_ref", rows: "none" },
76419
76469
  name: { kind: "text" },
76420
76470
  frozen_columns: { kind: "scalar" },
76421
76471
  // summary keys are field_keys on `table_id`, values are view-summary
@@ -76452,13 +76502,13 @@ var TOOL_INPUT_SEMANTICS = {
76452
76502
  },
76453
76503
  // ─── Buttons (table-scoped field with action) ─────────────────────────
76454
76504
  press_button: {
76455
- table_id: { kind: "table_ref", removes: false },
76505
+ table_id: { kind: "table_ref", rows: "unnamed" },
76456
76506
  record_id: { kind: "record_ref" },
76457
76507
  field_key: { kind: "field_key_ref", table_from: "table_id", writes: false }
76458
76508
  },
76459
76509
  // ─── Table management ─────────────────────────────────────────────────
76460
76510
  update_table: {
76461
- table_id: { kind: "table_ref", removes: false },
76511
+ table_id: { kind: "table_ref", rows: "change" },
76462
76512
  name: { kind: "text" },
76463
76513
  description: { kind: "text" },
76464
76514
  // add_fields carries NEW field definitions (not refs to existing ones).
@@ -76898,7 +76948,7 @@ function bodyWrites(source) {
76898
76948
  const semantic = semantics[kwarg];
76899
76949
  if (semantic === void 0) continue;
76900
76950
  if (semantic.kind === "table_ref") {
76901
- if (semantic.removes) deletes.push(resolveTableFrom(semantics, step.input, kwarg));
76951
+ if (semantic.rows === "remove") deletes.push(resolveTableFrom(semantics, step.input, kwarg));
76902
76952
  continue;
76903
76953
  }
76904
76954
  if (!("writes" in semantic) || !semantic.writes) continue;
@@ -78268,45 +78318,6 @@ async function assertFilterTraversalsResolve(loadTable, filter2, fields, subject
78268
78318
  }
78269
78319
  }
78270
78320
 
78271
- // ../shared/src/interpolate.ts
78272
- init_define_LOTICS_KIT_VERSIONS();
78273
- function interpolateObject(obj, context) {
78274
- if (obj === null || obj === void 0) {
78275
- return obj;
78276
- }
78277
- if (typeof obj === "string") {
78278
- const expressionMatches = obj.match(/\{\{.*?\}\}/g);
78279
- if (expressionMatches && expressionMatches.length > 0) {
78280
- if (expressionMatches.length === 1 && obj === expressionMatches[0]) {
78281
- const expression = obj.slice(2, -2).trim();
78282
- return evaluateJsExpression(expression, context);
78283
- }
78284
- const parts = obj.split(/(\{\{.*?\}\})/g);
78285
- const evaluatedParts = parts.map((part) => {
78286
- if (part.startsWith("{{") && part.endsWith("}}")) {
78287
- const expression = part.slice(2, -2).trim();
78288
- const result = evaluateJsExpression(expression, context);
78289
- return result != null ? String(result) : "";
78290
- }
78291
- return part;
78292
- });
78293
- return evaluatedParts.join("");
78294
- }
78295
- return obj;
78296
- }
78297
- if (Array.isArray(obj)) {
78298
- return obj.map((item) => interpolateObject(item, context));
78299
- }
78300
- if (typeof obj === "object") {
78301
- const result = {};
78302
- for (const [key, value] of Object.entries(obj)) {
78303
- result[key] = interpolateObject(value, context);
78304
- }
78305
- return result;
78306
- }
78307
- return obj;
78308
- }
78309
-
78310
78321
  // ../shared/src/app_query_gate.ts
78311
78322
  function refused(refusal) {
78312
78323
  return { ok: false, refusal };
@@ -78616,7 +78627,7 @@ function reportQueryVerdict(verdict) {
78616
78627
  warn(
78617
78628
  `\u26A0 ${verdict.fullScans.length} scan${verdict.fullScans.length === 1 ? "" : "s"} no index can narrow (advisory, nothing is refused):
78618
78629
  ` + verdict.fullScans.map(({ finding }) => ` ${finding}`).join("\n") + `
78619
- An index serves an exact match (\`equals\`/\`is_any_of\`, indexed at deploy) for a whole identifier, and the source's \`search\` for free text. The same term in \`search\` beside a \`contains\` returns exactly the \`contains\` rows, found through that index when the term is selective.`
78630
+ An index serves an exact match (\`equals\`/\`is_any_of\`, indexed at deploy) for a whole identifier, and the source's \`search\` for free text. The same term in \`search\` beside a \`contains\` returns the \`contains\` rows, found through that index.`
78620
78631
  );
78621
78632
  }
78622
78633
  return verdict.refused.length;
@@ -81346,6 +81357,7 @@ function createSource(plan) {
81346
81357
  const opening = [
81347
81358
  `const made = await create_records({`,
81348
81359
  ` table_id: ${str(plan.table.id)},`,
81360
+ ` ids: isNull(i.${RECORD_INPUT}) ? null : [i.${RECORD_INPUT}],`,
81349
81361
  ` records: [`,
81350
81362
  ` {`,
81351
81363
  ...cells.map((cell) => ` ${cell}`),
@@ -82456,7 +82468,7 @@ function fieldInput(ctx, bf, required2) {
82456
82468
  }
82457
82469
  }
82458
82470
  function createInputs(ctx, alias2, writes) {
82459
- const inputs = {};
82471
+ const inputs = { [RECORD_INPUT]: { type: "text", required: false } };
82460
82472
  if (writes.parent !== void 0) {
82461
82473
  const via = fieldOf2(ctx, alias2, writes.parent);
82462
82474
  if (via.field.type !== "select_record_link") fail(`"${alias2}.${via.alias}" files a row under its record, so it must be a link`);
@@ -86705,6 +86717,7 @@ var READ_OPS = /* @__PURE__ */ new Set([
86705
86717
  "context",
86706
86718
  "query",
86707
86719
  "field_options",
86720
+ "write_model",
86708
86721
  "members",
86709
86722
  "agentRuns",
86710
86723
  "agentRun.get",
@@ -86737,6 +86750,7 @@ function refusalMessage(refused2, by) {
86737
86750
  var SUPPORTED_OPS = /* @__PURE__ */ new Set([
86738
86751
  "query",
86739
86752
  "field_options",
86753
+ "write_model",
86740
86754
  "workflow",
86741
86755
  "members",
86742
86756
  "context",
@@ -86792,6 +86806,8 @@ async function dispatchRpc(client, body, opts) {
86792
86806
  }
86793
86807
  return client.appFieldOptions(body.app_id, p.alias);
86794
86808
  }
86809
+ case "write_model":
86810
+ return client.appWriteModel(body.app_id);
86795
86811
  case "workflow": {
86796
86812
  const p = body.payload;
86797
86813
  if (!p || typeof p.alias !== "string") {
@@ -88233,7 +88249,7 @@ var PROBES = {
88233
88249
  partial_count: {
88234
88250
  law: "reviewing.md",
88235
88251
  section: "8o",
88236
- fix: "State the server's count of the set, never the rows a page loaded: count the read (`useCount`) and step or badge against that total."
88252
+ fix: "State the server's count of the set, never the rows a page loaded: count the read (`useQuery`'s `total`) and step or badge against that total."
88237
88253
  },
88238
88254
  record_primary: {
88239
88255
  law: "hierarchy.md",
@@ -89242,6 +89258,8 @@ function modelAnswer(data) {
89242
89258
  return { members: [{ ...PREVIEW_MEMBER }] };
89243
89259
  case "field_options":
89244
89260
  return typeof alias2 === "string" ? { fields: data.field_options[alias2] ?? {}, units: data.field_units[alias2] ?? {} } : { fields: {}, units: {} };
89261
+ case "write_model":
89262
+ return { workflows: {}, queries: {}, tables: [] };
89245
89263
  case "query": {
89246
89264
  if (typeof alias2 !== "string") throw new Error("a query names its alias; this call named none");
89247
89265
  const params = stated2(payload, "params");
@@ -1426,6 +1426,12 @@ export declare class LoticsClient {
1426
1426
  }>;
1427
1427
  }>;
1428
1428
  }>;
1429
+ /**
1430
+ * What the app's writes will change — each bound workflow's record-write subset, each query's column
1431
+ * sources, the fields they name. Mirrors GET /v1/apps/{app_id}/write_model; the dev server hands it to
1432
+ * the app's SDK whole, so its parts stay opaque here.
1433
+ */
1434
+ appWriteModel(app_id: string): Promise<unknown>;
1429
1435
  /**
1430
1436
  * Execute a workflow by alias declared in package.json#lotics.workflows.
1431
1437
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
@@ -883,6 +883,14 @@ var LoticsClient = class {
883
883
  async appFieldOptions(app_id, alias) {
884
884
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias });
885
885
  }
886
+ /**
887
+ * What the app's writes will change — each bound workflow's record-write subset, each query's column
888
+ * sources, the fields they name. Mirrors GET /v1/apps/{app_id}/write_model; the dev server hands it to
889
+ * the app's SDK whole, so its parts stay opaque here.
890
+ */
891
+ async appWriteModel(app_id) {
892
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/write_model`);
893
+ }
886
894
  /**
887
895
  * Execute a workflow by alias declared in package.json#lotics.workflows.
888
896
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
@@ -81,7 +81,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
81
81
  | `lotics app workflow check [alias...]` | Check the editable workflow bodies locally — **every alias you name**, or all of them when you name none; an alias that is not bound is refused BEFORE any body is checked, so a green ✓ never sits under an exit 1 — locally first, in the **server's own order** — parse, then type-check — then on the server. **Parse** runs `parseWorkflowJs` from `@lotics/shared` (the SAME module `verifyWorkflow` calls, never a second implementation) over the stripped body `set` would upload, with `toolNames: undefined` (the CLI ships no tool registry, so tool-name resolution stays a server check while every shape/scope rule runs here). A body the subset rejects reports **that error alone** and skips the compiler — it never reaches the server's compiler either, so tsc's opinion of it is noise. **Type-check** then builds an **isolated** `ts.Program` per alias from exactly that alias's `{body, globals}` pair — mirroring the server, which verifies one body at a time — so the per-alias ambient `trigger` never collides and `trigger.app_workflow.inputs` is checked against the right alias. All aliases run in ONE node process (N programs, not N `tsc` spawns), with the SAME compile options the server uses at set-time verify (lib `es2022` with no DOM, target ES2022, strict, NodeNext, `types:[]`, skipLibCheck) and the app's OWN `typescript` (resolved from its `node_modules`, never bundled into the CLI). What the compiler sees is the **checked source**, not the file: `rewriteAccumulatorAppends` from `@lotics/shared` — the SAME transform the server applies before its set-time compile — is applied in memory, so a pulled body's canonical `out = concat(out, [item])` accumulator checks green here exactly as it saves there, and the body on disk is never rewritten. Reports `<file>:<line>:<col> - <TS####\|subset>` at the **physical** line in `src/workflows/<alias>.ts`, so an editor jump lands on the offending code (these are deliberately NOT `set`'s body-relative numbers — `set` prints no file path, so there is no format to agree with); exits non-zero if any alias fails. **Then every body the local passes found clean goes to the server**: `set_app_workflow` with `verify_only: true`, sent exactly what `set` sends (the manifest's `inputs`/`outputs`/`description` and the synced `expected_body_sha`), which runs every check a save runs — names, lint, structural validation, table reach, the published-API guard — and writes nothing. Its issues print in the same `<file>:<line>:<col> - <source>/<code>` form at the physical line; one tied to a step rather than a position prints against the file, naming the step; a refusal (a stale baseline, a draft, a break) prints as `server/<code>`. All of them exit 1. A server it cannot reach — no credentials, the network, a server that predates `verify_only` — is a warning naming each alias and why, and the exit is then the local verdict's, so a green run says which of the two it is. A bound alias with no body file yet warns + skips; a body with no globals errors (naming `lotics app codegen`, which refreshes types WITHOUT touching the body — a pull would overwrite it). **It also keeps the types honest.** Each alias's `.lotics/workflows/<alias>.globals.d.ts` carries a `// lotics:declaration <hash>` stamp of the manifest declaration it was rendered from; `check` compares it to `package.json#lotics.workflows.<alias>` and, when they differ, re-renders that alias's dts from the LOCAL declaration before compiling. Without it the verdict was confidently wrong in the exact case an author needs it — declare an input, run `check`, and get `TS2339: Property 'x' does not exist` pointing at your body for a schema the types have never been told about. The server renders a dts from a SUPPLIED declaration, so this works before the manifest has ever been deployed, which is when it matters (the order is edit → check → set). That refresh is skipped when the stamps match, and with no credentials or a failed fetch it WARNS and checks against the older types rather than blocking. A file written before the stamp existed reads as unknown, never as matching, so a pre-existing checkout heals on its first run. |
82
82
  | `lotics app subdomain <new-subdomain>` | Rename the app's public address under the instance's apps domain via `PUT /v1/apps/{id}/subdomain`. app_id comes from the local `package.json` manifest; the chosen slug must be a valid DNS label and free; the old address stops resolving. |
83
83
  | `lotics app rename "<new name>" [--description <d>] [--icon <lucide-name>] [--theme <color>]` | **The app's display metadata, live and on disk, in one verb** — via the `update_app` tool (the single setter for name/description/icon/theme). app_id comes from the local `package.json` manifest; the public address (`subdomain`) and the code (`deploy`) are unchanged. **It also writes `package.json#name`**, folded from the new display name by the scaffold's own rule (`starter_template.ts` — diacritics are FOLDED, never dropped, so `Điều xe` is `dieu-xe` and not `i-u`). Nothing else folds it, so a rename that skipped it left every `npm` line, every CI log and every reader of the project calling the app by its old name. Written only after the server took the rename, and surgically: no other manifest key moves. The three flags set the branding `app check` warns about — a missing icon or colour draws a generic tile, a missing description gives the app's chat agent a roster of aliases and no brief — through the same call, so setting them is never a second `lotics run update_app` that forgets the manifest write. `--theme` takes the COLOUR (`theme.color` is the whole of what the launcher reads), not a JSON object. A flag you omit changes nothing: `update_app` merges, and absent means unchanged. CLEARING one is still `lotics run update_app` with an explicit `null` — a CLI flag has no spelling for that a shell cannot produce by accident. |
84
- | `lotics app dev [path] [--port <n>] [--vite-port <n>] [--view-as=<member_id>]` | Spawn Vite dev server + an RPC-forwarding HTTP server. **Refused in one sentence on a project with no `vite.config.*`** — an app created with `--api` serves no bundle, and without the config Vite fails on a missing entry document in a bundler's words about a file the author never expected to have. `app check --screens` is refused on the same project for the same reason, rather than reporting "nothing blocking" for a pass it never ran; plain `app check` runs everything else. **`--port` is the wrapper you open and `--vite-port` is the module server, each as `--port <n>` or `--port=<n>`; pin both to run several apps at once.** A value that is not a port number is refused. With no `--vite-port`, `vite.config`'s own `server.port` is used when it states a literal one — the CLI passes `--port … --strictPort` to Vite, and a CLI flag beats the config in Vite's precedence, so the config's value could otherwise never win. `--vite-port` still outranks it and says so. **The wrapper serves every path that is not one of its own `/_…` routes**, so `http://localhost:PORT/lo/rec_…` opens that screen directly; `?_loc=<url-encoded path>` still works and wins. With `LOTICS_UI_SRC` set, Vite is started with `--force`: its optimizer cache survives a restart, so a NEW file added to the linked kit tree otherwise left the browser running the previous build of the module that imported it, silently. The wrapper page embeds the iframe with `sandbox="allow-scripts allow-same-origin"` matching production; postMessage ops (query / workflow / members / context / upload / openExternal / urlState / agentRun) are forwarded to api.lotics.ai using the CLI's API key — file bytes move in **both** directions through the dev server's own relays, never browser↔storage: dev runs against the PROD bucket, whose CORS admits `https://*.lotics.app` and not `http://localhost:<port>`, so a direct browser transfer is blocked — no upload could complete and no preview engine (PDF/Word/Excel all FETCH the bytes) could read a file. `upload` mints a presigned URL and PUTs it **to `PUT /_upload/<file_id>`** from the wrapper page — same-origin, so no preflight and no CORS — and Node forwards it on; every presigned `url`/`thumbnail_url`/`preview_url` on a **file object** in an RPC result is rewritten to **`GET /_file/<token>`** (absolute — the iframe would resolve a relative path against Vite), which streams the bytes back with `Range` passthrough (206s intact, so PDF seeking works) and an `Access-Control-Allow-Origin` for the Vite origin (the one cross-origin hop left is OUR response to allow). Neither relay ever takes a destination from the client — it gets a `file_id`/token and transfers only to/from a URL it minted or observed itself, so there is no client-controlled target and no SSRF surface. A URL in a record's own text cell is NOT rewritten. Production is unchanged (direct-to-storage, no bytes through the API server); `openExternal` and `urlState.get/set` are handled locally (the latter read/write the wrapper page's own address bar — `set` writes in place via `replaceState` and browser back/forward broadcast a `url-state` message back, so `useUrlState` survives refresh and is shareable in the dev loop; in-app *routing* is the app's own (the iframe owns its url via `@lotics/app-runtime/router`), and the wrapper bakes the saved screen (`_loc`) into the iframe src on load so a refresh restores it, mirroring production); `agentRun` (streaming) is proxied through `POST /_agent_run`, which opens the run's SSE with the CLI key and pipes chunks back to the iframe (`stream-chunk`* → `stream-end`), so `useAgentRun` works in the dev loop just like production; `context` resolves the viewer (`member_id` from `cli/whoami` + `comments_enabled` from the local manifest) and fetches the app's stored `config` live from the app row, so `useConfig()` renders the same values as production. `--view-as` (global flag; also `LOTICS_VIEW_AS`) threads `x-view-as-member-id` so `is_current_member` + `context` resolve to that member — **admin key only** (the server 403s a non-admin), writes stay attributed to the key owner. Hot reload via Vite; full DevTools / Playwright access via plain localhost. **Every forwarded op logs one line naming its ALIAS** — `[rpc] query applicants 231ms` — and `query applicants (count)` for a count request, which is a SECOND full execution of the same query rather than a cheap lookup. When requests overlap the line carries `· N in flight`. That number is the one to watch: the server bounds how many app queries run at once, so requests past the bound wait and the wait lands inside each request's own duration — a burst reads as "every query got slower", which looks like a slow database and is not one. A screen firing its list plus three facet counts on one keystroke shows up here as eight lines over one or two aliases; see `@lotics/app-runtime` `docs/data_fetching.md` (`useCount`, and handing `usePaginatedQuery` a `total`) and `docs/queries.md` §10 for collapsing them. **Holds no realtime connection** — push belongs to the product frontend, so an app previewed here never updates on an external write (a CLI run, another tab, an agent): reload to see it. Deliberate rather than missing, since the alternative is a second implementation of the channel in the wrapper page, and a blanket poll here would hide an app whose queries do not declare their tables — the one mistake the real host punishes. The startup banner says `realtime: off` so this is visible without reading this table. The scaffold's `vite.config.ts` states `optimizeDeps: loticsOptimizeDeps()` — every published kit subpath, DERIVED from the kit's own `exports` rather than copied, and empty under `LOTICS_UI_SRC`. Vite's scanner reaches a subpath the moment something imports it, and meeting one mid-session re-optimizes, reloads, and inside this sandboxed iframe leaves two Reacts ("Invalid hook call") until a cold restart. `dev` and `codegen` heal that line, and the `server.fs.allow` one beside it, into a config scaffolded before them. Binds **loopback only** (`127.0.0.1`) — `/_rpc` dispatches with the developer's API key, so a socket on every interface would hand anyone on the network full read/write on the workspace. |
84
+ | `lotics app dev [path] [--port <n>] [--vite-port <n>] [--view-as=<member_id>]` | Spawn Vite dev server + an RPC-forwarding HTTP server. **Refused in one sentence on a project with no `vite.config.*`** — an app created with `--api` serves no bundle, and without the config Vite fails on a missing entry document in a bundler's words about a file the author never expected to have. `app check --screens` is refused on the same project for the same reason, rather than reporting "nothing blocking" for a pass it never ran; plain `app check` runs everything else. **`--port` is the wrapper you open and `--vite-port` is the module server, each as `--port <n>` or `--port=<n>`; pin both to run several apps at once.** A value that is not a port number is refused. With no `--vite-port`, `vite.config`'s own `server.port` is used when it states a literal one — the CLI passes `--port … --strictPort` to Vite, and a CLI flag beats the config in Vite's precedence, so the config's value could otherwise never win. `--vite-port` still outranks it and says so. **The wrapper serves every path that is not one of its own `/_…` routes**, so `http://localhost:PORT/lo/rec_…` opens that screen directly; `?_loc=<url-encoded path>` still works and wins. With `LOTICS_UI_SRC` set, Vite is started with `--force`: its optimizer cache survives a restart, so a NEW file added to the linked kit tree otherwise left the browser running the previous build of the module that imported it, silently. The wrapper page embeds the iframe with `sandbox="allow-scripts allow-same-origin"` matching production; postMessage ops (query / workflow / members / context / upload / openExternal / urlState / agentRun) are forwarded to api.lotics.ai using the CLI's API key — file bytes move in **both** directions through the dev server's own relays, never browser↔storage: dev runs against the PROD bucket, whose CORS admits `https://*.lotics.app` and not `http://localhost:<port>`, so a direct browser transfer is blocked — no upload could complete and no preview engine (PDF/Word/Excel all FETCH the bytes) could read a file. `upload` mints a presigned URL and PUTs it **to `PUT /_upload/<file_id>`** from the wrapper page — same-origin, so no preflight and no CORS — and Node forwards it on; every presigned `url`/`thumbnail_url`/`preview_url` on a **file object** in an RPC result is rewritten to **`GET /_file/<token>`** (absolute — the iframe would resolve a relative path against Vite), which streams the bytes back with `Range` passthrough (206s intact, so PDF seeking works) and an `Access-Control-Allow-Origin` for the Vite origin (the one cross-origin hop left is OUR response to allow). Neither relay ever takes a destination from the client — it gets a `file_id`/token and transfers only to/from a URL it minted or observed itself, so there is no client-controlled target and no SSRF surface. A URL in a record's own text cell is NOT rewritten. Production is unchanged (direct-to-storage, no bytes through the API server); `openExternal` and `urlState.get/set` are handled locally (the latter read/write the wrapper page's own address bar — `set` writes in place via `replaceState` and browser back/forward broadcast a `url-state` message back, so `useUrlState` survives refresh and is shareable in the dev loop; in-app *routing* is the app's own (the iframe owns its url via `@lotics/app-runtime/router`), and the wrapper bakes the saved screen (`_loc`) into the iframe src on load so a refresh restores it, mirroring production); `agentRun` (streaming) is proxied through `POST /_agent_run`, which opens the run's SSE with the CLI key and pipes chunks back to the iframe (`stream-chunk`* → `stream-end`), so `useAgentRun` works in the dev loop just like production; `context` resolves the viewer (`member_id` from `cli/whoami` + `comments_enabled` from the local manifest) and fetches the app's stored `config` live from the app row, so `useConfig()` renders the same values as production. `--view-as` (global flag; also `LOTICS_VIEW_AS`) threads `x-view-as-member-id` so `is_current_member` + `context` resolve to that member — **admin key only** (the server 403s a non-admin), writes stay attributed to the key owner. Hot reload via Vite; full DevTools / Playwright access via plain localhost. **Every forwarded op logs one line naming its ALIAS** — `[rpc] query applicants 231ms` — and `query applicants (count)` for a count request, which is a SECOND full execution of the same query rather than a cheap lookup. When requests overlap the line carries `· N in flight`. That number is the one to watch: the server bounds how many app queries run at once, so requests past the bound wait and the wait lands inside each request's own duration — a burst reads as "every query got slower", which looks like a slow database and is not one. A screen firing its list plus three facet counts on one keystroke shows up here as eight lines over one or two aliases; see `@lotics/app-runtime` `docs/data_fetching.md` (`total`, and `useQueries` for several counts at once) and `docs/queries.md` §10 for collapsing them. **Holds no realtime connection** — push belongs to the product frontend, so an app previewed here never updates on an external write (a CLI run, another tab, an agent): reload to see it. Deliberate rather than missing, since the alternative is a second implementation of the channel in the wrapper page, and a blanket poll here would hide an app whose queries do not declare their tables — the one mistake the real host punishes. The startup banner says `realtime: off` so this is visible without reading this table. The scaffold's `vite.config.ts` states `optimizeDeps: loticsOptimizeDeps()` — every published kit subpath, DERIVED from the kit's own `exports` rather than copied, and empty under `LOTICS_UI_SRC`. Vite's scanner reaches a subpath the moment something imports it, and meeting one mid-session re-optimizes, reloads, and inside this sandboxed iframe leaves two Reacts ("Invalid hook call") until a cold restart. `dev` and `codegen` heal that line, and the `server.fs.allow` one beside it, into a config scaffolded before them. Binds **loopback only** (`127.0.0.1`) — `/_rpc` dispatches with the developer's API key, so a socket on every interface would hand anyone on the network full read/write on the workspace. |
85
85
  | `LOTICS_UI_SRC=<abs path to packages/ui/src>` (env, not a command) | Dev-link `@lotics/ui` to a monorepo checkout for the length of ONE command, **for every tool at once**. The app's `vite.config.ts` gets its whole `resolve` block from the kit (`resolve: loticsResolve()` — `@lotics/ui/vite`), which reads the variable at call time and adds the `@lotics/ui/*` → working-copy alias, so kit edits go live under `lotics app dev` (HMR) and bundle under `lotics app deploy`. In the same breath, every command that regenerates types (`create`/`pull`/`dev`/`deploy`/`codegen`, all via `writeAppDts`) writes **`.lotics/tsconfig.link.json`** — the matching `paths`, which the app's `tsconfig.json` `extends` — so `tsc`, vitest, eslint and your EDITOR resolve the same copy Vite does. Unset ⇒ every one of them goes back to `node_modules`, and the generated file is rewritten inert. **Why `paths` and not `npm link`:** under the dev-link a kit file sits OUTSIDE the app's `node_modules` and resolves its OWN `react` from the monorepo — two copies in one program and every shared type stops matching ("Two different types with this name exist, but they are unrelated"). The generated file therefore also pins every peer @lotics/ui declares to the APP's copy, types-package first (`react` → `@types/react`; pinning the runtime package instead strands tsc on a `.js` with no declarations). The pin set is derived from the installed kit's `peerDependencies`, so it tracks the kit rather than rotting. **The one hand-written file edited is `vite.config.ts`**, by `dev` and `codegen`, and only for the two blocks that are CALLS into `@lotics/ui/vite` (`optimizeDeps: loticsOptimizeDeps()`, `loticsFsAllow()` in `server.fs.allow`), spliced at the scaffold's own anchors in a config that already imports the subpath; a key the author answers themselves is left as typed and named back instead. Everything else generated lives in `.lotics/` (the CLI's own dir). Identical for a monorepo app and an EXTERNAL one (e.g. `~/lotics_apps`). `app deploy` still warns whenever the variable is set — that the bundle carries kit code from your working copy, or that the app's config predates `loticsResolve()` and never reads it, so the PUBLISHED kit is going out. An app whose `tsconfig.json` already `extends` something else is told rather than rewritten: add `./.lotics/tsconfig.link.json` to the array yourself. |
86
86
  | `lotics file preview <file\|fil_id> [-o out.png]` | (also `lotics preview`) A PDF is refused and the refusal names the route: `lotics file download <fil_id>`, then `pdftoppm -png -r 150` (poppler-utils) for one PNG per page — "open it" is an instruction for a person at a screen, and the caller here is usually an agent. A `.html` renders as the page it is — served from its own directory so what it refers to beside it resolves, read once its images have loaded, captured at its content size — which is how a demo's paper props (an official letter, a stamped minute, a supplier's bill) are looked at before they go into an `html` template. Otherwise render a .docx/.xlsx to a PNG using the SAME engines the frontend FilePreview uses (`@lotics/docx` `loadDocxIntoElement` / `@lotics/xlsx` `drawSpreadsheet`) — so what you see matches an operator. Accepts a **local path** OR a stored **`fil_…` id** (a bare id, no extension): an id is first downloaded to a temp dir (the same presign path as `lotics file download`), rendered, then the transient source is removed; with no `-o` the PNG lands in cwd under the stored file's base name. Drives a headless Chrome over **CDP with only Node built-ins** — zero npm deps, the CLI stays a single bundled binary. The browser render logic is a separate browser bundle shipped at `dist/render_page.js`, served over a throwaway localhost http server and screenshotted full-page. **Requires a Chrome/Chromium on the machine** — detected from `CHROME_PATH`/`LOTICS_CHROME`, then Playwright's installed chromium, then system paths — inherent to rendering these browser formats; a clear "install a browser" error otherwise. PDFs need no render (open them directly). |
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.260.0",
3
+ "version": "0.261.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {