@lotics/cli 0.27.0 → 0.29.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.
@@ -129,6 +129,36 @@ export declare class LoticsClient {
129
129
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
130
130
  */
131
131
  appWorkflow(app_id: string, alias: string, inputs: unknown): Promise<unknown>;
132
+ /**
133
+ * Mint a presigned URL for uploading a file into an app. Mirrors
134
+ * POST /v1/apps/{app_id}/files/upload-url.
135
+ */
136
+ appRequestFileUpload(app_id: string, body: {
137
+ filename: string;
138
+ mime_type: string;
139
+ file_size: number;
140
+ }): Promise<{
141
+ file_id: string;
142
+ file_storage_key: string;
143
+ upload_url: string;
144
+ }>;
145
+ /**
146
+ * Finalize a presigned upload once the bytes are in storage. Mirrors
147
+ * POST /v1/apps/{app_id}/files/complete.
148
+ */
149
+ appCompleteFileUpload(app_id: string, body: {
150
+ file_id: string;
151
+ file_storage_key: string;
152
+ filename: string;
153
+ }): Promise<{
154
+ file: {
155
+ id: string;
156
+ filename: string;
157
+ mime_type: string;
158
+ url?: string;
159
+ thumbnail_url?: string;
160
+ };
161
+ }>;
132
162
  deployAppVersion(args: {
133
163
  app_id: string;
134
164
  source_archive: Buffer;
@@ -175,6 +175,20 @@ export class LoticsClient {
175
175
  async appWorkflow(app_id, alias, inputs) {
176
176
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/workflows/${encodeURIComponent(alias)}/execute`, { inputs });
177
177
  }
178
+ /**
179
+ * Mint a presigned URL for uploading a file into an app. Mirrors
180
+ * POST /v1/apps/{app_id}/files/upload-url.
181
+ */
182
+ async appRequestFileUpload(app_id, body) {
183
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/upload-url`, body);
184
+ }
185
+ /**
186
+ * Finalize a presigned upload once the bytes are in storage. Mirrors
187
+ * POST /v1/apps/{app_id}/files/complete.
188
+ */
189
+ async appCompleteFileUpload(app_id, body) {
190
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/files/complete`, body);
191
+ }
178
192
  async deployAppVersion(args) {
179
193
  const formData = new FormData();
180
194
  // Wrap Buffers as Uint8Array views so the Blob constructor accepts them
@@ -6,7 +6,7 @@
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
8
  import { LoticsClient } from "../client.js";
9
- export type RpcOp = "query" | "workflow";
9
+ export type RpcOp = "query" | "workflow" | "upload_url" | "upload_complete";
10
10
  export interface RpcRequest {
11
11
  app_id: string;
12
12
  op: RpcOp;
@@ -5,7 +5,12 @@
5
5
  * Errors are thrown — the HTTP server caller serializes them to a 500 with
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
- const SUPPORTED_OPS = new Set(["query", "workflow"]);
8
+ const SUPPORTED_OPS = new Set([
9
+ "query",
10
+ "workflow",
11
+ "upload_url",
12
+ "upload_complete",
13
+ ]);
9
14
  export async function dispatchRpc(client, body) {
10
15
  if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
11
16
  throw new Error("RPC envelope must include app_id and op");
@@ -28,6 +33,34 @@ export async function dispatchRpc(client, body) {
28
33
  }
29
34
  return client.appWorkflow(body.app_id, p.alias, p.inputs);
30
35
  }
36
+ case "upload_url": {
37
+ const p = body.payload;
38
+ if (!p ||
39
+ typeof p.filename !== "string" ||
40
+ typeof p.mime_type !== "string" ||
41
+ typeof p.file_size !== "number") {
42
+ throw new Error("upload_url payload must include filename, mime_type, file_size");
43
+ }
44
+ return client.appRequestFileUpload(body.app_id, {
45
+ filename: p.filename,
46
+ mime_type: p.mime_type,
47
+ file_size: p.file_size,
48
+ });
49
+ }
50
+ case "upload_complete": {
51
+ const p = body.payload;
52
+ if (!p ||
53
+ typeof p.file_id !== "string" ||
54
+ typeof p.file_storage_key !== "string" ||
55
+ typeof p.filename !== "string") {
56
+ throw new Error("upload_complete payload must include file_id, file_storage_key, filename");
57
+ }
58
+ return client.appCompleteFileUpload(body.app_id, {
59
+ file_id: p.file_id,
60
+ file_storage_key: p.file_storage_key,
61
+ filename: p.filename,
62
+ });
63
+ }
31
64
  default: {
32
65
  // Unreachable — SUPPORTED_OPS gates above.
33
66
  throw new Error(`Unhandled RPC op: ${body.op}`);
@@ -59,24 +59,59 @@ export function buildWrapperPage(args) {
59
59
  const APP_ID = ${JSON.stringify(app_id)};
60
60
  const iframe = document.getElementById("app");
61
61
 
62
+ async function rpc(op, payload) {
63
+ const res = await fetch("/_rpc", {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ app_id: APP_ID, op: op, payload: payload }),
67
+ });
68
+ const text = await res.text();
69
+ if (!res.ok) {
70
+ let detail = text;
71
+ try { detail = JSON.parse(text).message ?? text; } catch (_) {}
72
+ throw new Error(detail || ("HTTP " + res.status));
73
+ }
74
+ return JSON.parse(text);
75
+ }
76
+
77
+ // The iframe SDK sends one "upload" op carrying a File. A File can't
78
+ // cross the JSON /_rpc hop, so the upload runs here in the browser:
79
+ // mint a presigned URL, PUT the bytes to storage, then finalize.
80
+ async function handleUpload(payload) {
81
+ const file = payload && payload.file;
82
+ if (!(file instanceof File)) {
83
+ throw new Error("upload payload must include a File");
84
+ }
85
+ const init = await rpc("upload_url", {
86
+ filename: file.name,
87
+ mime_type: file.type,
88
+ file_size: file.size,
89
+ });
90
+ const putRes = await fetch(init.upload_url, {
91
+ method: "PUT",
92
+ body: file,
93
+ headers: { "Content-Type": file.type },
94
+ });
95
+ if (!putRes.ok) {
96
+ throw new Error("Storage upload failed (" + putRes.status + ")");
97
+ }
98
+ const completed = await rpc("upload_complete", {
99
+ file_id: init.file_id,
100
+ file_storage_key: init.file_storage_key,
101
+ filename: file.name,
102
+ });
103
+ return completed.file;
104
+ }
105
+
62
106
  window.addEventListener("message", async function (event) {
63
107
  if (event.source !== iframe.contentWindow) return;
64
108
  const msg = event.data;
65
109
  if (!msg || typeof msg.id !== "number" || typeof msg.op !== "string") return;
66
110
  const startedAt = performance.now();
67
111
  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);
112
+ const data = msg.op === "upload"
113
+ ? await handleUpload(msg.payload)
114
+ : await rpc(msg.op, msg.payload);
80
115
  const ms = Math.round(performance.now() - startedAt);
81
116
  console.debug("[lotics-dev] " + msg.op + " " + ms + "ms", data);
82
117
  iframe.contentWindow.postMessage({ id: msg.id, type: "result", data: data }, "*");
@@ -10,8 +10,11 @@
10
10
  *
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
- * - Entry: src/App.tsx with `export default`. main.tsx wires `mount(<App/>)`
14
- * and imports @lotics/ui/index.css for the base style reset.
13
+ * - Entry: src/App.tsx with `export default`. main.tsx wires
14
+ * `mount(<PortalHost><App/></PortalHost>)` PortalHost is the render
15
+ * target @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
+ * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
17
+ * (path-independent Inter @font-face bundle — Text renders unstyled without it).
15
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
16
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
17
20
  * Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
@@ -10,8 +10,11 @@
10
10
  *
11
11
  * Conventions (locked decisions):
12
12
  * - Vite + React + TypeScript (strict mode).
13
- * - Entry: src/App.tsx with `export default`. main.tsx wires `mount(<App/>)`
14
- * and imports @lotics/ui/index.css for the base style reset.
13
+ * - Entry: src/App.tsx with `export default`. main.tsx wires
14
+ * `mount(<PortalHost><App/></PortalHost>)` PortalHost is the render
15
+ * target @lotics/ui overlays (Popover/Tooltip/Dialog) need — and imports
16
+ * @lotics/ui/index.css (base style reset) + @lotics/ui/fonts.css
17
+ * (path-independent Inter @font-face bundle — Text renders unstyled without it).
15
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
16
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
17
20
  * Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
@@ -47,8 +50,8 @@ export function buildStarterTemplate(args) {
47
50
  test: "vitest run",
48
51
  },
49
52
  dependencies: {
50
- "@lotics/app-sdk": "^0.5.0",
51
- "@lotics/ui": "^0.2.0",
53
+ "@lotics/app-sdk": "^0.6.0",
54
+ "@lotics/ui": "^1.3.0",
52
55
  "@react-native-picker/picker": "^2.7.0",
53
56
  "expo-image": "~3.0.9",
54
57
  "lucide-react": "^0.562.0",
@@ -135,6 +138,12 @@ export default defineConfig({
135
138
  { find: "react-native", replacement: "react-native-web" },
136
139
  ],
137
140
  extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".jsx", ".js"],
141
+ // @lotics/ui ships source and is consumed across many subpath entries
142
+ // (./card, ./metric, ./use_screen_size, …). Without dedupe, Vite can
143
+ // pre-bundle a subpath into its own chunk with a second React copy — a
144
+ // hook called from there hits a null dispatcher ("Invalid hook call").
145
+ // Pin React (and RN-Web) to a single instance shared by every chunk.
146
+ dedupe: ["react", "react-dom", "react-native-web"],
138
147
  },
139
148
  build: {
140
149
  outDir: "dist",
@@ -186,10 +195,18 @@ export default defineConfig({
186
195
  {
187
196
  path: "src/main.tsx",
188
197
  content: `import "@lotics/ui/index.css";
198
+ import "@lotics/ui/fonts.css";
199
+ import { PortalHost } from "@lotics/ui/portal";
189
200
  import { mount } from "@lotics/app-sdk";
190
201
  import App from "./App";
191
202
 
192
- mount(<App />);
203
+ // PortalHost is the render target for @lotics/ui overlays (Popover, Tooltip,
204
+ // Dialog). Without it, Portal renders nothing — keep it wrapping the app.
205
+ mount(
206
+ <PortalHost>
207
+ <App />
208
+ </PortalHost>,
209
+ );
193
210
  `,
194
211
  },
195
212
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {