@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.
@@ -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. 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.
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]`
@@ -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. Write package.json's `lotics` field with app_id + version metadata.
139
- * 4. Run `npm install`.
140
- * 5. (TODO) Generate `.lotics/types.ts` with workspace-typed augmentations.
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
- writeAppMeta(targetPath, {
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>] Build + upload current dir as a new version
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>] Build + upload the current directory");
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
- await appDeploy(client, { message });
530
+ const forceWorkflowSync = restArgs.includes("--force-workflow-sync");
531
+ await appDeploy(client, { message, forceWorkflowSync });
525
532
  return;
526
533
  }
527
534
  if (subcommand === "dev") {
@@ -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 → workflow_id map from the app's package.json `lotics.workflows`.
128
- * Always sent (empty object when none declared) so the server can
129
- * overwrite apps.workflows authoritatively. Deleting an alias from the
130
- * manifest removes it from the DB on next deploy.
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
- workflows?: Record<string, string>;
157
+ force_workflow_sync?: boolean;
133
158
  }): Promise<{
134
159
  version_id: string;
135
160
  version_number: number;
@@ -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
- // Static welcome screen using @lotics/ui primitives no useTable call
184
- // so the freshly-deployed v1 renders cleanly without needing a real
185
- // table id. The user's first edit is to replace this with their app.
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 style={{ padding: 24, maxWidth: 640, marginHorizontal: "auto" }}>
195
- <Stack gap={16}>
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
- <Stack gap={8} style={{ padding: 16 }}>
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
- </Stack>
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
- </Stack>
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.22.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",