@lotics/cli 0.64.0 → 0.66.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
@@ -181,8 +181,14 @@ lotics app workflow pull # rewrite src/workflows/*.ts + globa
181
181
  lotics app workflow check # typecheck every body locally ([alias] for one)
182
182
  lotics app workflow set issueInvoice # push the edited src/workflows/issueInvoice.ts
183
183
 
184
- # Dev-link @lotics/ui to the monorepo's packages/ui/src for live HMR (monorepo only)
185
- lotics ui link card # edits to packages/ui/src go live
184
+ # Iterate on a named query WITHOUT a deploy: push package.json#lotics.queries.<alias>
185
+ # to apps.queries (server-validated like a deploy). apps.queries is manifest-
186
+ # authoritative, so the next `app deploy` re-syncs it — keep the manifest current.
187
+ lotics app query set openInvoices # push package.json#lotics.queries.openInvoices
188
+
189
+ # Dev-link @lotics/ui to packages/ui/src for live HMR (Vite alias; deploy bundles it)
190
+ lotics ui link card # monorepo: packages/ui/src found automatically
191
+ lotics ui link card --ui-src /abs/monorepo/packages/ui/src # external app (e.g. ~/lotics_apps)
186
192
  lotics ui link card --remove # finalize: PR + publish, then drop the alias
187
193
  ```
188
194
 
@@ -87,16 +87,23 @@ export declare function writeWorkflowFile(projectDir: string, alias: string, sou
87
87
  */
88
88
  export declare function stripWorkflowHeader(content: string): string;
89
89
  /**
90
- * Add the workflow-body globs to the main `tsconfig.json`'s `exclude` if missing,
91
- * preserving every other exclude. The starter ships these excludes already, but
92
- * an app SCAFFOLDED before this CLI release (or one with a hand-written tsconfig)
93
- * doesn't and pulling bodies into it would otherwise break its
94
- * `npm run typecheck`: the bodies pull the app's DOM lib (the server doesn't) and
95
- * the per-alias ambient globals collide on `trigger`. Idempotent a second pull
96
- * is a no-op. Warns exactly what it added. A missing/unparseable tsconfig is a
97
- * non-fatal warn (the pull itself still succeeds); the author fixes the config.
90
+ * Heal a pre-existing app's `tsconfig.json` so its generated types load and its
91
+ * `npm run typecheck` stays honest. Idempotent, run on every pull / codegen /
92
+ * deploy. Two things the current starter ships but an app SCAFFOLDED before those
93
+ * releases (or with a hand-written tsconfig) can lack:
94
+ *
95
+ * 1. `include`: a bare `.lotics` is rewritten to the recursive `LOTICS_INCLUDE_GLOB`.
96
+ * A bare dot-dir is skipped by TypeScript's include-glob walk, so the codegen'd
97
+ * `.d.ts` never enter the program every `useQuery`/`useWorkflow`/`useAgentRun`
98
+ * param and result silently falls back to `unknown`.
99
+ * 2. `exclude`: the workflow-body globs — the bodies use the app's DOM lib (the
100
+ * server doesn't) and the per-alias ambient globals collide on `trigger`,
101
+ * poisoning typecheck. Checked separately by `lotics app workflow check`.
102
+ *
103
+ * A missing / unparseable tsconfig is a non-fatal warn (the caller still succeeds);
104
+ * the author fixes the config.
98
105
  */
99
- export declare function ensureWorkflowTsconfigExcludes(projectDir: string): void;
106
+ export declare function ensureAppTsconfig(projectDir: string): void;
100
107
  /**
101
108
  * `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
102
109
  * manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
@@ -296,6 +303,23 @@ export declare function appExecuteWorkflow(client: LoticsClient, args: {
296
303
  export declare function appWorkflowSet(client: LoticsClient, args: {
297
304
  alias: string;
298
305
  }): Promise<void>;
306
+ /**
307
+ * `lotics app query set <alias>` — push `package.json#lotics.queries.<alias>` to
308
+ * `apps.queries` through `set_app_query`, WITHOUT a deploy. The deploy-free inner
309
+ * loop for named queries, parallel to `lotics app workflow set` for workflows.
310
+ *
311
+ * The declaration (`{ ast, params? }`) is read from the manifest — the same map
312
+ * `useQuery` codegen reads and `lotics app deploy` syncs authoritatively. The
313
+ * server validates it exactly as a deploy does (alias identifier, workspace-only
314
+ * tables, resolvable fields, declared params). Because `apps.queries` is
315
+ * manifest-authoritative, the next `lotics app deploy` overwrites this from the
316
+ * manifest — so keep the manifest as the source of truth; this only skips the
317
+ * build/upload round-trip while iterating. Errors (unbound alias, validation
318
+ * failure) print to stderr and exit non-zero.
319
+ */
320
+ export declare function appQuerySet(client: LoticsClient, args: {
321
+ alias: string;
322
+ }): Promise<void>;
299
323
  /**
300
324
  * `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
301
325
  * server without a full `lotics app pull` (no source archive, no npm install).
@@ -339,4 +363,5 @@ export declare function appUiLink(args: {
339
363
  projectDir?: string;
340
364
  component: string;
341
365
  remove?: boolean;
366
+ uiSrc?: string;
342
367
  }): void;
@@ -64,6 +64,17 @@ const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
64
64
  * with `/` separators — tsconfig globs are POSIX even on Windows.
65
65
  */
66
66
  const WORKFLOW_TSCONFIG_EXCLUDES = ["src/workflows", ".lotics/workflows"];
67
+ /**
68
+ * The include glob that actually loads the generated `.lotics/*.d.ts` companions.
69
+ * A BARE `.lotics` entry loads NONE of them: TypeScript's include-glob walk skips
70
+ * dot-directories, so `useQuery` / `useWorkflow` / `useAgentRun` fall back to their
71
+ * untyped string overloads and every param / input / result silently becomes
72
+ * `unknown`. The current starter emits the glob (`starter_template.ts`); apps
73
+ * scaffolded before that fix shipped the bare form and need healing.
74
+ */
75
+ const LOTICS_INCLUDE_GLOB = ".lotics/**/*";
76
+ /** The bare `.lotics` forms an older starter emitted — all skipped by the glob walk. */
77
+ const STALE_LOTICS_INCLUDES = new Set([".lotics", "./.lotics", ".lotics/"]);
67
78
  /**
68
79
  * The `async function __workflow(...)` wrapper a workflow body sits inside —
69
80
  * the SAME envelope the server compiles the body within at `set_app_workflow`
@@ -304,20 +315,27 @@ function writeAppMeta(projectDir, meta) {
304
315
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
305
316
  }
306
317
  /**
307
- * Add the workflow-body globs to the main `tsconfig.json`'s `exclude` if missing,
308
- * preserving every other exclude. The starter ships these excludes already, but
309
- * an app SCAFFOLDED before this CLI release (or one with a hand-written tsconfig)
310
- * doesn't and pulling bodies into it would otherwise break its
311
- * `npm run typecheck`: the bodies pull the app's DOM lib (the server doesn't) and
312
- * the per-alias ambient globals collide on `trigger`. Idempotent a second pull
313
- * is a no-op. Warns exactly what it added. A missing/unparseable tsconfig is a
314
- * non-fatal warn (the pull itself still succeeds); the author fixes the config.
318
+ * Heal a pre-existing app's `tsconfig.json` so its generated types load and its
319
+ * `npm run typecheck` stays honest. Idempotent, run on every pull / codegen /
320
+ * deploy. Two things the current starter ships but an app SCAFFOLDED before those
321
+ * releases (or with a hand-written tsconfig) can lack:
322
+ *
323
+ * 1. `include`: a bare `.lotics` is rewritten to the recursive `LOTICS_INCLUDE_GLOB`.
324
+ * A bare dot-dir is skipped by TypeScript's include-glob walk, so the codegen'd
325
+ * `.d.ts` never enter the program every `useQuery`/`useWorkflow`/`useAgentRun`
326
+ * param and result silently falls back to `unknown`.
327
+ * 2. `exclude`: the workflow-body globs — the bodies use the app's DOM lib (the
328
+ * server doesn't) and the per-alias ambient globals collide on `trigger`,
329
+ * poisoning typecheck. Checked separately by `lotics app workflow check`.
330
+ *
331
+ * A missing / unparseable tsconfig is a non-fatal warn (the caller still succeeds);
332
+ * the author fixes the config.
315
333
  */
316
- export function ensureWorkflowTsconfigExcludes(projectDir) {
334
+ export function ensureAppTsconfig(projectDir) {
317
335
  const tsconfigPath = path.join(projectDir, "tsconfig.json");
318
336
  if (!fs.existsSync(tsconfigPath)) {
319
- console.error(`⚠ No tsconfig.json at ${projectDir} — could not ensure ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} are excluded. ` +
320
- `Add them to your tsconfig's "exclude" so npm run typecheck skips the workflow bodies.`);
337
+ console.error(`⚠ No tsconfig.json at ${projectDir} — could not ensure "include" has "${LOTICS_INCLUDE_GLOB}" ` +
338
+ `(a bare .lotics loads none of the generated types) or "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")}.`);
321
339
  return;
322
340
  }
323
341
  let parsed;
@@ -326,30 +344,47 @@ export function ensureWorkflowTsconfigExcludes(projectDir) {
326
344
  }
327
345
  catch (err) {
328
346
  console.error(`⚠ Could not parse tsconfig.json (${err instanceof Error ? err.message : String(err)}) — ` +
329
- `add ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} to its "exclude" manually so npm run typecheck skips the workflow bodies.`);
347
+ `ensure "include" has "${LOTICS_INCLUDE_GLOB}" and "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} manually.`);
330
348
  return;
331
349
  }
332
350
  if (!parsed || typeof parsed !== "object")
333
351
  return;
334
- // `exclude` is a TOP-LEVEL tsconfig field — tsc ignores `compilerOptions.exclude`,
335
- // so the workflow globs must land at the top level or `npm run typecheck` still
336
- // loads the bodies + their colliding per-alias globals.
337
- const current = Array.isArray(parsed.exclude) ? parsed.exclude : [];
338
- const currentStrings = current.filter((e) => typeof e === "string");
339
- const toAdd = WORKFLOW_TSCONFIG_EXCLUDES.filter((g) => !currentStrings.includes(g));
340
- if (toAdd.length === 0)
352
+ const changes = [];
353
+ // 1. Heal a stale bare `.lotics` include so the generated `.d.ts` actually load
354
+ // (a bare dot-dir is invisible to TypeScript's include-glob walk).
355
+ if (Array.isArray(parsed.include)) {
356
+ const inc = parsed.include.filter((e) => typeof e === "string");
357
+ if (inc.some((e) => STALE_LOTICS_INCLUDES.has(e))) {
358
+ const healed = inc.map((e) => (STALE_LOTICS_INCLUDES.has(e) ? LOTICS_INCLUDE_GLOB : e));
359
+ parsed.include = healed.filter((e, i) => healed.indexOf(e) === i); // dedupe if the glob was already present
360
+ changes.push(`rewrote a bare ".lotics" to "${LOTICS_INCLUDE_GLOB}" in "include" (a dot-dir loads zero generated types)`);
361
+ }
362
+ }
363
+ // 2. Ensure the workflow-body excludes. `exclude` is a TOP-LEVEL tsconfig field —
364
+ // tsc ignores `compilerOptions.exclude`, so they must land at the top level.
365
+ const currentEx = Array.isArray(parsed.exclude)
366
+ ? parsed.exclude.filter((e) => typeof e === "string")
367
+ : [];
368
+ const toAdd = WORKFLOW_TSCONFIG_EXCLUDES.filter((g) => !currentEx.includes(g));
369
+ if (toAdd.length > 0) {
370
+ parsed.exclude = [...currentEx, ...toAdd];
371
+ changes.push(`added ${toAdd.join(", ")} to "exclude"`);
372
+ }
373
+ if (changes.length === 0)
341
374
  return;
342
- parsed.exclude = [...currentStrings, ...toAdd];
343
375
  fs.writeFileSync(tsconfigPath, JSON.stringify(parsed, null, 2) + "\n");
344
- console.error(`Patched tsconfig.json: added ${toAdd.join(", ")} to "exclude" so npm run typecheck skips the workflow bodies ` +
345
- `(check them with: lotics app workflow check).`);
376
+ console.error(`Patched tsconfig.json: ${changes.join("; ")}.`);
346
377
  }
347
378
  /**
348
- * Write `.lotics/app_workflows.d.ts` + `.lotics/app_queries.d.ts` from the
349
- * manifest's `workflows` / `queries` maps. Called from `app create / pull /
350
- * dev / deploy` so the augmented `AppWorkflows` / `AppQueries` types stay in
351
- * sync with the manifest. The starter tsconfig.json includes `.lotics/` so
352
- * tsc + Vite pick the files up automatically.
379
+ * Write the three `.lotics/app_{workflows,queries,agents}.d.ts` companions from
380
+ * the manifest's maps, then heal the app's tsconfig so they actually load. Called
381
+ * from `app create / pull / dev / deploy / codegen`, so the augmented `AppWorkflows`
382
+ * / `AppQueries` / `AppAgents` types stay in sync with the manifest.
383
+ *
384
+ * The heal is at the write boundary on purpose: a `.d.ts` written but not loaded is
385
+ * useless (a bare `.lotics` include is skipped by TypeScript's include-glob walk and
386
+ * loads zero of them), so `ensureAppTsconfig` couples "wrote the types" with "the
387
+ * program can see them" — no caller can do one without the other.
353
388
  */
354
389
  function writeAppDts(projectDir, manifest) {
355
390
  const dotLotics = path.join(projectDir, ".lotics");
@@ -361,6 +396,7 @@ function writeAppDts(projectDir, manifest) {
361
396
  ];
362
397
  for (const [file, content] of written)
363
398
  fs.writeFileSync(file, content);
399
+ ensureAppTsconfig(projectDir);
364
400
  return written.map(([file]) => file);
365
401
  }
366
402
  /**
@@ -450,10 +486,6 @@ export async function appCodegen(args) {
450
486
  // it, so the local typecheck tracks the current workspace schema. Aliases
451
487
  // never pulled (no body file yet) are skipped — codegen isn't a pull.
452
488
  await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
453
- // Keep the main tsconfig excluding the workflow-body globs (idempotent) so
454
- // npm run typecheck never loads the bodies or their colliding per-alias globals.
455
- if (Object.keys(meta.workflows ?? {}).length > 0)
456
- ensureWorkflowTsconfigExcludes(projectDir);
457
489
  }
458
490
  /**
459
491
  * For each bound alias that already has a local body file, fetch its current
@@ -745,9 +777,6 @@ export async function appPull(client, args) {
745
777
  if (written.length > 0) {
746
778
  console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
747
779
  }
748
- // A pre-existing app's tsconfig may predate the workflow-body excludes; pulling
749
- // bodies into it would break its npm run typecheck. Patch it idempotently.
750
- ensureWorkflowTsconfigExcludes(targetPath);
751
780
  }
752
781
  console.error(`Installing npm dependencies...`);
753
782
  await runNpm(["install"], targetPath);
@@ -867,9 +896,13 @@ args) {
867
896
  // `capabilities` block turns every capability OFF on the next deploy
868
897
  // (fail-safe; the declaration is the grant).
869
898
  capabilities: meta.capabilities ?? {},
870
- // Workflow bindings are NOT a deploy concern — set_app_workflow /
871
- // remove_app_workflow own apps.workflows. The manifest's `workflows`
872
- // map is a pulled reflection used only for the .d.ts codegen above.
899
+ // Workflow BINDINGS are NOT a deploy concern — set_app_workflow /
900
+ // remove_app_workflow own apps.workflows. But the alias KEYS of the
901
+ // manifest's `workflows` map ARE sent (never the bindings): they record
902
+ // which aliases this bundle declares, so remove_app_workflow can refuse
903
+ // to unbind an alias the served version still calls. Drop an alias from
904
+ // the manifest + redeploy to lift that guard before removing its binding.
905
+ workflow_aliases: Object.keys(meta.workflows ?? {}),
873
906
  });
874
907
  writeAppMeta(projectDir, {
875
908
  ...meta,
@@ -1169,6 +1202,37 @@ export async function appWorkflowSet(client, args) {
1169
1202
  console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
1170
1203
  }
1171
1204
  }
1205
+ /**
1206
+ * `lotics app query set <alias>` — push `package.json#lotics.queries.<alias>` to
1207
+ * `apps.queries` through `set_app_query`, WITHOUT a deploy. The deploy-free inner
1208
+ * loop for named queries, parallel to `lotics app workflow set` for workflows.
1209
+ *
1210
+ * The declaration (`{ ast, params? }`) is read from the manifest — the same map
1211
+ * `useQuery` codegen reads and `lotics app deploy` syncs authoritatively. The
1212
+ * server validates it exactly as a deploy does (alias identifier, workspace-only
1213
+ * tables, resolvable fields, declared params). Because `apps.queries` is
1214
+ * manifest-authoritative, the next `lotics app deploy` overwrites this from the
1215
+ * manifest — so keep the manifest as the source of truth; this only skips the
1216
+ * build/upload round-trip while iterating. Errors (unbound alias, validation
1217
+ * failure) print to stderr and exit non-zero.
1218
+ */
1219
+ export async function appQuerySet(client, args) {
1220
+ const projectDir = process.cwd();
1221
+ const meta = readAppMeta(projectDir);
1222
+ const declaration = meta.queries?.[args.alias];
1223
+ if (!declaration) {
1224
+ console.error(`No query "${args.alias}" in package.json#lotics.queries. ` +
1225
+ `Declare it there (alias → { ast, params? }) first.`);
1226
+ process.exit(1);
1227
+ }
1228
+ const res = await client.setAppQuery(meta.app_id, args.alias, declaration);
1229
+ if (res.error) {
1230
+ console.error(`Failed to set query "${args.alias}": ${res.error}`);
1231
+ process.exit(1);
1232
+ }
1233
+ console.error(`Set query "${args.alias}" on ${meta.app_id}. ` +
1234
+ `(apps.queries is manifest-authoritative — the next 'lotics app deploy' re-syncs it.)`);
1235
+ }
1172
1236
  /**
1173
1237
  * `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
1174
1238
  * server without a full `lotics app pull` (no source archive, no npm install).
@@ -1187,9 +1251,10 @@ export async function appWorkflowPull(client) {
1187
1251
  const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
1188
1252
  console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
1189
1253
  (written.length > 0 ? ` (${written.join(", ")})` : ""));
1190
- // A pre-existing app's tsconfig may predate the workflow-body excludes; pulling
1191
- // bodies into it would break its npm run typecheck. Patch it idempotently.
1192
- ensureWorkflowTsconfigExcludes(projectDir);
1254
+ // This command writes workflow BODIES, not the `.d.ts` so it doesn't reach
1255
+ // `writeAppDts`'s heal. Heal here so the bodies land excluded and a stale
1256
+ // `.lotics` include doesn't leave the app's other generated types dead.
1257
+ ensureAppTsconfig(projectDir);
1193
1258
  }
1194
1259
  /**
1195
1260
  * `lotics app workflow check [alias]` — local TypeScript type check of the
@@ -1328,11 +1393,18 @@ export function appUiLink(args) {
1328
1393
  if (!fs.existsSync(viteConfigPath)) {
1329
1394
  throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
1330
1395
  }
1331
- const uiSrc = findUiSrcDir(projectDir);
1332
- if (!uiSrc) {
1333
- throw new Error("Cannot find packages/ui/src by walking up from this directory — `lotics ui link` " +
1334
- "requires a monorepo checkout. External apps consume @lotics/ui from npm; bump the " +
1335
- "package version and widen the app's dependency range instead.");
1396
+ // Resolve packages/ui/src. An explicit --ui-src / LOTICS_UI_SRC wins — that's how
1397
+ // an EXTERNAL app (one that consumes @lotics/ui from npm, with no monorepo above
1398
+ // it) links the local kit; otherwise walk up for a monorepo checkout.
1399
+ const explicit = args.uiSrc ?? process.env.LOTICS_UI_SRC;
1400
+ const uiSrc = explicit ? path.resolve(explicit) : findUiSrcDir(projectDir);
1401
+ if (!uiSrc || !fs.existsSync(uiSrc) || !fs.statSync(uiSrc).isDirectory()) {
1402
+ throw new Error(explicit
1403
+ ? `--ui-src / LOTICS_UI_SRC points at '${explicit}', which is not a directory. ` +
1404
+ `Pass the absolute path to the monorepo's packages/ui/src.`
1405
+ : "Cannot find packages/ui/src by walking up from this directory. For an EXTERNAL app " +
1406
+ "(consuming @lotics/ui from npm), pass --ui-src=<abs path to packages/ui/src> or set " +
1407
+ "LOTICS_UI_SRC; inside a monorepo checkout it is found automatically.");
1336
1408
  }
1337
1409
  // Validate the named component exists in src so a typo fails loud (the alias
1338
1410
  // itself stays package-wide — this is the advisory check the spec calls for).
@@ -1373,6 +1445,10 @@ export function appUiLink(args) {
1373
1445
  fs.writeFileSync(viteConfigPath, updated);
1374
1446
  console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
1375
1447
  console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1448
+ // The app's tsc still resolves @lotics/ui from node_modules (the published .d.ts) —
1449
+ // the kit `src` can't be typechecked in an app because it's RN-Web (uses
1450
+ // react-native-web types the app resolves as base react-native). Typecheck the kit
1451
+ // in packages/ui; the finalize publish restores the app's own typecheck.
1376
1452
  console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
1377
1453
  }
1378
1454
  /** Escape a string for literal use inside a RegExp. */
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { stampPulledManifest, undeclaredCapabilities, appDirName, defaultPullTarget, ensureWorkflowTsconfigExcludes, appCodegen, appUiLink, appVersions, appWorkflowSet, appWorkflowPull, appExecuteWorkflow, writeWorkflowFile, writeWorkflowGlobals, stripWorkflowHeader, FALLBACK_ENVELOPE_PREFIX, FALLBACK_ENVELOPE_SUFFIX, } from "./app_commands.js";
5
+ import { stampPulledManifest, undeclaredCapabilities, appDirName, defaultPullTarget, ensureAppTsconfig, appCodegen, appUiLink, appVersions, appWorkflowSet, appWorkflowPull, appExecuteWorkflow, writeWorkflowFile, writeWorkflowGlobals, stripWorkflowHeader, FALLBACK_ENVELOPE_PREFIX, FALLBACK_ENVELOPE_SUFFIX, } from "./app_commands.js";
6
6
  /**
7
7
  * `appPull` reads workflows from the live App row (server response), NOT from
8
8
  * the manifest embedded in the extracted source archive. The frozen archive
@@ -208,8 +208,9 @@ describe("appCodegen (.d.ts-only path)", () => {
208
208
  });
209
209
  /**
210
210
  * `appUiLink` edits the app's vite.config.ts resolve.alias to dev-link
211
- * @lotics/ui at the monorepo's packages/ui/src. It fails loud outside a
212
- * monorepo, validates the component exists, and insert/remove is idempotent.
211
+ * @lotics/ui at packages/ui/src auto-found in a monorepo checkout, or given
212
+ * explicitly via --ui-src for an external app. It validates the src + component,
213
+ * fails loud when neither resolves, and insert/remove is idempotent.
213
214
  */
214
215
  describe("appUiLink", () => {
215
216
  let root;
@@ -264,16 +265,30 @@ describe("appUiLink", () => {
264
265
  it("fails loud when the named component does not exist in packages/ui/src", () => {
265
266
  expect(() => appUiLink({ projectDir: appDir, component: "nonexistent" })).toThrow(/nonexistent/);
266
267
  });
267
- it("fails loud when there is no monorepo packages/ui/src above the project", () => {
268
+ it("fails loud when there is no packages/ui/src and no --ui-src override", () => {
268
269
  const lonely = fs.mkdtempSync(path.join(tmpdir(), "lotics-lonely-app-"));
269
270
  fs.writeFileSync(path.join(lonely, "vite.config.ts"), "export default { resolve: { alias: [] } };");
270
271
  try {
271
- expect(() => appUiLink({ projectDir: lonely, component: "card" })).toThrow(/monorepo checkout/);
272
+ expect(() => appUiLink({ projectDir: lonely, component: "card" })).toThrow(/--ui-src/);
272
273
  }
273
274
  finally {
274
275
  fs.rmSync(lonely, { recursive: true, force: true });
275
276
  }
276
277
  });
278
+ it("links an EXTERNAL app (no monorepo above) via an explicit --ui-src", () => {
279
+ const ext = fs.mkdtempSync(path.join(tmpdir(), "lotics-external-app-"));
280
+ fs.writeFileSync(path.join(ext, "vite.config.ts"), "export default { resolve: { alias: [] } };");
281
+ try {
282
+ appUiLink({ projectDir: ext, component: "card", uiSrc });
283
+ expect(fs.readFileSync(path.join(ext, "vite.config.ts"), "utf-8")).toContain(uiSrc);
284
+ }
285
+ finally {
286
+ fs.rmSync(ext, { recursive: true, force: true });
287
+ }
288
+ });
289
+ it("fails loud when --ui-src is not a directory", () => {
290
+ expect(() => appUiLink({ projectDir: appDir, component: "card", uiSrc: path.join(root, "nope") })).toThrow(/not a directory/);
291
+ });
277
292
  });
278
293
  /**
279
294
  * The workflow body files are how AA-1 Option B becomes "open a file → edit →
@@ -680,7 +695,7 @@ describe("defaultPullTarget", () => {
680
695
  expect(defaultPullTarget("app_X", "Sales Tracker")).toBe("Sales Tracker");
681
696
  });
682
697
  });
683
- describe("ensureWorkflowTsconfigExcludes", () => {
698
+ describe("ensureAppTsconfig", () => {
684
699
  let dir;
685
700
  beforeEach(() => {
686
701
  dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tsconfig-"));
@@ -692,37 +707,60 @@ describe("ensureWorkflowTsconfigExcludes", () => {
692
707
  const read = () => JSON.parse(fs.readFileSync(tsconfigPath(), "utf-8"));
693
708
  it("adds both workflow globs to the TOP-LEVEL exclude", () => {
694
709
  fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: {}, exclude: ["node_modules"] }));
695
- ensureWorkflowTsconfigExcludes(dir);
710
+ ensureAppTsconfig(dir);
696
711
  expect(read().exclude).toEqual(["node_modules", "src/workflows", ".lotics/workflows"]);
697
712
  });
698
713
  it("writes TOP-LEVEL even when a compilerOptions.exclude exists (tsc ignores the nested key)", () => {
699
714
  fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: { exclude: ["x"] } }));
700
- ensureWorkflowTsconfigExcludes(dir);
715
+ ensureAppTsconfig(dir);
701
716
  const cfg = read();
702
717
  expect(cfg.exclude).toEqual(["src/workflows", ".lotics/workflows"]);
703
718
  expect(cfg.compilerOptions.exclude).toEqual(["x"]); // left untouched (and tsc-ignored)
704
719
  });
705
720
  it("preserves pre-existing top-level excludes", () => {
706
721
  fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules", "dist"] }));
707
- ensureWorkflowTsconfigExcludes(dir);
722
+ ensureAppTsconfig(dir);
708
723
  expect(read().exclude).toEqual(["node_modules", "dist", "src/workflows", ".lotics/workflows"]);
709
724
  });
710
725
  it("is idempotent — a second call writes nothing new", () => {
711
726
  fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules"] }));
712
- ensureWorkflowTsconfigExcludes(dir);
727
+ ensureAppTsconfig(dir);
713
728
  const afterFirst = fs.readFileSync(tsconfigPath(), "utf-8");
714
- ensureWorkflowTsconfigExcludes(dir);
729
+ ensureAppTsconfig(dir);
715
730
  expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe(afterFirst);
716
731
  });
717
732
  it("warns + does not throw or create a file when there is no tsconfig", () => {
718
- expect(() => ensureWorkflowTsconfigExcludes(dir)).not.toThrow();
733
+ expect(() => ensureAppTsconfig(dir)).not.toThrow();
719
734
  expect(fs.existsSync(tsconfigPath())).toBe(false);
720
735
  });
721
736
  it("warns + does not throw on an unparseable tsconfig (left as-is)", () => {
722
737
  fs.writeFileSync(tsconfigPath(), "{ not json,, }");
723
- expect(() => ensureWorkflowTsconfigExcludes(dir)).not.toThrow();
738
+ expect(() => ensureAppTsconfig(dir)).not.toThrow();
724
739
  expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe("{ not json,, }");
725
740
  });
741
+ it("rewrites a bare .lotics include to the glob (a dot-dir loads zero generated types)", () => {
742
+ fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics"], exclude: ["node_modules"] }));
743
+ ensureAppTsconfig(dir);
744
+ expect(read().include).toEqual(["src", ".lotics/**/*"]);
745
+ });
746
+ it("leaves an already-correct .lotics/**/* include untouched (idempotent)", () => {
747
+ fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics/**/*"], exclude: ["node_modules", "src/workflows", ".lotics/workflows"] }));
748
+ const before = fs.readFileSync(tsconfigPath(), "utf-8");
749
+ ensureAppTsconfig(dir);
750
+ expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe(before);
751
+ });
752
+ it("dedupes when both a bare .lotics and the glob are present", () => {
753
+ fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: [".lotics", ".lotics/**/*", "src"] }));
754
+ ensureAppTsconfig(dir);
755
+ expect(read().include).toEqual([".lotics/**/*", "src"]);
756
+ });
757
+ it("heals the include AND adds the excludes in a single write", () => {
758
+ fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics"], exclude: ["node_modules"] }));
759
+ ensureAppTsconfig(dir);
760
+ const cfg = read();
761
+ expect(cfg.include).toEqual(["src", ".lotics/**/*"]);
762
+ expect(cfg.exclude).toEqual(["node_modules", "src/workflows", ".lotics/workflows"]);
763
+ });
726
764
  });
727
765
  describe("appVersions", () => {
728
766
  let logLines;
package/dist/args.d.ts CHANGED
@@ -23,6 +23,8 @@ export declare function parseArgs(argv: string[]): {
23
23
  apiKey?: string;
24
24
  workspace?: string;
25
25
  viewAs?: string;
26
+ /** `--ui-src <path>`: absolute packages/ui/src for `ui link` in an external app. */
27
+ uiSrc?: string;
26
28
  name?: string;
27
29
  timezone?: string;
28
30
  message?: string;
package/dist/args.js CHANGED
@@ -19,6 +19,7 @@ export function parseArgs(argv) {
19
19
  apiKey: undefined,
20
20
  workspace: undefined,
21
21
  viewAs: undefined,
22
+ uiSrc: undefined,
22
23
  name: undefined,
23
24
  timezone: undefined,
24
25
  message: undefined,
@@ -60,6 +61,9 @@ export function parseArgs(argv) {
60
61
  case "--view-as":
61
62
  flags.viewAs = argv[++i];
62
63
  break;
64
+ case "--ui-src":
65
+ flags.uiSrc = argv[++i];
66
+ break;
63
67
  case "--name":
64
68
  flags.name = argv[++i];
65
69
  break;
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ net.setDefaultAutoSelectFamilyAttemptTimeout(2000);
13
13
  import { LoticsClient, API_BASE_URL } from "./client.js";
14
14
  import { resolveContext, deleteConfig, getConfigPath, loadGlobalConfig, saveGlobalConfig, loadLocalConfig, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, resolveProfileByNameOrId, checkForUpdate, } from "./config.js";
15
15
  import { VERSION } from "./version.js";
16
- import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename, appVersions, appCodegen, appExecuteWorkflow, appWorkflowSet, appWorkflowPull, appWorkflowCheck, appUiLink, } from "./app_commands.js";
16
+ import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename, appVersions, appCodegen, appExecuteWorkflow, appWorkflowSet, appWorkflowPull, appWorkflowCheck, appQuerySet, appUiLink, } from "./app_commands.js";
17
17
  import { parseArgs } from "./args.js";
18
18
  import { ingestJsonArgs } from "./inputs.js";
19
19
  import { runXlsxCommand } from "./xlsx.js";
@@ -90,11 +90,16 @@ COMMANDS
90
90
  lotics app workflow pull Rewrite src/workflows/*.ts from the server
91
91
  lotics app workflow check [alias] Typecheck src/workflows bodies locally (one
92
92
  isolated program per alias; the app's own tsc)
93
+ lotics app query set <alias> Push package.json#lotics.queries.<alias> to
94
+ apps.queries via set_app_query (no deploy;
95
+ re-synced by the next deploy from the manifest)
93
96
  lotics app subdomain <new-subdomain> Rename the app's public <slug>.lotics.app address
94
97
  lotics app rename "<new name>" Rename the app's display name (launcher title)
95
98
  lotics app dev [path] Run the app locally with HMR (RPC forwarded to prod)
96
- lotics ui link <component> [--remove] Dev-link @lotics/ui to the monorepo's
97
- packages/ui/src for live HMR (monorepo only)
99
+ lotics ui link <component> [--ui-src <path>] [--remove]
100
+ Dev-link @lotics/ui to packages/ui/src (Vite alias
101
+ + tsc paths) for live HMR + typecheck. Monorepo apps
102
+ auto-find it; external apps pass --ui-src / LOTICS_UI_SRC
98
103
  lotics xlsx <subcommand> ... Read/write/edit .xlsx files on your local filesystem
99
104
  (uses the bundled Lotics xlsx engine; prefer over
100
105
  npm xlsx/exceljs for round-trip fidelity)
@@ -493,17 +498,19 @@ async function main() {
493
498
  if (subcommand === "link") {
494
499
  const component = toolArgs;
495
500
  if (!component) {
496
- console.error("Usage: lotics ui link <component> [--remove]");
497
- console.error("Dev-links @lotics/ui to the monorepo's packages/ui/src for live HMR.");
501
+ console.error("Usage: lotics ui link <component> [--ui-src <abs path>] [--remove]");
502
+ console.error("Dev-links @lotics/ui to packages/ui/src (Vite + tsc) for live HMR + typecheck.");
503
+ console.error("Monorepo apps find packages/ui/src automatically; external apps pass --ui-src / LOTICS_UI_SRC.");
498
504
  process.exit(1);
499
505
  }
500
506
  // `--remove` isn't a value-taking flag, so the parser leaves it as a
501
- // trailing positional (the component took `toolArgs`).
502
- appUiLink({ component, remove: restArgs.includes("--remove") });
507
+ // trailing positional (the component took `toolArgs`); `--ui-src` IS a
508
+ // value flag (flags.uiSrc), falling back to LOTICS_UI_SRC inside appUiLink.
509
+ appUiLink({ component, uiSrc: flags.uiSrc, remove: restArgs.includes("--remove") });
503
510
  return;
504
511
  }
505
512
  console.error(`Unknown ui subcommand: ${subcommand ?? "(none)"}`);
506
- console.error("Usage: lotics ui link <component> [--remove]");
513
+ console.error("Usage: lotics ui link <component> [--ui-src <abs path>] [--remove]");
507
514
  process.exit(1);
508
515
  }
509
516
  // --- lotics app workflow check [alias] — local typecheck, no auth, no network ---
@@ -606,6 +613,7 @@ async function main() {
606
613
  console.error(" lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body");
607
614
  console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
608
615
  console.error(" lotics app workflow check [alias] Typecheck src/workflows bodies locally");
616
+ console.error(" lotics app query set <alias> Push lotics.queries.<alias> to apps.queries (no deploy)");
609
617
  console.error(" lotics app subdomain <new-subdomain> Rename the app's public <slug>.lotics.app address");
610
618
  console.error(" lotics app rename \"<new name>\" Rename the app's display name (launcher title)");
611
619
  console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
@@ -826,6 +834,25 @@ async function main() {
826
834
  }
827
835
  workflowUsage();
828
836
  }
837
+ if (subcommand === "query") {
838
+ // `lotics app query set <alias>` — push package.json#lotics.queries.<alias>
839
+ // to apps.queries via set_app_query, no deploy. `toolArgs` is the verb;
840
+ // `restArgs` carries the alias.
841
+ const action = toolArgs;
842
+ if (action === "set") {
843
+ const alias = restArgs[0];
844
+ if (!alias) {
845
+ console.error("Usage: lotics app query set <alias>");
846
+ console.error("Pushes package.json#lotics.queries.<alias> to apps.queries via set_app_query (no deploy).");
847
+ process.exit(1);
848
+ }
849
+ await appQuerySet(client, { alias });
850
+ return;
851
+ }
852
+ console.error("Usage:");
853
+ console.error(" lotics app query set <alias> Push package.json#lotics.queries.<alias> to apps.queries (no deploy)");
854
+ process.exit(1);
855
+ }
829
856
  if (subcommand === "dev") {
830
857
  // First positional is an optional project path (defaults to cwd).
831
858
  // --port and --vite-port can override the wrapper / Vite ports.
package/dist/client.d.ts CHANGED
@@ -277,6 +277,18 @@ export declare class LoticsClient {
277
277
  name?: string;
278
278
  description?: string;
279
279
  }): Promise<ToolExecuteResult>;
280
+ /**
281
+ * Bind (create or replace) an app query by alias via the `set_app_query` tool
282
+ * — the deploy-free authoring path for `apps.queries`, parallel to
283
+ * `setAppWorkflow`. `declaration` is the `{ ast, params? }` from
284
+ * `package.json#lotics.queries.<alias>`. The server validates it exactly as a
285
+ * deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
286
+ * so the next `lotics app deploy` overwrites this from the manifest.
287
+ */
288
+ setAppQuery(app_id: string, alias: string, declaration: {
289
+ ast: unknown;
290
+ params?: Record<string, unknown>;
291
+ }): Promise<ToolExecuteResult>;
280
292
  /**
281
293
  * Fetch one app workflow's faithful source + bound input/output schemas via
282
294
  * `get_app_workflow`. `source` is the JS-subset body re-rendered from the
@@ -375,6 +387,14 @@ export declare class LoticsClient {
375
387
  capabilities?: {
376
388
  comments?: boolean;
377
389
  };
390
+ /**
391
+ * The manifest's `lotics.workflows` KEYS — the workflow aliases the deployed
392
+ * code declares (NOT the bindings; `set_app_workflow` / `remove_app_workflow`
393
+ * own `apps.workflows`). Recorded on the new version so `remove_app_workflow`
394
+ * refuses to unbind an alias the served version still calls. Always sent
395
+ * (empty array when none declared).
396
+ */
397
+ workflow_aliases?: string[];
378
398
  }): Promise<{
379
399
  version_id: string;
380
400
  version_number: number;