@lotics/cli 0.57.0 → 0.60.1

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/cli.js CHANGED
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ import dns from "node:dns";
3
+ import net from "node:net";
2
4
  import fs from "node:fs";
3
5
  import path from "node:path";
4
6
  import readline from "node:readline";
7
+ // WSL2 + Node 24's Happy-Eyeballs races IPv4/IPv6 and intermittently stalls on a
8
+ // dead IPv6 route to api.lotics.ai (curl works; node fetch ETIMEDOUTs). Prefer
9
+ // IPv4 and cap the per-family attempt so a bad IPv6 path fails fast to IPv4.
10
+ // Bin-only side effect — client.ts stays pure for SDK consumers.
11
+ dns.setDefaultResultOrder("ipv4first");
12
+ net.setDefaultAutoSelectFamilyAttemptTimeout(2000);
5
13
  import { LoticsClient, API_BASE_URL } from "./client.js";
6
14
  import { resolveContext, deleteConfig, getConfigPath, loadGlobalConfig, saveGlobalConfig, loadLocalConfig, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, resolveProfileByNameOrId, checkForUpdate, } from "./config.js";
7
15
  import { VERSION } from "./version.js";
8
- import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename } from "./app_commands.js";
16
+ import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename, appCodegen, appExecuteWorkflow, appWorkflowSet, appWorkflowPull, appUiLink, } from "./app_commands.js";
9
17
  import { parseArgs } from "./args.js";
18
+ import { ingestJsonArgs } from "./inputs.js";
10
19
  import { runXlsxCommand } from "./xlsx.js";
11
20
  import { runDocxCommand } from "./docx.js";
12
21
  function printHelp() {
@@ -65,9 +74,21 @@ COMMANDS
65
74
  lotics app deploy [-m <message>] Build + upload current dir as a new version
66
75
  (code + queries only — workflow bindings are
67
76
  managed by set_app_workflow / remove_app_workflow)
77
+ lotics app codegen [path] Regenerate .lotics/* (types + field/option ids)
78
+ from the manifest + workspace schema — no deploy
79
+ lotics app workflow run <alias> '<json>' Execute a bound app workflow end-to-end
80
+ (inputs: inline JSON, @file, or stdin;
81
+ --print-created reports created records +
82
+ a paste-ready cleanup plan; --cleanup also
83
+ deletes those records — NOT a rollback)
84
+ lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body
85
+ through set_app_workflow (server verifies)
86
+ lotics app workflow pull Rewrite src/workflows/*.ts from the server
68
87
  lotics app subdomain <new-subdomain> Rename the app's public <slug>.lotics.app address
69
88
  lotics app rename "<new name>" Rename the app's display name (launcher title)
70
89
  lotics app dev [path] Run the app locally with HMR (RPC forwarded to prod)
90
+ lotics ui link <component> [--remove] Dev-link @lotics/ui to the monorepo's
91
+ packages/ui/src for live HMR (monorepo only)
71
92
  lotics xlsx <subcommand> ... Read/write/edit .xlsx files on your local filesystem
72
93
  (uses the bundled Lotics xlsx engine; prefer over
73
94
  npm xlsx/exceljs for round-trip fidelity)
@@ -461,6 +482,40 @@ async function main() {
461
482
  await runDocxCommand(subcommand, toolArgs, restArgs);
462
483
  return;
463
484
  }
485
+ // --- lotics ui link <component> [--remove] — local vite.config edit, no auth ---
486
+ if (command === "ui") {
487
+ if (subcommand === "link") {
488
+ const component = toolArgs;
489
+ if (!component) {
490
+ console.error("Usage: lotics ui link <component> [--remove]");
491
+ console.error("Dev-links @lotics/ui to the monorepo's packages/ui/src for live HMR.");
492
+ process.exit(1);
493
+ }
494
+ // `--remove` isn't a value-taking flag, so the parser leaves it as a
495
+ // trailing positional (the component took `toolArgs`).
496
+ appUiLink({ component, remove: restArgs.includes("--remove") });
497
+ return;
498
+ }
499
+ console.error(`Unknown ui subcommand: ${subcommand ?? "(none)"}`);
500
+ console.error("Usage: lotics ui link <component> [--remove]");
501
+ process.exit(1);
502
+ }
503
+ // --- lotics app codegen [path] — regenerate .lotics/* without a deploy ---
504
+ // The .d.ts companions need no auth; app_fields.ts needs a workspace, resolved
505
+ // when credentials are available (offline/unauth still does the .d.ts work).
506
+ if (command === "app" && subcommand === "codegen") {
507
+ const projectDir = toolArgs;
508
+ const ctx = resolveContext(flags);
509
+ if (!ctx) {
510
+ await appCodegen({ projectDir });
511
+ return;
512
+ }
513
+ const viewAsMemberId = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
514
+ const client = new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId, viewAsMemberId });
515
+ await resolveWorkspace(client, ctx);
516
+ await appCodegen({ projectDir, client });
517
+ return;
518
+ }
464
519
  // --- lotics org [list | use <name|id> [--local]] — credential store, config-only ---
465
520
  if (command === "org") {
466
521
  const global = loadGlobalConfig() ?? {};
@@ -524,11 +579,17 @@ async function main() {
524
579
  console.error('Run "lotics --help" for usage.');
525
580
  process.exit(1);
526
581
  }
582
+ // `ui` and `app codegen` are handled above (no-auth / optional-client) and
583
+ // return before this guard — they never reach the requireClient path.
527
584
  if (command === "app" && !subcommand) {
528
585
  console.error("Usage:");
529
586
  console.error(" lotics app create <name> [path] Scaffold a new app locally");
530
587
  console.error(" lotics app pull <app_id> [path] Pull an existing app for local editing");
531
588
  console.error(" lotics app deploy [-m <message>] Build + upload the current directory");
589
+ console.error(" lotics app codegen [path] Regenerate .lotics/* (types + field ids) — no deploy");
590
+ console.error(" lotics app workflow run <alias> '<json>' Execute a bound app workflow end-to-end");
591
+ console.error(" lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body");
592
+ console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
532
593
  console.error(" lotics app subdomain <new-subdomain> Rename the app's public <slug>.lotics.app address");
533
594
  console.error(" lotics app rename \"<new name>\" Rename the app's display name (launcher title)");
534
595
  console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
@@ -677,6 +738,64 @@ async function main() {
677
738
  await appRename(client, { name: newName });
678
739
  return;
679
740
  }
741
+ if (subcommand === "workflow") {
742
+ // `lotics app workflow <run|set|pull> …` — disambiguated subcommands so an
743
+ // alias can never collide with the verb. `toolArgs` is the verb; `restArgs`
744
+ // carries the alias (+ inputs for run). No bare `workflow <alias>` form.
745
+ const action = toolArgs;
746
+ const workflowUsage = () => {
747
+ console.error("Usage:");
748
+ console.error(" lotics app workflow run <alias> '<json>' Execute a bound app workflow");
749
+ console.error(" lotics app workflow set <alias> Push src/workflows/<alias>.ts");
750
+ console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
751
+ process.exit(1);
752
+ };
753
+ if (action === "run") {
754
+ // `run <alias> '<json>'` — inputs use the SAME ingestion as `lotics run`
755
+ // (inline / @file / stdin) so bulk inputs bypass ARG_MAX.
756
+ const alias = restArgs[0];
757
+ if (!alias) {
758
+ console.error("Usage: lotics app workflow run <alias> '<json>'");
759
+ console.error(" lotics app workflow run <alias> @inputs.json (read inputs from a file)");
760
+ console.error(" cat inputs.json | lotics app workflow run <alias> (read inputs from stdin)");
761
+ console.error("Flags: --print-created (report created records + cleanup plan + caveat)");
762
+ console.error(" --cleanup (also delete the created records — records only, NOT a rollback)");
763
+ process.exit(1);
764
+ }
765
+ const ingested = await ingestJsonArgs({
766
+ rawArg: restArgs[1],
767
+ stdinIsTTY: process.stdin.isTTY ?? false,
768
+ readFile: (p) => fs.readFileSync(p, "utf-8"),
769
+ readStdin,
770
+ });
771
+ if (ingested.kind === "error") {
772
+ console.error(ingested.message);
773
+ process.exit(1);
774
+ }
775
+ await appExecuteWorkflow(client, {
776
+ alias,
777
+ inputs: ingested.args,
778
+ printCreated: flags.printCreated,
779
+ cleanup: flags.cleanup,
780
+ });
781
+ return;
782
+ }
783
+ if (action === "set") {
784
+ const alias = restArgs[0];
785
+ if (!alias) {
786
+ console.error("Usage: lotics app workflow set <alias>");
787
+ console.error("Pushes the edited src/workflows/<alias>.ts body via set_app_workflow.");
788
+ process.exit(1);
789
+ }
790
+ await appWorkflowSet(client, { alias });
791
+ return;
792
+ }
793
+ if (action === "pull") {
794
+ await appWorkflowPull(client);
795
+ return;
796
+ }
797
+ workflowUsage();
798
+ }
680
799
  if (subcommand === "dev") {
681
800
  // First positional is an optional project path (defaults to cwd).
682
801
  // --port and --vite-port can override the wrapper / Vite ports.
@@ -782,36 +901,19 @@ async function main() {
782
901
  // lotics run <tool> [json_args]
783
902
  if (command === "run") {
784
903
  const toolName = subcommand;
785
- let rawArgs = toolArgs;
786
- if (rawArgs && rawArgs.startsWith("@")) {
787
- // `@<path>` read the JSON args from a local file. JSON args always
788
- // start with `{`, so a leading `@` is unambiguous. This (and piped
789
- // stdin below) carries payloads too large for an inline arg, which the
790
- // OS caps (ARG_MAX) e.g. a knowledge doc's `content` or a bulk update.
791
- // Reads only a file the caller explicitly named; no new trust boundary.
792
- const argsPath = rawArgs.slice(1);
793
- try {
794
- rawArgs = fs.readFileSync(argsPath, "utf-8");
795
- }
796
- catch (err) {
797
- console.error(`Cannot read args file "${argsPath}": ${err instanceof Error ? err.message : String(err)}`);
798
- process.exit(1);
799
- }
800
- }
801
- else if (!rawArgs && !process.stdin.isTTY) {
802
- // Piped/redirected stdin — `… | lotics run <tool>` or `< file.json`.
803
- rawArgs = await readStdin();
804
- }
805
- let args = {};
806
- if (rawArgs) {
807
- try {
808
- args = JSON.parse(rawArgs);
809
- }
810
- catch {
811
- console.error(`Invalid JSON: ${rawArgs}`);
812
- process.exit(1);
813
- }
904
+ // `@file` / piped stdin carry payloads too large for an inline arg (the OS
905
+ // caps ARG_MAX). Shared with `lotics app workflow` so both ingest identically.
906
+ const ingested = await ingestJsonArgs({
907
+ rawArg: toolArgs,
908
+ stdinIsTTY: process.stdin.isTTY ?? false,
909
+ readFile: (p) => fs.readFileSync(p, "utf-8"),
910
+ readStdin,
911
+ });
912
+ if (ingested.kind === "error") {
913
+ console.error(ingested.message);
914
+ process.exit(1);
814
915
  }
916
+ const args = ingested.args;
815
917
  const timeoutMs = flags.timeout ?? 60000;
816
918
  // Always request text format so model_output is available; --json only affects CLI output
817
919
  const result = await client.execute(toolName, args, { format: "text", timeoutMs });
package/dist/client.d.ts CHANGED
@@ -162,6 +162,25 @@ export declare class LoticsClient {
162
162
  workspace_id: string;
163
163
  current_version_id: string | null;
164
164
  }>;
165
+ /**
166
+ * Resolve the display name + fields (incl. select options) of the given tables
167
+ * — the schema `lotics app codegen` turns into the runtime `.lotics/app_fields.ts`
168
+ * alias maps. One `get_table` call per id (the tool surface has no batch
169
+ * variant); a missing/inaccessible table is dropped rather than throwing, so a
170
+ * stale id in the scope set never fails codegen.
171
+ */
172
+ getWorkspaceSchema(tableIds: string[]): Promise<Array<{
173
+ id: string;
174
+ name: string;
175
+ fields: Array<{
176
+ id: string;
177
+ name: string;
178
+ options?: Array<{
179
+ id: string;
180
+ label: string;
181
+ }>;
182
+ }>;
183
+ }>>;
165
184
  /**
166
185
  * Rename an app's public subdomain — its `<slug>.lotics.app` address.
167
186
  * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
@@ -224,6 +243,45 @@ export declare class LoticsClient {
224
243
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
225
244
  */
226
245
  appWorkflow(app_id: string, alias: string, inputs: unknown): Promise<unknown>;
246
+ /**
247
+ * Bind (create or replace) an app workflow by alias via the `set_app_workflow`
248
+ * tool — the SINGLE author of `apps.workflows` + the workflow row. `source` is
249
+ * the verbatim JS-subset body (no `on({...})` trigger). `inputs`/`outputs` are
250
+ * the typed schemas declared in `package.json#lotics.workflows.<alias>`. The
251
+ * server re-verifies the body and echoes the bound `outputs` (declared, else
252
+ * DERIVED from `return({ data })`), so the CLI can show the author what shape
253
+ * `result.data` will carry. Wraps the tool rather than a bespoke endpoint so
254
+ * the file flow stays a convenience over the existing single-author contract.
255
+ */
256
+ setAppWorkflow(app_id: string, alias: string, body: {
257
+ source: string;
258
+ inputs?: Record<string, unknown>;
259
+ outputs?: Record<string, unknown>;
260
+ name?: string;
261
+ description?: string;
262
+ }): Promise<ToolExecuteResult>;
263
+ /**
264
+ * Fetch one app workflow's faithful source + bound input/output schemas via
265
+ * `get_app_workflow`. `source` is the JS-subset body re-rendered from the
266
+ * persisted step tree (incl. the `return({ data })` clause, opaque field/option
267
+ * keys) — the exact text `lotics app workflow set` would push back. Feeds
268
+ * `lotics app pull`, which writes it to `src/workflows/<alias>.ts`.
269
+ */
270
+ getAppWorkflow(app_id: string, alias: string): Promise<ToolExecuteResult>;
271
+ /**
272
+ * Fetch the server-generated workspace `.d.ts` + the wrapper envelope that
273
+ * make a `src/workflows/<alias>.ts` body locally typecheckable (GAP-59).
274
+ * The server is the single source of the type model — the CLI never
275
+ * re-implements it. `envelope_prefix`/`envelope_suffix` are the exact
276
+ * `async function __workflow(): …` wrapper the server compiles inside, so the
277
+ * local typecheck mirrors the set-time verdict. Mirrors
278
+ * POST /v1/apps/{app_id}/workflows/{alias}/dts.
279
+ */
280
+ getAppWorkflowDts(app_id: string, alias: string): Promise<{
281
+ dts: string;
282
+ envelope_prefix: string;
283
+ envelope_suffix: string;
284
+ }>;
227
285
  /**
228
286
  * Open a streaming agent run and return the RAW streamed `Response` (the
229
287
  * caller reads `res.body`). Unlike `request`, this does not buffer/parse the
package/dist/client.js CHANGED
@@ -187,6 +187,48 @@ export class LoticsClient {
187
187
  async createApp(body) {
188
188
  return this.request("POST", "/v1/apps", body);
189
189
  }
190
+ /**
191
+ * Resolve the display name + fields (incl. select options) of the given tables
192
+ * — the schema `lotics app codegen` turns into the runtime `.lotics/app_fields.ts`
193
+ * alias maps. One `get_table` call per id (the tool surface has no batch
194
+ * variant); a missing/inaccessible table is dropped rather than throwing, so a
195
+ * stale id in the scope set never fails codegen.
196
+ */
197
+ async getWorkspaceSchema(tableIds) {
198
+ const tables = await Promise.all(tableIds.map(async (table_id) => {
199
+ // JSON (the default) — `res.result` is the structured `get_table` output
200
+ // already; `text` would only also run `toModelOutput` per table for
201
+ // nothing. The field id is `key` and an option's id is `key` / label is
202
+ // `name` (the `TableField` / select-option schema shapes).
203
+ const res = await this.execute("get_table", { table_id });
204
+ if (res.error || res.result === null || typeof res.result !== "object")
205
+ return null;
206
+ const table = res.result;
207
+ if (typeof table.id !== "string" || typeof table.name !== "string" || !Array.isArray(table.fields)) {
208
+ return null;
209
+ }
210
+ const fields = table.fields.flatMap((field) => {
211
+ if (field === null || typeof field !== "object")
212
+ return [];
213
+ const f = field;
214
+ if (typeof f.key !== "string" || typeof f.name !== "string")
215
+ return [];
216
+ const options = Array.isArray(f.options)
217
+ ? f.options.flatMap((opt) => {
218
+ if (opt === null || typeof opt !== "object")
219
+ return [];
220
+ const o = opt;
221
+ return typeof o.key === "string" && typeof o.name === "string"
222
+ ? [{ id: o.key, label: o.name }]
223
+ : [];
224
+ })
225
+ : undefined;
226
+ return [{ id: f.key, name: f.name, ...(options && options.length > 0 ? { options } : {}) }];
227
+ });
228
+ return { id: table.id, name: table.name, fields };
229
+ }));
230
+ return tables.filter((t) => t !== null);
231
+ }
190
232
  /**
191
233
  * Rename an app's public subdomain — its `<slug>.lotics.app` address.
192
234
  * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
@@ -260,6 +302,49 @@ export class LoticsClient {
260
302
  return parsed ?? {};
261
303
  return { status: "error", message: transportErrorMessage(response.status, parsed) };
262
304
  }
305
+ /**
306
+ * Bind (create or replace) an app workflow by alias via the `set_app_workflow`
307
+ * tool — the SINGLE author of `apps.workflows` + the workflow row. `source` is
308
+ * the verbatim JS-subset body (no `on({...})` trigger). `inputs`/`outputs` are
309
+ * the typed schemas declared in `package.json#lotics.workflows.<alias>`. The
310
+ * server re-verifies the body and echoes the bound `outputs` (declared, else
311
+ * DERIVED from `return({ data })`), so the CLI can show the author what shape
312
+ * `result.data` will carry. Wraps the tool rather than a bespoke endpoint so
313
+ * the file flow stays a convenience over the existing single-author contract.
314
+ */
315
+ async setAppWorkflow(app_id, alias, body) {
316
+ return this.execute("set_app_workflow", {
317
+ app_id,
318
+ alias,
319
+ source: body.source,
320
+ ...(body.inputs ? { inputs: body.inputs } : {}),
321
+ ...(body.outputs ? { outputs: body.outputs } : {}),
322
+ ...(body.name ? { name: body.name } : {}),
323
+ ...(body.description ? { description: body.description } : {}),
324
+ });
325
+ }
326
+ /**
327
+ * Fetch one app workflow's faithful source + bound input/output schemas via
328
+ * `get_app_workflow`. `source` is the JS-subset body re-rendered from the
329
+ * persisted step tree (incl. the `return({ data })` clause, opaque field/option
330
+ * keys) — the exact text `lotics app workflow set` would push back. Feeds
331
+ * `lotics app pull`, which writes it to `src/workflows/<alias>.ts`.
332
+ */
333
+ async getAppWorkflow(app_id, alias) {
334
+ return this.execute("get_app_workflow", { app_id, alias });
335
+ }
336
+ /**
337
+ * Fetch the server-generated workspace `.d.ts` + the wrapper envelope that
338
+ * make a `src/workflows/<alias>.ts` body locally typecheckable (GAP-59).
339
+ * The server is the single source of the type model — the CLI never
340
+ * re-implements it. `envelope_prefix`/`envelope_suffix` are the exact
341
+ * `async function __workflow(): …` wrapper the server compiles inside, so the
342
+ * local typecheck mirrors the set-time verdict. Mirrors
343
+ * POST /v1/apps/{app_id}/workflows/{alias}/dts.
344
+ */
345
+ async getAppWorkflowDts(app_id, alias) {
346
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/dts`);
347
+ }
263
348
  /**
264
349
  * Open a streaming agent run and return the RAW streamed `Response` (the
265
350
  * caller reads `res.body`). Unlike `request`, this does not buffer/parse the
@@ -14,6 +14,7 @@
14
14
  import http from "node:http";
15
15
  import net from "node:net";
16
16
  import { spawn } from "node:child_process";
17
+ import { ipv4ChildEnv } from "../child_env.js";
17
18
  import { dispatchRpc } from "./rpc_handler.js";
18
19
  import { buildWrapperPage } from "./wrapper_page.js";
19
20
  const DEFAULT_PORT = 5174;
@@ -55,7 +56,7 @@ export async function startDevServer(args) {
55
56
  const viteChild = spawn("npx", ["vite", "--port", String(vitePort), "--strictPort"], {
56
57
  cwd: args.projectDir,
57
58
  stdio: [process.stdin.isTTY ? "inherit" : "ignore", "inherit", "inherit"],
58
- env: { ...process.env, FORCE_COLOR: "1" },
59
+ env: ipv4ChildEnv({ ...process.env, FORCE_COLOR: "1" }),
59
60
  });
60
61
  let stopped = false;
61
62
  let stoppingResolve = null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` from a workspace schema.
3
+ *
4
+ * Unlike the `.d.ts` companions (which only augment types), this is a real
5
+ * `.ts` module: apps import the string VALUES at runtime to address fields and
6
+ * select options by a stable, human-readable alias instead of pasting opaque
7
+ * `fld_…` / `opt_…` ids into their source:
8
+ *
9
+ * import { F, OPT } from "../.lotics/app_fields";
10
+ * record.data[F.SHIPMENTS.status] // "fld_…"
11
+ * if (status === OPT.SHIPMENTS.status.cleared) // "opt_…"
12
+ *
13
+ * Aliases are derived from display names (NFD-stripped, non-alnum → `_`, deduped
14
+ * in stable order), so a rename on the platform re-runs codegen and the app's
15
+ * call sites move with it. Pure function — same schema → same bytes. Idempotent.
16
+ */
17
+ /** One select option as it appears in a field's metadata. */
18
+ export interface TableFieldOption {
19
+ /** Stable id (`opt_…`) — the value persisted in record data. */
20
+ id: string;
21
+ /** Display label the option alias is slugified from. */
22
+ label: string;
23
+ }
24
+ /** A single table field with the metadata codegen needs. */
25
+ export interface TableFieldSchema {
26
+ /** Stable id (`fld_…`) — the value emitted under `F`. */
27
+ id: string;
28
+ /** Display name the field alias is slugified from. */
29
+ name: string;
30
+ /** Present only for select/multi-select fields — drives the `OPT` map. */
31
+ options?: TableFieldOption[];
32
+ }
33
+ /** The schema of one table, as returned by `client.getWorkspaceSchema`. */
34
+ export interface TableSchema {
35
+ /** Stable id (`tbl_…`). Carried for fidelity; not emitted by name. */
36
+ id: string;
37
+ /** Display name the TABLE alias is slugified from. */
38
+ name: string;
39
+ fields: TableFieldSchema[];
40
+ }
41
+ /**
42
+ * Slugify a display name to a valid TS identifier. NFD-normalize then strip
43
+ * diacritics so "Lô hàng" and "Lo hang" don't collide on the accent, lowercase,
44
+ * non-alnum → `_`, collapse runs, trim edge `_`. A leading digit (identifiers
45
+ * can't start with one) and the empty result both get a `_` prefix/placeholder.
46
+ * `upper` uppercases the result (TABLE aliases read as constants).
47
+ */
48
+ export declare function slugifyAlias(name: string, upper: boolean): string;
49
+ /**
50
+ * Generate the full `.lotics/app_fields.ts` source. `tables` is the resolved
51
+ * workspace schema (the subset the app touches). An empty list yields valid,
52
+ * empty `F`/`OPT` maps so the file always compiles and imports resolve.
53
+ */
54
+ export declare function generateAppFields(tables: TableSchema[]): string;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` from a workspace schema.
3
+ *
4
+ * Unlike the `.d.ts` companions (which only augment types), this is a real
5
+ * `.ts` module: apps import the string VALUES at runtime to address fields and
6
+ * select options by a stable, human-readable alias instead of pasting opaque
7
+ * `fld_…` / `opt_…` ids into their source:
8
+ *
9
+ * import { F, OPT } from "../.lotics/app_fields";
10
+ * record.data[F.SHIPMENTS.status] // "fld_…"
11
+ * if (status === OPT.SHIPMENTS.status.cleared) // "opt_…"
12
+ *
13
+ * Aliases are derived from display names (NFD-stripped, non-alnum → `_`, deduped
14
+ * in stable order), so a rename on the platform re-runs codegen and the app's
15
+ * call sites move with it. Pure function — same schema → same bytes. Idempotent.
16
+ */
17
+ import { isValidIdentifier } from "./generate_app_workflows_dts.js";
18
+ const HEADER = `// Auto-generated by 'lotics app codegen' (and app pull/dev/deploy).
19
+ // DO NOT EDIT — regenerated from the workspace schema.
20
+ //
21
+ // Runtime field + option ids addressed by stable display-name aliases:
22
+ // record.data[F.<TABLE>.<field>] → "fld_…"
23
+ // value === OPT.<TABLE>.<field>.<option> → "opt_…"
24
+ // A rename on the platform re-runs codegen and moves these in lockstep.
25
+ `;
26
+ /**
27
+ * Slugify a display name to a valid TS identifier. NFD-normalize then strip
28
+ * diacritics so "Lô hàng" and "Lo hang" don't collide on the accent, lowercase,
29
+ * non-alnum → `_`, collapse runs, trim edge `_`. A leading digit (identifiers
30
+ * can't start with one) and the empty result both get a `_` prefix/placeholder.
31
+ * `upper` uppercases the result (TABLE aliases read as constants).
32
+ */
33
+ export function slugifyAlias(name, upper) {
34
+ const stripped = name
35
+ .normalize("NFD")
36
+ .replace(/[̀-ͯ]/g, "") // combining diacritical marks
37
+ .replace(/đ/g, "d")
38
+ .replace(/Đ/g, "D"); // not a combining mark — map explicitly
39
+ const cased = upper ? stripped.toUpperCase() : stripped.toLowerCase();
40
+ const slug = cased
41
+ .replace(/[^a-zA-Z0-9]+/g, "_")
42
+ .replace(/^_+|_+$/g, "");
43
+ if (slug === "")
44
+ return "_";
45
+ return /^[0-9]/.test(slug) ? `_${slug}` : slug;
46
+ }
47
+ /**
48
+ * Assign each input a unique alias, preserving input order. The first claim on
49
+ * a slug keeps it; later collisions get `_2`, `_3`, … so the mapping is stable
50
+ * across regenerations (order is the schema's field/option order). Returns the
51
+ * aliases positionally aligned with `names`.
52
+ */
53
+ function dedupeAliases(names, upper) {
54
+ const used = new Map();
55
+ return names.map((name) => {
56
+ const base = slugifyAlias(name, upper);
57
+ const seen = used.get(base);
58
+ if (seen === undefined) {
59
+ used.set(base, 1);
60
+ return base;
61
+ }
62
+ let n = seen + 1;
63
+ while (used.has(`${base}_${n}`))
64
+ n++;
65
+ used.set(base, n);
66
+ used.set(`${base}_${n}`, 1);
67
+ return `${base}_${n}`;
68
+ });
69
+ }
70
+ /** A property key for an object literal — bare when a valid identifier, else quoted. */
71
+ function propKey(alias) {
72
+ return isValidIdentifier(alias) ? alias : JSON.stringify(alias);
73
+ }
74
+ /** Resolve table + field aliases once, so the `F`/`OPT` maps and the union types agree. */
75
+ function aliasTables(tables) {
76
+ const tableAliases = dedupeAliases(tables.map((t) => t.name), true);
77
+ return tables.map((table, i) => {
78
+ const fieldAliases = dedupeAliases(table.fields.map((f) => f.name), false);
79
+ return {
80
+ alias: tableAliases[i],
81
+ table,
82
+ fields: table.fields.map((field, j) => ({ alias: fieldAliases[j], field })),
83
+ };
84
+ });
85
+ }
86
+ /** Emit the `F` map: `{ TABLE: { field: "fld_…" } }` plus its literal-union type. */
87
+ function emitFieldMap(aliased) {
88
+ const tableBlocks = aliased.map(({ alias, fields }) => {
89
+ const fieldLines = fields.map(({ alias: fieldAlias, field }) => ` ${propKey(fieldAlias)}: ${JSON.stringify(field.id)},`);
90
+ return ` ${propKey(alias)}: {\n${fieldLines.join("\n")}\n },`;
91
+ });
92
+ return `export const F = {\n${tableBlocks.join("\n")}\n} as const;`;
93
+ }
94
+ /**
95
+ * Emit the `OPT` map: `{ TABLE: { selectField: { option: "opt_…" } } }`. Only
96
+ * fields that carry options appear. Tables and fields with no options are
97
+ * omitted entirely (so `OPT.SHIPMENTS` may be absent — that's by design).
98
+ */
99
+ function emitOptionMap(aliased) {
100
+ const tableBlocks = [];
101
+ for (const { alias, fields } of aliased) {
102
+ const fieldBlocks = [];
103
+ for (const { alias: fieldAlias, field } of fields) {
104
+ const options = field.options ?? [];
105
+ if (options.length === 0)
106
+ continue;
107
+ const optionAliases = dedupeAliases(options.map((o) => o.label), false);
108
+ const optionLines = options.map((option, i) => ` ${propKey(optionAliases[i])}: ${JSON.stringify(option.id)},`);
109
+ fieldBlocks.push(` ${propKey(fieldAlias)}: {\n${optionLines.join("\n")}\n },`);
110
+ }
111
+ if (fieldBlocks.length === 0)
112
+ continue;
113
+ tableBlocks.push(` ${propKey(alias)}: {\n${fieldBlocks.join("\n")}\n },`);
114
+ }
115
+ if (tableBlocks.length === 0)
116
+ return `export const OPT = {} as const;`;
117
+ return `export const OPT = {\n${tableBlocks.join("\n")}\n} as const;`;
118
+ }
119
+ /**
120
+ * Generate the full `.lotics/app_fields.ts` source. `tables` is the resolved
121
+ * workspace schema (the subset the app touches). An empty list yields valid,
122
+ * empty `F`/`OPT` maps so the file always compiles and imports resolve.
123
+ */
124
+ export function generateAppFields(tables) {
125
+ if (tables.length === 0) {
126
+ return `${HEADER}
127
+ export const F = {} as const;
128
+
129
+ export const OPT = {} as const;
130
+
131
+ /** Field-id alias map (empty — no tables in scope). */
132
+ export type AppFields = typeof F;
133
+ /** Select-option alias map (empty — no tables in scope). */
134
+ export type AppOptions = typeof OPT;
135
+ `;
136
+ }
137
+ const aliased = aliasTables(tables);
138
+ return `${HEADER}
139
+ ${emitFieldMap(aliased)}
140
+
141
+ ${emitOptionMap(aliased)}
142
+
143
+ /** Field-id alias map: \`F[<TABLE>][<field>]\` is the \`fld_…\` id (literal-typed). */
144
+ export type AppFields = typeof F;
145
+ /** Select-option alias map: \`OPT[<TABLE>][<field>][<option>]\` is the \`opt_…\` id. */
146
+ export type AppOptions = typeof OPT;
147
+ `;
148
+ }
@@ -0,0 +1 @@
1
+ export {};