@lotics/cli 0.69.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import { parseArgs } from "./args.js";
18
18
  import { ingestJsonArgs } from "./inputs.js";
19
19
  import { runXlsxCommand } from "./xlsx.js";
20
20
  import { runDocxCommand } from "./docx.js";
21
+ import { runPreviewCommand } from "./preview.js";
21
22
  function printHelp() {
22
23
  console.log(`Lotics CLI v${VERSION} — AI agent interface for Lotics
23
24
 
@@ -66,10 +67,6 @@ COMMANDS
66
67
  lotics run <tool> '<json>' Execute a tool
67
68
  lotics run <tool> @args.json Read JSON args from a file (large payloads)
68
69
  cat args.json | lotics run <tool> Read JSON args from stdin (large payloads)
69
- lotics upload <file|dir...> Upload files (directories expand to their immediate files)
70
- lotics download <file_id> Download a file by ID
71
- lotics download record <record_id> <field_key>
72
- Download all files on a record file field
73
70
  lotics app create <name> [path] Create a new custom-code app + scaffold locally
74
71
  lotics app pull <app_id> [path] Bootstrap full local env (source + npm install + types)
75
72
  lotics app deploy -m <message> Build + upload current dir as a new version
@@ -107,6 +104,12 @@ COMMANDS
107
104
  lotics docx <subcommand> ... Read/write/edit .docx files on your local filesystem
108
105
  (uses the bundled Lotics OOXML engine; preserves
109
106
  tables, images, and unknown markup verbatim)
107
+ lotics file upload <file|dir...> Upload files (alias: lotics upload)
108
+ lotics file download <file_id> Download a file by ID (alias: lotics download)
109
+ lotics file download record <record_id> <field_key>
110
+ Download all files on a record file field
111
+ lotics file preview <file> [-o png] Render a .docx/.xlsx to a PNG (frontend engines;
112
+ needs a Chrome/Chromium on the machine)
110
113
 
111
114
  FLAGS
112
115
  --json Full JSON output (default is human-readable text)
@@ -392,7 +395,22 @@ function resolveUploadPaths(rawPaths) {
392
395
  return result;
393
396
  }
394
397
  async function main() {
395
- const { command, subcommand, toolArgs, restArgs, flags } = parseArgs(process.argv.slice(2));
398
+ const parsed = parseArgs(process.argv.slice(2));
399
+ const { restArgs, flags } = parsed;
400
+ let { command, subcommand, toolArgs } = parsed;
401
+ // `file` is a thin noun-group over the file verbs: `lotics file <upload|download|preview>
402
+ // <args…>` strips the prefix and re-dispatches to the same handlers as the top-level
403
+ // verbs (which stay as aliases). Positional shift only — flags are untouched, and
404
+ // `download record <id> <field>` still works as `file download record <id> <field>`.
405
+ if (command === "file") {
406
+ if (!subcommand) {
407
+ printHelp();
408
+ return;
409
+ }
410
+ command = subcommand;
411
+ subcommand = toolArgs;
412
+ toolArgs = restArgs.shift();
413
+ }
396
414
  if (flags.help || (!command && !flags.version)) {
397
415
  printHelp();
398
416
  return;
@@ -494,6 +512,10 @@ async function main() {
494
512
  await runDocxCommand(subcommand, toolArgs, restArgs);
495
513
  return;
496
514
  }
515
+ if (command === "preview") {
516
+ await runPreviewCommand(subcommand, flags);
517
+ return;
518
+ }
497
519
  // --- lotics ui link <component> [--remove] — local vite.config edit, no auth ---
498
520
  if (command === "ui") {
499
521
  if (subcommand === "link") {
@@ -26,7 +26,9 @@ export declare function generateAppWorkflowsDts(workflows: Record<string, AppWor
26
26
  * to `unknown` rather than throwing — the server's deploy-time schema parse
27
27
  * catches structurally-invalid declarations before they reach us.
28
28
  */
29
- export declare function inputsToType(inputs: Record<string, unknown>): string;
29
+ export declare function inputsToType(inputs: Record<string, unknown>, opts?: {
30
+ nullableOptional?: boolean;
31
+ }): string;
30
32
  /**
31
33
  * Map an output `fields` map (also the top-level `outputs`) to a TS object type.
32
34
  * Mirrors the backend `outputObjectToTsType`; recursive for nested object/array.
@@ -41,7 +41,7 @@ declare module "@lotics/app-sdk" {
41
41
  // No typed inputs ⇒ untyped `Record<string, unknown>` callable. An alias with
42
42
  // declared `outputs` also gets an `AppWorkflowResults` entry → typed result.data.
43
43
  const valueType = declaration.inputs
44
- ? inputsToType(declaration.inputs)
44
+ ? inputsToType(declaration.inputs, { nullableOptional: true })
45
45
  : "Record<string, unknown>";
46
46
  const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
47
47
  inputLines.push(` ${aliasKey}: ${valueType};`);
@@ -74,16 +74,27 @@ ${inputLines.join("\n")}
74
74
  * to `unknown` rather than throwing — the server's deploy-time schema parse
75
75
  * catches structurally-invalid declarations before they reach us.
76
76
  */
77
- export function inputsToType(inputs) {
77
+ export function inputsToType(inputs, opts) {
78
+ // A WORKFLOW optional input also accepts `null` — the diff-write "clear this
79
+ // field" signal (the backend validates it, the body forwards it to
80
+ // `update_records({ set: { fld: null } })`). So `useWorkflow` call sites can
81
+ // pass `null` to unset a field without a loose `Record<string, unknown>` cast.
82
+ // Query params + agent inputs have no "clear", so they stay `T` (the caller
83
+ // omits the flag). Only the TOP level is nullable: a nested object's optional
84
+ // field mirrors the backend's plain `.optional()` (the recursive call below
85
+ // omits the flag), so the type never claims a nested clear the server rejects.
86
+ const nullableOptional = opts?.nullableOptional === true;
78
87
  const fields = [];
79
88
  for (const [key, decl] of Object.entries(inputs)) {
80
89
  if (decl === null || typeof decl !== "object")
81
90
  continue;
82
91
  const d = decl;
83
92
  const tsType = inputDeclToTsType(d);
84
- const optional = d.required === false ? "?" : "";
93
+ const isOptional = d.required === false;
94
+ const fieldType = isOptional && nullableOptional ? `${tsType} | null` : tsType;
95
+ const optional = isOptional ? "?" : "";
85
96
  const fieldKey = isValidIdentifier(key) ? key : JSON.stringify(key);
86
- fields.push(` ${fieldKey}${optional}: ${tsType};`);
97
+ fields.push(` ${fieldKey}${optional}: ${fieldType};`);
87
98
  }
88
99
  if (fields.length === 0)
89
100
  return "Record<string, never>";
@@ -58,6 +58,32 @@ describe("generateAppWorkflowsDts", () => {
58
58
  expect(dts).toContain("note?: string;");
59
59
  expect(dts).toContain("ref: string;");
60
60
  });
61
+ it("types a TOP-LEVEL optional workflow input as `T | null` (the diff-write clear signal), required stays `T`, nested stays plain optional", () => {
62
+ const dts = generateAppWorkflowsDts({
63
+ updateTask: {
64
+ workflow_id: "wfl_u",
65
+ inputs: {
66
+ record_id: { type: "record_link", table_id: "tbl_x" },
67
+ due: { type: "date", required: false },
68
+ status: { type: "select", required: false, options: [{ label: "Done", value: "opt_done" }] },
69
+ tags: { type: "select", multi: true, required: false, options: [{ label: "A", value: "opt_a" }] },
70
+ meta: {
71
+ type: "object",
72
+ required: false,
73
+ fields: { note: { type: "text", required: false } },
74
+ },
75
+ },
76
+ },
77
+ });
78
+ expect(dts).toContain("record_id: string;"); // required → NOT nullable
79
+ expect(dts).toContain("due?: string | null;"); // optional scalar → clearable
80
+ expect(dts).toContain('status?: "opt_done" | null;');
81
+ expect(dts).toContain('tags?: ReadonlyArray<"opt_a"> | null;'); // optional multi
82
+ // The object itself is a top-level optional → nullable; its NESTED optional
83
+ // field mirrors the backend's plain `.optional()` (no `| null`).
84
+ expect(dts).toContain("note?: string;");
85
+ expect(dts).not.toContain("note?: string | null;");
86
+ });
61
87
  it("maps record_link/select outputs the same way as inputs", () => {
62
88
  const dts = generateAppWorkflowsDts({
63
89
  lookup: {
@@ -0,0 +1,3 @@
1
+ export declare function runPreviewCommand(filePath: string | undefined, flags: {
2
+ output?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,233 @@
1
+ // `lotics preview <file.docx|.xlsx> [--out x.png]` — render a generated document to
2
+ // a PNG using the SAME engines the frontend FilePreview uses. No npm deps: drives a
3
+ // headless Chrome over CDP with Node built-ins (WebSocket/fetch/http/child_process),
4
+ // keeping the CLI a single bundled binary. The render logic lives in the esbuild
5
+ // browser bundle dist/render_page.js (built by build_cli.mjs); this file orchestrates
6
+ // Chrome. Chrome itself is external (system Chrome / Playwright chromium / CHROME_PATH)
7
+ // — inherent to rendering docx/xlsx, which are browser-rendered formats.
8
+ import { spawn } from "node:child_process";
9
+ import { createServer } from "node:http";
10
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, readdirSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, dirname, resolve, extname, basename } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ function fail(msg) {
17
+ console.error(msg);
18
+ process.exit(1);
19
+ }
20
+ /** Locate a Chrome/Chromium binary: explicit env → Playwright's install → system. */
21
+ function findChrome() {
22
+ const env = process.env.LOTICS_CHROME || process.env.CHROME_PATH;
23
+ if (env && existsSync(env))
24
+ return env;
25
+ const home = process.env.HOME || "";
26
+ // Playwright installs under ms-playwright/chromium-<rev>/… Enumerate with readdirSync
27
+ // (Node 10+) — NOT node:fs globSync, which is Node 22+; a globSync import would fail to
28
+ // load the whole bundled CLI on the Node 18+ it supports.
29
+ const pwDir = process.platform === "darwin"
30
+ ? `${home}/Library/Caches/ms-playwright`
31
+ : `${home}/.cache/ms-playwright`;
32
+ if (existsSync(pwDir)) {
33
+ const rel = process.platform === "darwin"
34
+ ? ["chrome-mac/Chromium.app/Contents/MacOS/Chromium"]
35
+ : ["chrome-linux/chrome", "chrome-linux/headless_shell"];
36
+ const revs = readdirSync(pwDir)
37
+ .filter((n) => n.startsWith("chromium-") || n.startsWith("chromium_headless_shell-"))
38
+ .sort()
39
+ .reverse();
40
+ for (const rev of revs) {
41
+ for (const r of rel) {
42
+ const bin = join(pwDir, rev, r);
43
+ if (existsSync(bin))
44
+ return bin;
45
+ }
46
+ }
47
+ }
48
+ const systemPaths = [
49
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
50
+ "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
51
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
52
+ ];
53
+ return systemPaths.find((p) => existsSync(p)) ?? null;
54
+ }
55
+ /** Minimal CDP client over the built-in WebSocket. */
56
+ async function cdpConnect(wsUrl) {
57
+ const ws = new WebSocket(wsUrl);
58
+ await new Promise((res, rej) => {
59
+ ws.onopen = () => res();
60
+ ws.onerror = () => rej(new Error("CDP websocket failed to open"));
61
+ });
62
+ let id = 0;
63
+ const pending = new Map();
64
+ // If Chrome exits mid-render the socket closes with no reply — settle every in-flight
65
+ // request so `send()` rejects instead of hanging forever (the finally then cleans up).
66
+ ws.onclose = () => {
67
+ for (const done of pending.values())
68
+ done(Promise.reject(new Error("CDP connection closed (Chrome exited?)")));
69
+ pending.clear();
70
+ };
71
+ ws.onmessage = (e) => {
72
+ const m = JSON.parse(String(e.data));
73
+ if (m.id != null && pending.has(m.id)) {
74
+ const done = pending.get(m.id);
75
+ pending.delete(m.id);
76
+ done(m.error ? Promise.reject(new Error(m.error.message)) : m.result);
77
+ }
78
+ };
79
+ const send = (method, params = {}) => new Promise((res) => {
80
+ const i = ++id;
81
+ pending.set(i, (r) => res(r));
82
+ ws.send(JSON.stringify({ id: i, method, params }));
83
+ });
84
+ return { send, close: () => ws.close() };
85
+ }
86
+ export async function runPreviewCommand(filePath, flags) {
87
+ if (!filePath)
88
+ fail("Usage: lotics preview <file.docx|.xlsx> [--out <file.png>]");
89
+ const abs = resolve(filePath);
90
+ if (!existsSync(abs))
91
+ fail(`File not found: ${abs}`);
92
+ const ext = extname(abs).toLowerCase();
93
+ const type = ext === ".docx" ? "docx" : (ext === ".xlsx" || ext === ".xls" || ext === ".csv") ? "xlsx" : null;
94
+ if (!type)
95
+ fail(`Unsupported file type "${ext}" — preview supports .docx and .xlsx/.csv. (PDFs open directly — no preview needed.)`);
96
+ // preview drives Chrome over CDP via the built-in WebSocket (Node 22+). The rest of the
97
+ // CLI supports Node 18, so fail this one command clearly rather than with "WebSocket is
98
+ // not defined" — and before launching Chrome.
99
+ if (typeof WebSocket === "undefined") {
100
+ fail("lotics preview needs Node 22+ (it drives Chrome over CDP via the built-in WebSocket). Upgrade Node and retry.");
101
+ }
102
+ const bundlePath = join(HERE, "..", "render_page.js");
103
+ if (!existsSync(bundlePath))
104
+ fail(`Render bundle missing at ${bundlePath} — reinstall @lotics/cli (build step failed).`);
105
+ const chrome = findChrome();
106
+ if (!chrome) {
107
+ fail("No Chrome/Chromium found. Set CHROME_PATH to a Chrome binary, or install one:\n" +
108
+ " npx playwright install chromium (then it's auto-detected)\n" +
109
+ " or install Google Chrome / Chromium via your package manager.");
110
+ }
111
+ const b64 = readFileSync(abs).toString("base64");
112
+ const bundle = readFileSync(bundlePath, "utf8");
113
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;background:#fff;font-family:sans-serif}` +
114
+ `#root{padding:20px;max-width:1240px;margin:0 auto}</style></head><body><div id="root"></div>` +
115
+ `<script>window.__LOTICS_RENDER=${JSON.stringify({ type, b64 })}</script>` +
116
+ `<script src="/render_page.js"></script></body></html>`;
117
+ const server = createServer((req, res) => {
118
+ if (req.url === "/render_page.js") {
119
+ res.writeHead(200, { "content-type": "application/javascript" });
120
+ res.end(bundle);
121
+ }
122
+ else {
123
+ res.writeHead(200, { "content-type": "text/html" });
124
+ res.end(html);
125
+ }
126
+ });
127
+ await new Promise((r) => server.listen(0, "127.0.0.1", () => r()));
128
+ const httpPort = server.address().port;
129
+ const udd = mkdtempSync(join(tmpdir(), "lotics-render-"));
130
+ const child = spawn(chrome, [
131
+ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
132
+ // Large multi-sheet renders exhaust the (often tiny) /dev/shm in containers/WSL2 and
133
+ // crash the tab; back shared memory with /tmp instead.
134
+ "--disable-dev-shm-usage",
135
+ "--force-device-scale-factor=2", "--remote-debugging-port=0", `--user-data-dir=${udd}`,
136
+ "about:blank",
137
+ ], { stdio: "ignore" });
138
+ const cleanup = () => {
139
+ try {
140
+ child.kill();
141
+ }
142
+ catch { /* ignore */ }
143
+ try {
144
+ server.close();
145
+ }
146
+ catch { /* ignore */ }
147
+ try {
148
+ rmSync(udd, { recursive: true, force: true });
149
+ }
150
+ catch { /* ignore */ }
151
+ };
152
+ try {
153
+ // Chrome writes the chosen debug port to DevToolsActivePort (first line).
154
+ let cdpPort = 0;
155
+ const portFile = join(udd, "DevToolsActivePort");
156
+ for (let i = 0; i < 100 && !cdpPort; i++) {
157
+ if (existsSync(portFile)) {
158
+ const p = parseInt(readFileSync(portFile, "utf8").split("\n")[0], 10);
159
+ if (p)
160
+ cdpPort = p;
161
+ }
162
+ if (!cdpPort)
163
+ await sleep(100);
164
+ }
165
+ if (!cdpPort)
166
+ throw new Error("Chrome did not expose a debugging port (launch failed?).");
167
+ let target;
168
+ for (let i = 0; i < 60 && !target?.webSocketDebuggerUrl; i++) {
169
+ try {
170
+ const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json();
171
+ target = list.find((t) => t.type === "page");
172
+ }
173
+ catch { /* not ready */ }
174
+ if (!target?.webSocketDebuggerUrl)
175
+ await sleep(100);
176
+ }
177
+ if (!target?.webSocketDebuggerUrl)
178
+ throw new Error("No Chrome page target available.");
179
+ const cdp = await cdpConnect(target.webSocketDebuggerUrl);
180
+ await cdp.send("Page.enable");
181
+ await cdp.send("Runtime.enable");
182
+ await cdp.send("Page.navigate", { url: `http://127.0.0.1:${httpPort}/` });
183
+ // Poll for the render-done flag the page sets.
184
+ let err;
185
+ let done = false;
186
+ const warnings = [];
187
+ for (let i = 0; i < 200; i++) {
188
+ const r = await cdp.send("Runtime.evaluate", {
189
+ expression: "({done: !!window.__loticsDone, err: window.__loticsError || '', warnings: window.__loticsWarnings || []})",
190
+ returnByValue: true,
191
+ });
192
+ const v = r.result?.value;
193
+ if (v?.done) {
194
+ done = true;
195
+ err = v.err || undefined;
196
+ if (v.warnings?.length)
197
+ warnings.push(...v.warnings);
198
+ break;
199
+ }
200
+ await sleep(75);
201
+ }
202
+ if (!done)
203
+ throw new Error("Render timed out (page never signaled completion).");
204
+ if (err)
205
+ throw new Error(`Render engine error: ${err}`);
206
+ // Size the capture to the full rendered content, then screenshot beyond viewport.
207
+ // Clamp to Chrome's ceiling: the PNG is 2× (device scale) the CSS clip, so a CSS side
208
+ // over ~15000px would blow past the ~32767px canvas limit. A very long docx or several
209
+ // huge sheets hit this — clamp explicitly and WARN, never clip in silence.
210
+ const metrics = await cdp.send("Page.getLayoutMetrics");
211
+ const size = metrics.cssContentSize ?? metrics.contentSize ?? { width: 1240, height: 1600 };
212
+ const MAX_CAPTURE = 15000; // CSS px; ×2 device scale → ≤30000px PNG
213
+ const w = Math.ceil(size.width);
214
+ const h = Math.ceil(size.height);
215
+ if (w > MAX_CAPTURE || h > MAX_CAPTURE) {
216
+ warnings.push(`content ${w}×${h}px clipped to ${Math.min(w, MAX_CAPTURE)}×${Math.min(h, MAX_CAPTURE)}px (exceeds the ${MAX_CAPTURE}px capture limit)`);
217
+ }
218
+ const shot = await cdp.send("Page.captureScreenshot", {
219
+ format: "png",
220
+ captureBeyondViewport: true,
221
+ clip: { x: 0, y: 0, width: Math.min(w, MAX_CAPTURE), height: Math.min(h, MAX_CAPTURE), scale: 1 },
222
+ });
223
+ cdp.close();
224
+ const outPath = flags.output ? resolve(flags.output) : join(dirname(abs), basename(abs, ext) + ".png");
225
+ writeFileSync(outPath, Buffer.from(shot.data, "base64"));
226
+ console.log(`Rendered ${basename(abs)} → ${outPath} (${Math.min(w, MAX_CAPTURE)}×${Math.min(h, MAX_CAPTURE)}, via ${basename(chrome)})`);
227
+ for (const warn of warnings)
228
+ console.error(` ⚠ ${warn}`);
229
+ }
230
+ finally {
231
+ cleanup();
232
+ }
233
+ }