@lotics/cli 0.19.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.
- package/dist/src/app_commands.d.ts +18 -0
- package/dist/src/app_commands.js +54 -0
- package/dist/src/cli.js +20 -1
- package/dist/src/client.d.ts +26 -1
- package/dist/src/client.js +36 -0
- package/dist/src/dev/rpc_handler.d.ts +15 -0
- package/dist/src/dev/rpc_handler.js +51 -0
- package/dist/src/dev/server.d.ts +45 -0
- package/dist/src/dev/server.js +198 -0
- package/dist/src/dev/wrapper_page.d.ts +22 -0
- package/dist/src/dev/wrapper_page.js +106 -0
- package/dist/src/starter_template.d.ts +6 -2
- package/dist/src/starter_template.js +71 -25
- package/package.json +1 -1
|
@@ -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>;
|
package/dist/src/app_commands.js
CHANGED
|
@@ -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);
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/client.js
CHANGED
|
@@ -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, "&")
|
|
101
|
+
.replace(/</g, "<")
|
|
102
|
+
.replace(/>/g, ">");
|
|
103
|
+
}
|
|
104
|
+
function escapeAttr(s) {
|
|
105
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
106
|
+
}
|
|
@@ -10,11 +10,15 @@
|
|
|
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/>)
|
|
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.
|
|
14
15
|
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
15
16
|
* endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
|
|
16
17
|
* Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
|
|
17
|
-
* -
|
|
18
|
+
* - react-native aliased to react-native-web so @lotics/ui's RN primitives
|
|
19
|
+
* render in a pure-web environment. `.web.tsx` is prioritized in the
|
|
20
|
+
* resolve.extensions list.
|
|
21
|
+
* - @lotics/app-sdk + @lotics/ui peer-supplied; user installs both via npm.
|
|
18
22
|
* - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
|
|
19
23
|
* workflow that runs all three on every PR.
|
|
20
24
|
*/
|
|
@@ -10,11 +10,15 @@
|
|
|
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/>)
|
|
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.
|
|
14
15
|
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
15
16
|
* endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
|
|
16
17
|
* Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
|
|
17
|
-
* -
|
|
18
|
+
* - react-native aliased to react-native-web so @lotics/ui's RN primitives
|
|
19
|
+
* render in a pure-web environment. `.web.tsx` is prioritized in the
|
|
20
|
+
* resolve.extensions list.
|
|
21
|
+
* - @lotics/app-sdk + @lotics/ui peer-supplied; user installs both via npm.
|
|
18
22
|
* - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
|
|
19
23
|
* workflow that runs all three on every PR.
|
|
20
24
|
*/
|
|
@@ -43,9 +47,17 @@ export function buildStarterTemplate(args) {
|
|
|
43
47
|
test: "vitest run",
|
|
44
48
|
},
|
|
45
49
|
dependencies: {
|
|
46
|
-
"@lotics/app-sdk": "^0.
|
|
50
|
+
"@lotics/app-sdk": "^0.2.0",
|
|
51
|
+
"@lotics/ui": "^0.2.0",
|
|
52
|
+
"@react-native-picker/picker": "^2.7.0",
|
|
53
|
+
"expo-image": "~3.0.9",
|
|
54
|
+
"lucide-react": "^0.562.0",
|
|
55
|
+
"lucide-react-native": "^0.562.0",
|
|
47
56
|
react: "^19.0.0",
|
|
48
57
|
"react-dom": "^19.0.0",
|
|
58
|
+
"react-native": "0.81.0",
|
|
59
|
+
"react-native-svg": "^15.0.0",
|
|
60
|
+
"react-native-web": "^0.21.0",
|
|
49
61
|
},
|
|
50
62
|
devDependencies: {
|
|
51
63
|
"@testing-library/react": "^16.1.0",
|
|
@@ -99,8 +111,19 @@ import react from "@vitejs/plugin-react";
|
|
|
99
111
|
// render endpoint rewrites those to /v1/apps/{id}/asset/... so the bundle
|
|
100
112
|
// loads via the platform's asset proxy. Don't change \`base\` unless you
|
|
101
113
|
// also adjust the rewrite logic in backend/api/apps.ts:rewriteAssetPaths.
|
|
114
|
+
//
|
|
115
|
+
// react-native → react-native-web alias lets @lotics/ui's RN primitives
|
|
116
|
+
// (View, Text, Pressable, StyleSheet, etc.) render in a pure-web environment.
|
|
117
|
+
// .web.tsx is prioritized in resolve.extensions so per-target variants
|
|
118
|
+
// (avatar.web.tsx, wave_avatar.web.tsx) win over the native .tsx file.
|
|
102
119
|
export default defineConfig({
|
|
103
120
|
plugins: [react()],
|
|
121
|
+
resolve: {
|
|
122
|
+
alias: {
|
|
123
|
+
"react-native": "react-native-web",
|
|
124
|
+
},
|
|
125
|
+
extensions: [".web.tsx", ".web.ts", ".tsx", ".ts", ".jsx", ".js"],
|
|
126
|
+
},
|
|
104
127
|
build: {
|
|
105
128
|
outDir: "dist",
|
|
106
129
|
sourcemap: true,
|
|
@@ -129,7 +152,8 @@ export default defineConfig({
|
|
|
129
152
|
},
|
|
130
153
|
{
|
|
131
154
|
path: "src/main.tsx",
|
|
132
|
-
content: `import
|
|
155
|
+
content: `import "@lotics/ui/index.css";
|
|
156
|
+
import { mount } from "@lotics/app-sdk";
|
|
133
157
|
import App from "./App";
|
|
134
158
|
|
|
135
159
|
mount(<App />);
|
|
@@ -137,22 +161,35 @@ mount(<App />);
|
|
|
137
161
|
},
|
|
138
162
|
{
|
|
139
163
|
path: "src/App.tsx",
|
|
140
|
-
// Static welcome screen — no useTable call
|
|
141
|
-
// renders cleanly without needing a real
|
|
142
|
-
// edit is to replace this with their
|
|
143
|
-
content: `
|
|
164
|
+
// Static welcome screen using @lotics/ui primitives — no useTable call
|
|
165
|
+
// so the freshly-deployed v1 renders cleanly without needing a real
|
|
166
|
+
// table id. The user's first edit is to replace this with their app.
|
|
167
|
+
content: `import { View } from "react-native";
|
|
168
|
+
import { Stack } from "@lotics/ui/stack";
|
|
169
|
+
import { Card } from "@lotics/ui/card";
|
|
170
|
+
import { Text } from "@lotics/ui/text";
|
|
171
|
+
import { Button } from "@lotics/ui/button";
|
|
172
|
+
|
|
173
|
+
export default function App() {
|
|
144
174
|
return (
|
|
145
|
-
<
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
<View style={{ padding: 24, maxWidth: 640, marginHorizontal: "auto" }}>
|
|
176
|
+
<Stack gap={16}>
|
|
177
|
+
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
178
|
+
<Card>
|
|
179
|
+
<Stack gap={8} style={{ padding: 16 }}>
|
|
180
|
+
<Text>This is your new Lotics app.</Text>
|
|
181
|
+
<Text color="muted">
|
|
182
|
+
Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
183
|
+
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
184
|
+
</Text>
|
|
185
|
+
</Stack>
|
|
186
|
+
</Card>
|
|
187
|
+
<Text color="muted" size="sm">
|
|
188
|
+
Hooks from @lotics/app-sdk: useTable, useMutate, useAction, useQuery, useWorkflow.
|
|
189
|
+
</Text>
|
|
190
|
+
<Button title="Get started" onPress={() => {}} color="primary" />
|
|
191
|
+
</Stack>
|
|
192
|
+
</View>
|
|
156
193
|
);
|
|
157
194
|
}
|
|
158
195
|
`,
|
|
@@ -164,9 +201,9 @@ import { render } from "@testing-library/react";
|
|
|
164
201
|
import App from "./App";
|
|
165
202
|
|
|
166
203
|
describe("App", () => {
|
|
167
|
-
test("renders
|
|
168
|
-
const {
|
|
169
|
-
expect(
|
|
204
|
+
test("renders without crashing", () => {
|
|
205
|
+
const { container } = render(<App />);
|
|
206
|
+
expect(container).not.toBeNull();
|
|
170
207
|
});
|
|
171
208
|
});
|
|
172
209
|
`,
|
|
@@ -235,13 +272,22 @@ lotics app deploy # build + upload as a new version
|
|
|
235
272
|
lotics app deploy -m "what" # with a commit-message-style note
|
|
236
273
|
\`\`\`
|
|
237
274
|
|
|
238
|
-
## SDK
|
|
275
|
+
## SDK + UI components
|
|
239
276
|
|
|
240
277
|
\`\`\`tsx
|
|
241
|
-
import { mount, useTable, useMutate, useAction, useQuery } from "@lotics/app-sdk";
|
|
278
|
+
import { mount, useTable, useMutate, useAction, useQuery, useWorkflow } from "@lotics/app-sdk";
|
|
279
|
+
import { Stack } from "@lotics/ui/stack";
|
|
280
|
+
import { Card } from "@lotics/ui/card";
|
|
281
|
+
import { Button } from "@lotics/ui/button";
|
|
282
|
+
import { Text } from "@lotics/ui/text";
|
|
283
|
+
// ... and Grid, Dialog, DatePicker, FormPicker, BarChart, LineChart, PieChart, Metric, ProgressBar, etc.
|
|
242
284
|
\`\`\`
|
|
243
285
|
|
|
244
|
-
|
|
286
|
+
@lotics/ui ships React Native primitives that render via react-native-web
|
|
287
|
+
in this Vite app (the alias is preconfigured in \`vite.config.ts\`). See
|
|
288
|
+
the full export list at https://www.npmjs.com/package/@lotics/ui.
|
|
289
|
+
|
|
290
|
+
See https://lotics.ai/docs/app-sdk for the SDK reference.
|
|
245
291
|
`,
|
|
246
292
|
},
|
|
247
293
|
];
|