@lotics/cli 0.18.0 → 0.19.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 +45 -0
- package/dist/src/app_commands.js +246 -0
- package/dist/src/cli.js +45 -1
- package/dist/src/client.d.ts +43 -0
- package/dist/src/client.js +49 -0
- package/dist/src/starter_template.d.ts +29 -0
- package/dist/src/starter_template.js +251 -0
- package/package.json +1 -1
|
@@ -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) {
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/client.js
CHANGED
|
@@ -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,29 @@
|
|
|
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
|
+
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
15
|
+
* endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
|
|
16
|
+
* Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
|
|
17
|
+
* - `@lotics/app-sdk` peer-supplied; user installs it via npm.
|
|
18
|
+
* - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
|
|
19
|
+
* workflow that runs all three on every PR.
|
|
20
|
+
*/
|
|
21
|
+
export interface StarterFile {
|
|
22
|
+
path: string;
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function buildStarterTemplate(args: {
|
|
26
|
+
app_name: string;
|
|
27
|
+
app_id: string;
|
|
28
|
+
workspace_id: string;
|
|
29
|
+
}): StarterFile[];
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
+
* - Vite default `base: "/"` so emitted asset URLs are absolute; the render
|
|
15
|
+
* endpoint rewrites root-relative paths to `/v1/apps/{id}/asset/...`.
|
|
16
|
+
* Don't switch to `base: "./"` without updating `rewriteAssetPaths`.
|
|
17
|
+
* - `@lotics/app-sdk` peer-supplied; user installs it via npm.
|
|
18
|
+
* - oxlint + vitest + tsc out of the box, plus a GitHub Actions CI
|
|
19
|
+
* workflow that runs all three on every PR.
|
|
20
|
+
*/
|
|
21
|
+
export function buildStarterTemplate(args) {
|
|
22
|
+
const sanitizedPkgName = args.app_name
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
25
|
+
.replace(/-+/g, "-")
|
|
26
|
+
.replace(/^-|-$/g, "")
|
|
27
|
+
.slice(0, 64) || "lotics-app";
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
path: "package.json",
|
|
31
|
+
content: JSON.stringify({
|
|
32
|
+
name: sanitizedPkgName,
|
|
33
|
+
version: "0.0.1",
|
|
34
|
+
private: true,
|
|
35
|
+
type: "module",
|
|
36
|
+
scripts: {
|
|
37
|
+
dev: "vite",
|
|
38
|
+
build: "vite build",
|
|
39
|
+
preview: "vite preview",
|
|
40
|
+
typecheck: "tsc --noEmit",
|
|
41
|
+
// Scope to src/ — oxlint's default scan walks node_modules too.
|
|
42
|
+
lint: "oxlint src",
|
|
43
|
+
test: "vitest run",
|
|
44
|
+
},
|
|
45
|
+
dependencies: {
|
|
46
|
+
"@lotics/app-sdk": "^0.1.0",
|
|
47
|
+
react: "^19.0.0",
|
|
48
|
+
"react-dom": "^19.0.0",
|
|
49
|
+
},
|
|
50
|
+
devDependencies: {
|
|
51
|
+
"@testing-library/react": "^16.1.0",
|
|
52
|
+
"@types/react": "^19.0.0",
|
|
53
|
+
"@types/react-dom": "^19.0.0",
|
|
54
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
55
|
+
jsdom: "^25.0.0",
|
|
56
|
+
oxlint: "^0.13.0",
|
|
57
|
+
typescript: "^5.6.0",
|
|
58
|
+
vite: "^5.4.0",
|
|
59
|
+
vitest: "^2.1.0",
|
|
60
|
+
},
|
|
61
|
+
lotics: {
|
|
62
|
+
app_id: args.app_id,
|
|
63
|
+
workspace_id: args.workspace_id,
|
|
64
|
+
current_version_id: null,
|
|
65
|
+
version_number: null,
|
|
66
|
+
},
|
|
67
|
+
}, null, 2) + "\n",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
path: "tsconfig.json",
|
|
71
|
+
content: JSON.stringify({
|
|
72
|
+
compilerOptions: {
|
|
73
|
+
target: "ES2022",
|
|
74
|
+
useDefineForClassFields: true,
|
|
75
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
76
|
+
module: "ESNext",
|
|
77
|
+
skipLibCheck: true,
|
|
78
|
+
moduleResolution: "Bundler",
|
|
79
|
+
allowImportingTsExtensions: true,
|
|
80
|
+
resolveJsonModule: true,
|
|
81
|
+
isolatedModules: true,
|
|
82
|
+
noEmit: true,
|
|
83
|
+
jsx: "react-jsx",
|
|
84
|
+
strict: true,
|
|
85
|
+
noUnusedLocals: true,
|
|
86
|
+
noUnusedParameters: true,
|
|
87
|
+
noFallthroughCasesInSwitch: true,
|
|
88
|
+
},
|
|
89
|
+
include: ["src", ".lotics"],
|
|
90
|
+
}, null, 2) + "\n",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
path: "vite.config.ts",
|
|
94
|
+
content: `/// <reference types="vitest" />
|
|
95
|
+
import { defineConfig } from "vite";
|
|
96
|
+
import react from "@vitejs/plugin-react";
|
|
97
|
+
|
|
98
|
+
// Vite default base (/) emits absolute asset URLs in index.html. Lotics's
|
|
99
|
+
// render endpoint rewrites those to /v1/apps/{id}/asset/... so the bundle
|
|
100
|
+
// loads via the platform's asset proxy. Don't change \`base\` unless you
|
|
101
|
+
// also adjust the rewrite logic in backend/api/apps.ts:rewriteAssetPaths.
|
|
102
|
+
export default defineConfig({
|
|
103
|
+
plugins: [react()],
|
|
104
|
+
build: {
|
|
105
|
+
outDir: "dist",
|
|
106
|
+
sourcemap: true,
|
|
107
|
+
},
|
|
108
|
+
test: {
|
|
109
|
+
environment: "jsdom",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
`,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
path: "index.html",
|
|
116
|
+
content: `<!doctype html>
|
|
117
|
+
<html lang="en">
|
|
118
|
+
<head>
|
|
119
|
+
<meta charset="UTF-8" />
|
|
120
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
121
|
+
<title>${escapeHtml(args.app_name)}</title>
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<div id="root"></div>
|
|
125
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
126
|
+
</body>
|
|
127
|
+
</html>
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
path: "src/main.tsx",
|
|
132
|
+
content: `import { mount } from "@lotics/app-sdk";
|
|
133
|
+
import App from "./App";
|
|
134
|
+
|
|
135
|
+
mount(<App />);
|
|
136
|
+
`,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
path: "src/App.tsx",
|
|
140
|
+
// Static welcome screen — no useTable call so the freshly-deployed v1
|
|
141
|
+
// renders cleanly without needing a real table id. The user's first
|
|
142
|
+
// edit is to replace this with their actual app.
|
|
143
|
+
content: `export default function App() {
|
|
144
|
+
return (
|
|
145
|
+
<main style={{ fontFamily: "system-ui, sans-serif", padding: 24, maxWidth: 640, margin: "0 auto" }}>
|
|
146
|
+
<h1 style={{ marginBottom: 8 }}>${escapeHtml(args.app_name)}</h1>
|
|
147
|
+
<p style={{ color: "#52525b", marginBottom: 16 }}>
|
|
148
|
+
This is your new Lotics app. Edit <code>src/App.tsx</code> and run{" "}
|
|
149
|
+
<code>lotics app deploy</code> to publish.
|
|
150
|
+
</p>
|
|
151
|
+
<p style={{ color: "#52525b" }}>
|
|
152
|
+
Available hooks from <code>@lotics/app-sdk</code>:{" "}
|
|
153
|
+
<code>useTable</code>, <code>useMutate</code>, <code>useAction</code>, <code>useQuery</code>.
|
|
154
|
+
</p>
|
|
155
|
+
</main>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
`,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
path: "src/App.test.tsx",
|
|
162
|
+
content: `import { describe, test, expect } from "vitest";
|
|
163
|
+
import { render } from "@testing-library/react";
|
|
164
|
+
import App from "./App";
|
|
165
|
+
|
|
166
|
+
describe("App", () => {
|
|
167
|
+
test("renders a heading", () => {
|
|
168
|
+
const { getByRole } = render(<App />);
|
|
169
|
+
expect(getByRole("heading", { level: 1 })).not.toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
`,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
path: ".github/workflows/ci.yml",
|
|
176
|
+
content: `name: CI
|
|
177
|
+
|
|
178
|
+
on:
|
|
179
|
+
push:
|
|
180
|
+
branches: [main]
|
|
181
|
+
pull_request:
|
|
182
|
+
|
|
183
|
+
jobs:
|
|
184
|
+
ci:
|
|
185
|
+
runs-on: ubuntu-latest
|
|
186
|
+
steps:
|
|
187
|
+
- uses: actions/checkout@v4
|
|
188
|
+
- uses: actions/setup-node@v4
|
|
189
|
+
with:
|
|
190
|
+
node-version: 22
|
|
191
|
+
- run: npm ci
|
|
192
|
+
- run: npm run typecheck
|
|
193
|
+
- run: npm run lint
|
|
194
|
+
- run: npm test
|
|
195
|
+
- run: npm run build
|
|
196
|
+
`,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
path: ".gitignore",
|
|
200
|
+
content: `node_modules
|
|
201
|
+
dist
|
|
202
|
+
*.tsbuildinfo
|
|
203
|
+
.DS_Store
|
|
204
|
+
.lotics
|
|
205
|
+
coverage
|
|
206
|
+
`,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
path: "README.md",
|
|
210
|
+
content: `# ${args.app_name}
|
|
211
|
+
|
|
212
|
+
A Lotics custom-code app. Authored locally, deployed via CLI.
|
|
213
|
+
|
|
214
|
+
## Develop
|
|
215
|
+
|
|
216
|
+
\`\`\`bash
|
|
217
|
+
npm install # already done by 'lotics app create'
|
|
218
|
+
npm run dev # local Vite dev server (UI only — real data needs deploy)
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
## Quality
|
|
222
|
+
|
|
223
|
+
\`\`\`bash
|
|
224
|
+
npm run typecheck # tsc --noEmit
|
|
225
|
+
npm run lint # oxlint
|
|
226
|
+
npm test # vitest run
|
|
227
|
+
\`\`\`
|
|
228
|
+
|
|
229
|
+
CI runs all three on every PR via \`.github/workflows/ci.yml\`.
|
|
230
|
+
|
|
231
|
+
## Deploy
|
|
232
|
+
|
|
233
|
+
\`\`\`bash
|
|
234
|
+
lotics app deploy # build + upload as a new version
|
|
235
|
+
lotics app deploy -m "what" # with a commit-message-style note
|
|
236
|
+
\`\`\`
|
|
237
|
+
|
|
238
|
+
## SDK
|
|
239
|
+
|
|
240
|
+
\`\`\`tsx
|
|
241
|
+
import { mount, useTable, useMutate, useAction, useQuery } from "@lotics/app-sdk";
|
|
242
|
+
\`\`\`
|
|
243
|
+
|
|
244
|
+
See https://lotics.ai/docs/app-sdk for the full reference.
|
|
245
|
+
`,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
function escapeHtml(s) {
|
|
250
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
251
|
+
}
|