@lotics/cli 0.20.0 → 0.21.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.
@@ -43,3 +43,21 @@ export declare function appDeploy(client: LoticsClient, args: {
43
43
  projectDir?: string;
44
44
  message?: string;
45
45
  }): Promise<void>;
46
+ /**
47
+ * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
48
+ *
49
+ * Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
50
+ * that bridges the iframe's hooks (useQuery, useMutate, useAction,
51
+ * useWorkflow) to api.lotics.ai using the CLI's stored API key. Wrapper
52
+ * iframe matches the production sandbox attributes exactly — null origin,
53
+ * allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
54
+ *
55
+ * Lifecycle: Vite as a child process (HMR over WebSocket), HTTP server on a
56
+ * sibling port serving the wrapper HTML + the /_rpc dispatcher. SIGINT kills
57
+ * both cleanly.
58
+ */
59
+ export declare function appDev(client: LoticsClient, args: {
60
+ projectDir?: string;
61
+ port?: number;
62
+ vitePort?: number;
63
+ }): Promise<void>;
@@ -16,6 +16,7 @@ import path from "node:path";
16
16
  import { spawn } from "node:child_process";
17
17
  import { tmpdir } from "node:os";
18
18
  import { buildStarterTemplate } from "./starter_template.js";
19
+ import { startDevServer, openBrowser } from "./dev/server.js";
19
20
  /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
20
21
  function runTar(args, cwd) {
21
22
  return new Promise((resolve, reject) => {
@@ -244,3 +245,56 @@ export async function appDeploy(client, args) {
244
245
  fs.unlinkSync(tmpDist);
245
246
  }
246
247
  }
248
+ /**
249
+ * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
250
+ *
251
+ * Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
252
+ * that bridges the iframe's hooks (useQuery, useMutate, useAction,
253
+ * useWorkflow) to api.lotics.ai using the CLI's stored API key. Wrapper
254
+ * iframe matches the production sandbox attributes exactly — null origin,
255
+ * allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
256
+ *
257
+ * Lifecycle: Vite as a child process (HMR over WebSocket), HTTP server on a
258
+ * sibling port serving the wrapper HTML + the /_rpc dispatcher. SIGINT kills
259
+ * both cleanly.
260
+ */
261
+ export async function appDev(client, args) {
262
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
263
+ const meta = readAppMeta(projectDir);
264
+ // Sanity: confirm the app exists in the workspace the CLI is auth'd into.
265
+ // Surfaces a clear error if the project's app_id has been deleted or the
266
+ // CLI is pointed at the wrong workspace.
267
+ const app = await client.getApp(meta.app_id);
268
+ const handle = await startDevServer({
269
+ projectDir,
270
+ app_id: meta.app_id,
271
+ app_name: app.name,
272
+ workspace_id: meta.workspace_id,
273
+ api_url: client.baseUrl,
274
+ port: args.port,
275
+ vitePort: args.vitePort,
276
+ client,
277
+ });
278
+ await handle.ready;
279
+ const url = `http://localhost:${handle.port}`;
280
+ console.error(`\n lotics app dev`);
281
+ console.error(` app: ${app.name} (${meta.app_id})`);
282
+ console.error(` workspace: ${meta.workspace_id}`);
283
+ console.error(` vite: http://localhost:${handle.vitePort}/`);
284
+ console.error(` open: ${url}`);
285
+ console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
286
+ console.error(` Ctrl-C to stop.\n`);
287
+ openBrowser(url);
288
+ // Block until SIGINT.
289
+ await new Promise((resolve) => {
290
+ const onSig = () => {
291
+ process.off("SIGINT", onSig);
292
+ process.off("SIGTERM", onSig);
293
+ resolve();
294
+ };
295
+ process.on("SIGINT", onSig);
296
+ process.on("SIGTERM", onSig);
297
+ });
298
+ console.error("\nStopping…");
299
+ await handle.stop();
300
+ }
package/dist/src/cli.js CHANGED
@@ -5,7 +5,7 @@ import readline from "node:readline";
5
5
  import { LoticsClient, API_BASE_URL } from "./client.js";
6
6
  import { resolveAuth, loadConfig, saveConfig, deleteConfig, checkForUpdate } from "./config.js";
7
7
  import { VERSION } from "./version.js";
8
- import { appCreate, appPull, appDeploy } from "./app_commands.js";
8
+ import { appCreate, appPull, appDeploy, appDev } from "./app_commands.js";
9
9
  function printHelp() {
10
10
  console.log(`Lotics CLI v${VERSION} — AI agent interface for Lotics
11
11
 
@@ -47,6 +47,7 @@ COMMANDS
47
47
  lotics app create <name> [path] Create a new custom-code app + scaffold locally
48
48
  lotics app pull <app_id> [path] Bootstrap full local env (source + npm install + types)
49
49
  lotics app deploy [-m <message>] Build + upload current dir as a new version
50
+ lotics app dev [path] Run the app locally with HMR (RPC forwarded to prod)
50
51
 
51
52
  FLAGS
52
53
  --json Full JSON output (default is human-readable text)
@@ -403,6 +404,7 @@ async function main() {
403
404
  console.error(" lotics app create <name> [path] Scaffold a new app locally");
404
405
  console.error(" lotics app pull <app_id> [path] Pull an existing app for local editing");
405
406
  console.error(" lotics app deploy [-m <message>] Build + upload the current directory");
407
+ console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
406
408
  process.exit(1);
407
409
  }
408
410
  if (command === "run" && !subcommand) {
@@ -522,6 +524,23 @@ async function main() {
522
524
  await appDeploy(client, { message });
523
525
  return;
524
526
  }
527
+ if (subcommand === "dev") {
528
+ // First positional is an optional project path (defaults to cwd).
529
+ // --port and --vite-port can override the wrapper / Vite ports.
530
+ const projectDir = toolArgs;
531
+ let port;
532
+ let vitePort;
533
+ for (const a of restArgs) {
534
+ const portMatch = /^--port=(\d+)$/.exec(a);
535
+ const vitePortMatch = /^--vite-port=(\d+)$/.exec(a);
536
+ if (portMatch)
537
+ port = Number(portMatch[1]);
538
+ else if (vitePortMatch)
539
+ vitePort = Number(vitePortMatch[1]);
540
+ }
541
+ await appDev(client, { projectDir, port, vitePort });
542
+ return;
543
+ }
525
544
  console.error(`Unknown app subcommand: ${subcommand}`);
526
545
  console.error("Run 'lotics app' for usage.");
527
546
  process.exit(1);
@@ -35,7 +35,10 @@ export declare const API_BASE_URL: string;
35
35
  export declare class LoticsClient {
36
36
  private apiKey;
37
37
  private workspaceId;
38
- private baseUrl;
38
+ /** API URL the client is configured against. Read-only after construction.
39
+ * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
40
+ * shows it in the banner). */
41
+ readonly baseUrl: string;
39
42
  constructor(options: LoticsClientOptions);
40
43
  private throwResponseError;
41
44
  private buildHeaders;
@@ -92,6 +95,28 @@ export declare class LoticsClient {
92
95
  build_status: string;
93
96
  }>;
94
97
  getAppVersionSourceUrl(app_id: string, version_id: string): Promise<string>;
98
+ /**
99
+ * Run a query AST scoped to an app's IAM principal.
100
+ * Mirrors POST /v1/apps/{app_id}/query.
101
+ */
102
+ appQuery(app_id: string, ast: unknown): Promise<{
103
+ rows: unknown[];
104
+ }>;
105
+ /**
106
+ * Update records in a workspace table scoped to an app's IAM principal.
107
+ * Mirrors PATCH /v1/apps/{app_id}/tables/{table_id}/records.
108
+ */
109
+ appMutate(app_id: string, table_id: string, records: unknown): Promise<unknown>;
110
+ /**
111
+ * Execute an app-declared action.
112
+ * Mirrors POST /v1/apps/{app_id}/actions/{action_id}.
113
+ */
114
+ appAction(app_id: string, action_id: string, inputs: unknown): Promise<unknown>;
115
+ /**
116
+ * Execute a workflow by alias declared in package.json#lotics.workflows.
117
+ * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
118
+ */
119
+ appWorkflow(app_id: string, alias: string, inputs: unknown): Promise<unknown>;
95
120
  deployAppVersion(args: {
96
121
  app_id: string;
97
122
  source_archive: Buffer;
@@ -49,6 +49,9 @@ export const API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai
49
49
  export class LoticsClient {
50
50
  apiKey;
51
51
  workspaceId;
52
+ /** API URL the client is configured against. Read-only after construction.
53
+ * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
54
+ * shows it in the banner). */
52
55
  baseUrl;
53
56
  constructor(options) {
54
57
  this.apiKey = options.apiKey;
@@ -153,6 +156,39 @@ export class LoticsClient {
153
156
  const result = await this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}/source`);
154
157
  return result.url;
155
158
  }
159
+ // ── App iframe RPC endpoints ──────────────────────────────────────────────
160
+ // These mirror the four ops handled by frontend/features/app_ui/app_iframe_host.tsx.
161
+ // The deployed iframe sends postMessage to the parent frontend, which calls
162
+ // these same endpoints via the user's session cookie. `lotics app dev`
163
+ // forwards the iframe's postMessage to these methods using the CLI's API key.
164
+ /**
165
+ * Run a query AST scoped to an app's IAM principal.
166
+ * Mirrors POST /v1/apps/{app_id}/query.
167
+ */
168
+ async appQuery(app_id, ast) {
169
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/query`, { ast });
170
+ }
171
+ /**
172
+ * Update records in a workspace table scoped to an app's IAM principal.
173
+ * Mirrors PATCH /v1/apps/{app_id}/tables/{table_id}/records.
174
+ */
175
+ async appMutate(app_id, table_id, records) {
176
+ return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/tables/${encodeURIComponent(table_id)}/records`, { records });
177
+ }
178
+ /**
179
+ * Execute an app-declared action.
180
+ * Mirrors POST /v1/apps/{app_id}/actions/{action_id}.
181
+ */
182
+ async appAction(app_id, action_id, inputs) {
183
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/actions/${encodeURIComponent(action_id)}`, { inputs });
184
+ }
185
+ /**
186
+ * Execute a workflow by alias declared in package.json#lotics.workflows.
187
+ * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
188
+ */
189
+ async appWorkflow(app_id, alias, inputs) {
190
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`, { inputs });
191
+ }
156
192
  async deployAppVersion(args) {
157
193
  const formData = new FormData();
158
194
  // Wrap Buffers as Uint8Array views so the Blob constructor accepts them
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Stateless dispatcher: takes a parsed RPC envelope from the wrapper page
3
+ * and routes to the matching LoticsClient method.
4
+ *
5
+ * Errors are thrown — the HTTP server caller serializes them to a 500 with
6
+ * { message }. Same shape as the production iframe-host's error path.
7
+ */
8
+ import { LoticsClient } from "../client.js";
9
+ export type RpcOp = "query" | "mutate" | "action" | "workflow";
10
+ export interface RpcRequest {
11
+ app_id: string;
12
+ op: RpcOp;
13
+ payload: unknown;
14
+ }
15
+ export declare function dispatchRpc(client: LoticsClient, body: RpcRequest): Promise<unknown>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Stateless dispatcher: takes a parsed RPC envelope from the wrapper page
3
+ * and routes to the matching LoticsClient method.
4
+ *
5
+ * Errors are thrown — the HTTP server caller serializes them to a 500 with
6
+ * { message }. Same shape as the production iframe-host's error path.
7
+ */
8
+ const SUPPORTED_OPS = new Set(["query", "mutate", "action", "workflow"]);
9
+ export async function dispatchRpc(client, body) {
10
+ if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
11
+ throw new Error("RPC envelope must include app_id and op");
12
+ }
13
+ if (!SUPPORTED_OPS.has(body.op)) {
14
+ throw new Error(`Unknown RPC op: ${body.op}`);
15
+ }
16
+ switch (body.op) {
17
+ case "query": {
18
+ const p = body.payload;
19
+ const ast = p?.ast;
20
+ if (ast === undefined) {
21
+ throw new Error("query payload must include `ast`");
22
+ }
23
+ return client.appQuery(body.app_id, ast);
24
+ }
25
+ case "mutate": {
26
+ const p = body.payload;
27
+ if (!p || typeof p.table_id !== "string") {
28
+ throw new Error("mutate payload must include `table_id`");
29
+ }
30
+ return client.appMutate(body.app_id, p.table_id, p.records);
31
+ }
32
+ case "action": {
33
+ const p = body.payload;
34
+ if (!p || typeof p.action_id !== "string") {
35
+ throw new Error("action payload must include `action_id`");
36
+ }
37
+ return client.appAction(body.app_id, p.action_id, p.inputs);
38
+ }
39
+ case "workflow": {
40
+ const p = body.payload;
41
+ if (!p || typeof p.alias !== "string") {
42
+ throw new Error("workflow payload must include `alias`");
43
+ }
44
+ return client.appWorkflow(body.app_id, p.alias, p.inputs);
45
+ }
46
+ default: {
47
+ // Unreachable — SUPPORTED_OPS gates above.
48
+ throw new Error(`Unhandled RPC op: ${body.op}`);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `lotics app dev` orchestrator.
3
+ *
4
+ * Two child processes managed in one Node lifecycle:
5
+ * 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
6
+ * stdio inherited so Vite's own logging surfaces to the developer.
7
+ * 2. node:http server on <port> serving:
8
+ * GET / → wrapper HTML (cached: no)
9
+ * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
+ * * → 404
11
+ *
12
+ * SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
13
+ */
14
+ import { type ChildProcess } from "node:child_process";
15
+ import { LoticsClient } from "../client.js";
16
+ export interface DevServerArgs {
17
+ projectDir: string;
18
+ app_id: string;
19
+ app_name: string;
20
+ workspace_id: string;
21
+ /** API URL the CLI is configured against — shown in the header banner. */
22
+ api_url: string;
23
+ /** Preferred wrapper port. If taken, auto-picks a free one. */
24
+ port?: number;
25
+ /** Preferred Vite port. If taken, auto-picks a free one. */
26
+ vitePort?: number;
27
+ client: LoticsClient;
28
+ }
29
+ export interface DevServerHandle {
30
+ port: number;
31
+ vitePort: number;
32
+ /** Resolves when both Vite + HTTP server are listening. */
33
+ ready: Promise<void>;
34
+ /** Stops Vite + HTTP server. Safe to call multiple times. */
35
+ stop: () => Promise<void>;
36
+ }
37
+ export declare function startDevServer(args: DevServerArgs): Promise<DevServerHandle>;
38
+ /** Used by the CLI command for cross-platform `open <url>`. Best-effort —
39
+ * if the platform opener isn't installed (common in WSL, headless CI, some
40
+ * Linux minimal images), we log a note and keep the dev server running so
41
+ * the developer can copy/paste the URL manually. */
42
+ export declare function openBrowser(url: string): void;
43
+ declare global {
44
+ type _UnusedChildProcess = ChildProcess;
45
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `lotics app dev` orchestrator.
3
+ *
4
+ * Two child processes managed in one Node lifecycle:
5
+ * 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
6
+ * stdio inherited so Vite's own logging surfaces to the developer.
7
+ * 2. node:http server on <port> serving:
8
+ * GET / → wrapper HTML (cached: no)
9
+ * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
+ * * → 404
11
+ *
12
+ * SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
13
+ */
14
+ import http from "node:http";
15
+ import net from "node:net";
16
+ import { spawn } from "node:child_process";
17
+ import { dispatchRpc } from "./rpc_handler.js";
18
+ import { buildWrapperPage } from "./wrapper_page.js";
19
+ const DEFAULT_PORT = 5174;
20
+ const DEFAULT_VITE_PORT = 5173;
21
+ export async function startDevServer(args) {
22
+ const wrapperPort = await pickPort(args.port ?? DEFAULT_PORT);
23
+ const vitePort = await pickPort(args.vitePort ?? DEFAULT_VITE_PORT, wrapperPort);
24
+ const viteUrl = `http://localhost:${vitePort}/`;
25
+ const html = buildWrapperPage({
26
+ app_name: args.app_name,
27
+ app_id: args.app_id,
28
+ workspace_id: args.workspace_id,
29
+ vite_url: viteUrl,
30
+ api_url: args.api_url,
31
+ });
32
+ // ── Vite child ──────────────────────────────────────────────────────────
33
+ // `npx vite` resolves to the project's installed Vite (devDependency in
34
+ // the starter). Inherit stdio so the dev sees Vite's own banner + HMR logs.
35
+ const viteChild = spawn("npx", ["vite", "--port", String(vitePort), "--strictPort"], {
36
+ cwd: args.projectDir,
37
+ stdio: "inherit",
38
+ env: { ...process.env, FORCE_COLOR: "1" },
39
+ });
40
+ let stopped = false;
41
+ let stoppingResolve = null;
42
+ const stoppingPromise = new Promise((resolve) => {
43
+ stoppingResolve = resolve;
44
+ });
45
+ viteChild.on("exit", (code, signal) => {
46
+ if (!stopped) {
47
+ console.error(`Vite exited unexpectedly (code=${code} signal=${signal})`);
48
+ void stopAll();
49
+ }
50
+ });
51
+ // Belt and suspenders: kill Vite if the parent dies for any reason.
52
+ // process.on('exit') runs synchronously on every termination path
53
+ // (including uncaught exceptions). spawn() returns immediately so we
54
+ // can grab the pid before any failure mode.
55
+ const killViteOnExit = () => {
56
+ if (!viteChild.killed && viteChild.pid) {
57
+ try {
58
+ process.kill(viteChild.pid, "SIGTERM");
59
+ }
60
+ catch {
61
+ // process already gone — fine
62
+ }
63
+ }
64
+ };
65
+ process.once("exit", killViteOnExit);
66
+ // ── HTTP server ────────────────────────────────────────────────────────
67
+ const server = http.createServer(async (req, res) => {
68
+ const url = req.url ?? "/";
69
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
70
+ res.writeHead(200, {
71
+ "Content-Type": "text/html; charset=utf-8",
72
+ "Cache-Control": "no-cache, no-store, must-revalidate",
73
+ });
74
+ res.end(html);
75
+ return;
76
+ }
77
+ if (req.method === "POST" && url === "/_rpc") {
78
+ try {
79
+ const body = await readJson(req);
80
+ const startedAt = Date.now();
81
+ const result = await dispatchRpc(args.client, {
82
+ app_id: body.app_id,
83
+ op: body.op,
84
+ payload: body.payload,
85
+ });
86
+ const ms = Date.now() - startedAt;
87
+ process.stderr.write(`[rpc] ${body.op} ${ms}ms\n`);
88
+ res.writeHead(200, { "Content-Type": "application/json" });
89
+ res.end(JSON.stringify(result));
90
+ }
91
+ catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ process.stderr.write(`[rpc] ERROR ${message}\n`);
94
+ res.writeHead(500, { "Content-Type": "application/json" });
95
+ res.end(JSON.stringify({ message }));
96
+ }
97
+ return;
98
+ }
99
+ res.writeHead(404, { "Content-Type": "text/plain" });
100
+ res.end("Not Found");
101
+ });
102
+ await new Promise((resolve, reject) => {
103
+ server.once("error", reject);
104
+ server.listen(wrapperPort, () => {
105
+ server.off("error", reject);
106
+ resolve();
107
+ });
108
+ });
109
+ // ── Lifecycle ───────────────────────────────────────────────────────────
110
+ async function stopAll() {
111
+ if (stopped)
112
+ return;
113
+ stopped = true;
114
+ if (!viteChild.killed) {
115
+ viteChild.kill("SIGINT");
116
+ }
117
+ await new Promise((resolve) => server.close(() => resolve()));
118
+ stoppingResolve?.();
119
+ }
120
+ // Vite startup is async — we don't get a "ready" signal cleanly across
121
+ // versions, so we wait for a fixed grace period before resolving `ready`.
122
+ // The wrapper page handles iframe load failures (Vite still warming up =
123
+ // the iframe shows a brief refused-connection until Vite responds).
124
+ const ready = new Promise((resolve) => {
125
+ setTimeout(resolve, 800);
126
+ });
127
+ // Expose stoppingPromise on stop() so callers can await it cleanly.
128
+ return {
129
+ port: wrapperPort,
130
+ vitePort,
131
+ ready,
132
+ stop: async () => {
133
+ await stopAll();
134
+ await stoppingPromise;
135
+ },
136
+ };
137
+ }
138
+ // ── Helpers ────────────────────────────────────────────────────────────────
139
+ async function pickPort(preferred, ...avoid) {
140
+ // Try preferred first. If it's taken or in `avoid`, ask the OS for any
141
+ // free port via listen(0).
142
+ if (!avoid.includes(preferred) && (await isPortFree(preferred))) {
143
+ return preferred;
144
+ }
145
+ return new Promise((resolve, reject) => {
146
+ const srv = net.createServer();
147
+ srv.once("error", reject);
148
+ srv.listen(0, () => {
149
+ const addr = srv.address();
150
+ const port = typeof addr === "object" && addr ? addr.port : 0;
151
+ srv.close(() => (port ? resolve(port) : reject(new Error("Failed to pick port"))));
152
+ });
153
+ });
154
+ }
155
+ function isPortFree(port) {
156
+ return new Promise((resolve) => {
157
+ const srv = net.createServer();
158
+ srv.once("error", () => resolve(false));
159
+ srv.listen(port, () => {
160
+ srv.close(() => resolve(true));
161
+ });
162
+ });
163
+ }
164
+ async function readJson(req) {
165
+ const chunks = [];
166
+ for await (const chunk of req) {
167
+ chunks.push(chunk);
168
+ }
169
+ const raw = Buffer.concat(chunks).toString("utf-8");
170
+ if (!raw)
171
+ throw new Error("empty request body");
172
+ const parsed = JSON.parse(raw);
173
+ if (typeof parsed.app_id !== "string" || typeof parsed.op !== "string") {
174
+ throw new Error("body must include app_id and op");
175
+ }
176
+ return parsed;
177
+ }
178
+ /** Used by the CLI command for cross-platform `open <url>`. Best-effort —
179
+ * if the platform opener isn't installed (common in WSL, headless CI, some
180
+ * Linux minimal images), we log a note and keep the dev server running so
181
+ * the developer can copy/paste the URL manually. */
182
+ export function openBrowser(url) {
183
+ const cmd = process.platform === "darwin"
184
+ ? "open"
185
+ : process.platform === "win32"
186
+ ? "start"
187
+ : "xdg-open";
188
+ const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
189
+ child.on("error", (err) => {
190
+ if (err.code === "ENOENT") {
191
+ process.stderr.write(`Note: ${cmd} not available — open ${url} manually.\n`);
192
+ }
193
+ else {
194
+ process.stderr.write(`Note: could not auto-open browser (${err.message}).\n`);
195
+ }
196
+ });
197
+ child.unref();
198
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Builds the wrapper HTML for `lotics app dev`.
3
+ *
4
+ * The wrapper page is served at http://localhost:<port>/ and embeds the
5
+ * project's Vite dev server in a sandboxed iframe with the same
6
+ * sandbox="allow-scripts" attribute production uses. The iframe sends
7
+ * postMessage RPCs to this wrapper, which forwards them to the local
8
+ * /_rpc endpoint, which dispatches to api.lotics.ai with the CLI's API
9
+ * key.
10
+ *
11
+ * Protocol matches `frontend/features/app_ui/app_iframe_host.tsx` exactly:
12
+ * iframe → wrapper: { id: number, op: string, payload: unknown }
13
+ * wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
14
+ */
15
+ export interface WrapperPageArgs {
16
+ app_name: string;
17
+ app_id: string;
18
+ workspace_id: string;
19
+ vite_url: string;
20
+ api_url: string;
21
+ }
22
+ export declare function buildWrapperPage(args: WrapperPageArgs): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Builds the wrapper HTML for `lotics app dev`.
3
+ *
4
+ * The wrapper page is served at http://localhost:<port>/ and embeds the
5
+ * project's Vite dev server in a sandboxed iframe with the same
6
+ * sandbox="allow-scripts" attribute production uses. The iframe sends
7
+ * postMessage RPCs to this wrapper, which forwards them to the local
8
+ * /_rpc endpoint, which dispatches to api.lotics.ai with the CLI's API
9
+ * key.
10
+ *
11
+ * Protocol matches `frontend/features/app_ui/app_iframe_host.tsx` exactly:
12
+ * iframe → wrapper: { id: number, op: string, payload: unknown }
13
+ * wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
14
+ */
15
+ export function buildWrapperPage(args) {
16
+ const { app_name, app_id, workspace_id, vite_url, api_url } = args;
17
+ return `<!doctype html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8" />
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
22
+ <title>${escapeHtml(app_name)} — lotics app dev</title>
23
+ <style>
24
+ html, body { margin: 0; height: 100%; }
25
+ body {
26
+ display: flex; flex-direction: column;
27
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
28
+ background: #f4f4f5;
29
+ }
30
+ header {
31
+ padding: 8px 14px;
32
+ background: #18181b;
33
+ color: #fafafa;
34
+ font-size: 12px;
35
+ display: flex; align-items: center; gap: 12px;
36
+ flex-shrink: 0;
37
+ }
38
+ header strong { font-size: 13px; }
39
+ header code {
40
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
41
+ font-size: 11px;
42
+ background: rgba(255,255,255,0.08);
43
+ padding: 1px 6px; border-radius: 3px;
44
+ }
45
+ header span { color: #a1a1aa; }
46
+ iframe { border: 0; flex: 1; width: 100%; background: #fff; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <header>
51
+ <strong>${escapeHtml(app_name)}</strong>
52
+ <span>app_id <code>${escapeHtml(app_id)}</code></span>
53
+ <span>workspace <code>${escapeHtml(workspace_id)}</code></span>
54
+ <span>RPC → <code>${escapeHtml(api_url)}</code></span>
55
+ </header>
56
+ <iframe id="app" sandbox="allow-scripts" src="${escapeAttr(vite_url)}"></iframe>
57
+ <script>
58
+ (function () {
59
+ const APP_ID = ${JSON.stringify(app_id)};
60
+ const iframe = document.getElementById("app");
61
+
62
+ window.addEventListener("message", async function (event) {
63
+ if (event.source !== iframe.contentWindow) return;
64
+ const msg = event.data;
65
+ if (!msg || typeof msg.id !== "number" || typeof msg.op !== "string") return;
66
+ const startedAt = performance.now();
67
+ try {
68
+ const res = await fetch("/_rpc", {
69
+ method: "POST",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify({ app_id: APP_ID, op: msg.op, payload: msg.payload }),
72
+ });
73
+ const text = await res.text();
74
+ if (!res.ok) {
75
+ let detail = text;
76
+ try { detail = JSON.parse(text).message ?? text; } catch (_) {}
77
+ throw new Error(detail || ("HTTP " + res.status));
78
+ }
79
+ const data = JSON.parse(text);
80
+ const ms = Math.round(performance.now() - startedAt);
81
+ console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
82
+ iframe.contentWindow.postMessage({ id: msg.id, type: "result", data: data }, "*");
83
+ } catch (err) {
84
+ const message = err && err.message ? err.message : String(err);
85
+ console.error("[lotics-dev] " + msg.op + " failed:", message);
86
+ iframe.contentWindow.postMessage(
87
+ { id: msg.id, type: "error", message: message },
88
+ "*"
89
+ );
90
+ }
91
+ });
92
+ })();
93
+ </script>
94
+ </body>
95
+ </html>
96
+ `;
97
+ }
98
+ function escapeHtml(s) {
99
+ return s
100
+ .replace(/&/g, "&amp;")
101
+ .replace(/</g, "&lt;")
102
+ .replace(/>/g, "&gt;");
103
+ }
104
+ function escapeAttr(s) {
105
+ return escapeHtml(s).replace(/"/g, "&quot;");
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {