@lotics/cli 0.28.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,10 @@
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 (base style reset) + @lotics/ui/fonts.css
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
15
17
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
16
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
17
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
@@ -10,8 +10,10 @@
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 (base style reset) + @lotics/ui/fonts.css
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
15
17
  * (path-independent Inter @font-face bundle — Text renders unstyled without it).
16
18
  * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
17
19
  * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
@@ -48,7 +50,7 @@ export function buildStarterTemplate(args) {
48
50
  test: "vitest run",
49
51
  },
50
52
  dependencies: {
51
- "@lotics/app-sdk": "^0.5.0",
53
+ "@lotics/app-sdk": "^0.6.0",
52
54
  "@lotics/ui": "^1.3.0",
53
55
  "@react-native-picker/picker": "^2.7.0",
54
56
  "expo-image": "~3.0.9",
@@ -194,10 +196,17 @@ export default defineConfig({
194
196
  path: "src/main.tsx",
195
197
  content: `import "@lotics/ui/index.css";
196
198
  import "@lotics/ui/fonts.css";
199
+ import { PortalHost } from "@lotics/ui/portal";
197
200
  import { mount } from "@lotics/app-sdk";
198
201
  import App from "./App";
199
202
 
200
- 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
+ );
201
210
  `,
202
211
  },
203
212
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {