@lotics/cli 0.56.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
@@ -1,3 +1,11 @@
1
+ /**
2
+ * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
3
+ * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
4
+ * 5xx, or a JSON body without a `message` falls back to a body-free,
5
+ * status-derived message. `parsed` is the JSON.parse of the body, or `null`.
6
+ * In parity (by value, no shared dep) with `packages/app-sdk/src/rpc.ts`.
7
+ */
8
+ export declare function transportErrorMessage(status: number, parsed: unknown): string;
1
9
  /** One sort key forwarded to the app query RPC (wire shape of a `TableRecordSort` entry). */
2
10
  export interface AppQuerySortKey {
3
11
  field_key: string;
@@ -154,6 +162,25 @@ export declare class LoticsClient {
154
162
  workspace_id: string;
155
163
  current_version_id: string | null;
156
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
+ }>>;
157
184
  /**
158
185
  * Rename an app's public subdomain — its `<slug>.lotics.app` address.
159
186
  * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
@@ -216,6 +243,45 @@ export declare class LoticsClient {
216
243
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
217
244
  */
218
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
+ }>;
219
285
  /**
220
286
  * Open a streaming agent run and return the RAW streamed `Response` (the
221
287
  * caller reads `res.body`). Unlike `request`, this does not buffer/parse the
package/dist/client.js CHANGED
@@ -1,5 +1,36 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ /**
4
+ * A user-facing message for a transport/gateway failure — derived from the HTTP
5
+ * status, never from the response body. A 524 (edge timeout on a long run), any
6
+ * 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw body. In
7
+ * parity (by value, no shared dep) with the published transport in
8
+ * `packages/app-sdk/src/rpc.ts`.
9
+ */
10
+ function gatewayErrorMessage(status) {
11
+ if (status === 524) {
12
+ return "The request took too long to finish (gateway timeout). It may still be running — check back in a moment, or try again.";
13
+ }
14
+ if (status >= 500) {
15
+ return "The service is temporarily unavailable. Please try again shortly.";
16
+ }
17
+ return "The service returned an unexpected response. Please try again.";
18
+ }
19
+ /**
20
+ * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
21
+ * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
22
+ * 5xx, or a JSON body without a `message` falls back to a body-free,
23
+ * status-derived message. `parsed` is the JSON.parse of the body, or `null`.
24
+ * In parity (by value, no shared dep) with `packages/app-sdk/src/rpc.ts`.
25
+ */
26
+ export function transportErrorMessage(status, parsed) {
27
+ const jsonMessage = parsed && typeof parsed.message === "string"
28
+ ? parsed.message
29
+ : null;
30
+ return parsed === null || status >= 500 || jsonMessage === null
31
+ ? gatewayErrorMessage(status)
32
+ : jsonMessage;
33
+ }
3
34
  function findAvailableFilename(dir, filename, reserved) {
4
35
  // `reserved` tracks absolute paths claimed by in-flight downloads in the same
5
36
  // batch — required for parallel callers because the file may not be on disk
@@ -156,6 +187,48 @@ export class LoticsClient {
156
187
  async createApp(body) {
157
188
  return this.request("POST", "/v1/apps", body);
158
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
+ }
159
232
  /**
160
233
  * Rename an app's public subdomain — its `<slug>.lotics.app` address.
161
234
  * Mirrors PUT /v1/apps/{app_id}/subdomain. The old subdomain stops
@@ -200,7 +273,77 @@ export class LoticsClient {
200
273
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
201
274
  */
202
275
  async appWorkflow(app_id, alias, inputs) {
203
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`, { inputs });
276
+ // Its own transport (not the generic `request`, whose `throwResponseError`
277
+ // shape is the CLI contract elsewhere): a transport/gateway failure resolves
278
+ // to a `WorkflowResult` error `{ status, message }` — never a thrown HTML
279
+ // body — so the dev RPC bridge forwards `{status:"error"}` to the iframe,
280
+ // matching the deployed standalone SDK (`@lotics/app-sdk` standaloneWorkflow).
281
+ const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`;
282
+ const headers = this.buildHeaders();
283
+ headers["Content-Type"] = "application/json";
284
+ let response;
285
+ try {
286
+ response = await fetch(url, { method: "POST", headers, body: JSON.stringify({ inputs }) });
287
+ }
288
+ catch (err) {
289
+ return { status: "error", message: err instanceof Error ? err.message : "The workflow request failed." };
290
+ }
291
+ const text = await response.text();
292
+ let parsed = null;
293
+ if (text) {
294
+ try {
295
+ parsed = JSON.parse(text);
296
+ }
297
+ catch {
298
+ // non-JSON body (e.g. a gateway HTML error page) — never echoed
299
+ }
300
+ }
301
+ if (response.ok)
302
+ return parsed ?? {};
303
+ return { status: "error", message: transportErrorMessage(response.status, parsed) };
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`);
204
347
  }
205
348
  /**
206
349
  * Open a streaming agent run and return the RAW streamed `Response` (the
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { LoticsClient, transportErrorMessage } from "./client.js";
3
+ describe("transportErrorMessage", () => {
4
+ it("returns a body-free gateway message for a non-JSON 524 (never the HTML)", () => {
5
+ const msg = transportErrorMessage(524, null);
6
+ expect(msg).not.toMatch(/<html|<!DOCTYPE/i);
7
+ expect(msg.toLowerCase()).toContain("gateway timeout");
8
+ });
9
+ it("returns a friendly message for any 5xx, ignoring a JSON body", () => {
10
+ expect(transportErrorMessage(503, { message: "internal detail" })).not.toBe("internal detail");
11
+ expect(transportErrorMessage(503, { message: "internal detail" })).not.toMatch(/<html/i);
12
+ });
13
+ it("surfaces a genuine 4xx JSON error message verbatim", () => {
14
+ expect(transportErrorMessage(400, { message: "record_id is required" })).toBe("record_id is required");
15
+ });
16
+ it("never leaks a non-JSON 4xx body (WAF / redirect HTML)", () => {
17
+ expect(transportErrorMessage(403, null)).not.toMatch(/<html|<!DOCTYPE/i);
18
+ });
19
+ it("falls back to a friendly message for a JSON body with no message field", () => {
20
+ expect(transportErrorMessage(409, { error_code: "CONFLICT" })).toBeTruthy();
21
+ expect(transportErrorMessage(409, { error_code: "CONFLICT" })).not.toMatch(/<html/i);
22
+ });
23
+ });
24
+ describe("LoticsClient.appWorkflow", () => {
25
+ afterEach(() => vi.unstubAllGlobals());
26
+ const client = new LoticsClient({ apiKey: "ltk_test", workspaceId: "wsp_test" });
27
+ it("normalizes a 524 HTML gateway response to a WorkflowResult error (no raw HTML)", async () => {
28
+ const html = "<!DOCTYPE html><html><head><title>error</title></head><body>524: A timeout occurred</body></html>";
29
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(html, { status: 524 })));
30
+ const result = (await client.appWorkflow("app_1", "wf", {}));
31
+ expect(result.status).toBe("error");
32
+ expect(result.message).not.toMatch(/<html|<!DOCTYPE/i);
33
+ });
34
+ it("passes a 200 workflow result through unchanged", async () => {
35
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ status: "success", data: { x: 1 } }), { status: 200 })));
36
+ const result = await client.appWorkflow("app_1", "wf", {});
37
+ expect(result).toEqual({ status: "success", data: { x: 1 } });
38
+ });
39
+ it("returns a WorkflowResult error (not a throw) on a network failure", async () => {
40
+ vi.stubGlobal("fetch", vi.fn(async () => {
41
+ throw new Error("ECONNREFUSED");
42
+ }));
43
+ const result = (await client.appWorkflow("app_1", "wf", {}));
44
+ expect(result.status).toBe("error");
45
+ expect(result.message).toContain("ECONNREFUSED");
46
+ });
47
+ });
@@ -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;