@lotics/cli 0.18.0 → 0.20.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.
@@ -0,0 +1,45 @@
1
+ import { LoticsClient } from "./client.js";
2
+ /**
3
+ * `lotics app create <name> [path]`
4
+ *
5
+ * Creates the app server-side, scaffolds a Vite+React+TS project locally
6
+ * from the embedded starter template, runs `npm install` + `npm run build`,
7
+ * then deploys the result as v1. After this returns, `lotics app deploy`
8
+ * works end-to-end on the same directory.
9
+ *
10
+ * Why scaffold + deploy rather than pull-from-server-starter: the deploy
11
+ * pipeline requires a built dist tree, and v1 doesn't run builds server-side.
12
+ * Doing the build locally on first create keeps the platform infrastructure
13
+ * minimal — the starter template is embedded in the CLI binary and bumps
14
+ * with CLI releases. (Server-side build is the v2 path.)
15
+ */
16
+ export declare function appCreate(client: LoticsClient, args: {
17
+ name: string;
18
+ targetPath?: string;
19
+ }): Promise<void>;
20
+ /**
21
+ * `lotics app pull <app_id> [path]`
22
+ *
23
+ * Bootstraps a full local dev environment for an existing app:
24
+ * 1. Fetch the current version's source archive from R2 (presigned URL).
25
+ * 2. Extract into the target directory.
26
+ * 3. Write package.json's `lotics` field with app_id + version metadata.
27
+ * 4. Run `npm install`.
28
+ * 5. (TODO) Generate `.lotics/types.ts` with workspace-typed augmentations.
29
+ */
30
+ export declare function appPull(client: LoticsClient, args: {
31
+ app_id: string;
32
+ targetPath?: string;
33
+ }): Promise<void>;
34
+ /**
35
+ * `lotics app deploy [-m <message>]`
36
+ *
37
+ * Builds the project locally with `npm run build`, tars source + dist, and
38
+ * uploads to the deploy endpoint. The endpoint validates structure, stores
39
+ * both archives in R2, creates an app_versions row, and atomically advances
40
+ * the app's current_version pointer.
41
+ */
42
+ export declare function appDeploy(client: LoticsClient, args: {
43
+ projectDir?: string;
44
+ message?: string;
45
+ }): Promise<void>;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `lotics app *` subcommands — create / pull / deploy.
3
+ *
4
+ * Apps are user-owned npm projects. Source-of-truth during editing is the
5
+ * local filesystem; the server stores deployed bundles (source.tar.gz + dist)
6
+ * in R2 keyed per version. These commands are the only sanctioned lifecycle
7
+ * surface — everything else (validate, logs, watch) folds into a flag on
8
+ * deploy or lives in the web UI.
9
+ *
10
+ * Tarball handling shells out to the system `tar` command. CLI users are
11
+ * developers; macOS/Linux/WSL all ship a working tar. Avoids adding a
12
+ * runtime dep to the published @lotics/cli package.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { spawn } from "node:child_process";
17
+ import { tmpdir } from "node:os";
18
+ import { buildStarterTemplate } from "./starter_template.js";
19
+ /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
20
+ function runTar(args, cwd) {
21
+ return new Promise((resolve, reject) => {
22
+ const proc = spawn("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
23
+ let stderr = "";
24
+ proc.stderr.on("data", (chunk) => {
25
+ stderr += chunk.toString();
26
+ });
27
+ proc.on("error", reject);
28
+ proc.on("exit", (code) => {
29
+ if (code === 0)
30
+ resolve();
31
+ else
32
+ reject(new Error(`tar ${args.join(" ")} failed with exit ${code}: ${stderr}`));
33
+ });
34
+ });
35
+ }
36
+ /** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
37
+ function runNpm(args, cwd) {
38
+ return new Promise((resolve, reject) => {
39
+ const proc = spawn("npm", args, { cwd, stdio: "inherit" });
40
+ proc.on("error", reject);
41
+ proc.on("exit", (code) => {
42
+ if (code === 0)
43
+ resolve();
44
+ else
45
+ reject(new Error(`npm ${args.join(" ")} exited with code ${code}`));
46
+ });
47
+ });
48
+ }
49
+ function readAppMeta(projectDir) {
50
+ const pkgPath = path.join(projectDir, "package.json");
51
+ if (!fs.existsSync(pkgPath)) {
52
+ throw new Error(`No package.json found at ${projectDir}. Run 'lotics app create' first.`);
53
+ }
54
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
55
+ if (!pkg.lotics?.app_id || !pkg.lotics.workspace_id) {
56
+ throw new Error(`package.json is missing the 'lotics' field with app_id and workspace_id. ` +
57
+ `Was this folder created with 'lotics app create'?`);
58
+ }
59
+ return {
60
+ app_id: pkg.lotics.app_id,
61
+ workspace_id: pkg.lotics.workspace_id,
62
+ current_version_id: pkg.lotics.current_version_id ?? null,
63
+ version_number: pkg.lotics.version_number ?? null,
64
+ workflows: pkg.lotics.workflows ?? {},
65
+ };
66
+ }
67
+ function writeAppMeta(projectDir, meta) {
68
+ const pkgPath = path.join(projectDir, "package.json");
69
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
70
+ pkg.lotics = meta;
71
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
72
+ }
73
+ async function downloadToFile(url, destPath) {
74
+ const response = await fetch(url);
75
+ if (!response.ok) {
76
+ throw new Error(`Failed to download ${url}: ${response.status}`);
77
+ }
78
+ const buffer = Buffer.from(await response.arrayBuffer());
79
+ fs.writeFileSync(destPath, buffer);
80
+ }
81
+ /**
82
+ * `lotics app create <name> [path]`
83
+ *
84
+ * Creates the app server-side, scaffolds a Vite+React+TS project locally
85
+ * from the embedded starter template, runs `npm install` + `npm run build`,
86
+ * then deploys the result as v1. After this returns, `lotics app deploy`
87
+ * works end-to-end on the same directory.
88
+ *
89
+ * Why scaffold + deploy rather than pull-from-server-starter: the deploy
90
+ * pipeline requires a built dist tree, and v1 doesn't run builds server-side.
91
+ * Doing the build locally on first create keeps the platform infrastructure
92
+ * minimal — the starter template is embedded in the CLI binary and bumps
93
+ * with CLI releases. (Server-side build is the v2 path.)
94
+ */
95
+ export async function appCreate(client, args) {
96
+ const targetPath = path.resolve(args.targetPath ?? args.name);
97
+ if (fs.existsSync(targetPath)) {
98
+ const entries = fs.readdirSync(targetPath);
99
+ if (entries.length > 0) {
100
+ throw new Error(`Target directory ${targetPath} is not empty.`);
101
+ }
102
+ }
103
+ fs.mkdirSync(targetPath, { recursive: true });
104
+ // Server-side row first — if this fails, no local files have been touched.
105
+ const app = await client.createApp({ name: args.name });
106
+ console.error(`Created app: ${app.name} (${app.id})`);
107
+ // Scaffold the starter into the target directory.
108
+ const files = buildStarterTemplate({
109
+ app_name: args.name,
110
+ app_id: app.id,
111
+ workspace_id: app.workspace_id,
112
+ });
113
+ for (const file of files) {
114
+ const fullPath = path.join(targetPath, file.path);
115
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
116
+ fs.writeFileSync(fullPath, file.content);
117
+ }
118
+ console.error(`Scaffolded ${files.length} files into ${targetPath}`);
119
+ console.error("Installing npm dependencies...");
120
+ await runNpm(["install"], targetPath);
121
+ console.error("Building initial version...");
122
+ await appDeploy(client, {
123
+ projectDir: targetPath,
124
+ message: "Initial version",
125
+ });
126
+ console.error(`\nReady. Next steps:`);
127
+ console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
128
+ console.error(` # edit src/App.tsx, then:`);
129
+ console.error(` lotics app deploy`);
130
+ }
131
+ /**
132
+ * `lotics app pull <app_id> [path]`
133
+ *
134
+ * Bootstraps a full local dev environment for an existing app:
135
+ * 1. Fetch the current version's source archive from R2 (presigned URL).
136
+ * 2. Extract into the target directory.
137
+ * 3. Write package.json's `lotics` field with app_id + version metadata.
138
+ * 4. Run `npm install`.
139
+ * 5. (TODO) Generate `.lotics/types.ts` with workspace-typed augmentations.
140
+ */
141
+ export async function appPull(client, args) {
142
+ const app = await client.getApp(args.app_id);
143
+ if (!app.current_version_id) {
144
+ throw new Error(`App ${app.id} has no published version yet. Deploy from another machine first, or use 'lotics app create' to scaffold a new app.`);
145
+ }
146
+ const version = await client.getAppVersion(app.id, app.current_version_id);
147
+ const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
148
+ const targetPath = path.resolve(args.targetPath ?? app.name);
149
+ fs.mkdirSync(targetPath, { recursive: true });
150
+ // Download to a temp file because `tar -xz` reads from a real path.
151
+ const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
152
+ console.error(`Downloading source archive...`);
153
+ await downloadToFile(sourceUrl, tmpFile);
154
+ try {
155
+ console.error(`Extracting to ${targetPath}...`);
156
+ await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
157
+ }
158
+ finally {
159
+ if (fs.existsSync(tmpFile))
160
+ fs.unlinkSync(tmpFile);
161
+ }
162
+ writeAppMeta(targetPath, {
163
+ app_id: app.id,
164
+ workspace_id: app.workspace_id,
165
+ current_version_id: app.current_version_id,
166
+ version_number: version.version,
167
+ });
168
+ console.error(`Installing npm dependencies...`);
169
+ await runNpm(["install"], targetPath);
170
+ console.error(`\nReady. Next steps:`);
171
+ console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
172
+ console.error(` # edit src/App.tsx`);
173
+ console.error(` lotics app deploy`);
174
+ }
175
+ /**
176
+ * `lotics app deploy [-m <message>]`
177
+ *
178
+ * Builds the project locally with `npm run build`, tars source + dist, and
179
+ * uploads to the deploy endpoint. The endpoint validates structure, stores
180
+ * both archives in R2, creates an app_versions row, and atomically advances
181
+ * the app's current_version pointer.
182
+ */
183
+ export async function appDeploy(client, args) {
184
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
185
+ const meta = readAppMeta(projectDir);
186
+ // Build locally so the server doesn't need a build sandbox in v1.
187
+ console.error("Building...");
188
+ await runNpm(["run", "build"], projectDir);
189
+ const distDir = path.join(projectDir, "dist");
190
+ if (!fs.existsSync(distDir)) {
191
+ throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
192
+ `Check that 'npm run build' is configured correctly (Vite/etc. defaults to dist/).`);
193
+ }
194
+ const tmpSource = path.join(tmpdir(), `lotics-source-${Date.now()}.tar.gz`);
195
+ const tmpDist = path.join(tmpdir(), `lotics-dist-${Date.now()}.tar.gz`);
196
+ try {
197
+ console.error("Packaging source...");
198
+ await runTar([
199
+ "-czf",
200
+ tmpSource,
201
+ "--exclude=node_modules",
202
+ "--exclude=dist",
203
+ "--exclude=.lotics",
204
+ "--exclude=*.tsbuildinfo",
205
+ "--exclude=.git",
206
+ ".",
207
+ ], projectDir);
208
+ console.error("Packaging dist...");
209
+ await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
210
+ console.error("Uploading...");
211
+ try {
212
+ const result = await client.deployAppVersion({
213
+ app_id: meta.app_id,
214
+ source_archive: fs.readFileSync(tmpSource),
215
+ dist_archive: fs.readFileSync(tmpDist),
216
+ prev_version_id: meta.current_version_id,
217
+ message: args.message,
218
+ // Sync apps.workflows from the manifest. Server validates each
219
+ // workflow_id exists in the workspace before committing.
220
+ workflows: meta.workflows ?? {},
221
+ });
222
+ writeAppMeta(projectDir, {
223
+ ...meta,
224
+ current_version_id: result.version_id,
225
+ version_number: result.version_number,
226
+ });
227
+ console.error(`Deployed v${result.version_number} (${result.version_id})`);
228
+ console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
229
+ }
230
+ catch (err) {
231
+ const e = err;
232
+ if (e.code === "VERSION_CONFLICT") {
233
+ throw new Error(`Deploy conflict: server is at version ${e.current_version_id ?? "unknown"} ` +
234
+ `but local copy is based on ${meta.current_version_id ?? "no version"}. ` +
235
+ `Pull the latest before redeploying:\n lotics app pull ${meta.app_id}`);
236
+ }
237
+ throw err;
238
+ }
239
+ }
240
+ finally {
241
+ if (fs.existsSync(tmpSource))
242
+ fs.unlinkSync(tmpSource);
243
+ if (fs.existsSync(tmpDist))
244
+ fs.unlinkSync(tmpDist);
245
+ }
246
+ }
package/dist/src/cli.js CHANGED
@@ -5,6 +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
9
  function printHelp() {
9
10
  console.log(`Lotics CLI v${VERSION} — AI agent interface for Lotics
10
11
 
@@ -43,6 +44,9 @@ COMMANDS
43
44
  lotics download <file_id> Download a file by ID
44
45
  lotics download record <record_id> <field_key>
45
46
  Download all files on a record file field
47
+ lotics app create <name> [path] Create a new custom-code app + scaffold locally
48
+ lotics app pull <app_id> [path] Bootstrap full local env (source + npm install + types)
49
+ lotics app deploy [-m <message>] Build + upload current dir as a new version
46
50
 
47
51
  FLAGS
48
52
  --json Full JSON output (default is human-readable text)
@@ -389,11 +393,18 @@ async function main() {
389
393
  return;
390
394
  }
391
395
  // --- Validate command before auth ---
392
- if (command !== "tools" && command !== "upload" && command !== "run" && command !== "download" && command !== "workspace") {
396
+ if (command !== "tools" && command !== "upload" && command !== "run" && command !== "download" && command !== "workspace" && command !== "app") {
393
397
  console.error(`Unknown command: ${command}`);
394
398
  console.error('Run "lotics --help" for usage.');
395
399
  process.exit(1);
396
400
  }
401
+ if (command === "app" && !subcommand) {
402
+ console.error("Usage:");
403
+ console.error(" lotics app create <name> [path] Scaffold a new app locally");
404
+ console.error(" lotics app pull <app_id> [path] Pull an existing app for local editing");
405
+ console.error(" lotics app deploy [-m <message>] Build + upload the current directory");
406
+ process.exit(1);
407
+ }
397
408
  if (command === "run" && !subcommand) {
398
409
  console.error('Usage: lotics run <tool> \'<json_args>\'');
399
410
  console.error('Run "lotics tools" to see available tools.');
@@ -482,6 +493,39 @@ async function main() {
482
493
  }
483
494
  // Ensure workspace is resolved for all remaining commands
484
495
  await resolveWorkspace(client);
496
+ // lotics app create / pull / deploy
497
+ if (command === "app") {
498
+ if (subcommand === "create") {
499
+ const name = toolArgs;
500
+ if (!name) {
501
+ console.error("Usage: lotics app create <name> [path]");
502
+ process.exit(1);
503
+ }
504
+ const targetPath = restArgs[0];
505
+ await appCreate(client, { name, targetPath });
506
+ return;
507
+ }
508
+ if (subcommand === "pull") {
509
+ const appId = toolArgs;
510
+ if (!appId) {
511
+ console.error("Usage: lotics app pull <app_id> [path]");
512
+ process.exit(1);
513
+ }
514
+ const targetPath = restArgs[0];
515
+ await appPull(client, { app_id: appId, targetPath });
516
+ return;
517
+ }
518
+ if (subcommand === "deploy") {
519
+ // -m / --message can be passed via toolArgs or after a flag-like delimiter.
520
+ // Keep it simple: any positional arg after `deploy` is treated as the message.
521
+ const message = toolArgs;
522
+ await appDeploy(client, { message });
523
+ return;
524
+ }
525
+ console.error(`Unknown app subcommand: ${subcommand}`);
526
+ console.error("Run 'lotics app' for usage.");
527
+ process.exit(1);
528
+ }
485
529
  // lotics tools / lotics tools <name>
486
530
  if (command === "tools") {
487
531
  if (subcommand) {
@@ -67,6 +67,49 @@ export declare class LoticsClient {
67
67
  format?: "json" | "text";
68
68
  timeoutMs?: number;
69
69
  }): Promise<ToolExecuteResult>;
70
+ getApp(app_id: string): Promise<{
71
+ id: string;
72
+ name: string;
73
+ workspace_id: string;
74
+ current_version_id: string | null;
75
+ }>;
76
+ createApp(body: {
77
+ name: string;
78
+ description?: string;
79
+ icon?: string;
80
+ }): Promise<{
81
+ id: string;
82
+ name: string;
83
+ workspace_id: string;
84
+ current_version_id: string | null;
85
+ }>;
86
+ getAppVersion(app_id: string, version_id: string): Promise<{
87
+ id: string;
88
+ app_id: string;
89
+ version: number;
90
+ r2_prefix: string;
91
+ entry_html_path: string;
92
+ build_status: string;
93
+ }>;
94
+ getAppVersionSourceUrl(app_id: string, version_id: string): Promise<string>;
95
+ deployAppVersion(args: {
96
+ app_id: string;
97
+ source_archive: Buffer;
98
+ dist_archive: Buffer;
99
+ prev_version_id?: string | null;
100
+ message?: string | null;
101
+ /**
102
+ * Alias → workflow_id map from the app's package.json `lotics.workflows`.
103
+ * Always sent (empty object when none declared) so the server can
104
+ * overwrite apps.workflows authoritatively. Deleting an alias from the
105
+ * manifest removes it from the DB on next deploy.
106
+ */
107
+ workflows?: Record<string, string>;
108
+ }): Promise<{
109
+ version_id: string;
110
+ version_number: number;
111
+ bundle_size_bytes: number;
112
+ }>;
70
113
  downloadFile(url: string, outputPath: string): Promise<string>;
71
114
  downloadFileById(fileId: string, outputDir?: string, options?: {
72
115
  reserved?: Set<string>;
@@ -139,6 +139,55 @@ export class LoticsClient {
139
139
  }
140
140
  return this.request("POST", "/v1/tools/execute", body);
141
141
  }
142
+ // --- Apps ---
143
+ async getApp(app_id) {
144
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
145
+ }
146
+ async createApp(body) {
147
+ return this.request("POST", "/v1/apps", { ...body, ui: { type: "custom_code" } });
148
+ }
149
+ async getAppVersion(app_id, version_id) {
150
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}`);
151
+ }
152
+ async getAppVersionSourceUrl(app_id, version_id) {
153
+ const result = await this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/versions/${encodeURIComponent(version_id)}/source`);
154
+ return result.url;
155
+ }
156
+ async deployAppVersion(args) {
157
+ const formData = new FormData();
158
+ // Wrap Buffers as Uint8Array views so the Blob constructor accepts them
159
+ // (Node Buffer's `ArrayBufferLike` underlying buffer doesn't match the
160
+ // BlobPart contract directly under strict TS).
161
+ formData.append("source", new Blob([new Uint8Array(args.source_archive)], { type: "application/gzip" }), "source.tar.gz");
162
+ formData.append("dist", new Blob([new Uint8Array(args.dist_archive)], { type: "application/gzip" }), "dist.tar.gz");
163
+ if (args.prev_version_id) {
164
+ formData.append("prev_version_id", args.prev_version_id);
165
+ }
166
+ if (args.message) {
167
+ formData.append("message", args.message);
168
+ }
169
+ // Always send workflows — empty object is meaningful (clears any
170
+ // previously-declared aliases). Server validates each entry.
171
+ formData.append("workflows", JSON.stringify(args.workflows ?? {}));
172
+ const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
173
+ const response = await fetch(url, {
174
+ method: "POST",
175
+ headers: this.buildHeaders(), // no Content-Type — fetch sets multipart boundary
176
+ body: formData,
177
+ });
178
+ if (response.status === 409) {
179
+ const conflict = (await response.json());
180
+ const err = new Error(conflict.message ?? "Deploy conflict — pull required");
181
+ err.code = "VERSION_CONFLICT";
182
+ err.current_version_id =
183
+ conflict.current_version_id ?? null;
184
+ throw err;
185
+ }
186
+ if (!response.ok) {
187
+ await this.throwResponseError(response);
188
+ }
189
+ return response.json();
190
+ }
142
191
  async downloadFile(url, outputPath) {
143
192
  const response = await fetch(url);
144
193
  if (!response.ok) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Starter template for `lotics app create`.
3
+ *
4
+ * The CLI scaffolds these files into the user's target directory, then runs
5
+ * `npm install` + `npm run build` + `lotics app deploy` to populate v1.
6
+ *
7
+ * Embedded as string literals here (rather than packaged as files alongside
8
+ * the CLI) so the CLI binary is self-contained and the starter survives
9
+ * `npm publish` cleanly. Updates to the starter ship in CLI releases.
10
+ *
11
+ * Conventions (locked decisions):
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.
15
+ * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
16
+ * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
17
+ * Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
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.
22
+ * - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
23
+ * workflow that runs all three on every PR.
24
+ */
25
+ export interface StarterFile {
26
+ path: string;
27
+ content: string;
28
+ }
29
+ export declare function buildStarterTemplate(args: {
30
+ app_name: string;
31
+ app_id: string;
32
+ workspace_id: string;
33
+ }): StarterFile[];
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Starter template for `lotics app create`.
3
+ *
4
+ * The CLI scaffolds these files into the user's target directory, then runs
5
+ * `npm install` + `npm run build` + `lotics app deploy` to populate v1.
6
+ *
7
+ * Embedded as string literals here (rather than packaged as files alongside
8
+ * the CLI) so the CLI binary is self-contained and the starter survives
9
+ * `npm publish` cleanly. Updates to the starter ship in CLI releases.
10
+ *
11
+ * Conventions (locked decisions):
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.
15
+ * - Vite default `base: "/"` so emitted asset URLs are absolute; the render
16
+ * endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
17
+ * Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
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.
22
+ * - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
23
+ * workflow that runs all three on every PR.
24
+ */
25
+ export function buildStarterTemplate(args) {
26
+ const sanitizedPkgName = args.app_name
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9-]/g, "-")
29
+ .replace(/-+/g, "-")
30
+ .replace(/^-|-$/g, "")
31
+ .slice(0, 64) || "lotics-app";
32
+ return [
33
+ {
34
+ path: "package.json",
35
+ content: JSON.stringify({
36
+ name: sanitizedPkgName,
37
+ version: "0.0.1",
38
+ private: true,
39
+ type: "module",
40
+ scripts: {
41
+ dev: "vite",
42
+ build: "vite build",
43
+ preview: "vite preview",
44
+ typecheck: "tsc --noEmit",
45
+ // Scope to src/ — oxlint's default scan walks node_modules too.
46
+ lint: "oxlint src",
47
+ test: "vitest run",
48
+ },
49
+ dependencies: {
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",
56
+ react: "^19.0.0",
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",
61
+ },
62
+ devDependencies: {
63
+ "@testing-library/react": "^16.1.0",
64
+ "@types/react": "^19.0.0",
65
+ "@types/react-dom": "^19.0.0",
66
+ "@vitejs/plugin-react": "^4.3.0",
67
+ jsdom: "^25.0.0",
68
+ oxlint: "^0.13.0",
69
+ typescript: "^5.6.0",
70
+ vite: "^5.4.0",
71
+ vitest: "^2.1.0",
72
+ },
73
+ lotics: {
74
+ app_id: args.app_id,
75
+ workspace_id: args.workspace_id,
76
+ current_version_id: null,
77
+ version_number: null,
78
+ },
79
+ }, null, 2) + "\n",
80
+ },
81
+ {
82
+ path: "tsconfig.json",
83
+ content: JSON.stringify({
84
+ compilerOptions: {
85
+ target: "ES2022",
86
+ useDefineForClassFields: true,
87
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
88
+ module: "ESNext",
89
+ skipLibCheck: true,
90
+ moduleResolution: "Bundler",
91
+ allowImportingTsExtensions: true,
92
+ resolveJsonModule: true,
93
+ isolatedModules: true,
94
+ noEmit: true,
95
+ jsx: "react-jsx",
96
+ strict: true,
97
+ noUnusedLocals: true,
98
+ noUnusedParameters: true,
99
+ noFallthroughCasesInSwitch: true,
100
+ },
101
+ include: ["src", ".lotics"],
102
+ }, null, 2) + "\n",
103
+ },
104
+ {
105
+ path: "vite.config.ts",
106
+ content: `/// <reference types="vitest" />
107
+ import { defineConfig } from "vite";
108
+ import react from "@vitejs/plugin-react";
109
+
110
+ // Vite default base (/) emits absolute asset URLs in index.html. Lotics's
111
+ // render endpoint rewrites those to /v1/apps/{id}/asset/... so the bundle
112
+ // loads via the platform's asset proxy. Don't change \`base\` unless you
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.
119
+ export default defineConfig({
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
+ },
127
+ build: {
128
+ outDir: "dist",
129
+ sourcemap: true,
130
+ },
131
+ test: {
132
+ environment: "jsdom",
133
+ },
134
+ });
135
+ `,
136
+ },
137
+ {
138
+ path: "index.html",
139
+ content: `<!doctype html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="UTF-8" />
143
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
144
+ <title>${escapeHtml(args.app_name)}</title>
145
+ </head>
146
+ <body>
147
+ <div id="root"></div>
148
+ <script type="module" src="/src/main.tsx"></script>
149
+ </body>
150
+ </html>
151
+ `,
152
+ },
153
+ {
154
+ path: "src/main.tsx",
155
+ content: `import "@lotics/ui/index.css";
156
+ import { mount } from "@lotics/app-sdk";
157
+ import App from "./App";
158
+
159
+ mount(<App />);
160
+ `,
161
+ },
162
+ {
163
+ path: "src/App.tsx",
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() {
174
+ return (
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>
193
+ );
194
+ }
195
+ `,
196
+ },
197
+ {
198
+ path: "src/App.test.tsx",
199
+ content: `import { describe, test, expect } from "vitest";
200
+ import { render } from "@testing-library/react";
201
+ import App from "./App";
202
+
203
+ describe("App", () => {
204
+ test("renders without crashing", () => {
205
+ const { container } = render(<App />);
206
+ expect(container).not.toBeNull();
207
+ });
208
+ });
209
+ `,
210
+ },
211
+ {
212
+ path: ".github/workflows/ci.yml",
213
+ content: `name: CI
214
+
215
+ on:
216
+ push:
217
+ branches: [main]
218
+ pull_request:
219
+
220
+ jobs:
221
+ ci:
222
+ runs-on: ubuntu-latest
223
+ steps:
224
+ - uses: actions/checkout@v4
225
+ - uses: actions/setup-node@v4
226
+ with:
227
+ node-version: 22
228
+ - run: npm ci
229
+ - run: npm run typecheck
230
+ - run: npm run lint
231
+ - run: npm test
232
+ - run: npm run build
233
+ `,
234
+ },
235
+ {
236
+ path: ".gitignore",
237
+ content: `node_modules
238
+ dist
239
+ *.tsbuildinfo
240
+ .DS_Store
241
+ .lotics
242
+ coverage
243
+ `,
244
+ },
245
+ {
246
+ path: "README.md",
247
+ content: `# ${args.app_name}
248
+
249
+ A Lotics custom-code app. Authored locally, deployed via CLI.
250
+
251
+ ## Develop
252
+
253
+ \`\`\`bash
254
+ npm install # already done by 'lotics app create'
255
+ npm run dev # local Vite dev server (UI only — real data needs deploy)
256
+ \`\`\`
257
+
258
+ ## Quality
259
+
260
+ \`\`\`bash
261
+ npm run typecheck # tsc --noEmit
262
+ npm run lint # oxlint
263
+ npm test # vitest run
264
+ \`\`\`
265
+
266
+ CI runs all three on every PR via \`.github/workflows/ci.yml\`.
267
+
268
+ ## Deploy
269
+
270
+ \`\`\`bash
271
+ lotics app deploy # build + upload as a new version
272
+ lotics app deploy -m "what" # with a commit-message-style note
273
+ \`\`\`
274
+
275
+ ## SDK + UI components
276
+
277
+ \`\`\`tsx
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.
284
+ \`\`\`
285
+
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.
291
+ `,
292
+ },
293
+ ];
294
+ }
295
+ function escapeHtml(s) {
296
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
297
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {