@lotics/cli 0.24.1 → 0.27.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/README.md CHANGED
@@ -46,9 +46,23 @@ lotics auth api-key # interactive prompt
46
46
  lotics auth api-key ltk_... # non-interactive
47
47
  ```
48
48
 
49
- API key is saved to `~/.lotics/config.json`. Run `lotics auth logout` to remove saved credentials.
49
+ API key is saved to the config file. Run `lotics auth logout` to remove saved credentials.
50
50
 
51
- Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > `~/.lotics/config.json`.
51
+ Auth priority: `--api-key` flag > `LOTICS_API_KEY` env > config file.
52
+
53
+ ### Config file location
54
+
55
+ The CLI resolves `.lotics/config.json` by walking up from the current working directory — the first ancestor that has one wins, otherwise the global `~/.lotics/config.json`. A per-directory config lets a project or worktree pin its own account and workspace; commands run from a subdirectory still resolve to it.
56
+
57
+ Create one by passing `--local` to `lotics auth`:
58
+
59
+ ```bash
60
+ cd my-worktree
61
+ lotics auth api-key ltk_... --local # writes ./.lotics/config.json
62
+ lotics workspace select wks_... # auto-resolves to the local config
63
+ ```
64
+
65
+ `.lotics/` should be gitignored. Note: an exported `LOTICS_API_KEY` overrides the config file's key.
52
66
 
53
67
  ## Workspaces
54
68
 
@@ -11,6 +11,18 @@ export type AppWorkflowDeclaration = {
11
11
  workflow_id: string;
12
12
  inputs?: Record<string, unknown>;
13
13
  };
14
+ /**
15
+ * Manifest declaration for one named query:
16
+ * `"alias": { ast, params?: { key: { type, … } } }`
17
+ *
18
+ * `ast` is a fixed query AST template; `params` declares the typed value
19
+ * holes (`{{params.<name>}}`) the app fills via `useQuery(alias, params)`.
20
+ * Custom-code apps never send a raw AST — this declaration is the contract.
21
+ */
22
+ export type AppQueryDeclaration = {
23
+ ast: unknown;
24
+ params?: Record<string, unknown>;
25
+ };
14
26
  /**
15
27
  * Stamp the post-extraction manifest with server-authoritative meta. Called
16
28
  * by `appPull` after the source archive lands on disk.
@@ -29,6 +41,7 @@ export declare function stampPulledManifest(projectDir: string, args: {
29
41
  current_version_id: string;
30
42
  version_number: number;
31
43
  workflows: Record<string, AppWorkflowDeclaration>;
44
+ queries: Record<string, AppQueryDeclaration>;
32
45
  }): void;
33
46
  /**
34
47
  * `lotics app create <name> [path]`
@@ -86,8 +99,8 @@ export declare function appDeploy(client: LoticsClient, args: {
86
99
  * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
87
100
  *
88
101
  * Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
89
- * that bridges the iframe's hooks (useQuery, useMutate, useAction,
90
- * useWorkflow) to api.lotics.ai using the CLI's stored API key. Wrapper
102
+ * that bridges the iframe's hooks (useQuery, useWorkflow) to api.lotics.ai
103
+ * using the CLI's stored API key. Wrapper
91
104
  * iframe matches the production sandbox attributes exactly — null origin,
92
105
  * allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
93
106
  *
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
18
18
  import { buildStarterTemplate } from "./starter_template.js";
19
19
  import { startDevServer, openBrowser } from "./dev/server.js";
20
20
  import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
21
+ import { generateAppQueriesDts } from "./generate_app_queries_dts.js";
21
22
  /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
22
23
  function runTar(args, cwd) {
23
24
  return new Promise((resolve, reject) => {
@@ -64,6 +65,7 @@ function readAppMeta(projectDir) {
64
65
  current_version_id: pkg.lotics.current_version_id ?? null,
65
66
  version_number: pkg.lotics.version_number ?? null,
66
67
  workflows: pkg.lotics.workflows ?? {},
68
+ queries: pkg.lotics.queries ?? {},
67
69
  };
68
70
  }
69
71
  function writeAppMeta(projectDir, meta) {
@@ -73,16 +75,17 @@ function writeAppMeta(projectDir, meta) {
73
75
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
74
76
  }
75
77
  /**
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.
78
+ * Write `.lotics/app_workflows.d.ts` + `.lotics/app_queries.d.ts` from the
79
+ * manifest's `workflows` / `queries` maps. Called from `app create / pull /
80
+ * dev / deploy` so the augmented `AppWorkflows` / `AppQueries` types stay in
81
+ * sync with the manifest. The starter tsconfig.json includes `.lotics/` so
82
+ * tsc + Vite pick the files up automatically.
80
83
  */
81
- function writeAppWorkflowsDts(projectDir, workflows) {
84
+ function writeAppDts(projectDir, manifest) {
82
85
  const dotLotics = path.join(projectDir, ".lotics");
83
86
  fs.mkdirSync(dotLotics, { recursive: true });
84
- const dts = generateAppWorkflowsDts(workflows);
85
- fs.writeFileSync(path.join(dotLotics, "app_workflows.d.ts"), dts);
87
+ fs.writeFileSync(path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows));
88
+ fs.writeFileSync(path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries));
86
89
  }
87
90
  /**
88
91
  * Stamp the post-extraction manifest with server-authoritative meta. Called
@@ -103,8 +106,9 @@ export function stampPulledManifest(projectDir, args) {
103
106
  current_version_id: args.current_version_id,
104
107
  version_number: args.version_number,
105
108
  workflows: args.workflows,
109
+ queries: args.queries,
106
110
  });
107
- writeAppWorkflowsDts(projectDir, args.workflows);
111
+ writeAppDts(projectDir, { workflows: args.workflows, queries: args.queries });
108
112
  }
109
113
  async function downloadToFile(url, destPath) {
110
114
  const response = await fetch(url);
@@ -155,7 +159,7 @@ export async function appCreate(client, args) {
155
159
  // Codegen pass for AppWorkflows typing. Empty manifest on first create —
156
160
  // emits a base augmentation file so future deploys produce idempotent diffs
157
161
  // rather than introducing a new tracked file later.
158
- writeAppWorkflowsDts(targetPath, {});
162
+ writeAppDts(targetPath, {});
159
163
  console.error("Installing npm dependencies...");
160
164
  await runNpm(["install"], targetPath);
161
165
  console.error("Building initial version...");
@@ -217,6 +221,7 @@ export async function appPull(client, args) {
217
221
  current_version_id: app.current_version_id,
218
222
  version_number: version.version,
219
223
  workflows: app.workflows ?? {},
224
+ queries: app.queries ?? {},
220
225
  });
221
226
  console.error(`Installing npm dependencies...`);
222
227
  await runNpm(["install"], targetPath);
@@ -239,7 +244,7 @@ export async function appDeploy(client, args) {
239
244
  // Regenerate AppWorkflows typing before the build picks up source. Keeps
240
245
  // .lotics/app_workflows.d.ts in sync with the manifest's workflows map
241
246
  // every time the developer ships.
242
- writeAppWorkflowsDts(projectDir, meta.workflows);
247
+ writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries });
243
248
  // Build locally so the server doesn't need a build sandbox in v1.
244
249
  console.error("Building...");
245
250
  await runNpm(["run", "build"], projectDir);
@@ -275,6 +280,9 @@ export async function appDeploy(client, args) {
275
280
  // Sync apps.workflows from the manifest. Server validates each
276
281
  // workflow_id exists in the workspace before committing.
277
282
  workflows: meta.workflows ?? {},
283
+ // Sync apps.queries from the manifest. Server validates each query
284
+ // template (parseQueryNode, table access, param coverage).
285
+ queries: meta.queries ?? {},
278
286
  // Opt into destructive workflow removal. Default false — the server
279
287
  // rejects deploys whose manifest is missing aliases the App row has.
280
288
  force_workflow_sync: args.forceWorkflowSync,
@@ -308,8 +316,8 @@ export async function appDeploy(client, args) {
308
316
  * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
309
317
  *
310
318
  * Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
311
- * that bridges the iframe's hooks (useQuery, useMutate, useAction,
312
- * useWorkflow) to api.lotics.ai using the CLI's stored API key. Wrapper
319
+ * that bridges the iframe's hooks (useQuery, useWorkflow) to api.lotics.ai
320
+ * using the CLI's stored API key. Wrapper
313
321
  * iframe matches the production sandbox attributes exactly — null origin,
314
322
  * allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
315
323
  *
@@ -323,7 +331,7 @@ export async function appDev(client, args) {
323
331
  // Regenerate AppWorkflows typing before Vite spins up so the dev typecheck
324
332
  // sees the current shape. Doesn't watch for manifest changes mid-session —
325
333
  // re-running `lotics app dev` after editing the manifest is the loop.
326
- writeAppWorkflowsDts(projectDir, meta.workflows);
334
+ writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries });
327
335
  // Sanity: confirm the app exists in the workspace the CLI is auth'd into.
328
336
  // Surfaces a clear error if the project's app_id has been deleted or the
329
337
  // CLI is pointed at the wrong workspace.
@@ -56,6 +56,7 @@ describe("stampPulledManifest", () => {
56
56
  agent_authored: { workflow_id: "wfl_authored_by_agent" },
57
57
  renamed: { workflow_id: "wfl_renamed_on_server" },
58
58
  },
59
+ queries: {},
59
60
  });
60
61
  const stamped = readStampedManifest();
61
62
  expect(stamped.app_id).toBe("app_live");
@@ -80,6 +81,7 @@ describe("stampPulledManifest", () => {
80
81
  current_version_id: "apv_x",
81
82
  version_number: 2,
82
83
  workflows: {},
84
+ queries: {},
83
85
  });
84
86
  const stamped = readStampedManifest();
85
87
  expect(stamped.workflows).toEqual({});
@@ -94,6 +96,7 @@ describe("stampPulledManifest", () => {
94
96
  workflows: {
95
97
  only_live: { workflow_id: "wfl_only_live" },
96
98
  },
99
+ queries: {},
97
100
  });
98
101
  const dtsPath = path.join(workDir, ".lotics", "app_workflows.d.ts");
99
102
  expect(fs.existsSync(dtsPath)).toBe(true);
package/dist/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { LoticsClient, API_BASE_URL } from "./client.js";
6
- import { resolveAuth, loadConfig, saveConfig, deleteConfig, checkForUpdate } from "./config.js";
6
+ import { resolveAuth, loadConfig, saveConfig, deleteConfig, getConfigPath, checkForUpdate } from "./config.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { appCreate, appPull, appDeploy, appDev } from "./app_commands.js";
9
9
  function printHelp() {
@@ -58,8 +58,15 @@ FLAGS
58
58
  -o <path> Output dir for downloads
59
59
  --as <name> Override upload filename
60
60
  --api-key <key> API key (overrides saved config and LOTICS_API_KEY)
61
+ --local (lotics auth) Save credentials to ./.lotics/config.json
61
62
  --version Show version
62
63
 
64
+ CONFIG
65
+ Credentials resolve from a .lotics/config.json found by walking up from the
66
+ current directory, else the global ~/.lotics/config.json. Run "lotics auth
67
+ api-key <key> --local" inside a directory (e.g. a worktree) to pin it to its
68
+ own account and workspace.
69
+
63
70
  OUTPUT
64
71
  Default output is a compact text summary optimized for AI agents —
65
72
  use it directly, no parsing needed. --json returns raw structured
@@ -92,13 +99,20 @@ function printAuthHelp() {
92
99
  lotics auth whoami Show the current account's name, email, and organization
93
100
  lotics auth logout Remove saved credentials
94
101
 
95
- Signup flags:
96
- --name <name> Display name (defaults to email prefix)
97
- --timezone <timezone> Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
102
+ Auth flags:
103
+ --local Save credentials to ./.lotics/config.json (this directory)
104
+ instead of the global ~/.lotics — applies to signup + api-key
105
+ --name <name> (signup) Display name (defaults to email prefix)
106
+ --timezone <timezone> (signup) Workspace timezone (defaults to UTC, e.g. Asia/Ho_Chi_Minh)
98
107
 
99
108
  Signup sends a magic link email so you can access the Lotics web app.
100
109
  Use lotics auth web to request a new magic link at any time.
101
- Auth priority: --api-key flag > LOTICS_API_KEY env > saved config.`);
110
+
111
+ Auth priority: --api-key flag > LOTICS_API_KEY env > config file.
112
+ Config file: .lotics/config.json found by walking up from the current directory,
113
+ else ~/.lotics/config.json. A per-directory config pins a project or worktree to
114
+ its own account and workspace; --local creates one. Note: an exported
115
+ LOTICS_API_KEY env var overrides the config file's key.`);
102
116
  }
103
117
  function parseArgs(argv) {
104
118
  const flags = {
@@ -109,6 +123,7 @@ function parseArgs(argv) {
109
123
  apiKey: undefined,
110
124
  name: undefined,
111
125
  timezone: undefined,
126
+ local: false,
112
127
  version: false,
113
128
  help: false,
114
129
  };
@@ -142,6 +157,9 @@ function parseArgs(argv) {
142
157
  case "--timezone":
143
158
  flags.timezone = argv[++i];
144
159
  break;
160
+ case "--local":
161
+ flags.local = true;
162
+ break;
145
163
  case "--version":
146
164
  case "-v":
147
165
  flags.version = true;
@@ -223,19 +241,21 @@ async function handleSignup(positionalEmail, flags) {
223
241
  }
224
242
  process.exit(1);
225
243
  }
226
- const existing = loadConfig() ?? {};
244
+ const scope = flags.local ? "local" : "auto";
245
+ const existing = loadConfig(scope) ?? {};
227
246
  saveConfig({
228
247
  ...existing,
229
248
  api_key: data.api_key,
230
249
  email: data.email,
231
250
  workspace_id: data.workspace_id,
232
- });
251
+ }, scope);
233
252
  console.error(`Account created. You can now use the CLI.`);
234
- console.error(` Email: ${data.email}`);
253
+ console.error(` Email: ${data.email}`);
254
+ console.error(` Config: ${getConfigPath(scope)}`);
235
255
  console.error(`\nCheck your email for a magic link to access the Lotics web app.`);
236
256
  console.error(`Run "lotics auth web" to request a new link at any time.`);
237
257
  }
238
- async function handleSetup(providedKey) {
258
+ async function handleSetup(providedKey, local) {
239
259
  const apiKey = providedKey ?? await prompt("Enter your API key: ");
240
260
  if (!apiKey) {
241
261
  console.error("No API key provided.");
@@ -252,7 +272,8 @@ async function handleSetup(providedKey) {
252
272
  console.error(`Authentication failed: ${message}`);
253
273
  process.exit(1);
254
274
  }
255
- const existing = loadConfig() ?? {};
275
+ const scope = local ? "local" : "auto";
276
+ const existing = loadConfig(scope) ?? {};
256
277
  const newConfig = { ...existing, api_key: apiKey, email };
257
278
  // Auto-resolve workspace
258
279
  try {
@@ -269,8 +290,9 @@ async function handleSetup(providedKey) {
269
290
  const msg = error instanceof Error ? error.message : String(error);
270
291
  console.error(`Warning: could not resolve workspace: ${msg}`);
271
292
  }
272
- saveConfig(newConfig);
293
+ saveConfig(newConfig, scope);
273
294
  console.error("Authenticated.");
295
+ console.error(` Config: ${getConfigPath(scope)}`);
274
296
  }
275
297
  function requireClient(flags) {
276
298
  const auth = resolveAuth(flags);
@@ -356,7 +378,7 @@ async function main() {
356
378
  return;
357
379
  }
358
380
  if (subcommand === "api-key") {
359
- await handleSetup(toolArgs ?? flags.apiKey);
381
+ await handleSetup(toolArgs ?? flags.apiKey, flags.local);
360
382
  return;
361
383
  }
362
384
  if (subcommand === "whoami") {
@@ -377,6 +399,7 @@ async function main() {
377
399
  console.log(`Email: ${info.email}`);
378
400
  console.log(`Org: ${info.organization_name} (${info.organization_id})`);
379
401
  }
402
+ console.error(`Config: ${getConfigPath()}`);
380
403
  return;
381
404
  }
382
405
  if (subcommand === "logout") {
@@ -495,6 +518,7 @@ async function main() {
495
518
  }
496
519
  }
497
520
  }
521
+ console.error(`Config: ${getConfigPath()}`);
498
522
  return;
499
523
  }
500
524
  // Ensure workspace is resolved for all remaining commands
@@ -86,6 +86,14 @@ export declare class LoticsClient {
86
86
  workflow_id: string;
87
87
  inputs?: Record<string, unknown>;
88
88
  }> | null;
89
+ /**
90
+ * Live alias → query declaration map from `apps.queries`. Source of truth
91
+ * for `lotics app pull`. Null/undefined on apps with no declared queries.
92
+ */
93
+ queries?: Record<string, {
94
+ ast: unknown;
95
+ params?: Record<string, unknown>;
96
+ }> | null;
89
97
  }>;
90
98
  createApp(body: {
91
99
  name: string;
@@ -107,22 +115,15 @@ export declare class LoticsClient {
107
115
  }>;
108
116
  getAppVersionSourceUrl(app_id: string, version_id: string): Promise<string>;
109
117
  /**
110
- * Run a query AST scoped to an app's IAM principal.
111
- * Mirrors POST /v1/apps/{app_id}/query.
118
+ * Run a named query declared in the app's manifest, scoped to the app's IAM
119
+ * principal. Mirrors POST /v1/apps/{app_id}/query.
112
120
  */
113
- appQuery(app_id: string, ast: unknown): Promise<{
121
+ appQuery(app_id: string, body: {
122
+ alias: string;
123
+ params?: Record<string, unknown>;
124
+ }): Promise<{
114
125
  rows: unknown[];
115
126
  }>;
116
- /**
117
- * Update records in a workspace table scoped to an app's IAM principal.
118
- * Mirrors PATCH /v1/apps/{app_id}/tables/{table_id}/records.
119
- */
120
- appMutate(app_id: string, table_id: string, records: unknown): Promise<unknown>;
121
- /**
122
- * Execute an app-declared action.
123
- * Mirrors POST /v1/apps/{app_id}/actions/{action_id}.
124
- */
125
- appAction(app_id: string, action_id: string, inputs: unknown): Promise<unknown>;
126
127
  /**
127
128
  * Execute a workflow by alias declared in package.json#lotics.workflows.
128
129
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
@@ -147,6 +148,16 @@ export declare class LoticsClient {
147
148
  workflow_id: string;
148
149
  inputs?: Record<string, unknown>;
149
150
  }>;
151
+ /**
152
+ * Alias → query declaration map from `package.json#lotics.queries`. Each
153
+ * value is `{ ast, params? }` — a fixed query AST template and an optional
154
+ * typed param schema. Always sent (empty object when none declared) so the
155
+ * server overwrites apps.queries authoritatively.
156
+ */
157
+ queries?: Record<string, {
158
+ ast: unknown;
159
+ params?: Record<string, unknown>;
160
+ }>;
150
161
  /**
151
162
  * Opt into destructive workflow removal. When false/absent, the server
152
163
  * rejects a deploy whose `workflows` map is missing aliases that exist
@@ -157,30 +157,16 @@ export class LoticsClient {
157
157
  return result.url;
158
158
  }
159
159
  // ── App iframe RPC endpoints ──────────────────────────────────────────────
160
- // These mirror the four ops handled by frontend/features/app_ui/app_iframe_host.tsx.
160
+ // These mirror the two ops handled by frontend/features/app_ui/app_iframe_host.tsx.
161
161
  // The deployed iframe sends postMessage to the parent frontend, which calls
162
162
  // these same endpoints via the user's session cookie. `lotics app dev`
163
163
  // forwards the iframe's postMessage to these methods using the CLI's API key.
164
164
  /**
165
- * Run a query AST scoped to an app's IAM principal.
166
- * Mirrors POST /v1/apps/{app_id}/query.
165
+ * Run a named query declared in the app's manifest, scoped to the app's IAM
166
+ * principal. Mirrors POST /v1/apps/{app_id}/query.
167
167
  */
168
- async appQuery(app_id, ast) {
169
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/query`, { ast });
170
- }
171
- /**
172
- * Update records in a workspace table scoped to an app's IAM principal.
173
- * Mirrors PATCH /v1/apps/{app_id}/tables/{table_id}/records.
174
- */
175
- async appMutate(app_id, table_id, records) {
176
- return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/tables/${encodeURIComponent(table_id)}/records`, { records });
177
- }
178
- /**
179
- * Execute an app-declared action.
180
- * Mirrors POST /v1/apps/{app_id}/actions/{action_id}.
181
- */
182
- async appAction(app_id, action_id, inputs) {
183
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/actions/${encodeURIComponent(action_id)}`, { inputs });
168
+ async appQuery(app_id, body) {
169
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/query`, body);
184
170
  }
185
171
  /**
186
172
  * Execute a workflow by alias declared in package.json#lotics.workflows.
@@ -205,6 +191,9 @@ export class LoticsClient {
205
191
  // Always send workflows — empty object is meaningful (clears any
206
192
  // previously-declared aliases). Server validates each entry.
207
193
  formData.append("workflows", JSON.stringify(args.workflows ?? {}));
194
+ // Always send queries — empty object clears any previously-declared
195
+ // named queries. Server validates each template.
196
+ formData.append("queries", JSON.stringify(args.queries ?? {}));
208
197
  // Only post the override flag when the user explicitly opts in. The
209
198
  // server defaults to "honor the guard" — opt-in is loud, opt-out
210
199
  // requires intent.
@@ -5,10 +5,22 @@ export interface LoticsConfig {
5
5
  last_update_check?: number;
6
6
  latest_version?: string;
7
7
  }
8
- export declare function loadConfig(): LoticsConfig | null;
9
- export declare function saveConfig(config: LoticsConfig): void;
10
- export declare function deleteConfig(): void;
11
- export declare function getConfigPath(): string;
8
+ /**
9
+ * Where a config operation reads from or writes to.
10
+ *
11
+ * - `"auto"` — walk up from the current working directory; the first ancestor
12
+ * containing `.lotics/config.json` wins, else the global `~/.lotics`. This
13
+ * lets a project or worktree pin its own account and workspace, and keeps
14
+ * resolution stable when commands run from a subdirectory.
15
+ * - `"local"` — `.lotics/config.json` directly under the current working
16
+ * directory. Used to bootstrap a per-directory config (`lotics auth --local`)
17
+ * before any local file exists for `"auto"` to discover.
18
+ */
19
+ export type ConfigScope = "auto" | "local";
20
+ export declare function loadConfig(scope?: ConfigScope): LoticsConfig | null;
21
+ export declare function saveConfig(config: LoticsConfig, scope?: ConfigScope): void;
22
+ export declare function deleteConfig(scope?: ConfigScope): void;
23
+ export declare function getConfigPath(scope?: ConfigScope): string;
12
24
  /**
13
25
  * Check for a newer CLI version. Synchronous — prints a warning to stderr
14
26
  * if the cached latest version is newer than current. Kicks off a background
@@ -1,31 +1,59 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- const CONFIG_DIR = path.join(os.homedir(), ".lotics");
5
- const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
6
- export function loadConfig() {
4
+ /**
5
+ * Resolve the config file for a scope.
6
+ *
7
+ * Resolution keys on the `config.json` *file*, never the `.lotics` directory:
8
+ * scaffolded custom-code apps already use `.lotics/` for generated types, so
9
+ * directory presence cannot signal a local config.
10
+ */
11
+ function configFileForScope(scope) {
12
+ if (scope === "local") {
13
+ return path.join(process.cwd(), ".lotics", "config.json");
14
+ }
15
+ let dir = process.cwd();
16
+ for (;;) {
17
+ const candidate = path.join(dir, ".lotics", "config.json");
18
+ if (fs.existsSync(candidate)) {
19
+ return candidate;
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) {
23
+ break;
24
+ }
25
+ dir = parent;
26
+ }
27
+ return path.join(os.homedir(), ".lotics", "config.json");
28
+ }
29
+ export function loadConfig(scope = "auto") {
7
30
  try {
8
- const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
31
+ const raw = fs.readFileSync(configFileForScope(scope), "utf-8");
9
32
  return JSON.parse(raw);
10
33
  }
11
34
  catch {
12
35
  return null;
13
36
  }
14
37
  }
15
- export function saveConfig(config) {
16
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf-8");
38
+ export function saveConfig(config, scope = "auto") {
39
+ const file = configFileForScope(scope);
40
+ // The config file holds an API key — keep it owner-only. `mode` on
41
+ // writeFileSync applies only when the file is created, so chmod after to
42
+ // also tighten a config written before this protection existed.
43
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
45
+ fs.chmodSync(file, 0o600);
18
46
  }
19
- export function deleteConfig() {
47
+ export function deleteConfig(scope = "auto") {
20
48
  try {
21
- fs.unlinkSync(CONFIG_FILE);
49
+ fs.unlinkSync(configFileForScope(scope));
22
50
  }
23
51
  catch {
24
52
  // Already deleted or never existed
25
53
  }
26
54
  }
27
- export function getConfigPath() {
28
- return CONFIG_FILE;
55
+ export function getConfigPath(scope = "auto") {
56
+ return configFileForScope(scope);
29
57
  }
30
58
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
31
59
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "./config.js";
6
+ /**
7
+ * Config resolution walks up from the current working directory: the first
8
+ * ancestor with a `.lotics/config.json` wins, otherwise the global
9
+ * `~/.lotics/config.json`. This lets a project or worktree pin its own
10
+ * account/workspace and keeps resolution stable when commands run from a
11
+ * subdirectory. Scope `"local"` targets the current directory exactly — used
12
+ * to bootstrap a per-directory config before any local file exists.
13
+ *
14
+ * These tests pin both `os.homedir()` and `process.cwd()` to temp dirs so the
15
+ * real home directory is never touched.
16
+ */
17
+ describe("config file resolution", () => {
18
+ let homeDir;
19
+ let projectDir;
20
+ let originalCwd;
21
+ beforeEach(() => {
22
+ originalCwd = process.cwd();
23
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-home-"));
24
+ projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "lotics-project-"));
25
+ vi.spyOn(os, "homedir").mockReturnValue(homeDir);
26
+ process.chdir(projectDir);
27
+ });
28
+ afterEach(() => {
29
+ process.chdir(originalCwd);
30
+ vi.restoreAllMocks();
31
+ fs.rmSync(homeDir, { recursive: true, force: true });
32
+ fs.rmSync(projectDir, { recursive: true, force: true });
33
+ });
34
+ const globalFile = () => path.join(homeDir, ".lotics", "config.json");
35
+ const localFile = (dir = projectDir) => path.join(dir, ".lotics", "config.json");
36
+ function write(file, data) {
37
+ fs.mkdirSync(path.dirname(file), { recursive: true });
38
+ fs.writeFileSync(file, JSON.stringify(data));
39
+ }
40
+ function read(file) {
41
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
42
+ }
43
+ function chdirInto(...segments) {
44
+ const dir = path.join(projectDir, ...segments);
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ process.chdir(dir);
47
+ return dir;
48
+ }
49
+ it("returns null when no config exists anywhere", () => {
50
+ expect(loadConfig()).toBeNull();
51
+ });
52
+ it("reads the global config when no local config exists", () => {
53
+ write(globalFile(), { api_key: "ltk_global" });
54
+ expect(loadConfig()?.api_key).toBe("ltk_global");
55
+ expect(getConfigPath()).toBe(globalFile());
56
+ });
57
+ it("prefers a .lotics/config.json in the current directory over the global one", () => {
58
+ write(globalFile(), { api_key: "ltk_global" });
59
+ write(localFile(), { api_key: "ltk_local" });
60
+ expect(loadConfig()?.api_key).toBe("ltk_local");
61
+ expect(getConfigPath()).toBe(localFile());
62
+ });
63
+ it("walks up to an ancestor's config when run from a subdirectory", () => {
64
+ write(localFile(), { api_key: "ltk_project_root" });
65
+ chdirInto("backend", "features");
66
+ expect(loadConfig()?.api_key).toBe("ltk_project_root");
67
+ expect(getConfigPath()).toBe(localFile());
68
+ });
69
+ it("auto saveConfig writes the resolved ancestor file, not the subdirectory", () => {
70
+ write(localFile(), { api_key: "ltk_old" });
71
+ const nested = chdirInto("sub");
72
+ saveConfig({ api_key: "ltk_new" });
73
+ expect(read(localFile()).api_key).toBe("ltk_new");
74
+ expect(fs.existsSync(localFile(nested))).toBe(false);
75
+ });
76
+ it("auto saveConfig writes the global config when no local config exists", () => {
77
+ saveConfig({ api_key: "ltk_new" });
78
+ expect(fs.existsSync(localFile())).toBe(false);
79
+ expect(read(globalFile()).api_key).toBe("ltk_new");
80
+ });
81
+ it("scope 'local' creates ./.lotics/config.json even when none existed", () => {
82
+ saveConfig({ api_key: "ltk_pinned" }, "local");
83
+ expect(read(localFile()).api_key).toBe("ltk_pinned");
84
+ expect(getConfigPath("local")).toBe(localFile());
85
+ });
86
+ it("saveConfig writes the credentials file owner-only (0600)", () => {
87
+ saveConfig({ api_key: "ltk_secret" }, "local");
88
+ expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
89
+ });
90
+ it("saveConfig tightens an over-permissive pre-existing config file", () => {
91
+ write(localFile(), { api_key: "ltk_old" });
92
+ fs.chmodSync(localFile(), 0o644);
93
+ saveConfig({ api_key: "ltk_new" }, "local");
94
+ expect(fs.statSync(localFile()).mode & 0o777).toBe(0o600);
95
+ });
96
+ it("loadConfig('local') never inherits the global config", () => {
97
+ write(globalFile(), { api_key: "ltk_global" });
98
+ expect(loadConfig("local")).toBeNull();
99
+ });
100
+ it("loadConfig('local') ignores an ancestor's config — it reads the exact cwd only", () => {
101
+ write(localFile(), { api_key: "ltk_root" });
102
+ chdirInto("sub");
103
+ expect(loadConfig("local")).toBeNull();
104
+ });
105
+ it("deleteConfig removes the active local config, leaving the global one untouched", () => {
106
+ write(globalFile(), { api_key: "ltk_global" });
107
+ write(localFile(), { api_key: "ltk_local" });
108
+ deleteConfig();
109
+ expect(fs.existsSync(localFile())).toBe(false);
110
+ expect(fs.existsSync(globalFile())).toBe(true);
111
+ });
112
+ });
@@ -6,7 +6,7 @@
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
8
  import { LoticsClient } from "../client.js";
9
- export type RpcOp = "query" | "mutate" | "action" | "workflow";
9
+ export type RpcOp = "query" | "workflow";
10
10
  export interface RpcRequest {
11
11
  app_id: string;
12
12
  op: RpcOp;
@@ -5,7 +5,7 @@
5
5
  * Errors are thrown — the HTTP server caller serializes them to a 500 with
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
- const SUPPORTED_OPS = new Set(["query", "mutate", "action", "workflow"]);
8
+ const SUPPORTED_OPS = new Set(["query", "workflow"]);
9
9
  export async function dispatchRpc(client, body) {
10
10
  if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
11
11
  throw new Error("RPC envelope must include app_id and op");
@@ -16,25 +16,10 @@ export async function dispatchRpc(client, body) {
16
16
  switch (body.op) {
17
17
  case "query": {
18
18
  const p = body.payload;
19
- const ast = p?.ast;
20
- if (ast === undefined) {
21
- throw new Error("query payload must include `ast`");
22
- }
23
- return client.appQuery(body.app_id, ast);
24
- }
25
- case "mutate": {
26
- const p = body.payload;
27
- if (!p || typeof p.table_id !== "string") {
28
- throw new Error("mutate payload must include `table_id`");
29
- }
30
- return client.appMutate(body.app_id, p.table_id, p.records);
31
- }
32
- case "action": {
33
- const p = body.payload;
34
- if (!p || typeof p.action_id !== "string") {
35
- throw new Error("action payload must include `action_id`");
19
+ if (!p || typeof p.alias !== "string") {
20
+ throw new Error("query payload must include `alias`");
36
21
  }
37
- return client.appAction(body.app_id, p.action_id, p.inputs);
22
+ return client.appQuery(body.app_id, { alias: p.alias, params: p.params });
38
23
  }
39
24
  case "workflow": {
40
25
  const p = body.payload;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Codegen: emit `.lotics/app_queries.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 `AppQueries` for every alias declared in
7
+ * `package.json#lotics.queries`, mapping each to its declared param shape:
8
+ *
9
+ * - `params` declared → `alias: { …declared shape… }`
10
+ * - `params` omitted → `alias: Record<string, never>` (no params)
11
+ *
12
+ * Result: `useQuery("alias", params)` is typed against the manifest, and an
13
+ * undeclared alias is a compile-time error.
14
+ *
15
+ * The typed-input → TS-type mappers are shared with the workflow codegen —
16
+ * both surfaces use the same `AppWorkflowInput` vocabulary.
17
+ *
18
+ * Pure function. Same inputs → same output. Idempotent.
19
+ */
20
+ import type { AppQueryDeclaration } from "./app_commands.js";
21
+ export declare function generateAppQueriesDts(queries: Record<string, AppQueryDeclaration> | undefined): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Codegen: emit `.lotics/app_queries.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 `AppQueries` for every alias declared in
7
+ * `package.json#lotics.queries`, mapping each to its declared param shape:
8
+ *
9
+ * - `params` declared → `alias: { …declared shape… }`
10
+ * - `params` omitted → `alias: Record<string, never>` (no params)
11
+ *
12
+ * Result: `useQuery("alias", params)` is typed against the manifest, and an
13
+ * undeclared alias is a compile-time error.
14
+ *
15
+ * The typed-input → TS-type mappers are shared with the workflow codegen —
16
+ * both surfaces use the same `AppWorkflowInput` vocabulary.
17
+ *
18
+ * Pure function. Same inputs → same output. Idempotent.
19
+ */
20
+ import { inputsToType, isValidIdentifier } from "./generate_app_workflows_dts.js";
21
+ const HEADER = `// Auto-generated by 'lotics app pull/dev/deploy'.
22
+ // DO NOT EDIT — regenerated from package.json#lotics.queries.
23
+ //
24
+ // This file gives \`useQuery("alias", params)\` typed params at call sites by
25
+ // augmenting the @lotics/app-sdk \`AppQueries\` interface.
26
+
27
+ import "@lotics/app-sdk";
28
+ `;
29
+ export function generateAppQueriesDts(queries) {
30
+ const entries = Object.entries(queries ?? {});
31
+ if (entries.length === 0) {
32
+ return `${HEADER}
33
+ // No queries declared in package.json#lotics.queries.
34
+ // Add an entry to enable typed useQuery("alias", params) at call sites.
35
+ declare module "@lotics/app-sdk" {
36
+ interface AppQueries {}
37
+ }
38
+ `;
39
+ }
40
+ // Sort alphabetically for deterministic output across regenerations.
41
+ entries.sort(([a], [b]) => a.localeCompare(b));
42
+ const lines = [];
43
+ for (const [alias, declaration] of entries) {
44
+ // `inputsToType` returns `Record<string, never>` for an empty/absent
45
+ // param schema — exactly the no-params placeholder `useQuery` expects.
46
+ const valueType = inputsToType(declaration.params ?? {});
47
+ const aliasKey = isValidIdentifier(alias) ? alias : JSON.stringify(alias);
48
+ lines.push(` ${aliasKey}: ${valueType};`);
49
+ }
50
+ return `${HEADER}
51
+ declare module "@lotics/app-sdk" {
52
+ interface AppQueries {
53
+ ${lines.join("\n")}
54
+ }
55
+ }
56
+ `;
57
+ }
@@ -15,3 +15,16 @@
15
15
  */
16
16
  import type { AppWorkflowDeclaration } from "./app_commands.js";
17
17
  export declare function generateAppWorkflowsDts(workflows: Record<string, AppWorkflowDeclaration> | undefined): string;
18
+ /**
19
+ * Map an `inputs: { key: AppWorkflowInput }` schema to a TS literal type
20
+ * string. Mirrors the backend .d.ts generator's behavior — same vocabulary,
21
+ * same conventions — so workflow body types and app call-site types agree.
22
+ *
23
+ * Note: `inputs` here is `Record<string, unknown>` rather than typed because
24
+ * the CLI doesn't import zod or the apps schema (zero-dep policy in @lotics/sdk).
25
+ * We pattern-match on the `type` field and let unknown shapes fall through
26
+ * to `unknown` rather than throwing — the server's deploy-time schema parse
27
+ * catches structurally-invalid declarations before they reach us.
28
+ */
29
+ export declare function inputsToType(inputs: Record<string, unknown>): string;
30
+ export declare function isValidIdentifier(name: string): boolean;
@@ -64,7 +64,7 @@ ${lines.join("\n")}
64
64
  * to `unknown` rather than throwing — the server's deploy-time schema parse
65
65
  * catches structurally-invalid declarations before they reach us.
66
66
  */
67
- function inputsToType(inputs) {
67
+ export function inputsToType(inputs) {
68
68
  const fields = [];
69
69
  for (const [key, decl] of Object.entries(inputs)) {
70
70
  if (decl === null || typeof decl !== "object")
@@ -118,6 +118,6 @@ function inputDeclToTsType(decl) {
118
118
  }
119
119
  }
120
120
  const IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
121
- function isValidIdentifier(name) {
121
+ export function isValidIdentifier(name) {
122
122
  return IDENTIFIER_REGEX.test(name);
123
123
  }
@@ -47,7 +47,7 @@ export function buildStarterTemplate(args) {
47
47
  test: "vitest run",
48
48
  },
49
49
  dependencies: {
50
- "@lotics/app-sdk": "^0.2.0",
50
+ "@lotics/app-sdk": "^0.5.0",
51
51
  "@lotics/ui": "^0.2.0",
52
52
  "@react-native-picker/picker": "^2.7.0",
53
53
  "expo-image": "~3.0.9",
@@ -239,7 +239,7 @@ export default function App() {
239
239
  </View>
240
240
  </Card>
241
241
  <Text color="muted" size="sm">
242
- Hooks from @lotics/app-sdk: useTable, useMutate, useAction, useQuery, useWorkflow.
242
+ Hooks from @lotics/app-sdk: useQuery, useWorkflow.
243
243
  </Text>
244
244
  <Button title="Get started" onPress={() => {}} color="primary" />
245
245
  </View>
@@ -400,7 +400,7 @@ lotics app deploy -m "what" # with a commit-message-style note
400
400
  ## SDK + UI components
401
401
 
402
402
  \`\`\`tsx
403
- import { mount, useTable, useMutate, useAction, useQuery, useWorkflow } from "@lotics/app-sdk";
403
+ import { mount, useQuery, useWorkflow } from "@lotics/app-sdk";
404
404
  import { Stack } from "@lotics/ui/stack";
405
405
  import { Card } from "@lotics/ui/card";
406
406
  import { Button } from "@lotics/ui/button";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.24.1",
3
+ "version": "0.27.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {