@lotics/cli 0.88.0 → 0.88.1

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.
Files changed (87) hide show
  1. package/dist/render_page.js +100 -100
  2. package/dist/src/cli.js +100 -100
  3. package/dist/{client.d.ts → src/client.d.ts} +1 -1
  4. package/dist/src/package_content_types.d.ts +70 -0
  5. package/dist/src/package_content_types.js +12 -0
  6. package/package.json +2 -2
  7. package/dist/app_commands.d.ts +0 -401
  8. package/dist/app_commands.js +0 -1520
  9. package/dist/app_commands.test.d.ts +0 -1
  10. package/dist/app_commands.test.js +0 -898
  11. package/dist/app_workflow_check.d.ts +0 -77
  12. package/dist/app_workflow_check.js +0 -169
  13. package/dist/app_workflow_check.test.d.ts +0 -1
  14. package/dist/app_workflow_check.test.js +0 -166
  15. package/dist/args.d.ts +0 -42
  16. package/dist/args.js +0 -121
  17. package/dist/args.test.d.ts +0 -1
  18. package/dist/args.test.js +0 -88
  19. package/dist/child_env.d.ts +0 -13
  20. package/dist/child_env.js +0 -24
  21. package/dist/cli.d.ts +0 -2
  22. package/dist/cli.js +0 -1095
  23. package/dist/cli_dispatch.test.d.ts +0 -1
  24. package/dist/cli_dispatch.test.js +0 -89
  25. package/dist/client.test.d.ts +0 -1
  26. package/dist/client.test.js +0 -47
  27. package/dist/config.d.ts +0 -124
  28. package/dist/config.js +0 -315
  29. package/dist/config.test.d.ts +0 -1
  30. package/dist/config.test.js +0 -319
  31. package/dist/dev/file_relay.d.ts +0 -39
  32. package/dist/dev/file_relay.js +0 -87
  33. package/dist/dev/file_relay.test.d.ts +0 -1
  34. package/dist/dev/file_relay.test.js +0 -87
  35. package/dist/dev/rpc_handler.d.ts +0 -21
  36. package/dist/dev/rpc_handler.js +0 -166
  37. package/dist/dev/rpc_handler.test.d.ts +0 -1
  38. package/dist/dev/rpc_handler.test.js +0 -107
  39. package/dist/dev/server.d.ts +0 -75
  40. package/dist/dev/server.js +0 -443
  41. package/dist/dev/upload_relay.d.ts +0 -36
  42. package/dist/dev/upload_relay.js +0 -61
  43. package/dist/dev/upload_relay.test.d.ts +0 -1
  44. package/dist/dev/upload_relay.test.js +0 -76
  45. package/dist/dev/wrapper_page.d.ts +0 -40
  46. package/dist/dev/wrapper_page.js +0 -335
  47. package/dist/docx.d.ts +0 -2
  48. package/dist/docx.js +0 -341
  49. package/dist/docx.test.d.ts +0 -1
  50. package/dist/docx.test.js +0 -145
  51. package/dist/file_command_io.d.ts +0 -12
  52. package/dist/file_command_io.js +0 -34
  53. package/dist/generate_app_agents_dts.d.ts +0 -16
  54. package/dist/generate_app_agents_dts.js +0 -62
  55. package/dist/generate_app_agents_dts.test.d.ts +0 -1
  56. package/dist/generate_app_agents_dts.test.js +0 -45
  57. package/dist/generate_app_fields.d.ts +0 -56
  58. package/dist/generate_app_fields.js +0 -148
  59. package/dist/generate_app_fields.test.d.ts +0 -1
  60. package/dist/generate_app_fields.test.js +0 -108
  61. package/dist/generate_app_queries_dts.d.ts +0 -21
  62. package/dist/generate_app_queries_dts.js +0 -57
  63. package/dist/generate_app_workflows_dts.d.ts +0 -39
  64. package/dist/generate_app_workflows_dts.js +0 -229
  65. package/dist/generate_app_workflows_dts.test.d.ts +0 -1
  66. package/dist/generate_app_workflows_dts.test.js +0 -109
  67. package/dist/generate_package_fields.d.ts +0 -49
  68. package/dist/generate_package_fields.js +0 -157
  69. package/dist/generate_package_fields.test.d.ts +0 -1
  70. package/dist/generate_package_fields.test.js +0 -59
  71. package/dist/inputs.d.ts +0 -38
  72. package/dist/inputs.js +0 -50
  73. package/dist/inputs.test.d.ts +0 -1
  74. package/dist/inputs.test.js +0 -89
  75. package/dist/preview.d.ts +0 -3
  76. package/dist/preview.js +0 -233
  77. package/dist/starter_template.d.ts +0 -64
  78. package/dist/starter_template.js +0 -753
  79. package/dist/starter_template.test.d.ts +0 -1
  80. package/dist/starter_template.test.js +0 -115
  81. package/dist/version.d.ts +0 -1
  82. package/dist/version.js +0 -6
  83. package/dist/xlsx.d.ts +0 -2
  84. package/dist/xlsx.js +0 -489
  85. package/dist/xlsx.test.d.ts +0 -1
  86. package/dist/xlsx.test.js +0 -131
  87. /package/dist/{client.js → src/client.js} +0 -0
@@ -1,1520 +0,0 @@
1
- /**
2
- * `lotics app *` subcommands — create / pull / deploy.
3
- *
4
- * Apps are user-owned npm projects. Source-of-truth during editing is the
5
- * local filesystem; the server stores deployed bundles (source.tar.gz + dist)
6
- * in R2 keyed per version. These commands are the only sanctioned lifecycle
7
- * surface — everything else (validate, logs, watch) folds into a flag on
8
- * deploy or lives in the web UI.
9
- *
10
- * Tarball handling shells out to the system `tar` command. CLI users are
11
- * developers; macOS/Linux/WSL all ship a working tar. Avoids adding a
12
- * runtime dep to the published @lotics/cli package.
13
- */
14
- import fs from "node:fs";
15
- import path from "node:path";
16
- import { spawn } from "node:child_process";
17
- import { tmpdir } from "node:os";
18
- import { buildStarterTemplate } from "./starter_template.js";
19
- import { startDevServer, openBrowser } from "./dev/server.js";
20
- import { ipv4ChildEnv } from "./child_env.js";
21
- import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
22
- import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
23
- import { generateAppQueriesDts } from "./generate_app_queries_dts.js";
24
- import { collectQueryTableIds } from "@lotics/shared/app_query_ast";
25
- import { generateAppFields } from "./generate_app_fields.js";
26
- import { generatePackageAppFields } from "./generate_package_fields.js";
27
- import { loadProjectTypescript, checkWorkflowBodies, } from "./app_workflow_check.js";
28
- /**
29
- * Resolve the latest published version of a package from the npm registry.
30
- * Returns null on any failure (network error, 404, malformed payload) so
31
- * callers can fall back to a static pin rather than crashing `app create`.
32
- *
33
- * 1.5s timeout — npm registry is fast when reachable; the fallback is fine
34
- * the rare times it isn't, and we don't want to block scaffold on a hang.
35
- */
36
- export async function fetchLatestNpmVersion(packageName) {
37
- try {
38
- const controller = new AbortController();
39
- const timeout = setTimeout(() => controller.abort(), 1500);
40
- const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
41
- signal: controller.signal,
42
- headers: { accept: "application/json" },
43
- });
44
- clearTimeout(timeout);
45
- if (!response.ok)
46
- return null;
47
- const body = (await response.json());
48
- return typeof body.version === "string" ? body.version : null;
49
- }
50
- catch {
51
- return null;
52
- }
53
- }
54
- /** The directory, relative to the project root, that holds editable workflow bodies. */
55
- const WORKFLOWS_DIR = path.join("src", "workflows");
56
- /** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
57
- const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
58
- /**
59
- * The two globs the MAIN tsconfig must `exclude`: the editable workflow bodies AND
60
- * their per-alias ambient globals. The bodies use the app's DOM lib (the server
61
- * doesn't) and each globals file declares its own ambient `trigger` — loading 22
62
- * of them into the app's program collides those declarations and poisons
63
- * `npm run typecheck`. Bodies are type-checked separately by
64
- * `lotics app workflow check` (one isolated program per alias). Always written
65
- * with `/` separators — tsconfig globs are POSIX even on Windows.
66
- */
67
- const WORKFLOW_TSCONFIG_EXCLUDES = ["src/workflows", ".lotics/workflows"];
68
- /**
69
- * The include glob that actually loads the generated `.lotics/*.d.ts` companions.
70
- * A BARE `.lotics` entry loads NONE of them: TypeScript's include-glob walk skips
71
- * dot-directories, so `useQuery` / `useWorkflow` / `useAgentRun` fall back to their
72
- * untyped string overloads and every param / input / result silently becomes
73
- * `unknown`. The current starter emits the glob (`starter_template.ts`); apps
74
- * scaffolded before that fix shipped the bare form and need healing.
75
- */
76
- const LOTICS_INCLUDE_GLOB = ".lotics/**/*";
77
- /** The bare `.lotics` forms an older starter emitted — all skipped by the glob walk. */
78
- const STALE_LOTICS_INCLUDES = new Set([".lotics", "./.lotics", ".lotics/"]);
79
- /**
80
- * The `async function __workflow(...)` wrapper a workflow body sits inside —
81
- * the SAME envelope the server compiles the body within at `set_app_workflow`
82
- * verify time (GAP-59). Carried so a body that uses top-level `await` and ends
83
- * with `return({...})` typechecks locally exactly as the server checks it. The
84
- * server returns the canonical strings (`getAppWorkflowDts`); these are the
85
- * offline fallback when the dts fetch fails so the file is still wrapped — a
86
- * test pins them equal to the server's, so they can't drift.
87
- */
88
- export const FALLBACK_ENVELOPE_PREFIX = "async function __workflow(): Promise<__WorkflowReturn | void> {\n";
89
- export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
90
- /**
91
- * Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
92
- * reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
93
- * calls — server-generated), and the body sits inside the SAME `__workflow`
94
- * wrapper the server compiles within, so a local `lotics app workflow check`
95
- * mirrors the set-time verdict (GAP-59). The wrapper + reference + comment lines
96
- * are CLI bookkeeping, stripped on `set`; the filename IS the alias — renaming it
97
- * orphans the body.
98
- */
99
- function workflowFileHeader(alias) {
100
- const refPath = path
101
- .join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`)
102
- .split(path.sep)
103
- .join("/");
104
- return (`/// <reference path="${refPath}" />\n` +
105
- `// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
106
- `// lines below, then check + push with:\n` +
107
- `// lotics app workflow check ${alias}\n` +
108
- `// lotics app workflow set ${alias}\n` +
109
- `// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
110
- `// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
111
- `// set) — they only make the body typecheck locally against the workspace types.\n` +
112
- `// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
113
- // `export {};` makes the file a MODULE. `lotics app workflow check` compiles
114
- // each body in its OWN isolated program (just this body + its globals), so the
115
- // `__workflow` function never collides across bodies; the marker is retained as
116
- // stable CLI bookkeeping (stripWorkflowHeader anchors on it) and is stripped on
117
- // `set`, so it has no effect on what the server verifies.
118
- `export {};\n`);
119
- }
120
- /** Absolute path of one workflow body file, given the project root + alias. */
121
- function workflowFilePath(projectDir, alias) {
122
- return path.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
123
- }
124
- /** Absolute path of one alias's ambient globals `.d.ts`. */
125
- function workflowGlobalsPath(projectDir, alias) {
126
- return path.join(projectDir, WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`);
127
- }
128
- /**
129
- * Write `.lotics/workflows/<alias>.globals.d.ts` = the server-generated ambient
130
- * declarations the body typechecks against. Idempotent. Returns the path.
131
- */
132
- export function writeWorkflowGlobals(projectDir, alias, dts) {
133
- const dir = path.join(projectDir, WORKFLOW_GLOBALS_DIR);
134
- fs.mkdirSync(dir, { recursive: true });
135
- const file = workflowGlobalsPath(projectDir, alias);
136
- fs.writeFileSync(file, `${dts.replace(/\s+$/, "")}\n`);
137
- return file;
138
- }
139
- /**
140
- * Write `src/workflows/<alias>.ts` = header (triple-slash reference + comments)
141
- * + the body wrapped in the `__workflow` envelope. Idempotent (a re-pull
142
- * overwrites with the current server body). The raw `source` round-trips on
143
- * `set` (the header + wrapper are stripped). Exported for direct unit testing —
144
- * the surrounding pull shells out to `tar`/`npm`.
145
- */
146
- export function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX }) {
147
- const dir = path.join(projectDir, WORKFLOWS_DIR);
148
- fs.mkdirSync(dir, { recursive: true });
149
- const file = workflowFilePath(projectDir, alias);
150
- // Body verbatim inside the wrapper so a later `set` round-trips byte-for-byte
151
- // (modulo the header + wrapper, which are stripped). The prefix already ends
152
- // with a newline and the suffix begins with one (server envelope shape).
153
- const body = source.replace(/\s+$/, "");
154
- fs.writeFileSync(file, `${workflowFileHeader(alias)}\n${envelope.prefix}${body}${envelope.suffix}\n`);
155
- return file;
156
- }
157
- /** Matches the `__workflow` envelope opener (the wrapper `writeWorkflowFile`
158
- * prepends). Recognized structurally (not by exact return-type text) so a
159
- * future envelope-signature tweak doesn't silently leave the wrapper in the
160
- * pushed source. */
161
- const WORKFLOW_WRAPPER_OPENER = /^\s*async\s+function\s+__workflow\s*\(/;
162
- /**
163
- * Strip the CLI bookkeeping back off a workflow body before pushing it: the
164
- * triple-slash reference + the `//` header comments + the `export {};` marker +
165
- * blank lines, then the `__workflow` envelope (the opening
166
- * `async function __workflow(...) {` line and the matching trailing `}`). `set`
167
- * sends ONLY the JS-subset body the author edited — the server stays the single
168
- * verifier.
169
- *
170
- * The strip is anchored on the GENERATED bookkeeping, never on "the file happens
171
- * to start with comments": the leading comment/marker/blank block is only peeled
172
- * when it is immediately followed by the `__workflow` wrapper opener (the exact
173
- * shape `writeWorkflowFile` produces). A hand-written, wrapper-LESS body whose
174
- * first lines are comments therefore round-trips unchanged — its comments are
175
- * real source, not bookkeeping, and must not be silently eaten.
176
- */
177
- export function stripWorkflowHeader(content) {
178
- const lines = content.split("\n");
179
- // Probe how far a generated header would extend: `//`/`///` lines, then the
180
- // `export {};` module marker, then the blank line(s) before the wrapper.
181
- let headerEnd = 0;
182
- while (headerEnd < lines.length && /^\s*\/\//.test(lines[headerEnd]))
183
- headerEnd++;
184
- while (headerEnd < lines.length && /^\s*export\s*\{\s*\}\s*;?\s*$/.test(lines[headerEnd]))
185
- headerEnd++;
186
- while (headerEnd < lines.length && lines[headerEnd].trim() === "")
187
- headerEnd++;
188
- // Peel the header ONLY when the wrapper opener follows it — that's the proof
189
- // the leading block was generated bookkeeping and not the author's own source.
190
- // If the file opens with the wrapper directly (degraded pull with no header),
191
- // peel from there. Otherwise leave the body verbatim.
192
- let start;
193
- if (headerEnd < lines.length && WORKFLOW_WRAPPER_OPENER.test(lines[headerEnd])) {
194
- start = headerEnd;
195
- }
196
- else if (lines.length > 0 && WORKFLOW_WRAPPER_OPENER.test(lines[0])) {
197
- start = 0;
198
- }
199
- else {
200
- return content.replace(/\s+$/, "");
201
- }
202
- // `start` is the wrapper opener line — drop it and the matching trailing `}`.
203
- let end = lines.length - 1;
204
- while (end > start && lines[end].trim() === "")
205
- end--;
206
- if (lines[end]?.trim() === "}") {
207
- return lines.slice(start + 1, end).join("\n").replace(/\s+$/, "");
208
- }
209
- // Opener present but no closing `}` — a malformed wrapper; push the body after
210
- // the opener verbatim rather than guessing where the envelope ends.
211
- return lines.slice(start + 1).join("\n").replace(/\s+$/, "");
212
- }
213
- /**
214
- * Pull every workflow body the server can render into `src/workflows/<alias>.ts`,
215
- * plus the per-alias ambient globals `.lotics/workflows/<alias>.globals.d.ts` that
216
- * make the body locally typecheckable. The alias set comes from the live
217
- * `apps.workflows` map; each body comes from `get_app_workflow` (faithful source
218
- * re-rendered from the persisted step tree) and each globals + envelope from
219
- * `getAppWorkflowDts`. A legacy alias whose source can't be read is WARNED and
220
- * SKIPPED — never a failure — so a partially-migrated app still pulls. A dts
221
- * fetch failure is non-fatal too: the body is still written (with the fallback
222
- * envelope), only the local typecheck is degraded — mirrors `app_fields`.
223
- * Returns the aliases written.
224
- */
225
- async function writeWorkflowFiles(client, projectDir, app_id, aliases) {
226
- const written = [];
227
- for (const alias of aliases) {
228
- const res = await client.getAppWorkflow(app_id, alias);
229
- const source = res.error || res.result === null || typeof res.result !== "object"
230
- ? null
231
- : res.result.source;
232
- if (typeof source !== "string" || source.trim() === "") {
233
- console.error(`⚠ Skipped src/workflows/${alias}.ts — the server returned no readable source ` +
234
- `(${res.error ?? "legacy workflow with no rendered body"}).`);
235
- continue;
236
- }
237
- const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
238
- writeWorkflowFile(projectDir, alias, source, envelope);
239
- written.push(alias);
240
- }
241
- return written;
242
- }
243
- /**
244
- * Fetch + write one alias's ambient globals `.d.ts` and return the wrapper
245
- * envelope to wrap the body in. Network failure is non-fatal (warn, keep the
246
- * last-written globals if any) and the body is wrapped in the FALLBACK envelope
247
- * so it still parses — mirrors `app_fields`'s offline tolerance.
248
- */
249
- async function fetchWorkflowGlobals(client, projectDir, app_id, alias) {
250
- try {
251
- const { dts, envelope_prefix, envelope_suffix } = await client.getAppWorkflowDts(app_id, alias);
252
- writeWorkflowGlobals(projectDir, alias, dts);
253
- return { prefix: envelope_prefix, suffix: envelope_suffix };
254
- }
255
- catch (err) {
256
- console.error(`⚠ Could not fetch workflow types for "${alias}" (${err instanceof Error ? err.message : String(err)}). ` +
257
- `Wrote the body with the fallback wrapper; its local typecheck may be degraded.`);
258
- return { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX };
259
- }
260
- }
261
- /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
262
- export function runTar(args, cwd) {
263
- return new Promise((resolve, reject) => {
264
- const proc = spawn("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
265
- let stderr = "";
266
- proc.stderr.on("data", (chunk) => {
267
- stderr += chunk.toString();
268
- });
269
- proc.on("error", reject);
270
- proc.on("exit", (code) => {
271
- if (code === 0)
272
- resolve();
273
- else
274
- reject(new Error(`tar ${args.join(" ")} failed with exit ${code}: ${stderr}`));
275
- });
276
- });
277
- }
278
- /** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
279
- export function runNpm(args, cwd) {
280
- return new Promise((resolve, reject) => {
281
- const proc = spawn("npm", args, { cwd, stdio: "inherit", env: ipv4ChildEnv(process.env) });
282
- proc.on("error", reject);
283
- proc.on("exit", (code) => {
284
- if (code === 0)
285
- resolve();
286
- else
287
- reject(new Error(`npm ${args.join(" ")} exited with code ${code}`));
288
- });
289
- });
290
- }
291
- function readAppMeta(projectDir) {
292
- const pkgPath = path.join(projectDir, "package.json");
293
- if (!fs.existsSync(pkgPath)) {
294
- throw new Error(`No package.json found at ${projectDir}. Run 'lotics app create' first.`);
295
- }
296
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
297
- if (!pkg.lotics?.app_id || !pkg.lotics.workspace_id) {
298
- throw new Error(`package.json is missing the 'lotics' field with app_id and workspace_id. ` +
299
- `Was this folder created with 'lotics app create'?`);
300
- }
301
- return {
302
- app_id: pkg.lotics.app_id,
303
- workspace_id: pkg.lotics.workspace_id,
304
- current_version_id: pkg.lotics.current_version_id ?? null,
305
- version_number: pkg.lotics.version_number ?? null,
306
- workflows: pkg.lotics.workflows ?? {},
307
- queries: pkg.lotics.queries ?? {},
308
- agents: pkg.lotics.agents ?? {},
309
- capabilities: pkg.lotics.capabilities,
310
- };
311
- }
312
- function writeAppMeta(projectDir, meta) {
313
- const pkgPath = path.join(projectDir, "package.json");
314
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
315
- pkg.lotics = meta;
316
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
317
- }
318
- /**
319
- * Heal a pre-existing app's `tsconfig.json` so its generated types load and its
320
- * `npm run typecheck` stays honest. Idempotent, run on every pull / codegen /
321
- * deploy. Two things the current starter ships but an app SCAFFOLDED before those
322
- * releases (or with a hand-written tsconfig) can lack:
323
- *
324
- * 1. `include`: a bare `.lotics` is rewritten to the recursive `LOTICS_INCLUDE_GLOB`.
325
- * A bare dot-dir is skipped by TypeScript's include-glob walk, so the codegen'd
326
- * `.d.ts` never enter the program — every `useQuery`/`useWorkflow`/`useAgentRun`
327
- * param and result silently falls back to `unknown`.
328
- * 2. `exclude`: the workflow-body globs — the bodies use the app's DOM lib (the
329
- * server doesn't) and the per-alias ambient globals collide on `trigger`,
330
- * poisoning typecheck. Checked separately by `lotics app workflow check`.
331
- *
332
- * A missing / unparseable tsconfig is a non-fatal warn (the caller still succeeds);
333
- * the author fixes the config.
334
- */
335
- export function ensureAppTsconfig(projectDir) {
336
- const tsconfigPath = path.join(projectDir, "tsconfig.json");
337
- if (!fs.existsSync(tsconfigPath)) {
338
- console.error(`⚠ No tsconfig.json at ${projectDir} — could not ensure "include" has "${LOTICS_INCLUDE_GLOB}" ` +
339
- `(a bare .lotics loads none of the generated types) or "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")}.`);
340
- return;
341
- }
342
- let parsed;
343
- try {
344
- parsed = JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
345
- }
346
- catch (err) {
347
- console.error(`⚠ Could not parse tsconfig.json (${err instanceof Error ? err.message : String(err)}) — ` +
348
- `ensure "include" has "${LOTICS_INCLUDE_GLOB}" and "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} manually.`);
349
- return;
350
- }
351
- if (!parsed || typeof parsed !== "object")
352
- return;
353
- const changes = [];
354
- // 1. Heal a stale bare `.lotics` include so the generated `.d.ts` actually load
355
- // (a bare dot-dir is invisible to TypeScript's include-glob walk).
356
- if (Array.isArray(parsed.include)) {
357
- const inc = parsed.include.filter((e) => typeof e === "string");
358
- if (inc.some((e) => STALE_LOTICS_INCLUDES.has(e))) {
359
- const healed = inc.map((e) => (STALE_LOTICS_INCLUDES.has(e) ? LOTICS_INCLUDE_GLOB : e));
360
- parsed.include = healed.filter((e, i) => healed.indexOf(e) === i); // dedupe if the glob was already present
361
- changes.push(`rewrote a bare ".lotics" to "${LOTICS_INCLUDE_GLOB}" in "include" (a dot-dir loads zero generated types)`);
362
- }
363
- }
364
- // 2. Ensure the workflow-body excludes. `exclude` is a TOP-LEVEL tsconfig field —
365
- // tsc ignores `compilerOptions.exclude`, so they must land at the top level.
366
- const currentEx = Array.isArray(parsed.exclude)
367
- ? parsed.exclude.filter((e) => typeof e === "string")
368
- : [];
369
- const toAdd = WORKFLOW_TSCONFIG_EXCLUDES.filter((g) => !currentEx.includes(g));
370
- if (toAdd.length > 0) {
371
- parsed.exclude = [...currentEx, ...toAdd];
372
- changes.push(`added ${toAdd.join(", ")} to "exclude"`);
373
- }
374
- if (changes.length === 0)
375
- return;
376
- fs.writeFileSync(tsconfigPath, JSON.stringify(parsed, null, 2) + "\n");
377
- console.error(`Patched tsconfig.json: ${changes.join("; ")}.`);
378
- }
379
- /**
380
- * Write the three `.lotics/app_{workflows,queries,agents}.d.ts` companions from
381
- * the manifest's maps, then heal the app's tsconfig so they actually load. Called
382
- * from `app create / pull / dev / deploy / codegen`, so the augmented `AppWorkflows`
383
- * / `AppQueries` / `AppAgents` types stay in sync with the manifest.
384
- *
385
- * The heal is at the write boundary on purpose: a `.d.ts` written but not loaded is
386
- * useless (a bare `.lotics` include is skipped by TypeScript's include-glob walk and
387
- * loads zero of them), so `ensureAppTsconfig` couples "wrote the types" with "the
388
- * program can see them" — no caller can do one without the other.
389
- */
390
- export function writeAppDts(projectDir, manifest) {
391
- const dotLotics = path.join(projectDir, ".lotics");
392
- fs.mkdirSync(dotLotics, { recursive: true });
393
- const written = [
394
- [path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
395
- [path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
396
- [path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)],
397
- ];
398
- for (const [file, content] of written)
399
- fs.writeFileSync(file, content);
400
- ensureAppTsconfig(projectDir);
401
- return written.map(([file]) => file);
402
- }
403
- /**
404
- * The optional `package.json#lotics.codegen.tables` allowlist — extra table ids
405
- * to include in `app_fields.ts` beyond those the named queries reference (e.g.
406
- * tables an app only writes to via a workflow, never queries). Returns `[]` when
407
- * absent or malformed (codegen falls back to the query-derived set).
408
- */
409
- function readCodegenTablesAllowlist(projectDir) {
410
- const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"));
411
- const tables = pkg.lotics?.codegen?.tables;
412
- return Array.isArray(tables) ? tables.filter((t) => typeof t === "string") : [];
413
- }
414
- /**
415
- * The table ids `app_fields.ts` should cover: the union of every `from_table`
416
- * referenced by a declared query AST plus the optional manifest allowlist. The
417
- * query set is what the app can actually read; the allowlist widens it to tables
418
- * only ever written (so their field/option aliases are still addressable).
419
- */
420
- function resolveCodegenTableIds(projectDir, queries) {
421
- const ids = new Set();
422
- for (const decl of Object.values(queries)) {
423
- // The manifest stores the AST untyped (`unknown`) — it's the server's
424
- // deploy-time concern to verify. Cast at this single boundary so codegen
425
- // reuses the canonical exhaustive walker (a new QueryNode kind is a
426
- // compile error there, never a silently-dropped table).
427
- for (const id of collectQueryTableIds(decl.ast))
428
- ids.add(id);
429
- }
430
- for (const id of readCodegenTablesAllowlist(projectDir))
431
- ids.add(id);
432
- return [...ids];
433
- }
434
- /**
435
- * Write the runtime `.lotics/app_fields.ts` from a resolved workspace schema.
436
- * Separate from `writeAppDts` (the type-only companions) because this one needs
437
- * the client to fetch the schema. Returns the written path.
438
- */
439
- function writeAppFields(projectDir, tables) {
440
- const dotLotics = path.join(projectDir, ".lotics");
441
- fs.mkdirSync(dotLotics, { recursive: true });
442
- const file = path.join(dotLotics, "app_fields.ts");
443
- fs.writeFileSync(file, generateAppFields(tables));
444
- return file;
445
- }
446
- /**
447
- * Write the BINDING-resolved `.lotics/app_fields.ts` for a package installation
448
- * — the portable twin of `writeAppFields`. Instead of baking this workspace's
449
- * concrete ids, the module resolves `F`/`OPT`/`ROLE` from the installation's
450
- * live binding at load (`getAppBinding()`), so a published origin's source runs
451
- * unchanged in every workspace that installs the package. Returns the path.
452
- */
453
- function writeBindingAppFields(projectDir, binding) {
454
- const dotLotics = path.join(projectDir, ".lotics");
455
- fs.mkdirSync(dotLotics, { recursive: true });
456
- const file = path.join(dotLotics, "app_fields.ts");
457
- fs.writeFileSync(file, generatePackageAppFields(binding));
458
- return file;
459
- }
460
- /**
461
- * `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
462
- * manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
463
- * always written (synchronous, no network). When a `client` is available, the
464
- * runtime `app_fields.ts` is also regenerated — **branched on whether the app is
465
- * a package installation** (`getApp().package_id` set): a **linked/published**
466
- * app emits the BINDING-resolved form (`F`/`OPT`/`ROLE` resolved from the live
467
- * binding at module load via `getAppBinding()`, so the source stays portable
468
- * across every install), a **bespoke** app emits the baked form (this
469
- * workspace's concrete `fld_…`/`opt_…` ids, from the live schema of the tables
470
- * its queries reference + the allowlist). A network failure is non-fatal (warn,
471
- * keep the last-generated file) — so codegen still does useful work offline,
472
- * mirroring `app create`'s tolerance of an offline npm registry.
473
- */
474
- export async function appCodegen(args) {
475
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
476
- // readAppMeta throws the clear not-an-app error when there's no manifest.
477
- const meta = readAppMeta(projectDir);
478
- const dtsPaths = writeAppDts(projectDir, {
479
- workflows: meta.workflows,
480
- queries: meta.queries,
481
- agents: meta.agents,
482
- });
483
- for (const p of dtsPaths)
484
- console.error(`Regenerated ${p}`);
485
- if (!args.client) {
486
- console.error("Skipped .lotics/app_fields.ts — no workspace credentials resolved. " +
487
- "Run authenticated (or set LOTICS_API_KEY) to regenerate field/option ids.");
488
- return;
489
- }
490
- try {
491
- // Branch on linked-ness: a package installation (its own published origin OR
492
- // an install of another org's package) must ship PORTABLE source, so
493
- // app_fields resolves F/OPT/ROLE from the live binding at module load; a
494
- // bespoke app bakes this workspace's concrete ids. getApp is the authority —
495
- // `package_id` is set iff the app is a package installation.
496
- const app = await args.client.getApp(meta.app_id);
497
- if (app.package_id) {
498
- const binding = await args.client.appBinding(meta.app_id);
499
- const fieldsPath = writeBindingAppFields(projectDir, binding);
500
- const count = Object.keys(binding.fields).length;
501
- console.error(`Regenerated ${fieldsPath} (binding form — ${count} field alias${count === 1 ? "" : "es"} resolved per-install)`);
502
- }
503
- else {
504
- const tableIds = resolveCodegenTableIds(projectDir, meta.queries ?? {});
505
- const tables = await args.client.getWorkspaceSchema(tableIds);
506
- const fieldsPath = writeAppFields(projectDir, tables);
507
- console.error(`Regenerated ${fieldsPath} (${tables.length} table${tables.length === 1 ? "" : "s"})`);
508
- }
509
- }
510
- catch (err) {
511
- // Non-fatal: keep the last-generated app_fields.ts so an offline/transient
512
- // failure doesn't strip the app's field aliases (same tolerance as the npm
513
- // registry lookup on `app create`).
514
- console.error(`⚠ Could not regenerate .lotics/app_fields.ts (${err instanceof Error ? err.message : String(err)}). ` +
515
- `Kept the existing file.`);
516
- }
517
- // Refresh each bound workflow's ambient globals + re-wrap its EXISTING local
518
- // body in the current envelope (GAP-59). Codegen never re-fetches the body
519
- // (that would clobber local edits) — it strips the on-disk body and re-wraps
520
- // it, so the local typecheck tracks the current workspace schema. Aliases
521
- // never pulled (no body file yet) are skipped — codegen isn't a pull.
522
- await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
523
- }
524
- /**
525
- * For each bound alias that already has a local body file, fetch its current
526
- * ambient globals `.d.ts` + envelope and re-wrap the on-disk body. Preserves the
527
- * author's edits (strips + re-wraps, never re-fetches the body). A dts fetch
528
- * failure is non-fatal per alias (warned inside `fetchWorkflowGlobals`).
529
- */
530
- async function refreshWorkflowGlobals(client, projectDir, app_id, aliases) {
531
- for (const alias of aliases) {
532
- const file = workflowFilePath(projectDir, alias);
533
- if (!fs.existsSync(file))
534
- continue;
535
- const body = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
536
- if (body.trim() === "")
537
- continue;
538
- const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
539
- writeWorkflowFile(projectDir, alias, body, envelope);
540
- console.error(`Refreshed ${path.relative(projectDir, file)} + its workflow types`);
541
- }
542
- }
543
- /**
544
- * Stamp the post-extraction manifest with server-authoritative meta. Called
545
- * by `appPull` after the source archive lands on disk.
546
- *
547
- * Crucially, `workflows` is sourced from `app.workflows` (live DB state),
548
- * NOT from whatever the extracted archive's package.json contains. The
549
- * archive's embedded `lotics.workflows` is a frozen snapshot from deploy
550
- * time and would silently overwrite any agent-authored bindings made via
551
- * `set_app_workflow` since the last deploy. Exported for direct unit
552
- * testing — the rest of `appPull` involves shelling out to `tar`/`npm`,
553
- * which doesn't reward integration coverage.
554
- */
555
- export function stampPulledManifest(projectDir, args) {
556
- writeAppMeta(projectDir, {
557
- app_id: args.app_id,
558
- workspace_id: args.workspace_id,
559
- current_version_id: args.current_version_id,
560
- version_number: args.version_number,
561
- workflows: args.workflows,
562
- queries: args.queries,
563
- agents: args.agents,
564
- });
565
- writeAppDts(projectDir, { workflows: args.workflows, queries: args.queries, agents: args.agents });
566
- }
567
- async function downloadToFile(url, destPath) {
568
- const response = await fetch(url);
569
- if (!response.ok) {
570
- throw new Error(`Failed to download ${url}: ${response.status}`);
571
- }
572
- const buffer = Buffer.from(await response.arrayBuffer());
573
- fs.writeFileSync(destPath, buffer);
574
- }
575
- /**
576
- * A filesystem-safe SINGLE directory segment from an app's free-form display
577
- * name, for the default create/pull target. App names may contain "/" (and
578
- * other path-hostile characters); used verbatim as a directory, a name like
579
- * "Nhập/Xuất Cont" splits into NESTED folders ("Nhập" → "Xuất Cont") — and a
580
- * deploy then packages that nested copy into the source archive, so each later
581
- * pull re-extracts and compounds it. Neutralize path separators + reserved
582
- * characters to one segment, keeping spaces and unicode so the folder stays
583
- * recognizable. An explicit `targetPath` still overrides this entirely.
584
- */
585
- export function appDirName(name) {
586
- const cleaned = name
587
- .replace(/[/\\]+/g, "-") // path separators — the core bug
588
- .replace(/[<>:"|?*]/g, "") // other filesystem-reserved characters
589
- .replace(/\s+/g, " ") // collapse whitespace runs
590
- .trim()
591
- .replace(/^[.\s-]+|[.\s-]+$/g, ""); // no leading/trailing dot/space/dash → never "."/".."/hidden/blank
592
- return cleaned || "app";
593
- }
594
- /**
595
- * `lotics app create <name> [path]`
596
- *
597
- * Creates the app server-side, scaffolds a Vite+React+TS project locally
598
- * from the embedded starter template, runs `npm install` + `npm run build`,
599
- * then deploys the result as v1. After this returns, `lotics app deploy`
600
- * works end-to-end on the same directory.
601
- *
602
- * Why scaffold + deploy rather than pull-from-server-starter: the deploy
603
- * pipeline requires a built dist tree, and v1 doesn't run builds server-side.
604
- * Doing the build locally on first create keeps the platform infrastructure
605
- * minimal — the starter template is embedded in the CLI binary and bumps
606
- * with CLI releases. (Server-side build is the v2 path.)
607
- */
608
- export async function appCreate(client, args) {
609
- const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
610
- if (fs.existsSync(targetPath)) {
611
- const entries = fs.readdirSync(targetPath);
612
- if (entries.length > 0) {
613
- throw new Error(`Target directory ${targetPath} is not empty.`);
614
- }
615
- }
616
- fs.mkdirSync(targetPath, { recursive: true });
617
- // Server-side row first — if this fails, no local files have been touched.
618
- const app = await client.createApp({ name: args.name });
619
- console.error(`Created app: ${app.name} (${app.id})`);
620
- // Resolve latest versions in parallel; null falls back to the static pin
621
- // baked into the starter template. Lookups time out at 1.5s so an offline
622
- // create still succeeds (with the fallback) instead of hanging.
623
- const [uiLatest, sdkLatest] = await Promise.all([
624
- fetchLatestNpmVersion("@lotics/ui"),
625
- fetchLatestNpmVersion("@lotics/app-sdk"),
626
- ]);
627
- // Scaffold the starter into the target directory.
628
- const files = buildStarterTemplate({
629
- app_name: args.name,
630
- app_id: app.id,
631
- workspace_id: app.workspace_id,
632
- ui_version: uiLatest ? `^${uiLatest}` : undefined,
633
- sdk_version: sdkLatest ? `^${sdkLatest}` : undefined,
634
- });
635
- for (const file of files) {
636
- const fullPath = path.join(targetPath, file.path);
637
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
638
- fs.writeFileSync(fullPath, file.content);
639
- }
640
- console.error(`Scaffolded ${files.length} files into ${targetPath}`);
641
- // Codegen pass for AppWorkflows typing. Empty manifest on first create —
642
- // emits a base augmentation file so future deploys produce idempotent diffs
643
- // rather than introducing a new tracked file later.
644
- writeAppDts(targetPath, {});
645
- console.error("Installing npm dependencies...");
646
- await runNpm(["install"], targetPath);
647
- console.error("Building initial version...");
648
- await appDeploy(client, {
649
- projectDir: targetPath,
650
- message: "Initial version",
651
- });
652
- console.error(`\nReady. Next steps:`);
653
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
654
- console.error(` # edit src/App.tsx, then:`);
655
- console.error(` lotics app deploy`);
656
- }
657
- /**
658
- * `lotics app pull <app_id> [path]`
659
- *
660
- * Bootstraps a full local dev environment for an existing app:
661
- * 1. Fetch the current version's source archive from R2 (presigned URL).
662
- * 2. Extract into the target directory.
663
- * 3. Stamp package.json's `lotics` field with app_id + version metadata,
664
- * and the live `apps.workflows` map from the server (NOT the manifest
665
- * embedded in the source archive — that's a frozen snapshot from deploy
666
- * time and would silently overwrite any agent-authored bindings made via
667
- * `set_app_workflow` since the last deploy).
668
- * 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
669
- * `useWorkflow<"alias">` is typed at pull time.
670
- * 5. Run `npm install`.
671
- *
672
- * Runtime field/option id aliases (`.lotics/app_fields.ts`) are generated on
673
- * demand by `lotics app codegen`, which fetches the workspace schema — pull
674
- * leaves it to that command so a schema fetch never blocks the bootstrap.
675
- */
676
- /**
677
- * `lotics app subdomain <new>` — rename the current app's public address.
678
- * The app_id comes from the local `package.json` manifest. The old
679
- * `<slug>.lotics.app` stops resolving once the change lands.
680
- */
681
- export async function appSetSubdomain(client, args) {
682
- const meta = readAppMeta(process.cwd());
683
- const result = await client.setAppSubdomain(meta.app_id, args.subdomain);
684
- console.error(`Public address set: https://${result.public_subdomain}.lotics.app`);
685
- }
686
- /**
687
- * `lotics app rename <name>` — change the current app's display name (the
688
- * launcher/title shown in the workspace). The app_id comes from the local
689
- * `package.json` manifest. Wraps the `update_app` tool — the public address
690
- * (`lotics app subdomain`) and the code (`lotics app deploy`) are unchanged.
691
- */
692
- export async function appRename(client, args) {
693
- const meta = readAppMeta(process.cwd());
694
- const res = await client.execute("update_app", { app_id: meta.app_id, name: args.name });
695
- if (res.error)
696
- throw new Error(res.error);
697
- console.error(`App renamed: "${args.name}" (${meta.app_id})`);
698
- }
699
- /**
700
- * `lotics app versions [app_id]` — print the app's deploy history, newest
701
- * first: version number, when, deployer, build status, and the `-m` message.
702
- * With no `app_id`, reads it from the local `package.json` manifest (run inside
703
- * the app dir); pass an explicit `app_id` to inspect any app without pulling it.
704
- * The currently served version is marked with `*`. Admin-only server-side.
705
- */
706
- export async function appVersions(client, args) {
707
- const appId = args.app_id ?? readAppMeta(process.cwd()).app_id;
708
- const { current_version_id, versions } = await client.listAppVersions(appId, {
709
- limit: args.limit,
710
- });
711
- if (versions.length === 0) {
712
- console.error(`No versions found for ${appId}.`);
713
- return;
714
- }
715
- const fmtWhen = (iso) => {
716
- const d = new Date(iso);
717
- if (Number.isNaN(d.getTime()))
718
- return iso;
719
- const p = (n) => String(n).padStart(2, "0");
720
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
721
- };
722
- const rows = versions.map((v) => ({
723
- mark: v.id === current_version_id ? "*" : " ",
724
- ver: `v${v.version}`,
725
- when: fmtWhen(v.created_at),
726
- by: v.created_by_name ?? v.created_by ?? "—",
727
- status: v.build_status,
728
- message: v.message ?? "",
729
- }));
730
- const colWidth = (key, header) => Math.max(header.length, ...rows.map((r) => r[key].length));
731
- const wVer = colWidth("ver", "VER");
732
- const wWhen = colWidth("when", "WHEN");
733
- const wBy = colWidth("by", "DEPLOYER");
734
- const wStatus = colWidth("status", "STATUS");
735
- const pad = (s, n) => s.padEnd(n);
736
- // Title → stderr (status); the table → stdout (data), so it stays pipeable.
737
- console.error(`Deploy history — ${appId} (${versions.length} version${versions.length === 1 ? "" : "s"}, newest first; * = currently served)`);
738
- console.log(` ${pad("VER", wVer)} ${pad("WHEN", wWhen)} ${pad("DEPLOYER", wBy)} ${pad("STATUS", wStatus)} MESSAGE`);
739
- for (const r of rows) {
740
- console.log(`${r.mark} ${pad(r.ver, wVer)} ${pad(r.when, wWhen)} ${pad(r.by, wBy)} ${pad(r.status, wStatus)} ${r.message}`.trimEnd());
741
- }
742
- }
743
- /**
744
- * Where `lotics app pull <app_id>` lands when given NO explicit path. If the cwd
745
- * IS already this app's own project (its manifest `app_id` matches), refresh in
746
- * place — the documented `cd <app> && lotics app pull` flow. Otherwise a fresh
747
- * clone goes to an `appDirName(name)` subdir. Without this, pulling from inside
748
- * the app dropped a stray `./<name>/` subdir instead of refreshing the project.
749
- */
750
- export function defaultPullTarget(appId, appName) {
751
- const cwdPkgPath = path.join(process.cwd(), "package.json");
752
- if (fs.existsSync(cwdPkgPath)) {
753
- let pkg = null;
754
- try {
755
- pkg = JSON.parse(fs.readFileSync(cwdPkgPath, "utf-8"));
756
- }
757
- catch (err) {
758
- // An unparseable cwd package.json means we can't confirm this is the app's
759
- // own project — warn and fall through to a fresh clone rather than crash.
760
- console.error(`⚠ Could not parse ${cwdPkgPath} (${err instanceof Error ? err.message : String(err)}) — ` +
761
- `pulling into a fresh ${appDirName(appName)}/ subdir.`);
762
- }
763
- if (pkg?.lotics?.app_id === appId)
764
- return process.cwd();
765
- }
766
- return appDirName(appName);
767
- }
768
- export async function appPull(client, args) {
769
- const app = await client.getApp(args.app_id);
770
- if (!app.current_version_id) {
771
- throw new Error(`App ${app.id} has no published version yet. Deploy from another machine first, or use 'lotics app create' to scaffold a new app.`);
772
- }
773
- const version = await client.getAppVersion(app.id, app.current_version_id);
774
- const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
775
- const targetPath = path.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
776
- fs.mkdirSync(targetPath, { recursive: true });
777
- // Download to a temp file because `tar -xz` reads from a real path.
778
- const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
779
- console.error(`Downloading source archive...`);
780
- await downloadToFile(sourceUrl, tmpFile);
781
- try {
782
- console.error(`Extracting to ${targetPath}...`);
783
- await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
784
- }
785
- finally {
786
- if (fs.existsSync(tmpFile))
787
- fs.unlinkSync(tmpFile);
788
- }
789
- // Workflows live on the App row, not in the source archive. The R2 archive
790
- // is for code; pull the workflows live from `apps.workflows` so agent-
791
- // authored bindings (via set_app_workflow) land in the local manifest. The
792
- // archive's embedded `lotics.workflows` is a deploy-time snapshot and would
793
- // be stale relative to anything authored since.
794
- stampPulledManifest(targetPath, {
795
- app_id: app.id,
796
- workspace_id: app.workspace_id,
797
- current_version_id: app.current_version_id,
798
- version_number: version.version,
799
- workflows: app.workflows ?? {},
800
- queries: app.queries ?? {},
801
- agents: app.agents ?? {},
802
- });
803
- // Write each bound workflow's faithful body to src/workflows/<alias>.ts so the
804
- // author edits a real file and pushes with `lotics app workflow set <alias>`
805
- // — no more fetch/reconstruct/escape. Sourced from get_app_workflow (the live
806
- // workflow row), like the manifest's `workflows` map, so agent-authored bodies
807
- // survive the pull. A legacy alias with no rendered source warns and is skipped.
808
- const aliases = Object.keys(app.workflows ?? {});
809
- if (aliases.length > 0) {
810
- const written = await writeWorkflowFiles(client, targetPath, app.id, aliases);
811
- if (written.length > 0) {
812
- console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
813
- }
814
- }
815
- console.error(`Installing npm dependencies...`);
816
- await runNpm(["install"], targetPath);
817
- console.error(`\nReady. Next steps:`);
818
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
819
- console.error(` # edit src/App.tsx`);
820
- console.error(` lotics app deploy`);
821
- }
822
- /**
823
- * SDK calls that only work when the matching capability is declared in
824
- * `package.json#lotics.capabilities`. Used by the deploy pre-flight (GAP-29):
825
- * code that calls one of these but omits the capability silently 403s at
826
- * runtime, with no build or deploy error. Extend this map when a new capability
827
- * gate ships in `@lotics/app-sdk`.
828
- */
829
- const CAPABILITY_GATED_CALLS = {
830
- comments: ["useComments", "createComment", "updateComment", "deleteComment"],
831
- };
832
- /**
833
- * Capabilities the source CALLS but the manifest does not DECLARE — each one
834
- * silently 403s at runtime (GAP-29). Pure over the concatenated source text so
835
- * it's unit-testable; the deploy warns (non-blocking) on a non-empty result.
836
- */
837
- export function undeclaredCapabilities(sourceText, declared) {
838
- const used = [];
839
- for (const [capability, calls] of Object.entries(CAPABILITY_GATED_CALLS)) {
840
- const isCalled = calls.some((call) => new RegExp(`\\b${call}\\b`).test(sourceText));
841
- if (isCalled && declared?.[capability] !== true)
842
- used.push(capability);
843
- }
844
- return used;
845
- }
846
- /** Concatenated text of the app's `src/` files — the input to the capability pre-flight. */
847
- function readAppSourceText(projectDir) {
848
- const srcDir = path.join(projectDir, "src");
849
- if (!fs.existsSync(srcDir))
850
- return "";
851
- const parts = [];
852
- const walk = (dir) => {
853
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
854
- const full = path.join(dir, entry.name);
855
- if (entry.isDirectory())
856
- walk(full);
857
- else if (/\.(ts|tsx|js|jsx)$/.test(entry.name))
858
- parts.push(fs.readFileSync(full, "utf8"));
859
- }
860
- };
861
- walk(srcDir);
862
- return parts.join("\n");
863
- }
864
- /**
865
- * `lotics app deploy [-m <message>]`
866
- *
867
- * Builds the project locally with `npm run build`, tars source + dist, and
868
- * uploads to the deploy endpoint. The endpoint validates structure, stores
869
- * both archives in R2, creates an app_versions row, and atomically advances
870
- * the app's current_version pointer.
871
- */
872
- export async function appDeploy(client,
873
- // `message` is required — each deploy is a version row read back by
874
- // `lotics app versions`; a blank message loses the audit trail. The CLI
875
- // enforces non-empty at the dispatch; the type enforces it for every caller.
876
- args) {
877
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
878
- const meta = readAppMeta(projectDir);
879
- // Pre-flight (GAP-29): a capability the code calls but the manifest doesn't
880
- // declare silently 403s at runtime with no other signal — warn before shipping
881
- // it. Non-blocking, like the unbranded nudge below.
882
- const undeclared = undeclaredCapabilities(readAppSourceText(projectDir), meta.capabilities);
883
- if (undeclared.length > 0) {
884
- const block = JSON.stringify(Object.fromEntries(undeclared.map((c) => [c, true])));
885
- console.error(`\n⚠ This app calls capability-gated SDK functions for ${undeclared.join(", ")} but the ` +
886
- `manifest doesn't declare ${undeclared.length > 1 ? "them" : "it"} — those calls will 403 at runtime.\n` +
887
- ` Add to package.json#lotics.capabilities: ${block}`);
888
- }
889
- // Regenerate AppWorkflows typing before the build picks up source. Keeps
890
- // .lotics/app_workflows.d.ts in sync with the manifest's workflows map
891
- // every time the developer ships.
892
- writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries, agents: meta.agents });
893
- // Build locally so the server doesn't need a build sandbox in v1.
894
- console.error("Building...");
895
- await runNpm(["run", "build"], projectDir);
896
- const distDir = path.join(projectDir, "dist");
897
- if (!fs.existsSync(distDir)) {
898
- throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
899
- `Check that 'npm run build' is configured correctly (Vite/etc. defaults to dist/).`);
900
- }
901
- const tmpSource = path.join(tmpdir(), `lotics-source-${Date.now()}.tar.gz`);
902
- const tmpDist = path.join(tmpdir(), `lotics-dist-${Date.now()}.tar.gz`);
903
- try {
904
- console.error("Packaging source...");
905
- await runTar([
906
- "-czf",
907
- tmpSource,
908
- "--exclude=node_modules",
909
- "--exclude=dist",
910
- "--exclude=.lotics",
911
- "--exclude=*.tsbuildinfo",
912
- "--exclude=.git",
913
- ".",
914
- ], projectDir);
915
- console.error("Packaging dist...");
916
- await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
917
- console.error("Uploading...");
918
- try {
919
- const result = await client.deployAppVersion({
920
- app_id: meta.app_id,
921
- source_archive: fs.readFileSync(tmpSource),
922
- dist_archive: fs.readFileSync(tmpDist),
923
- prev_version_id: meta.current_version_id,
924
- message: args.message,
925
- // Sync apps.queries from the manifest. Server validates each query
926
- // template (parseQueryNode, table access, param coverage).
927
- queries: meta.queries ?? {},
928
- // Capabilities are manifest-authoritative (like queries): always send,
929
- // defaulting to `{}` when the manifest declares none — so deleting the
930
- // `capabilities` block turns every capability OFF on the next deploy
931
- // (fail-safe; the declaration is the grant).
932
- capabilities: meta.capabilities ?? {},
933
- // Workflow BINDINGS are NOT a deploy concern — set_app_workflow /
934
- // remove_app_workflow own apps.workflows. But the alias KEYS of the
935
- // manifest's `workflows` map ARE sent (never the bindings): they record
936
- // which aliases this bundle declares, so remove_app_workflow can refuse
937
- // to unbind an alias the served version still calls. Drop an alias from
938
- // the manifest + redeploy to lift that guard before removing its binding.
939
- workflow_aliases: Object.keys(meta.workflows ?? {}),
940
- });
941
- writeAppMeta(projectDir, {
942
- ...meta,
943
- current_version_id: result.version_id,
944
- version_number: result.version_number,
945
- });
946
- console.error(`Deployed v${result.version_number} (${result.version_id})`);
947
- console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
948
- // One post-deploy getApp feeds both advisory nudges (branding, unbound
949
- // aliases). Deploy already succeeded — a failed check must not mask that.
950
- try {
951
- const app = await client.getApp(meta.app_id);
952
- warnIfUnbranded(app);
953
- warnIfUnboundAliases(app, meta.workflows ?? {}, meta.agents ?? {});
954
- }
955
- catch (err) {
956
- console.error(`(skipped post-deploy checks: ${err.message})`);
957
- }
958
- }
959
- catch (err) {
960
- const e = err;
961
- if (e.code === "VERSION_CONFLICT") {
962
- throw new Error(`Deploy conflict: server is at version ${e.current_version_id ?? "unknown"} ` +
963
- `but local copy is based on ${meta.current_version_id ?? "no version"}. ` +
964
- `Pull the latest before redeploying:\n lotics app pull ${meta.app_id}`);
965
- }
966
- throw err;
967
- }
968
- }
969
- finally {
970
- if (fs.existsSync(tmpSource))
971
- fs.unlinkSync(tmpSource);
972
- if (fs.existsSync(tmpDist))
973
- fs.unlinkSync(tmpDist);
974
- }
975
- }
976
- /**
977
- * Non-blocking nudge after a successful deploy: an app with no icon/color shows a
978
- * generic tile in the launcher. Branding is set via `update_app` (the single
979
- * setter) — this only reminds; it never fails the deploy.
980
- */
981
- function warnIfUnbranded(app) {
982
- const missing = [];
983
- if (!app.icon)
984
- missing.push("icon");
985
- if (!app.theme?.color)
986
- missing.push("color");
987
- if (missing.length === 0)
988
- return;
989
- console.error(`\n⚠ This app has no ${missing.join(" or ")} set — it shows a generic tile in the launcher.\n` +
990
- ` Set it: lotics run update_app '{"app_id":"${app.id}","icon":"<lucide-name>","theme":{"color":"blue"}}'\n` +
991
- ` Find an icon: lotics run search_app_icons '{"query":"<word>"}'`);
992
- }
993
- /**
994
- * Non-blocking nudge after a successful deploy: catch manifest workflow/agent aliases
995
- * that are NOT bound on the server. Deploy ships code + queries only — it never binds
996
- * workflows/agents (`set_app_workflow` / `set_app_agent` own `apps.workflows`/`apps.agents`;
997
- * the manifest maps are a pulled reflection). So an alias hand-added to `lotics.workflows`
998
- * / `lotics.agents` (or bound then removed) compiles + deploys clean and only throws when
999
- * the app first calls `useWorkflow` / `useAgentRun`. Surface that divergence HERE — at
1000
- * deploy time — instead of at the user's first click. Advisory only; never fails the deploy.
1001
- */
1002
- function warnIfUnboundAliases(app, declaredWorkflows, declaredAgents) {
1003
- const boundWorkflows = new Set(Object.keys(app.workflows ?? {}));
1004
- const boundAgents = new Set(Object.keys(app.agents ?? {}));
1005
- const unboundWorkflows = Object.keys(declaredWorkflows).filter((a) => !boundWorkflows.has(a));
1006
- const unboundAgents = Object.keys(declaredAgents).filter((a) => !boundAgents.has(a));
1007
- if (unboundWorkflows.length === 0 && unboundAgents.length === 0)
1008
- return;
1009
- const lines = [
1010
- "\n⚠ The manifest declares aliases that are NOT bound on the server. Deploy ships code +",
1011
- " queries only — it does NOT bind workflows/agents, so the app will throw \"has no … alias\"",
1012
- " the first time it calls them. Bind each one:",
1013
- ];
1014
- for (const alias of unboundWorkflows)
1015
- lines.push(` • workflow "${alias}" → lotics app workflow set ${alias}`);
1016
- for (const alias of unboundAgents)
1017
- lines.push(` • agent "${alias}" → bind with set_app_agent (lotics run set_app_agent …)`);
1018
- console.error(lines.join("\n"));
1019
- }
1020
- /**
1021
- * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
1022
- *
1023
- * Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
1024
- * that bridges the iframe's hooks (useQuery, useWorkflow) to api.lotics.ai
1025
- * using the CLI's stored API key. Wrapper
1026
- * iframe matches the production sandbox attributes exactly — null origin,
1027
- * allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
1028
- *
1029
- * Lifecycle: Vite as a child process (HMR over WebSocket), HTTP server on a
1030
- * sibling port serving the wrapper HTML + the /_rpc dispatcher. SIGINT kills
1031
- * both cleanly.
1032
- */
1033
- export async function appDev(client, args) {
1034
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
1035
- const meta = readAppMeta(projectDir);
1036
- // Regenerate AppWorkflows typing before Vite spins up so the dev typecheck
1037
- // sees the current shape. Doesn't watch for manifest changes mid-session —
1038
- // re-running `lotics app dev` after editing the manifest is the loop.
1039
- writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries, agents: meta.agents });
1040
- // Sanity: confirm the app exists in the workspace the CLI is auth'd into.
1041
- // Surfaces a clear error if the project's app_id has been deleted or the
1042
- // CLI is pointed at the wrong workspace.
1043
- const app = await client.getApp(meta.app_id);
1044
- const handle = await startDevServer({
1045
- projectDir,
1046
- app_id: meta.app_id,
1047
- app_name: app.name,
1048
- workspace_id: meta.workspace_id,
1049
- api_url: client.baseUrl,
1050
- port: args.port,
1051
- vitePort: args.vitePort,
1052
- client,
1053
- commentsEnabled: meta.capabilities?.comments,
1054
- });
1055
- await handle.ready;
1056
- const url = `http://localhost:${handle.port}`;
1057
- console.error(`\n lotics app dev`);
1058
- console.error(` app: ${app.name} (${meta.app_id})`);
1059
- console.error(` workspace: ${meta.workspace_id}`);
1060
- if (client.viewAsMemberId) {
1061
- console.error(` view as: ${client.viewAsMemberId} (is_current_member resolves to this member)`);
1062
- }
1063
- console.error(` vite: http://localhost:${handle.vitePort}/`);
1064
- console.error(` open: ${url}`);
1065
- console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
1066
- console.error(` Ctrl-C to stop.\n`);
1067
- openBrowser(url);
1068
- // Block until SIGINT.
1069
- await new Promise((resolve) => {
1070
- const onSig = () => {
1071
- process.off("SIGINT", onSig);
1072
- process.off("SIGTERM", onSig);
1073
- resolve();
1074
- };
1075
- process.on("SIGINT", onSig);
1076
- process.on("SIGTERM", onSig);
1077
- });
1078
- console.error("\nStopping…");
1079
- await handle.stop();
1080
- }
1081
- /** Narrow the loose envelope's `created_records` into typed groups. */
1082
- function parseCreatedRecords(value) {
1083
- if (!Array.isArray(value))
1084
- return [];
1085
- const groups = [];
1086
- for (const entry of value) {
1087
- if (!entry || typeof entry !== "object")
1088
- continue;
1089
- const tableId = entry.table_id;
1090
- const recordIds = entry.record_ids;
1091
- if (typeof tableId !== "string" || !Array.isArray(recordIds))
1092
- continue;
1093
- const ids = recordIds.filter((id) => typeof id === "string");
1094
- if (ids.length > 0)
1095
- groups.push({ table_id: tableId, record_ids: ids });
1096
- }
1097
- return groups;
1098
- }
1099
- /** Narrow `irreversible_tool_calls` into a list of tool names (deduped, ordered). */
1100
- function parseIrreversibleToolNames(value) {
1101
- if (!Array.isArray(value))
1102
- return [];
1103
- const names = [];
1104
- for (const entry of value) {
1105
- if (!entry || typeof entry !== "object")
1106
- continue;
1107
- const name = entry.tool_name;
1108
- if (typeof name === "string" && !names.includes(name))
1109
- names.push(name);
1110
- }
1111
- return names;
1112
- }
1113
- /**
1114
- * Print the honest harvest of a run's side effects to stderr (GAP-58): created
1115
- * records grouped by table, a ready-to-paste `lotics run delete_records …` per
1116
- * table, then the MANDATORY caveat naming what cannot be auto-undone. Never
1117
- * deletes anything — this only reports.
1118
- */
1119
- function printSideEffects(summary) {
1120
- const created = parseCreatedRecords(summary.created_records);
1121
- const irreversibleTools = parseIrreversibleToolNames(summary.irreversible_tool_calls);
1122
- const subWorkflows = summary.sub_workflows_possible === true;
1123
- console.error("\nCreated records:");
1124
- if (created.length === 0) {
1125
- console.error(" (none with ids to clean up)");
1126
- }
1127
- else {
1128
- for (const group of created) {
1129
- console.error(` ${group.table_id}: ${group.record_ids.length} record(s)`);
1130
- const payload = JSON.stringify({ table_id: group.table_id, record_ids: group.record_ids });
1131
- console.error(` lotics run delete_records '${payload}'`);
1132
- }
1133
- }
1134
- // The caveat is mandatory and unconditional — a clean run still owes the
1135
- // reader the explicit "this is not a rollback" framing so cleanup is never
1136
- // mistaken for complete.
1137
- const irreversiblePart = irreversibleTools.length > 0
1138
- ? `Could NOT auto-undo (clean up manually): ${irreversibleTools.join(", ")}.`
1139
- : "Could NOT auto-undo: none.";
1140
- const subPart = subWorkflows
1141
- ? " Sub-workflows may have run (after_* table workflows) — their effects are NOT in this list."
1142
- : "";
1143
- console.error(`\n${irreversiblePart}${subPart}`);
1144
- }
1145
- /**
1146
- * Run the harvested deletes for created records ONLY (never files / external /
1147
- * notifications — those are reported, never silently undone). Best-effort: a
1148
- * failed delete is logged and the rest continue. Returns `false` when ANY delete
1149
- * failed, so the command boundary can exit non-zero — a CI script branching on
1150
- * the exit code must not read partial cleanup as success.
1151
- */
1152
- async function cleanupCreatedRecords(client, created) {
1153
- if (created.length === 0) {
1154
- console.error("\nNo created records to clean up.");
1155
- return true;
1156
- }
1157
- console.error("\nCleaning up created records (delete_records — records only):");
1158
- let allDeleted = true;
1159
- for (const group of created) {
1160
- const res = await client.execute("delete_records", {
1161
- table_id: group.table_id,
1162
- record_ids: group.record_ids,
1163
- });
1164
- if (res.error) {
1165
- console.error(` ✗ ${group.table_id}: ${res.error}`);
1166
- allDeleted = false;
1167
- }
1168
- else {
1169
- console.error(` ✓ ${group.table_id}: deleted ${group.record_ids.length} record(s)`);
1170
- }
1171
- }
1172
- return allDeleted;
1173
- }
1174
- /**
1175
- * `lotics app workflow run <alias> '<json>'` — execute a bound app workflow
1176
- * end-to-end against the live workspace. `app_id` comes from the local manifest
1177
- * (like deploy/dev), the alias must be bound server-side via `set_app_workflow`.
1178
- *
1179
- * The full `{ status, message, data, files, side_effects }` JSON prints to
1180
- * stdout (pipeable / assertable); a one-line human summary goes to stderr. A
1181
- * `status: "error"` envelope exits non-zero so a script can branch on it — the
1182
- * transport already normalizes a gateway/timeout failure into the same
1183
- * `{ status: "error" }` shape, so a failed run is never a thrown HTML body.
1184
- *
1185
- * `--print-created` (alias `--report-effects`) renders the honest post-run
1186
- * harvest (GAP-58): created records grouped by table, a paste-ready
1187
- * `delete_records` per table, and the mandatory caveat about what cannot be
1188
- * auto-undone. `--cleanup` (DEFAULT OFF) additionally runs the deletes for the
1189
- * harvested records ONLY — never files, external integrations, or notifications.
1190
- * Neither is a rollback; a rollback is structurally impossible here.
1191
- */
1192
- export async function appExecuteWorkflow(client, args) {
1193
- const meta = readAppMeta(process.cwd());
1194
- const result = (await client.appWorkflow(meta.app_id, args.alias, args.inputs));
1195
- console.log(JSON.stringify(result, null, 2));
1196
- const status = typeof result.status === "string" ? result.status : "unknown";
1197
- const message = typeof result.message === "string" ? result.message : "";
1198
- console.error(`Workflow "${args.alias}" → ${status}${message ? `: ${message}` : ""}`);
1199
- // --cleanup implies the report (you should always see what's being undone).
1200
- let cleanupFailed = false;
1201
- if ((args.printCreated || args.cleanup) && result.side_effects) {
1202
- printSideEffects(result.side_effects);
1203
- if (args.cleanup) {
1204
- const allDeleted = await cleanupCreatedRecords(client, parseCreatedRecords(result.side_effects.created_records));
1205
- cleanupFailed = !allDeleted;
1206
- }
1207
- }
1208
- else if (args.printCreated || args.cleanup) {
1209
- console.error("\n(no side-effect summary returned by the server)");
1210
- }
1211
- // Exit non-zero on an error run OR a partial cleanup — a script must not read
1212
- // either as success.
1213
- if (status === "error" || cleanupFailed)
1214
- process.exit(1);
1215
- }
1216
- /**
1217
- * `lotics app workflow set <alias>` — push the edited `src/workflows/<alias>.ts`
1218
- * body to the server through `set_app_workflow` (the single author of
1219
- * `apps.workflows`). The body is read from disk (header stripped); the typed
1220
- * `inputs`/`outputs` schemas come from `package.json#lotics.workflows.<alias>`,
1221
- * so a pulled-then-edited app keeps its declared contract. The server re-verifies
1222
- * the body and echoes the bound `outputs` (declared, else DERIVED from
1223
- * `return({ data })`) — the same guarantee as calling `set_app_workflow` by hand,
1224
- * with no fetch/reconstruct/escape. Errors (missing file, unbound alias, verify
1225
- * failure) print to stderr and exit non-zero.
1226
- *
1227
- * This is a CLI convenience over the existing tool — `lotics app deploy` is still
1228
- * NOT an author of workflows; the single-author invariant holds.
1229
- */
1230
- export async function appWorkflowSet(client, args) {
1231
- const projectDir = process.cwd();
1232
- const meta = readAppMeta(projectDir);
1233
- const declaration = meta.workflows?.[args.alias];
1234
- if (!declaration) {
1235
- console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
1236
- `Bind it first (set_app_workflow), then 'lotics app pull' to write its body and manifest entry.`);
1237
- process.exit(1);
1238
- }
1239
- const file = workflowFilePath(projectDir, args.alias);
1240
- if (!fs.existsSync(file)) {
1241
- console.error(`No workflow body at ${path.relative(projectDir, file)}. ` +
1242
- `Run 'lotics app pull ${meta.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`);
1243
- process.exit(1);
1244
- }
1245
- const source = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
1246
- if (source.trim() === "") {
1247
- console.error(`Workflow body ${path.relative(projectDir, file)} is empty after stripping the header.`);
1248
- process.exit(1);
1249
- }
1250
- const res = await client.setAppWorkflow(meta.app_id, args.alias, {
1251
- source,
1252
- inputs: declaration.inputs,
1253
- outputs: declaration.outputs,
1254
- });
1255
- if (res.error) {
1256
- console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
1257
- process.exit(1);
1258
- }
1259
- // set_app_workflow echoes { app_id, alias, workflow_id, outputs? } — outputs is
1260
- // declared-wins-else-DERIVED from return({ data }), the shape result.data carries.
1261
- const result = (res.result ?? {});
1262
- const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
1263
- console.error(`Set workflow "${args.alias}" → ${workflowId}`);
1264
- if (result.outputs && typeof result.outputs === "object") {
1265
- console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
1266
- }
1267
- }
1268
- /**
1269
- * `lotics app query set <alias>` — push `package.json#lotics.queries.<alias>` to
1270
- * `apps.queries` through `set_app_query`, WITHOUT a deploy. The deploy-free inner
1271
- * loop for named queries, parallel to `lotics app workflow set` for workflows.
1272
- *
1273
- * The declaration (`{ ast, params? }`) is read from the manifest — the same map
1274
- * `useQuery` codegen reads and `lotics app deploy` syncs authoritatively. The
1275
- * server validates it exactly as a deploy does (alias identifier, workspace-only
1276
- * tables, resolvable fields, declared params). Because `apps.queries` is
1277
- * manifest-authoritative, the next `lotics app deploy` overwrites this from the
1278
- * manifest — so keep the manifest as the source of truth; this only skips the
1279
- * build/upload round-trip while iterating. Errors (unbound alias, validation
1280
- * failure) print to stderr and exit non-zero.
1281
- */
1282
- export async function appQuerySet(client, args) {
1283
- const projectDir = process.cwd();
1284
- const meta = readAppMeta(projectDir);
1285
- const declaration = meta.queries?.[args.alias];
1286
- if (!declaration) {
1287
- console.error(`No query "${args.alias}" in package.json#lotics.queries. ` +
1288
- `Declare it there (alias → { ast, params? }) first.`);
1289
- process.exit(1);
1290
- }
1291
- const res = await client.setAppQuery(meta.app_id, args.alias, declaration);
1292
- if (res.error) {
1293
- console.error(`Failed to set query "${args.alias}": ${res.error}`);
1294
- process.exit(1);
1295
- }
1296
- console.error(`Set query "${args.alias}" on ${meta.app_id}. ` +
1297
- `(apps.queries is manifest-authoritative — the next 'lotics app deploy' re-syncs it.)`);
1298
- }
1299
- /**
1300
- * `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
1301
- * server without a full `lotics app pull` (no source archive, no npm install).
1302
- * The alias set + bodies come from the live App row (the same source `app pull`
1303
- * uses); a legacy alias with no rendered source warns and is skipped.
1304
- */
1305
- export async function appWorkflowPull(client) {
1306
- const projectDir = process.cwd();
1307
- const meta = readAppMeta(projectDir);
1308
- const app = await client.getApp(meta.app_id);
1309
- const aliases = Object.keys(app.workflows ?? {});
1310
- if (aliases.length === 0) {
1311
- console.error(`App ${meta.app_id} has no bound workflows.`);
1312
- return;
1313
- }
1314
- const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
1315
- console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
1316
- (written.length > 0 ? ` (${written.join(", ")})` : ""));
1317
- // This command writes workflow BODIES, not the `.d.ts` — so it doesn't reach
1318
- // `writeAppDts`'s heal. Heal here so the bodies land excluded and a stale
1319
- // `.lotics` include doesn't leave the app's other generated types dead.
1320
- ensureAppTsconfig(projectDir);
1321
- }
1322
- /**
1323
- * `lotics app workflow check [alias]` — local TypeScript type check of the
1324
- * editable workflow bodies, ONE isolated program per bound alias (GAP-59 fix).
1325
- *
1326
- * The dedicated `tsconfig.workflows.json` that GAP-59 first shipped compiled ALL
1327
- * aliases' bodies + per-alias ambient globals into a SINGLE program, so the N
1328
- * `declare const trigger: AppWorkflowTrigger` declarations (each with THAT
1329
- * alias's `app_workflow.inputs`) collided — tsc resolved one and every body
1330
- * checked `trigger.app_workflow.inputs` against the wrong alias. This command
1331
- * replaces that config: it builds a separate `ts.Program` per alias from exactly
1332
- * that alias's `{body, globals}` pair (mirroring the SERVER, which verifies one
1333
- * body at a time), so the ambient `trigger` is unambiguous and the verdict
1334
- * matches set-time. All aliases run in ONE process.
1335
- *
1336
- * `[alias]` checks one alias; omitted, checks every bound alias that has a body
1337
- * file. Exits non-zero if ANY alias has a type error. A bound alias with no body
1338
- * file yet (never pulled) is warned and skipped; an alias missing its globals
1339
- * file is an error (the body can't be checked without its types).
1340
- */
1341
- export async function appWorkflowCheck(args) {
1342
- const projectDir = process.cwd();
1343
- const meta = readAppMeta(projectDir);
1344
- const bound = Object.keys(meta.workflows ?? {});
1345
- let aliases;
1346
- if (args.alias) {
1347
- if (!bound.includes(args.alias)) {
1348
- console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
1349
- `Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`);
1350
- process.exit(1);
1351
- }
1352
- aliases = [args.alias];
1353
- }
1354
- else {
1355
- aliases = bound;
1356
- }
1357
- if (aliases.length === 0) {
1358
- console.error(`App ${meta.app_id} has no bound workflows to check.`);
1359
- return;
1360
- }
1361
- // Each alias contributes its OWN body + globals. A bound alias never pulled has
1362
- // no body file — warn + skip (not an error; the author hasn't pulled it). A
1363
- // body with no globals can't be checked — that IS an error (run a pull).
1364
- const toCheck = [];
1365
- for (const alias of aliases) {
1366
- const bodyPath = workflowFilePath(projectDir, alias);
1367
- const globalsPath = workflowGlobalsPath(projectDir, alias);
1368
- if (!fs.existsSync(bodyPath)) {
1369
- console.error(`⚠ Skipped "${alias}" — no body at ${path.relative(projectDir, bodyPath)}. ` +
1370
- `Run 'lotics app workflow pull' to write it.`);
1371
- continue;
1372
- }
1373
- if (!fs.existsSync(globalsPath)) {
1374
- console.error(`Cannot check "${alias}" — missing types at ${path.relative(projectDir, globalsPath)}. ` +
1375
- `Run 'lotics app workflow pull' (or 'lotics app codegen') to fetch them.`);
1376
- process.exit(1);
1377
- }
1378
- toCheck.push({ alias, input: { bodyPath, globalsPath } });
1379
- }
1380
- if (toCheck.length === 0) {
1381
- console.error("No workflow bodies to check (every bound alias was skipped).");
1382
- return;
1383
- }
1384
- const tsApi = await loadProjectTypescript(projectDir);
1385
- const results = checkWorkflowBodies(tsApi, toCheck);
1386
- printWorkflowCheckResults(projectDir, results);
1387
- const failed = results.filter((r) => r.issues.length > 0);
1388
- if (failed.length > 0)
1389
- process.exit(1);
1390
- }
1391
- /**
1392
- * Render the per-alias verdict to stderr (status) — a clean line per passing
1393
- * alias, then `<file>:<line>:<col> - TS####: message` per error, grouped by
1394
- * alias, with a final tally. Lines point at the author's body (the envelope
1395
- * offset already removed in `checkOneWorkflowBody`).
1396
- */
1397
- function printWorkflowCheckResults(projectDir, results) {
1398
- let totalErrors = 0;
1399
- for (const r of results) {
1400
- const rel = path.relative(projectDir, r.bodyPath);
1401
- if (r.issues.length === 0) {
1402
- console.error(`✓ ${r.alias} (${rel}) — no type errors`);
1403
- continue;
1404
- }
1405
- totalErrors += r.issues.length;
1406
- console.error(`✗ ${r.alias} (${rel}) — ${r.issues.length} error${r.issues.length === 1 ? "" : "s"}:`);
1407
- for (const issue of r.issues) {
1408
- // TS multi-line messages indent every continuation under the location line.
1409
- const [first, ...rest] = issue.message.split("\n");
1410
- console.error(` ${rel}:${issue.line}:${issue.col} - ${issue.code}: ${first}`);
1411
- for (const line of rest)
1412
- console.error(` ${line}`);
1413
- }
1414
- }
1415
- const passed = results.length - results.filter((r) => r.issues.length > 0).length;
1416
- console.error(totalErrors === 0
1417
- ? results.length === 1
1418
- ? `\nThe workflow body type-checks clean.`
1419
- : `\nAll ${results.length} workflow bodies type-check clean.`
1420
- : `\n${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - passed} of ${results.length} ${results.length === 1 ? "body" : "bodies"}.`);
1421
- }
1422
- /**
1423
- * Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
1424
- * not found — an external npm app author has no monorepo checkout, so `ui link`
1425
- * must fail loud rather than write a broken alias.
1426
- */
1427
- function findUiSrcDir(start) {
1428
- let dir = path.resolve(start);
1429
- for (;;) {
1430
- const candidate = path.join(dir, "packages", "ui", "src");
1431
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
1432
- return candidate;
1433
- const parent = path.dirname(dir);
1434
- if (parent === dir)
1435
- return null;
1436
- dir = parent;
1437
- }
1438
- }
1439
- // The dev-link alias entry — a package-wide `@lotics/ui/<subpath>` → local
1440
- // `packages/ui/src/<subpath>` redirect. Matched/removed by the literal `find`
1441
- // regex source so insert/remove is idempotent regardless of the replacement.
1442
- const UI_ALIAS_FIND_SOURCE = String.raw `/^@lotics\/ui\/(.+)$/`;
1443
- /**
1444
- * `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
1445
- * dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
1446
- * `packages/ui/src` go live (HMR) without a publish round-trip. `component` is
1447
- * advisory only — the alias is package-wide (one subpath regex covers every
1448
- * import); it's validated to exist under `packages/ui/src` so a typo fails here.
1449
- *
1450
- * Idempotent: linking twice is a no-op; `--remove` strips the one inserted
1451
- * entry and leaves the rest of `resolve.alias` intact.
1452
- */
1453
- export function appUiLink(args) {
1454
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
1455
- const viteConfigPath = path.join(projectDir, "vite.config.ts");
1456
- if (!fs.existsSync(viteConfigPath)) {
1457
- throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
1458
- }
1459
- // Resolve packages/ui/src. An explicit --ui-src / LOTICS_UI_SRC wins — that's how
1460
- // an EXTERNAL app (one that consumes @lotics/ui from npm, with no monorepo above
1461
- // it) links the local kit; otherwise walk up for a monorepo checkout.
1462
- const explicit = args.uiSrc ?? process.env.LOTICS_UI_SRC;
1463
- const uiSrc = explicit ? path.resolve(explicit) : findUiSrcDir(projectDir);
1464
- if (!uiSrc || !fs.existsSync(uiSrc) || !fs.statSync(uiSrc).isDirectory()) {
1465
- throw new Error(explicit
1466
- ? `--ui-src / LOTICS_UI_SRC points at '${explicit}', which is not a directory. ` +
1467
- `Pass the absolute path to the monorepo's packages/ui/src.`
1468
- : "Cannot find packages/ui/src by walking up from this directory. For an EXTERNAL app " +
1469
- "(consuming @lotics/ui from npm), pass --ui-src=<abs path to packages/ui/src> or set " +
1470
- "LOTICS_UI_SRC; inside a monorepo checkout it is found automatically.");
1471
- }
1472
- // Validate the named component exists in src so a typo fails loud (the alias
1473
- // itself stays package-wide — this is the advisory check the spec calls for).
1474
- const hasComponent = fs.existsSync(path.join(uiSrc, `${args.component}.tsx`)) ||
1475
- fs.existsSync(path.join(uiSrc, `${args.component}.ts`)) ||
1476
- fs.existsSync(path.join(uiSrc, args.component));
1477
- if (!hasComponent) {
1478
- throw new Error(`No '@lotics/ui/${args.component}' under ${uiSrc} (expected ${args.component}.tsx/.ts). ` +
1479
- `Check the component name.`);
1480
- }
1481
- const source = fs.readFileSync(viteConfigPath, "utf-8");
1482
- const aliasEntry = `{ find: ${UI_ALIAS_FIND_SOURCE}, replacement: ${JSON.stringify(`${uiSrc}/$1`)} },`;
1483
- const alreadyLinked = source.includes(UI_ALIAS_FIND_SOURCE);
1484
- if (args.remove) {
1485
- if (!alreadyLinked) {
1486
- console.error("No @lotics/ui dev-link alias present — nothing to remove.");
1487
- return;
1488
- }
1489
- // Drop the whole alias line (the entry + its own line), leaving the rest of
1490
- // resolve.alias untouched.
1491
- const stripped = source.replace(new RegExp(`^\\s*\\{ find: ${escapeRegExp(UI_ALIAS_FIND_SOURCE)}.*$\\n?`, "m"), "");
1492
- fs.writeFileSync(viteConfigPath, stripped);
1493
- console.error(`Removed the @lotics/ui dev-link alias from ${viteConfigPath}.`);
1494
- console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1495
- return;
1496
- }
1497
- if (alreadyLinked) {
1498
- console.error(`@lotics/ui is already dev-linked in ${viteConfigPath}.`);
1499
- return;
1500
- }
1501
- const aliasMatch = /resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\[/.exec(source);
1502
- if (!aliasMatch) {
1503
- throw new Error(`Could not find a resolve.alias array literal in ${viteConfigPath}. ` +
1504
- `Refresh vite.config.ts from the starter (packages/sdk/src/starter_template.ts) and retry.`);
1505
- }
1506
- const insertAt = aliasMatch.index + aliasMatch[0].length;
1507
- const updated = `${source.slice(0, insertAt)}\n ${aliasEntry}${source.slice(insertAt)}`;
1508
- fs.writeFileSync(viteConfigPath, updated);
1509
- console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
1510
- console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1511
- // The app's tsc still resolves @lotics/ui from node_modules (the published .d.ts) —
1512
- // the kit `src` can't be typechecked in an app because it's RN-Web (uses
1513
- // react-native-web types the app resolves as base react-native). Typecheck the kit
1514
- // in packages/ui; the finalize publish restores the app's own typecheck.
1515
- console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
1516
- }
1517
- /** Escape a string for literal use inside a RegExp. */
1518
- function escapeRegExp(s) {
1519
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1520
- }