@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/README.md +36 -0
- package/dist/app_commands.d.ts +125 -3
- package/dist/app_commands.js +613 -6
- package/dist/app_commands.test.js +501 -2
- package/dist/args.d.ts +4 -0
- package/dist/args.js +9 -0
- package/dist/args.test.js +12 -0
- package/dist/child_env.d.ts +13 -0
- package/dist/child_env.js +24 -0
- package/dist/cli.js +132 -30
- package/dist/client.d.ts +66 -0
- package/dist/client.js +144 -1
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +47 -0
- package/dist/dev/server.js +2 -1
- package/dist/generate_app_fields.d.ts +54 -0
- package/dist/generate_app_fields.js +148 -0
- package/dist/generate_app_fields.test.d.ts +1 -0
- package/dist/generate_app_fields.test.js +108 -0
- package/dist/inputs.d.ts +38 -0
- package/dist/inputs.js +50 -0
- package/dist/inputs.test.d.ts +1 -0
- package/dist/inputs.test.js +89 -0
- package/dist/src/cli.js +2483 -1414
- package/dist/starter_template.js +89 -0
- package/dist/starter_template.test.js +11 -0
- package/package.json +2 -1
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/dev/server.js
CHANGED
|
@@ -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;
|