@lotics/cli 0.70.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 +27 -5
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +233 -0
- package/dist/render_page.js +59068 -0
- package/dist/render_page.js.LEGAL.txt +14 -0
- package/dist/src/cli.js +247 -38
- package/package.json +1 -1
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
|
|
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") {
|
package/dist/preview.js
ADDED
|
@@ -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
|
+
}
|