@lotics/cli 0.22.0 → 0.24.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 +42 -3
- package/dist/src/app_commands.js +67 -4
- package/dist/src/app_commands.test.d.ts +1 -0
- package/dist/src/app_commands.test.js +103 -0
- package/dist/src/cli.js +10 -3
- package/dist/src/client.d.ts +30 -5
- package/dist/src/client.js +6 -0
- package/dist/src/generate_app_workflows_dts.d.ts +17 -0
- package/dist/src/generate_app_workflows_dts.js +123 -0
- package/dist/src/starter_template.js +96 -9
- package/package.json +5 -1
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
import { LoticsClient } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Manifest declaration for one workflow alias:
|
|
4
|
+
* `"alias": { workflow_id, inputs?: { key: { type, … } } }`
|
|
5
|
+
*
|
|
6
|
+
* When `inputs` is declared, the CLI codegen emits a typed `AppWorkflows[alias]`
|
|
7
|
+
* entry and the server validates payloads against it at execute time. Omit
|
|
8
|
+
* `inputs` for workflows that accept no typed inputs.
|
|
9
|
+
*/
|
|
10
|
+
export type AppWorkflowDeclaration = {
|
|
11
|
+
workflow_id: string;
|
|
12
|
+
inputs?: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Stamp the post-extraction manifest with server-authoritative meta. Called
|
|
16
|
+
* by `appPull` after the source archive lands on disk.
|
|
17
|
+
*
|
|
18
|
+
* Crucially, `workflows` is sourced from `app.workflows` (live DB state),
|
|
19
|
+
* NOT from whatever the extracted archive's package.json contains. The
|
|
20
|
+
* archive's embedded `lotics.workflows` is a frozen snapshot from deploy
|
|
21
|
+
* time and would silently overwrite any agent-authored bindings made via
|
|
22
|
+
* `set_app_workflow` since the last deploy. Exported for direct unit
|
|
23
|
+
* testing — the rest of `appPull` involves shelling out to `tar`/`npm`,
|
|
24
|
+
* which doesn't reward integration coverage.
|
|
25
|
+
*/
|
|
26
|
+
export declare function stampPulledManifest(projectDir: string, args: {
|
|
27
|
+
app_id: string;
|
|
28
|
+
workspace_id: string;
|
|
29
|
+
current_version_id: string;
|
|
30
|
+
version_number: number;
|
|
31
|
+
workflows: Record<string, AppWorkflowDeclaration>;
|
|
32
|
+
}): void;
|
|
2
33
|
/**
|
|
3
34
|
* `lotics app create <name> [path]`
|
|
4
35
|
*
|
|
@@ -23,9 +54,16 @@ export declare function appCreate(client: LoticsClient, args: {
|
|
|
23
54
|
* Bootstraps a full local dev environment for an existing app:
|
|
24
55
|
* 1. Fetch the current version's source archive from R2 (presigned URL).
|
|
25
56
|
* 2. Extract into the target directory.
|
|
26
|
-
* 3.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
57
|
+
* 3. Stamp package.json's `lotics` field with app_id + version metadata,
|
|
58
|
+
* and the live `apps.workflows` map from the server (NOT the manifest
|
|
59
|
+
* embedded in the source archive — that's a frozen snapshot from deploy
|
|
60
|
+
* time and would silently overwrite any agent-authored bindings made via
|
|
61
|
+
* `set_app_workflow` since the last deploy).
|
|
62
|
+
* 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
|
|
63
|
+
* `useWorkflow<"alias">` is typed at pull time.
|
|
64
|
+
* 5. Run `npm install`.
|
|
65
|
+
* 6. (TODO) Generate `.lotics/types.ts` with workspace tables augmentation —
|
|
66
|
+
* pending a `client.getWorkspaceSchema()` endpoint.
|
|
29
67
|
*/
|
|
30
68
|
export declare function appPull(client: LoticsClient, args: {
|
|
31
69
|
app_id: string;
|
|
@@ -42,6 +80,7 @@ export declare function appPull(client: LoticsClient, args: {
|
|
|
42
80
|
export declare function appDeploy(client: LoticsClient, args: {
|
|
43
81
|
projectDir?: string;
|
|
44
82
|
message?: string;
|
|
83
|
+
forceWorkflowSync?: boolean;
|
|
45
84
|
}): Promise<void>;
|
|
46
85
|
/**
|
|
47
86
|
* `lotics app dev [path] [--port=5174] [--vite-port=5173]`
|
package/dist/src/app_commands.js
CHANGED
|
@@ -17,6 +17,7 @@ import { spawn } from "node:child_process";
|
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { buildStarterTemplate } from "./starter_template.js";
|
|
19
19
|
import { startDevServer, openBrowser } from "./dev/server.js";
|
|
20
|
+
import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
|
|
20
21
|
/** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
|
|
21
22
|
function runTar(args, cwd) {
|
|
22
23
|
return new Promise((resolve, reject) => {
|
|
@@ -71,6 +72,40 @@ function writeAppMeta(projectDir, meta) {
|
|
|
71
72
|
pkg.lotics = meta;
|
|
72
73
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
73
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Write `.lotics/app_workflows.d.ts` from the manifest's `workflows` map.
|
|
77
|
+
* Called from `app pull`, `app dev`, and `app deploy` so the augmented
|
|
78
|
+
* `AppWorkflows` types stay in sync with the manifest. The starter
|
|
79
|
+
* tsconfig.json includes `.lotics/` so tsc + Vite pick it up automatically.
|
|
80
|
+
*/
|
|
81
|
+
function writeAppWorkflowsDts(projectDir, workflows) {
|
|
82
|
+
const dotLotics = path.join(projectDir, ".lotics");
|
|
83
|
+
fs.mkdirSync(dotLotics, { recursive: true });
|
|
84
|
+
const dts = generateAppWorkflowsDts(workflows);
|
|
85
|
+
fs.writeFileSync(path.join(dotLotics, "app_workflows.d.ts"), dts);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Stamp the post-extraction manifest with server-authoritative meta. Called
|
|
89
|
+
* by `appPull` after the source archive lands on disk.
|
|
90
|
+
*
|
|
91
|
+
* Crucially, `workflows` is sourced from `app.workflows` (live DB state),
|
|
92
|
+
* NOT from whatever the extracted archive's package.json contains. The
|
|
93
|
+
* archive's embedded `lotics.workflows` is a frozen snapshot from deploy
|
|
94
|
+
* time and would silently overwrite any agent-authored bindings made via
|
|
95
|
+
* `set_app_workflow` since the last deploy. Exported for direct unit
|
|
96
|
+
* testing — the rest of `appPull` involves shelling out to `tar`/`npm`,
|
|
97
|
+
* which doesn't reward integration coverage.
|
|
98
|
+
*/
|
|
99
|
+
export function stampPulledManifest(projectDir, args) {
|
|
100
|
+
writeAppMeta(projectDir, {
|
|
101
|
+
app_id: args.app_id,
|
|
102
|
+
workspace_id: args.workspace_id,
|
|
103
|
+
current_version_id: args.current_version_id,
|
|
104
|
+
version_number: args.version_number,
|
|
105
|
+
workflows: args.workflows,
|
|
106
|
+
});
|
|
107
|
+
writeAppWorkflowsDts(projectDir, args.workflows);
|
|
108
|
+
}
|
|
74
109
|
async function downloadToFile(url, destPath) {
|
|
75
110
|
const response = await fetch(url);
|
|
76
111
|
if (!response.ok) {
|
|
@@ -117,6 +152,10 @@ export async function appCreate(client, args) {
|
|
|
117
152
|
fs.writeFileSync(fullPath, file.content);
|
|
118
153
|
}
|
|
119
154
|
console.error(`Scaffolded ${files.length} files into ${targetPath}`);
|
|
155
|
+
// Codegen pass for AppWorkflows typing. Empty manifest on first create —
|
|
156
|
+
// emits a base augmentation file so future deploys produce idempotent diffs
|
|
157
|
+
// rather than introducing a new tracked file later.
|
|
158
|
+
writeAppWorkflowsDts(targetPath, {});
|
|
120
159
|
console.error("Installing npm dependencies...");
|
|
121
160
|
await runNpm(["install"], targetPath);
|
|
122
161
|
console.error("Building initial version...");
|
|
@@ -135,9 +174,16 @@ export async function appCreate(client, args) {
|
|
|
135
174
|
* Bootstraps a full local dev environment for an existing app:
|
|
136
175
|
* 1. Fetch the current version's source archive from R2 (presigned URL).
|
|
137
176
|
* 2. Extract into the target directory.
|
|
138
|
-
* 3.
|
|
139
|
-
*
|
|
140
|
-
*
|
|
177
|
+
* 3. Stamp package.json's `lotics` field with app_id + version metadata,
|
|
178
|
+
* and the live `apps.workflows` map from the server (NOT the manifest
|
|
179
|
+
* embedded in the source archive — that's a frozen snapshot from deploy
|
|
180
|
+
* time and would silently overwrite any agent-authored bindings made via
|
|
181
|
+
* `set_app_workflow` since the last deploy).
|
|
182
|
+
* 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
|
|
183
|
+
* `useWorkflow<"alias">` is typed at pull time.
|
|
184
|
+
* 5. Run `npm install`.
|
|
185
|
+
* 6. (TODO) Generate `.lotics/types.ts` with workspace tables augmentation —
|
|
186
|
+
* pending a `client.getWorkspaceSchema()` endpoint.
|
|
141
187
|
*/
|
|
142
188
|
export async function appPull(client, args) {
|
|
143
189
|
const app = await client.getApp(args.app_id);
|
|
@@ -160,11 +206,17 @@ export async function appPull(client, args) {
|
|
|
160
206
|
if (fs.existsSync(tmpFile))
|
|
161
207
|
fs.unlinkSync(tmpFile);
|
|
162
208
|
}
|
|
163
|
-
|
|
209
|
+
// Workflows live on the App row, not in the source archive. The R2 archive
|
|
210
|
+
// is for code; pull the workflows live from `apps.workflows` so agent-
|
|
211
|
+
// authored bindings (via set_app_workflow) land in the local manifest. The
|
|
212
|
+
// archive's embedded `lotics.workflows` is a deploy-time snapshot and would
|
|
213
|
+
// be stale relative to anything authored since.
|
|
214
|
+
stampPulledManifest(targetPath, {
|
|
164
215
|
app_id: app.id,
|
|
165
216
|
workspace_id: app.workspace_id,
|
|
166
217
|
current_version_id: app.current_version_id,
|
|
167
218
|
version_number: version.version,
|
|
219
|
+
workflows: app.workflows ?? {},
|
|
168
220
|
});
|
|
169
221
|
console.error(`Installing npm dependencies...`);
|
|
170
222
|
await runNpm(["install"], targetPath);
|
|
@@ -184,6 +236,10 @@ export async function appPull(client, args) {
|
|
|
184
236
|
export async function appDeploy(client, args) {
|
|
185
237
|
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
186
238
|
const meta = readAppMeta(projectDir);
|
|
239
|
+
// Regenerate AppWorkflows typing before the build picks up source. Keeps
|
|
240
|
+
// .lotics/app_workflows.d.ts in sync with the manifest's workflows map
|
|
241
|
+
// every time the developer ships.
|
|
242
|
+
writeAppWorkflowsDts(projectDir, meta.workflows);
|
|
187
243
|
// Build locally so the server doesn't need a build sandbox in v1.
|
|
188
244
|
console.error("Building...");
|
|
189
245
|
await runNpm(["run", "build"], projectDir);
|
|
@@ -219,6 +275,9 @@ export async function appDeploy(client, args) {
|
|
|
219
275
|
// Sync apps.workflows from the manifest. Server validates each
|
|
220
276
|
// workflow_id exists in the workspace before committing.
|
|
221
277
|
workflows: meta.workflows ?? {},
|
|
278
|
+
// Opt into destructive workflow removal. Default false — the server
|
|
279
|
+
// rejects deploys whose manifest is missing aliases the App row has.
|
|
280
|
+
force_workflow_sync: args.forceWorkflowSync,
|
|
222
281
|
});
|
|
223
282
|
writeAppMeta(projectDir, {
|
|
224
283
|
...meta,
|
|
@@ -261,6 +320,10 @@ export async function appDeploy(client, args) {
|
|
|
261
320
|
export async function appDev(client, args) {
|
|
262
321
|
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
263
322
|
const meta = readAppMeta(projectDir);
|
|
323
|
+
// Regenerate AppWorkflows typing before Vite spins up so the dev typecheck
|
|
324
|
+
// sees the current shape. Doesn't watch for manifest changes mid-session —
|
|
325
|
+
// re-running `lotics app dev` after editing the manifest is the loop.
|
|
326
|
+
writeAppWorkflowsDts(projectDir, meta.workflows);
|
|
264
327
|
// Sanity: confirm the app exists in the workspace the CLI is auth'd into.
|
|
265
328
|
// Surfaces a clear error if the project's app_id has been deleted or the
|
|
266
329
|
// CLI is pointed at the wrong workspace.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { stampPulledManifest } from "./app_commands.js";
|
|
6
|
+
/**
|
|
7
|
+
* `appPull` reads workflows from the live App row (server response), NOT from
|
|
8
|
+
* the manifest embedded in the extracted source archive. The frozen archive
|
|
9
|
+
* snapshot would silently overwrite any agent-authored bindings (via
|
|
10
|
+
* `set_app_workflow`) that landed on the App row after the last deploy.
|
|
11
|
+
*
|
|
12
|
+
* Tests `stampPulledManifest` directly — the part of `appPull` that decides
|
|
13
|
+
* which workflows source wins. The rest of `appPull` shells out to
|
|
14
|
+
* `tar`/`npm`, which is integration territory.
|
|
15
|
+
*/
|
|
16
|
+
describe("stampPulledManifest", () => {
|
|
17
|
+
let workDir;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-sdk-test-"));
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
function writeExtractedPackageJson(workflows) {
|
|
25
|
+
fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
|
|
26
|
+
name: "sample-app",
|
|
27
|
+
lotics: {
|
|
28
|
+
// Stale archived map — must NOT survive the stamp.
|
|
29
|
+
app_id: "app_old",
|
|
30
|
+
workspace_id: "wks_old",
|
|
31
|
+
current_version_id: "apv_old",
|
|
32
|
+
version_number: 1,
|
|
33
|
+
workflows,
|
|
34
|
+
},
|
|
35
|
+
}, null, 2));
|
|
36
|
+
}
|
|
37
|
+
function readStampedManifest() {
|
|
38
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(workDir, "package.json"), "utf-8"));
|
|
39
|
+
return pkg.lotics;
|
|
40
|
+
}
|
|
41
|
+
it("writes workflows from the live argument, overriding the archive-embedded manifest", () => {
|
|
42
|
+
// Simulate the source archive's embedded manifest containing a stale
|
|
43
|
+
// alias — the kind of thing that ships when an agent-authored workflow
|
|
44
|
+
// hasn't yet been propagated to a developer's local checkout.
|
|
45
|
+
writeExtractedPackageJson({
|
|
46
|
+
stale_archive_alias: { workflow_id: "wfl_archived_at_deploy_time" },
|
|
47
|
+
});
|
|
48
|
+
// Live state from the server — what `client.getApp()` returns. This
|
|
49
|
+
// includes the agent-authored alias added since the last deploy.
|
|
50
|
+
stampPulledManifest(workDir, {
|
|
51
|
+
app_id: "app_live",
|
|
52
|
+
workspace_id: "wks_live",
|
|
53
|
+
current_version_id: "apv_live",
|
|
54
|
+
version_number: 7,
|
|
55
|
+
workflows: {
|
|
56
|
+
agent_authored: { workflow_id: "wfl_authored_by_agent" },
|
|
57
|
+
renamed: { workflow_id: "wfl_renamed_on_server" },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const stamped = readStampedManifest();
|
|
61
|
+
expect(stamped.app_id).toBe("app_live");
|
|
62
|
+
expect(stamped.workspace_id).toBe("wks_live");
|
|
63
|
+
expect(stamped.current_version_id).toBe("apv_live");
|
|
64
|
+
expect(stamped.version_number).toBe(7);
|
|
65
|
+
// Only the live workflows are present. The archive's stale_archive_alias
|
|
66
|
+
// does not survive — proving we're not reading from the extracted manifest.
|
|
67
|
+
expect(stamped.workflows).toEqual({
|
|
68
|
+
agent_authored: { workflow_id: "wfl_authored_by_agent" },
|
|
69
|
+
renamed: { workflow_id: "wfl_renamed_on_server" },
|
|
70
|
+
});
|
|
71
|
+
expect(stamped.workflows).not.toHaveProperty("stale_archive_alias");
|
|
72
|
+
});
|
|
73
|
+
it("clears the local workflows when the server has none", () => {
|
|
74
|
+
writeExtractedPackageJson({
|
|
75
|
+
stale_archive_alias: { workflow_id: "wfl_should_disappear" },
|
|
76
|
+
});
|
|
77
|
+
stampPulledManifest(workDir, {
|
|
78
|
+
app_id: "app_x",
|
|
79
|
+
workspace_id: "wks_x",
|
|
80
|
+
current_version_id: "apv_x",
|
|
81
|
+
version_number: 2,
|
|
82
|
+
workflows: {},
|
|
83
|
+
});
|
|
84
|
+
const stamped = readStampedManifest();
|
|
85
|
+
expect(stamped.workflows).toEqual({});
|
|
86
|
+
});
|
|
87
|
+
it("emits .lotics/app_workflows.d.ts from the live workflows", () => {
|
|
88
|
+
writeExtractedPackageJson({});
|
|
89
|
+
stampPulledManifest(workDir, {
|
|
90
|
+
app_id: "app_y",
|
|
91
|
+
workspace_id: "wks_y",
|
|
92
|
+
current_version_id: "apv_y",
|
|
93
|
+
version_number: 3,
|
|
94
|
+
workflows: {
|
|
95
|
+
only_live: { workflow_id: "wfl_only_live" },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const dtsPath = path.join(workDir, ".lotics", "app_workflows.d.ts");
|
|
99
|
+
expect(fs.existsSync(dtsPath)).toBe(true);
|
|
100
|
+
const dts = fs.readFileSync(dtsPath, "utf-8");
|
|
101
|
+
expect(dts).toContain("only_live");
|
|
102
|
+
});
|
|
103
|
+
});
|
package/dist/src/cli.js
CHANGED
|
@@ -46,7 +46,10 @@ COMMANDS
|
|
|
46
46
|
Download all files on a record file field
|
|
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
|
-
lotics app deploy [-m <message>]
|
|
49
|
+
lotics app deploy [-m <message>] [--force-workflow-sync]
|
|
50
|
+
Build + upload current dir as a new version
|
|
51
|
+
(--force-workflow-sync wipes server-only
|
|
52
|
+
workflow aliases; default refuses)
|
|
50
53
|
lotics app dev [path] Run the app locally with HMR (RPC forwarded to prod)
|
|
51
54
|
|
|
52
55
|
FLAGS
|
|
@@ -403,7 +406,8 @@ async function main() {
|
|
|
403
406
|
console.error("Usage:");
|
|
404
407
|
console.error(" lotics app create <name> [path] Scaffold a new app locally");
|
|
405
408
|
console.error(" lotics app pull <app_id> [path] Pull an existing app for local editing");
|
|
406
|
-
console.error(" lotics app deploy [-m <message>]
|
|
409
|
+
console.error(" lotics app deploy [-m <message>] [--force-workflow-sync]");
|
|
410
|
+
console.error(" Build + upload the current directory");
|
|
407
411
|
console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
|
|
408
412
|
process.exit(1);
|
|
409
413
|
}
|
|
@@ -520,8 +524,11 @@ async function main() {
|
|
|
520
524
|
if (subcommand === "deploy") {
|
|
521
525
|
// -m / --message can be passed via toolArgs or after a flag-like delimiter.
|
|
522
526
|
// Keep it simple: any positional arg after `deploy` is treated as the message.
|
|
527
|
+
// --force-workflow-sync is a separate flag captured upstream via restArgs
|
|
528
|
+
// because it has no value (boolean toggle).
|
|
523
529
|
const message = toolArgs;
|
|
524
|
-
|
|
530
|
+
const forceWorkflowSync = restArgs.includes("--force-workflow-sync");
|
|
531
|
+
await appDeploy(client, { message, forceWorkflowSync });
|
|
525
532
|
return;
|
|
526
533
|
}
|
|
527
534
|
if (subcommand === "dev") {
|
package/dist/src/client.d.ts
CHANGED
|
@@ -75,6 +75,17 @@ export declare class LoticsClient {
|
|
|
75
75
|
name: string;
|
|
76
76
|
workspace_id: string;
|
|
77
77
|
current_version_id: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Live alias → workflow declaration map from `apps.workflows`. Source of
|
|
80
|
+
* truth for `lotics app pull` — supersedes the manifest embedded in the
|
|
81
|
+
* source archive so agent-authored bindings (via `set_app_workflow`)
|
|
82
|
+
* survive the pull/edit/deploy loop. Null/undefined on apps that have
|
|
83
|
+
* never had a workflow declared.
|
|
84
|
+
*/
|
|
85
|
+
workflows?: Record<string, {
|
|
86
|
+
workflow_id: string;
|
|
87
|
+
inputs?: Record<string, unknown>;
|
|
88
|
+
}> | null;
|
|
78
89
|
}>;
|
|
79
90
|
createApp(body: {
|
|
80
91
|
name: string;
|
|
@@ -124,12 +135,26 @@ export declare class LoticsClient {
|
|
|
124
135
|
prev_version_id?: string | null;
|
|
125
136
|
message?: string | null;
|
|
126
137
|
/**
|
|
127
|
-
* Alias →
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
138
|
+
* Alias → workflow declaration map from the app's `package.json#lotics.workflows`.
|
|
139
|
+
* Each value is a `{ workflow_id, inputs? }` object — inputs declares a typed
|
|
140
|
+
* schema or is omitted when the workflow accepts no typed inputs. Always sent
|
|
141
|
+
* (empty object when none declared) so the server can overwrite apps.workflows
|
|
142
|
+
* authoritatively. Deleting an alias from the manifest removes it from the DB
|
|
143
|
+
* on next deploy — gated by the destructive-removal guard unless
|
|
144
|
+
* `force_workflow_sync` is true.
|
|
145
|
+
*/
|
|
146
|
+
workflows?: Record<string, {
|
|
147
|
+
workflow_id: string;
|
|
148
|
+
inputs?: Record<string, unknown>;
|
|
149
|
+
}>;
|
|
150
|
+
/**
|
|
151
|
+
* Opt into destructive workflow removal. When false/absent, the server
|
|
152
|
+
* rejects a deploy whose `workflows` map is missing aliases that exist
|
|
153
|
+
* on the App row. Set true to deploy anyway and wipe the missing aliases —
|
|
154
|
+
* the user is asserting they know what they're doing. Wired to CLI flag
|
|
155
|
+
* `--force-workflow-sync`.
|
|
131
156
|
*/
|
|
132
|
-
|
|
157
|
+
force_workflow_sync?: boolean;
|
|
133
158
|
}): Promise<{
|
|
134
159
|
version_id: string;
|
|
135
160
|
version_number: number;
|
package/dist/src/client.js
CHANGED
|
@@ -205,6 +205,12 @@ export class LoticsClient {
|
|
|
205
205
|
// Always send workflows — empty object is meaningful (clears any
|
|
206
206
|
// previously-declared aliases). Server validates each entry.
|
|
207
207
|
formData.append("workflows", JSON.stringify(args.workflows ?? {}));
|
|
208
|
+
// Only post the override flag when the user explicitly opts in. The
|
|
209
|
+
// server defaults to "honor the guard" — opt-in is loud, opt-out
|
|
210
|
+
// requires intent.
|
|
211
|
+
if (args.force_workflow_sync) {
|
|
212
|
+
formData.append("force_workflow_sync", "true");
|
|
213
|
+
}
|
|
208
214
|
const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
|
|
209
215
|
const response = await fetch(url, {
|
|
210
216
|
method: "POST",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen: emit `.lotics/app_workflows.d.ts` from the app's manifest.
|
|
3
|
+
*
|
|
4
|
+
* The starter tsconfig.json includes `.lotics/` in its `include` list, so this
|
|
5
|
+
* file is auto-discovered by `tsc --noEmit` and the bundler. The augmentation
|
|
6
|
+
* adds a typed entry to `AppWorkflows` for every alias declared in
|
|
7
|
+
* `package.json#lotics.workflows`:
|
|
8
|
+
*
|
|
9
|
+
* - Shorthand `"alias": "wfl_..."` → `alias: Record<string, unknown>`
|
|
10
|
+
* (untyped — callsite accepts any payload)
|
|
11
|
+
* - Full `"alias": { workflow_id, inputs }` → `alias: { …declared shape… }`
|
|
12
|
+
* (typed — `useWorkflow<"alias">` returns a callable with shaped inputs)
|
|
13
|
+
*
|
|
14
|
+
* Pure function. Same inputs → same output. Idempotent.
|
|
15
|
+
*/
|
|
16
|
+
import type { AppWorkflowDeclaration } from "./app_commands.js";
|
|
17
|
+
export declare function generateAppWorkflowsDts(workflows: Record<string, AppWorkflowDeclaration> | undefined): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen: emit `.lotics/app_workflows.d.ts` from the app's manifest.
|
|
3
|
+
*
|
|
4
|
+
* The starter tsconfig.json includes `.lotics/` in its `include` list, so this
|
|
5
|
+
* file is auto-discovered by `tsc --noEmit` and the bundler. The augmentation
|
|
6
|
+
* adds a typed entry to `AppWorkflows` for every alias declared in
|
|
7
|
+
* `package.json#lotics.workflows`:
|
|
8
|
+
*
|
|
9
|
+
* - Shorthand `"alias": "wfl_..."` → `alias: Record<string, unknown>`
|
|
10
|
+
* (untyped — callsite accepts any payload)
|
|
11
|
+
* - Full `"alias": { workflow_id, inputs }` → `alias: { …declared shape… }`
|
|
12
|
+
* (typed — `useWorkflow<"alias">` returns a callable with shaped inputs)
|
|
13
|
+
*
|
|
14
|
+
* Pure function. Same inputs → same output. Idempotent.
|
|
15
|
+
*/
|
|
16
|
+
const HEADER = `// Auto-generated by 'lotics app pull/dev/deploy'.
|
|
17
|
+
// DO NOT EDIT — regenerated from package.json#lotics.workflows.
|
|
18
|
+
//
|
|
19
|
+
// This file gives \`useWorkflow("alias")\` a typed input parameter at call
|
|
20
|
+
// sites by augmenting the @lotics/app-sdk \`AppWorkflows\` interface.
|
|
21
|
+
|
|
22
|
+
import "@lotics/app-sdk";
|
|
23
|
+
`;
|
|
24
|
+
export function generateAppWorkflowsDts(workflows) {
|
|
25
|
+
const entries = Object.entries(workflows ?? {});
|
|
26
|
+
if (entries.length === 0) {
|
|
27
|
+
return `${HEADER}
|
|
28
|
+
// No workflows declared in package.json#lotics.workflows.
|
|
29
|
+
// Add an entry to enable typed useWorkflow<"alias"> at call sites.
|
|
30
|
+
declare module "@lotics/app-sdk" {
|
|
31
|
+
interface AppWorkflows {}
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
// Sort alphabetically for deterministic output across regenerations.
|
|
36
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const [alias, declaration] of entries) {
|
|
39
|
+
// declaration is always `{ workflow_id, inputs? }` per manifest schema.
|
|
40
|
+
// Workflows with no typed inputs omit `inputs:` and get an untyped
|
|
41
|
+
// `Record<string, unknown>` callable surface.
|
|
42
|
+
const valueType = declaration.inputs
|
|
43
|
+
? inputsToType(declaration.inputs)
|
|
44
|
+
: "Record<string, unknown>";
|
|
45
|
+
const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
|
|
46
|
+
lines.push(` ${aliasKey}: ${valueType};`);
|
|
47
|
+
}
|
|
48
|
+
return `${HEADER}
|
|
49
|
+
declare module "@lotics/app-sdk" {
|
|
50
|
+
interface AppWorkflows {
|
|
51
|
+
${lines.join("\n")}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Map an `inputs: { key: AppWorkflowInput }` schema to a TS literal type
|
|
58
|
+
* string. Mirrors the backend .d.ts generator's behavior — same vocabulary,
|
|
59
|
+
* same conventions — so workflow body types and app call-site types agree.
|
|
60
|
+
*
|
|
61
|
+
* Note: `inputs` here is `Record<string, unknown>` rather than typed because
|
|
62
|
+
* the CLI doesn't import zod or the apps schema (zero-dep policy in @lotics/sdk).
|
|
63
|
+
* We pattern-match on the `type` field and let unknown shapes fall through
|
|
64
|
+
* to `unknown` rather than throwing — the server's deploy-time schema parse
|
|
65
|
+
* catches structurally-invalid declarations before they reach us.
|
|
66
|
+
*/
|
|
67
|
+
function inputsToType(inputs) {
|
|
68
|
+
const fields = [];
|
|
69
|
+
for (const [key, decl] of Object.entries(inputs)) {
|
|
70
|
+
if (decl === null || typeof decl !== "object")
|
|
71
|
+
continue;
|
|
72
|
+
const d = decl;
|
|
73
|
+
const tsType = inputDeclToTsType(d);
|
|
74
|
+
const optional = d.required === false ? "?" : "";
|
|
75
|
+
const fieldKey = isValidIdentifier(key) ? key : JSON.stringify(key);
|
|
76
|
+
fields.push(` ${fieldKey}${optional}: ${tsType};`);
|
|
77
|
+
}
|
|
78
|
+
if (fields.length === 0)
|
|
79
|
+
return "Record<string, never>";
|
|
80
|
+
return `{\n${fields.join("\n")}\n }`;
|
|
81
|
+
}
|
|
82
|
+
function inputDeclToTsType(decl) {
|
|
83
|
+
const type = decl.type;
|
|
84
|
+
switch (type) {
|
|
85
|
+
case "text":
|
|
86
|
+
case "email":
|
|
87
|
+
case "date":
|
|
88
|
+
case "datetime":
|
|
89
|
+
return "string";
|
|
90
|
+
case "number":
|
|
91
|
+
return "number";
|
|
92
|
+
case "boolean":
|
|
93
|
+
return "boolean";
|
|
94
|
+
case "record_link":
|
|
95
|
+
case "member": {
|
|
96
|
+
const inner = "string";
|
|
97
|
+
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
98
|
+
}
|
|
99
|
+
case "select": {
|
|
100
|
+
const options = Array.isArray(decl.options) ? decl.options : [];
|
|
101
|
+
const literals = options
|
|
102
|
+
.map((o) => {
|
|
103
|
+
if (o !== null && typeof o === "object" && "value" in o && typeof o.value === "string") {
|
|
104
|
+
return JSON.stringify(o.value);
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
})
|
|
108
|
+
.filter((v) => v !== null);
|
|
109
|
+
const inner = literals.length > 0 ? literals.join(" | ") : "string";
|
|
110
|
+
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
111
|
+
}
|
|
112
|
+
case "date_range":
|
|
113
|
+
return "{ start: string; end: string }";
|
|
114
|
+
case "json":
|
|
115
|
+
return "unknown";
|
|
116
|
+
default:
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
121
|
+
function isValidIdentifier(name) {
|
|
122
|
+
return IDENTIFIER_REGEX.test(name);
|
|
123
|
+
}
|
|
@@ -161,6 +161,20 @@ export default defineConfig({
|
|
|
161
161
|
<meta charset="UTF-8" />
|
|
162
162
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
163
163
|
<title>${escapeHtml(args.app_name)}</title>
|
|
164
|
+
<style>
|
|
165
|
+
/*
|
|
166
|
+
* Full-viewport container chain for iframe apps. @lotics/ui's DataGrid
|
|
167
|
+
* (and any layout that uses \`flex: 1\` to fill available space) relies
|
|
168
|
+
* on a measured parent height. The browser default of html/body being
|
|
169
|
+
* content-sized collapses every \`flex: 1\` to 0 and the surface shrinks
|
|
170
|
+
* to fit only its rendered content.
|
|
171
|
+
*
|
|
172
|
+
* Also: #root is made a flex column so the outermost RN \`<View flex:1>\`
|
|
173
|
+
* inside App.tsx claims the full iframe height.
|
|
174
|
+
*/
|
|
175
|
+
html, body, #root { height: 100%; margin: 0; }
|
|
176
|
+
#root { display: flex; flex-direction: column; }
|
|
177
|
+
</style>
|
|
164
178
|
</head>
|
|
165
179
|
<body>
|
|
166
180
|
<div id="root"></div>
|
|
@@ -180,34 +194,55 @@ mount(<App />);
|
|
|
180
194
|
},
|
|
181
195
|
{
|
|
182
196
|
path: "src/App.tsx",
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
197
|
+
// Welcome screen demonstrates the full-container layout pattern that
|
|
198
|
+
// real apps (data grids, dashboards, full-page surfaces) rely on:
|
|
199
|
+
// - Outer <View flex:1> claims the full iframe height (works because
|
|
200
|
+
// index.html sets html/body/#root to 100% + #root is a flex column).
|
|
201
|
+
// - Inner View is content-sized + centered for a card layout.
|
|
202
|
+
//
|
|
203
|
+
// We deliberately do NOT use @lotics/ui/stack for the outer chain.
|
|
204
|
+
// Stack wraps each child in an unstyled <View>, which breaks `flex: 1`
|
|
205
|
+
// propagation to children that need to claim flexible height (e.g., a
|
|
206
|
+
// DataGrid that fills the remaining space below a header + search).
|
|
207
|
+
// Use plain <View> with `flexDirection` + `gap` for any outer layout
|
|
208
|
+
// that hosts a flex-filling child; Stack is fine for content-sized
|
|
209
|
+
// groupings inside such a chain.
|
|
186
210
|
content: `import { View } from "react-native";
|
|
187
|
-
import { Stack } from "@lotics/ui/stack";
|
|
188
211
|
import { Card } from "@lotics/ui/card";
|
|
189
212
|
import { Text } from "@lotics/ui/text";
|
|
190
213
|
import { Button } from "@lotics/ui/button";
|
|
191
214
|
|
|
215
|
+
// Outer <View flex:1> claims the full iframe height — works because
|
|
216
|
+
// index.html sets html/body/#root to 100% and #root is a flex column.
|
|
217
|
+
// For real layouts with a fill-remaining-space child (DataGrid etc.),
|
|
218
|
+
// keep this flex chain plain — @lotics/ui/stack wraps each child in an
|
|
219
|
+
// unstyled <View> that breaks \`flex: 1\` propagation.
|
|
192
220
|
export default function App() {
|
|
193
221
|
return (
|
|
194
|
-
<View
|
|
195
|
-
|
|
222
|
+
<View
|
|
223
|
+
style={{
|
|
224
|
+
flex: 1,
|
|
225
|
+
padding: 24,
|
|
226
|
+
justifyContent: "center",
|
|
227
|
+
alignItems: "center",
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<View style={{ maxWidth: 640, width: "100%", gap: 16 }}>
|
|
196
231
|
<Text size="xl" weight="semibold">${escapeHtml(args.app_name)}</Text>
|
|
197
232
|
<Card>
|
|
198
|
-
<
|
|
233
|
+
<View style={{ padding: 16, gap: 8 }}>
|
|
199
234
|
<Text>This is your new Lotics app.</Text>
|
|
200
235
|
<Text color="muted">
|
|
201
236
|
Edit <Text weight="medium">src/App.tsx</Text> and run{" "}
|
|
202
237
|
<Text weight="medium">lotics app deploy</Text> to publish.
|
|
203
238
|
</Text>
|
|
204
|
-
</
|
|
239
|
+
</View>
|
|
205
240
|
</Card>
|
|
206
241
|
<Text color="muted" size="sm">
|
|
207
242
|
Hooks from @lotics/app-sdk: useTable, useMutate, useAction, useQuery, useWorkflow.
|
|
208
243
|
</Text>
|
|
209
244
|
<Button title="Get started" onPress={() => {}} color="primary" />
|
|
210
|
-
</
|
|
245
|
+
</View>
|
|
211
246
|
</View>
|
|
212
247
|
);
|
|
213
248
|
}
|
|
@@ -230,6 +265,58 @@ declare module "lucide-react-native/dist/esm/icons/*" {
|
|
|
230
265
|
}>;
|
|
231
266
|
export default Icon;
|
|
232
267
|
}
|
|
268
|
+
`,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
path: "src/css_modules.d.ts",
|
|
272
|
+
content: `declare module "*.module.css" {
|
|
273
|
+
const classes: { [key: string]: string };
|
|
274
|
+
export default classes;
|
|
275
|
+
}
|
|
276
|
+
`,
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
path: "src/react_native.d.ts",
|
|
280
|
+
content: `import "react-native";
|
|
281
|
+
|
|
282
|
+
// Augments react-native's types with the web-only fields @lotics/ui consumes:
|
|
283
|
+
// Pressable's \`hovered\` callback state, plus web-only ViewStyle / TextStyle
|
|
284
|
+
// properties (cursor, outline, boxShadow, etc.) used by the grid + primitives.
|
|
285
|
+
// Each iframe app needs its own copy — TypeScript doesn't auto-pick-up
|
|
286
|
+
// \`.d.ts\` files inside dependencies. \`@lotics/ui\`'s grid sources include a
|
|
287
|
+
// triple-slash reference to its own copy of this file so the augmentation
|
|
288
|
+
// kicks in transitively.
|
|
289
|
+
declare module "react-native" {
|
|
290
|
+
interface PressableStateCallbackType {
|
|
291
|
+
hovered: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface ViewStyle {
|
|
295
|
+
backdropFilter?: string;
|
|
296
|
+
backgroundImage?: string;
|
|
297
|
+
boxShadow?: string;
|
|
298
|
+
boxSizing?: string;
|
|
299
|
+
cursor?: string;
|
|
300
|
+
touchAction?: string;
|
|
301
|
+
transitionDuration?: string;
|
|
302
|
+
transitionProperty?: string;
|
|
303
|
+
appearance?: string;
|
|
304
|
+
outline?: string;
|
|
305
|
+
outlineColor?: string;
|
|
306
|
+
outlineStyle?: string;
|
|
307
|
+
outlineWidth?: number;
|
|
308
|
+
outlineOffset?: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface TextStyle {
|
|
312
|
+
outline?: string;
|
|
313
|
+
outlineColor?: string;
|
|
314
|
+
outlineStyle?: string;
|
|
315
|
+
outlineWidth?: number;
|
|
316
|
+
outlineOffset?: number;
|
|
317
|
+
appearance?: string;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
233
320
|
`,
|
|
234
321
|
},
|
|
235
322
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Lotics SDK and CLI for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsgo",
|
|
18
18
|
"typecheck": "tsgo --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
19
20
|
"prepublishOnly": "npm run build"
|
|
20
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"vitest": "^4.0.15"
|
|
24
|
+
},
|
|
21
25
|
"keywords": [
|
|
22
26
|
"lotics",
|
|
23
27
|
"sdk",
|