@lotics/cli 0.56.0 → 0.60.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.
@@ -17,9 +17,12 @@ import { spawn } from "node:child_process";
17
17
  import { tmpdir } from "node:os";
18
18
  import { buildStarterTemplate } from "./starter_template.js";
19
19
  import { startDevServer, openBrowser } from "./dev/server.js";
20
+ import { ipv4ChildEnv } from "./child_env.js";
20
21
  import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
21
22
  import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
22
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";
23
26
  /**
24
27
  * Resolve the latest published version of a package from the npm registry.
25
28
  * Returns null on any failure (network error, 404, malformed payload) so
@@ -46,6 +49,190 @@ async function fetchLatestNpmVersion(packageName) {
46
49
  return null;
47
50
  }
48
51
  }
52
+ /** The directory, relative to the project root, that holds editable workflow bodies. */
53
+ const WORKFLOWS_DIR = path.join("src", "workflows");
54
+ /** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
55
+ const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
56
+ /**
57
+ * The `async function __workflow(...)` wrapper a workflow body sits inside —
58
+ * the SAME envelope the server compiles the body within at `set_app_workflow`
59
+ * verify time (GAP-59). Carried so a body that uses top-level `await` and ends
60
+ * with `return({...})` typechecks locally exactly as the server checks it. The
61
+ * server returns the canonical strings (`getAppWorkflowDts`); these are the
62
+ * offline fallback when the dts fetch fails so the file is still wrapped — a
63
+ * test pins them equal to the server's, so they can't drift.
64
+ */
65
+ export const FALLBACK_ENVELOPE_PREFIX = "async function __workflow(): Promise<__WorkflowReturn | void> {\n";
66
+ export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
67
+ /**
68
+ * Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
69
+ * reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
70
+ * calls — server-generated), and the body sits inside the SAME `__workflow`
71
+ * wrapper the server compiles within, so a local `tsc` now mirrors the set-time
72
+ * verdict (GAP-59). The wrapper + reference + comment lines are CLI bookkeeping,
73
+ * stripped on `set`; the filename IS the alias — renaming it orphans the body.
74
+ */
75
+ function workflowFileHeader(alias) {
76
+ const refPath = path
77
+ .join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`)
78
+ .split(path.sep)
79
+ .join("/");
80
+ return (`/// <reference path="${refPath}" />\n` +
81
+ `// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
82
+ `// lines below, then push with:\n` +
83
+ `// lotics app workflow set ${alias}\n` +
84
+ `// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
85
+ `// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
86
+ `// set) — they only make the body typecheck locally against the workspace types.\n` +
87
+ `// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
88
+ // `export {};` makes the file a MODULE so the per-file `__workflow` wrapper
89
+ // doesn't collide across the bodies the dedicated tsconfig globs together.
90
+ // The server compiles each body in isolation (script mode), so this is a
91
+ // local-only adaptation with no effect on the body's type-checking; it is
92
+ // CLI bookkeeping, stripped on `set`.
93
+ `export {};\n`);
94
+ }
95
+ /** Absolute path of one workflow body file, given the project root + alias. */
96
+ function workflowFilePath(projectDir, alias) {
97
+ return path.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
98
+ }
99
+ /** Absolute path of one alias's ambient globals `.d.ts`. */
100
+ function workflowGlobalsPath(projectDir, alias) {
101
+ return path.join(projectDir, WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`);
102
+ }
103
+ /**
104
+ * Write `.lotics/workflows/<alias>.globals.d.ts` = the server-generated ambient
105
+ * declarations the body typechecks against. Idempotent. Returns the path.
106
+ */
107
+ export function writeWorkflowGlobals(projectDir, alias, dts) {
108
+ const dir = path.join(projectDir, WORKFLOW_GLOBALS_DIR);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const file = workflowGlobalsPath(projectDir, alias);
111
+ fs.writeFileSync(file, `${dts.replace(/\s+$/, "")}\n`);
112
+ return file;
113
+ }
114
+ /**
115
+ * Write `src/workflows/<alias>.ts` = header (triple-slash reference + comments)
116
+ * + the body wrapped in the `__workflow` envelope. Idempotent (a re-pull
117
+ * overwrites with the current server body). The raw `source` round-trips on
118
+ * `set` (the header + wrapper are stripped). Exported for direct unit testing —
119
+ * the surrounding pull shells out to `tar`/`npm`.
120
+ */
121
+ export function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX }) {
122
+ const dir = path.join(projectDir, WORKFLOWS_DIR);
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ const file = workflowFilePath(projectDir, alias);
125
+ // Body verbatim inside the wrapper so a later `set` round-trips byte-for-byte
126
+ // (modulo the header + wrapper, which are stripped). The prefix already ends
127
+ // with a newline and the suffix begins with one (server envelope shape).
128
+ const body = source.replace(/\s+$/, "");
129
+ fs.writeFileSync(file, `${workflowFileHeader(alias)}\n${envelope.prefix}${body}${envelope.suffix}\n`);
130
+ return file;
131
+ }
132
+ /** Matches the `__workflow` envelope opener (the wrapper `writeWorkflowFile`
133
+ * prepends). Recognized structurally (not by exact return-type text) so a
134
+ * future envelope-signature tweak doesn't silently leave the wrapper in the
135
+ * pushed source. */
136
+ const WORKFLOW_WRAPPER_OPENER = /^\s*async\s+function\s+__workflow\s*\(/;
137
+ /**
138
+ * Strip the CLI bookkeeping back off a workflow body before pushing it: the
139
+ * triple-slash reference + the `//` header comments + the `export {};` marker +
140
+ * blank lines, then the `__workflow` envelope (the opening
141
+ * `async function __workflow(...) {` line and the matching trailing `}`). `set`
142
+ * sends ONLY the JS-subset body the author edited — the server stays the single
143
+ * verifier.
144
+ *
145
+ * The strip is anchored on the GENERATED bookkeeping, never on "the file happens
146
+ * to start with comments": the leading comment/marker/blank block is only peeled
147
+ * when it is immediately followed by the `__workflow` wrapper opener (the exact
148
+ * shape `writeWorkflowFile` produces). A hand-written, wrapper-LESS body whose
149
+ * first lines are comments therefore round-trips unchanged — its comments are
150
+ * real source, not bookkeeping, and must not be silently eaten.
151
+ */
152
+ export function stripWorkflowHeader(content) {
153
+ const lines = content.split("\n");
154
+ // Probe how far a generated header would extend: `//`/`///` lines, then the
155
+ // `export {};` module marker, then the blank line(s) before the wrapper.
156
+ let headerEnd = 0;
157
+ while (headerEnd < lines.length && /^\s*\/\//.test(lines[headerEnd]))
158
+ headerEnd++;
159
+ while (headerEnd < lines.length && /^\s*export\s*\{\s*\}\s*;?\s*$/.test(lines[headerEnd]))
160
+ headerEnd++;
161
+ while (headerEnd < lines.length && lines[headerEnd].trim() === "")
162
+ headerEnd++;
163
+ // Peel the header ONLY when the wrapper opener follows it — that's the proof
164
+ // the leading block was generated bookkeeping and not the author's own source.
165
+ // If the file opens with the wrapper directly (degraded pull with no header),
166
+ // peel from there. Otherwise leave the body verbatim.
167
+ let start;
168
+ if (headerEnd < lines.length && WORKFLOW_WRAPPER_OPENER.test(lines[headerEnd])) {
169
+ start = headerEnd;
170
+ }
171
+ else if (lines.length > 0 && WORKFLOW_WRAPPER_OPENER.test(lines[0])) {
172
+ start = 0;
173
+ }
174
+ else {
175
+ return content.replace(/\s+$/, "");
176
+ }
177
+ // `start` is the wrapper opener line — drop it and the matching trailing `}`.
178
+ let end = lines.length - 1;
179
+ while (end > start && lines[end].trim() === "")
180
+ end--;
181
+ if (lines[end]?.trim() === "}") {
182
+ return lines.slice(start + 1, end).join("\n").replace(/\s+$/, "");
183
+ }
184
+ // Opener present but no closing `}` — a malformed wrapper; push the body after
185
+ // the opener verbatim rather than guessing where the envelope ends.
186
+ return lines.slice(start + 1).join("\n").replace(/\s+$/, "");
187
+ }
188
+ /**
189
+ * Pull every workflow body the server can render into `src/workflows/<alias>.ts`,
190
+ * plus the per-alias ambient globals `.lotics/workflows/<alias>.globals.d.ts` that
191
+ * make the body locally typecheckable. The alias set comes from the live
192
+ * `apps.workflows` map; each body comes from `get_app_workflow` (faithful source
193
+ * re-rendered from the persisted step tree) and each globals + envelope from
194
+ * `getAppWorkflowDts`. A legacy alias whose source can't be read is WARNED and
195
+ * SKIPPED — never a failure — so a partially-migrated app still pulls. A dts
196
+ * fetch failure is non-fatal too: the body is still written (with the fallback
197
+ * envelope), only the local typecheck is degraded — mirrors `app_fields`.
198
+ * Returns the aliases written.
199
+ */
200
+ async function writeWorkflowFiles(client, projectDir, app_id, aliases) {
201
+ const written = [];
202
+ for (const alias of aliases) {
203
+ const res = await client.getAppWorkflow(app_id, alias);
204
+ const source = res.error || res.result === null || typeof res.result !== "object"
205
+ ? null
206
+ : res.result.source;
207
+ if (typeof source !== "string" || source.trim() === "") {
208
+ console.error(`⚠ Skipped src/workflows/${alias}.ts — the server returned no readable source ` +
209
+ `(${res.error ?? "legacy workflow with no rendered body"}).`);
210
+ continue;
211
+ }
212
+ const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
213
+ writeWorkflowFile(projectDir, alias, source, envelope);
214
+ written.push(alias);
215
+ }
216
+ return written;
217
+ }
218
+ /**
219
+ * Fetch + write one alias's ambient globals `.d.ts` and return the wrapper
220
+ * envelope to wrap the body in. Network failure is non-fatal (warn, keep the
221
+ * last-written globals if any) and the body is wrapped in the FALLBACK envelope
222
+ * so it still parses — mirrors `app_fields`'s offline tolerance.
223
+ */
224
+ async function fetchWorkflowGlobals(client, projectDir, app_id, alias) {
225
+ try {
226
+ const { dts, envelope_prefix, envelope_suffix } = await client.getAppWorkflowDts(app_id, alias);
227
+ writeWorkflowGlobals(projectDir, alias, dts);
228
+ return { prefix: envelope_prefix, suffix: envelope_suffix };
229
+ }
230
+ catch (err) {
231
+ console.error(`⚠ Could not fetch workflow types for "${alias}" (${err instanceof Error ? err.message : String(err)}). ` +
232
+ `Wrote the body with the fallback wrapper; its local typecheck may be degraded.`);
233
+ return { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX };
234
+ }
235
+ }
49
236
  /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
50
237
  function runTar(args, cwd) {
51
238
  return new Promise((resolve, reject) => {
@@ -66,7 +253,7 @@ function runTar(args, cwd) {
66
253
  /** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
67
254
  function runNpm(args, cwd) {
68
255
  return new Promise((resolve, reject) => {
69
- const proc = spawn("npm", args, { cwd, stdio: "inherit" });
256
+ const proc = spawn("npm", args, { cwd, stdio: "inherit", env: ipv4ChildEnv(process.env) });
70
257
  proc.on("error", reject);
71
258
  proc.on("exit", (code) => {
72
259
  if (code === 0)
@@ -113,9 +300,121 @@ function writeAppMeta(projectDir, meta) {
113
300
  function writeAppDts(projectDir, manifest) {
114
301
  const dotLotics = path.join(projectDir, ".lotics");
115
302
  fs.mkdirSync(dotLotics, { recursive: true });
116
- fs.writeFileSync(path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows));
117
- fs.writeFileSync(path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries));
118
- fs.writeFileSync(path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents));
303
+ const written = [
304
+ [path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
305
+ [path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
306
+ [path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)],
307
+ ];
308
+ for (const [file, content] of written)
309
+ fs.writeFileSync(file, content);
310
+ return written.map(([file]) => file);
311
+ }
312
+ /**
313
+ * The optional `package.json#lotics.codegen.tables` allowlist — extra table ids
314
+ * to include in `app_fields.ts` beyond those the named queries reference (e.g.
315
+ * tables an app only writes to via a workflow, never queries). Returns `[]` when
316
+ * absent or malformed (codegen falls back to the query-derived set).
317
+ */
318
+ function readCodegenTablesAllowlist(projectDir) {
319
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"));
320
+ const tables = pkg.lotics?.codegen?.tables;
321
+ return Array.isArray(tables) ? tables.filter((t) => typeof t === "string") : [];
322
+ }
323
+ /**
324
+ * The table ids `app_fields.ts` should cover: the union of every `from_table`
325
+ * referenced by a declared query AST plus the optional manifest allowlist. The
326
+ * query set is what the app can actually read; the allowlist widens it to tables
327
+ * only ever written (so their field/option aliases are still addressable).
328
+ */
329
+ function resolveCodegenTableIds(projectDir, queries) {
330
+ const ids = new Set();
331
+ for (const decl of Object.values(queries)) {
332
+ // The manifest stores the AST untyped (`unknown`) — it's the server's
333
+ // deploy-time concern to verify. Cast at this single boundary so codegen
334
+ // reuses the canonical exhaustive walker (a new QueryNode kind is a
335
+ // compile error there, never a silently-dropped table).
336
+ for (const id of collectQueryTableIds(decl.ast))
337
+ ids.add(id);
338
+ }
339
+ for (const id of readCodegenTablesAllowlist(projectDir))
340
+ ids.add(id);
341
+ return [...ids];
342
+ }
343
+ /**
344
+ * Write the runtime `.lotics/app_fields.ts` from a resolved workspace schema.
345
+ * Separate from `writeAppDts` (the type-only companions) because this one needs
346
+ * the client to fetch the schema. Returns the written path.
347
+ */
348
+ function writeAppFields(projectDir, tables) {
349
+ const dotLotics = path.join(projectDir, ".lotics");
350
+ fs.mkdirSync(dotLotics, { recursive: true });
351
+ const file = path.join(dotLotics, "app_fields.ts");
352
+ fs.writeFileSync(file, generateAppFields(tables));
353
+ return file;
354
+ }
355
+ /**
356
+ * `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
357
+ * manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
358
+ * always written (synchronous, no network). When a `client` is available, the
359
+ * runtime `app_fields.ts` is also regenerated from the live schema of the tables
360
+ * the app's queries reference (+ the allowlist); a network failure is non-fatal
361
+ * (warn, keep the last-generated file) — so codegen still does useful work
362
+ * offline, mirroring `app create`'s tolerance of an offline npm registry.
363
+ */
364
+ export async function appCodegen(args) {
365
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
366
+ // readAppMeta throws the clear not-an-app error when there's no manifest.
367
+ const meta = readAppMeta(projectDir);
368
+ const dtsPaths = writeAppDts(projectDir, {
369
+ workflows: meta.workflows,
370
+ queries: meta.queries,
371
+ agents: meta.agents,
372
+ });
373
+ for (const p of dtsPaths)
374
+ console.error(`Regenerated ${p}`);
375
+ if (!args.client) {
376
+ console.error("Skipped .lotics/app_fields.ts — no workspace credentials resolved. " +
377
+ "Run authenticated (or set LOTICS_API_KEY) to regenerate field/option ids.");
378
+ return;
379
+ }
380
+ const tableIds = resolveCodegenTableIds(projectDir, meta.queries ?? {});
381
+ try {
382
+ const tables = await args.client.getWorkspaceSchema(tableIds);
383
+ const fieldsPath = writeAppFields(projectDir, tables);
384
+ console.error(`Regenerated ${fieldsPath} (${tables.length} table${tables.length === 1 ? "" : "s"})`);
385
+ }
386
+ catch (err) {
387
+ // Non-fatal: keep the last-generated app_fields.ts so an offline/transient
388
+ // failure doesn't strip the app's field aliases (same tolerance as the npm
389
+ // registry lookup on `app create`).
390
+ console.error(`⚠ Could not fetch the workspace schema (${err instanceof Error ? err.message : String(err)}). ` +
391
+ `Kept the existing .lotics/app_fields.ts.`);
392
+ }
393
+ // Refresh each bound workflow's ambient globals + re-wrap its EXISTING local
394
+ // body in the current envelope (GAP-59). Codegen never re-fetches the body
395
+ // (that would clobber local edits) — it strips the on-disk body and re-wraps
396
+ // it, so the local typecheck tracks the current workspace schema. Aliases
397
+ // never pulled (no body file yet) are skipped — codegen isn't a pull.
398
+ await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
399
+ }
400
+ /**
401
+ * For each bound alias that already has a local body file, fetch its current
402
+ * ambient globals `.d.ts` + envelope and re-wrap the on-disk body. Preserves the
403
+ * author's edits (strips + re-wraps, never re-fetches the body). A dts fetch
404
+ * failure is non-fatal per alias (warned inside `fetchWorkflowGlobals`).
405
+ */
406
+ async function refreshWorkflowGlobals(client, projectDir, app_id, aliases) {
407
+ for (const alias of aliases) {
408
+ const file = workflowFilePath(projectDir, alias);
409
+ if (!fs.existsSync(file))
410
+ continue;
411
+ const body = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
412
+ if (body.trim() === "")
413
+ continue;
414
+ const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
415
+ writeWorkflowFile(projectDir, alias, body, envelope);
416
+ console.error(`Refreshed ${path.relative(projectDir, file)} + its workflow types`);
417
+ }
119
418
  }
120
419
  /**
121
420
  * Stamp the post-extraction manifest with server-authoritative meta. Called
@@ -245,8 +544,10 @@ export async function appCreate(client, args) {
245
544
  * 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
246
545
  * `useWorkflow<"alias">` is typed at pull time.
247
546
  * 5. Run `npm install`.
248
- * 6. (TODO) Generate `.lotics/types.ts` with workspace tables augmentation —
249
- * pending a `client.getWorkspaceSchema()` endpoint.
547
+ *
548
+ * Runtime field/option id aliases (`.lotics/app_fields.ts`) are generated on
549
+ * demand by `lotics app codegen`, which fetches the workspace schema — pull
550
+ * leaves it to that command so a schema fetch never blocks the bootstrap.
250
551
  */
251
552
  /**
252
553
  * `lotics app subdomain <new>` — rename the current app's public address.
@@ -306,6 +607,18 @@ export async function appPull(client, args) {
306
607
  queries: app.queries ?? {},
307
608
  agents: app.agents ?? {},
308
609
  });
610
+ // Write each bound workflow's faithful body to src/workflows/<alias>.ts so the
611
+ // author edits a real file and pushes with `lotics app workflow set <alias>`
612
+ // — no more fetch/reconstruct/escape. Sourced from get_app_workflow (the live
613
+ // workflow row), like the manifest's `workflows` map, so agent-authored bodies
614
+ // survive the pull. A legacy alias with no rendered source warns and is skipped.
615
+ const aliases = Object.keys(app.workflows ?? {});
616
+ if (aliases.length > 0) {
617
+ const written = await writeWorkflowFiles(client, targetPath, app.id, aliases);
618
+ if (written.length > 0) {
619
+ console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
620
+ }
621
+ }
309
622
  console.error(`Installing npm dependencies...`);
310
623
  await runNpm(["install"], targetPath);
311
624
  console.error(`\nReady. Next steps:`);
@@ -535,3 +848,297 @@ export async function appDev(client, args) {
535
848
  console.error("\nStopping…");
536
849
  await handle.stop();
537
850
  }
851
+ /** Narrow the loose envelope's `created_records` into typed groups. */
852
+ function parseCreatedRecords(value) {
853
+ if (!Array.isArray(value))
854
+ return [];
855
+ const groups = [];
856
+ for (const entry of value) {
857
+ if (!entry || typeof entry !== "object")
858
+ continue;
859
+ const tableId = entry.table_id;
860
+ const recordIds = entry.record_ids;
861
+ if (typeof tableId !== "string" || !Array.isArray(recordIds))
862
+ continue;
863
+ const ids = recordIds.filter((id) => typeof id === "string");
864
+ if (ids.length > 0)
865
+ groups.push({ table_id: tableId, record_ids: ids });
866
+ }
867
+ return groups;
868
+ }
869
+ /** Narrow `irreversible_tool_calls` into a list of tool names (deduped, ordered). */
870
+ function parseIrreversibleToolNames(value) {
871
+ if (!Array.isArray(value))
872
+ return [];
873
+ const names = [];
874
+ for (const entry of value) {
875
+ if (!entry || typeof entry !== "object")
876
+ continue;
877
+ const name = entry.tool_name;
878
+ if (typeof name === "string" && !names.includes(name))
879
+ names.push(name);
880
+ }
881
+ return names;
882
+ }
883
+ /**
884
+ * Print the honest harvest of a run's side effects to stderr (GAP-58): created
885
+ * records grouped by table, a ready-to-paste `lotics run delete_records …` per
886
+ * table, then the MANDATORY caveat naming what cannot be auto-undone. Never
887
+ * deletes anything — this only reports.
888
+ */
889
+ function printSideEffects(summary) {
890
+ const created = parseCreatedRecords(summary.created_records);
891
+ const irreversibleTools = parseIrreversibleToolNames(summary.irreversible_tool_calls);
892
+ const subWorkflows = summary.sub_workflows_possible === true;
893
+ console.error("\nCreated records:");
894
+ if (created.length === 0) {
895
+ console.error(" (none with ids to clean up)");
896
+ }
897
+ else {
898
+ for (const group of created) {
899
+ console.error(` ${group.table_id}: ${group.record_ids.length} record(s)`);
900
+ const payload = JSON.stringify({ table_id: group.table_id, record_ids: group.record_ids });
901
+ console.error(` lotics run delete_records '${payload}'`);
902
+ }
903
+ }
904
+ // The caveat is mandatory and unconditional — a clean run still owes the
905
+ // reader the explicit "this is not a rollback" framing so cleanup is never
906
+ // mistaken for complete.
907
+ const irreversiblePart = irreversibleTools.length > 0
908
+ ? `Could NOT auto-undo (clean up manually): ${irreversibleTools.join(", ")}.`
909
+ : "Could NOT auto-undo: none.";
910
+ const subPart = subWorkflows
911
+ ? " Sub-workflows may have run (after_* table workflows) — their effects are NOT in this list."
912
+ : "";
913
+ console.error(`\n${irreversiblePart}${subPart}`);
914
+ }
915
+ /**
916
+ * Run the harvested deletes for created records ONLY (never files / external /
917
+ * notifications — those are reported, never silently undone). Best-effort: a
918
+ * failed delete is logged and the rest continue. Returns `false` when ANY delete
919
+ * failed, so the command boundary can exit non-zero — a CI script branching on
920
+ * the exit code must not read partial cleanup as success.
921
+ */
922
+ async function cleanupCreatedRecords(client, created) {
923
+ if (created.length === 0) {
924
+ console.error("\nNo created records to clean up.");
925
+ return true;
926
+ }
927
+ console.error("\nCleaning up created records (delete_records — records only):");
928
+ let allDeleted = true;
929
+ for (const group of created) {
930
+ const res = await client.execute("delete_records", {
931
+ table_id: group.table_id,
932
+ record_ids: group.record_ids,
933
+ });
934
+ if (res.error) {
935
+ console.error(` ✗ ${group.table_id}: ${res.error}`);
936
+ allDeleted = false;
937
+ }
938
+ else {
939
+ console.error(` ✓ ${group.table_id}: deleted ${group.record_ids.length} record(s)`);
940
+ }
941
+ }
942
+ return allDeleted;
943
+ }
944
+ /**
945
+ * `lotics app workflow run <alias> '<json>'` — execute a bound app workflow
946
+ * end-to-end against the live workspace. `app_id` comes from the local manifest
947
+ * (like deploy/dev), the alias must be bound server-side via `set_app_workflow`.
948
+ *
949
+ * The full `{ status, message, data, files, side_effects }` JSON prints to
950
+ * stdout (pipeable / assertable); a one-line human summary goes to stderr. A
951
+ * `status: "error"` envelope exits non-zero so a script can branch on it — the
952
+ * transport already normalizes a gateway/timeout failure into the same
953
+ * `{ status: "error" }` shape, so a failed run is never a thrown HTML body.
954
+ *
955
+ * `--print-created` (alias `--report-effects`) renders the honest post-run
956
+ * harvest (GAP-58): created records grouped by table, a paste-ready
957
+ * `delete_records` per table, and the mandatory caveat about what cannot be
958
+ * auto-undone. `--cleanup` (DEFAULT OFF) additionally runs the deletes for the
959
+ * harvested records ONLY — never files, external integrations, or notifications.
960
+ * Neither is a rollback; a rollback is structurally impossible here.
961
+ */
962
+ export async function appExecuteWorkflow(client, args) {
963
+ const meta = readAppMeta(process.cwd());
964
+ const result = (await client.appWorkflow(meta.app_id, args.alias, args.inputs));
965
+ console.log(JSON.stringify(result, null, 2));
966
+ const status = typeof result.status === "string" ? result.status : "unknown";
967
+ const message = typeof result.message === "string" ? result.message : "";
968
+ console.error(`Workflow "${args.alias}" → ${status}${message ? `: ${message}` : ""}`);
969
+ // --cleanup implies the report (you should always see what's being undone).
970
+ let cleanupFailed = false;
971
+ if ((args.printCreated || args.cleanup) && result.side_effects) {
972
+ printSideEffects(result.side_effects);
973
+ if (args.cleanup) {
974
+ const allDeleted = await cleanupCreatedRecords(client, parseCreatedRecords(result.side_effects.created_records));
975
+ cleanupFailed = !allDeleted;
976
+ }
977
+ }
978
+ else if (args.printCreated || args.cleanup) {
979
+ console.error("\n(no side-effect summary returned by the server)");
980
+ }
981
+ // Exit non-zero on an error run OR a partial cleanup — a script must not read
982
+ // either as success.
983
+ if (status === "error" || cleanupFailed)
984
+ process.exit(1);
985
+ }
986
+ /**
987
+ * `lotics app workflow set <alias>` — push the edited `src/workflows/<alias>.ts`
988
+ * body to the server through `set_app_workflow` (the single author of
989
+ * `apps.workflows`). The body is read from disk (header stripped); the typed
990
+ * `inputs`/`outputs` schemas come from `package.json#lotics.workflows.<alias>`,
991
+ * so a pulled-then-edited app keeps its declared contract. The server re-verifies
992
+ * the body and echoes the bound `outputs` (declared, else DERIVED from
993
+ * `return({ data })`) — the same guarantee as calling `set_app_workflow` by hand,
994
+ * with no fetch/reconstruct/escape. Errors (missing file, unbound alias, verify
995
+ * failure) print to stderr and exit non-zero.
996
+ *
997
+ * This is a CLI convenience over the existing tool — `lotics app deploy` is still
998
+ * NOT an author of workflows; the single-author invariant holds.
999
+ */
1000
+ export async function appWorkflowSet(client, args) {
1001
+ const projectDir = process.cwd();
1002
+ const meta = readAppMeta(projectDir);
1003
+ const declaration = meta.workflows?.[args.alias];
1004
+ if (!declaration) {
1005
+ console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
1006
+ `Bind it first (set_app_workflow), then 'lotics app pull' to write its body and manifest entry.`);
1007
+ process.exit(1);
1008
+ }
1009
+ const file = workflowFilePath(projectDir, args.alias);
1010
+ if (!fs.existsSync(file)) {
1011
+ console.error(`No workflow body at ${path.relative(projectDir, file)}. ` +
1012
+ `Run 'lotics app pull ${meta.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`);
1013
+ process.exit(1);
1014
+ }
1015
+ const source = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
1016
+ if (source.trim() === "") {
1017
+ console.error(`Workflow body ${path.relative(projectDir, file)} is empty after stripping the header.`);
1018
+ process.exit(1);
1019
+ }
1020
+ const res = await client.setAppWorkflow(meta.app_id, args.alias, {
1021
+ source,
1022
+ inputs: declaration.inputs,
1023
+ outputs: declaration.outputs,
1024
+ });
1025
+ if (res.error) {
1026
+ console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
1027
+ process.exit(1);
1028
+ }
1029
+ // set_app_workflow echoes { app_id, alias, workflow_id, outputs? } — outputs is
1030
+ // declared-wins-else-DERIVED from return({ data }), the shape result.data carries.
1031
+ const result = (res.result ?? {});
1032
+ const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
1033
+ console.error(`Set workflow "${args.alias}" → ${workflowId}`);
1034
+ if (result.outputs && typeof result.outputs === "object") {
1035
+ console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
1036
+ }
1037
+ }
1038
+ /**
1039
+ * `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
1040
+ * server without a full `lotics app pull` (no source archive, no npm install).
1041
+ * The alias set + bodies come from the live App row (the same source `app pull`
1042
+ * uses); a legacy alias with no rendered source warns and is skipped.
1043
+ */
1044
+ export async function appWorkflowPull(client) {
1045
+ const projectDir = process.cwd();
1046
+ const meta = readAppMeta(projectDir);
1047
+ const app = await client.getApp(meta.app_id);
1048
+ const aliases = Object.keys(app.workflows ?? {});
1049
+ if (aliases.length === 0) {
1050
+ console.error(`App ${meta.app_id} has no bound workflows.`);
1051
+ return;
1052
+ }
1053
+ const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
1054
+ console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
1055
+ (written.length > 0 ? ` (${written.join(", ")})` : ""));
1056
+ }
1057
+ /**
1058
+ * Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
1059
+ * not found — an external npm app author has no monorepo checkout, so `ui link`
1060
+ * must fail loud rather than write a broken alias.
1061
+ */
1062
+ function findUiSrcDir(start) {
1063
+ let dir = path.resolve(start);
1064
+ for (;;) {
1065
+ const candidate = path.join(dir, "packages", "ui", "src");
1066
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
1067
+ return candidate;
1068
+ const parent = path.dirname(dir);
1069
+ if (parent === dir)
1070
+ return null;
1071
+ dir = parent;
1072
+ }
1073
+ }
1074
+ // The dev-link alias entry — a package-wide `@lotics/ui/<subpath>` → local
1075
+ // `packages/ui/src/<subpath>` redirect. Matched/removed by the literal `find`
1076
+ // regex source so insert/remove is idempotent regardless of the replacement.
1077
+ const UI_ALIAS_FIND_SOURCE = String.raw `/^@lotics\/ui\/(.+)$/`;
1078
+ /**
1079
+ * `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
1080
+ * dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
1081
+ * `packages/ui/src` go live (HMR) without a publish round-trip. `component` is
1082
+ * advisory only — the alias is package-wide (one subpath regex covers every
1083
+ * import); it's validated to exist under `packages/ui/src` so a typo fails here.
1084
+ *
1085
+ * Idempotent: linking twice is a no-op; `--remove` strips the one inserted
1086
+ * entry and leaves the rest of `resolve.alias` intact.
1087
+ */
1088
+ export function appUiLink(args) {
1089
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
1090
+ const viteConfigPath = path.join(projectDir, "vite.config.ts");
1091
+ if (!fs.existsSync(viteConfigPath)) {
1092
+ throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
1093
+ }
1094
+ const uiSrc = findUiSrcDir(projectDir);
1095
+ if (!uiSrc) {
1096
+ throw new Error("Cannot find packages/ui/src by walking up from this directory — `lotics ui link` " +
1097
+ "requires a monorepo checkout. External apps consume @lotics/ui from npm; bump the " +
1098
+ "package version and widen the app's dependency range instead.");
1099
+ }
1100
+ // Validate the named component exists in src so a typo fails loud (the alias
1101
+ // itself stays package-wide — this is the advisory check the spec calls for).
1102
+ const hasComponent = fs.existsSync(path.join(uiSrc, `${args.component}.tsx`)) ||
1103
+ fs.existsSync(path.join(uiSrc, `${args.component}.ts`)) ||
1104
+ fs.existsSync(path.join(uiSrc, args.component));
1105
+ if (!hasComponent) {
1106
+ throw new Error(`No '@lotics/ui/${args.component}' under ${uiSrc} (expected ${args.component}.tsx/.ts). ` +
1107
+ `Check the component name.`);
1108
+ }
1109
+ const source = fs.readFileSync(viteConfigPath, "utf-8");
1110
+ const aliasEntry = `{ find: ${UI_ALIAS_FIND_SOURCE}, replacement: ${JSON.stringify(`${uiSrc}/$1`)} },`;
1111
+ const alreadyLinked = source.includes(UI_ALIAS_FIND_SOURCE);
1112
+ if (args.remove) {
1113
+ if (!alreadyLinked) {
1114
+ console.error("No @lotics/ui dev-link alias present — nothing to remove.");
1115
+ return;
1116
+ }
1117
+ // Drop the whole alias line (the entry + its own line), leaving the rest of
1118
+ // resolve.alias untouched.
1119
+ const stripped = source.replace(new RegExp(`^\\s*\\{ find: ${escapeRegExp(UI_ALIAS_FIND_SOURCE)}.*$\\n?`, "m"), "");
1120
+ fs.writeFileSync(viteConfigPath, stripped);
1121
+ console.error(`Removed the @lotics/ui dev-link alias from ${viteConfigPath}.`);
1122
+ console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1123
+ return;
1124
+ }
1125
+ if (alreadyLinked) {
1126
+ console.error(`@lotics/ui is already dev-linked in ${viteConfigPath}.`);
1127
+ return;
1128
+ }
1129
+ const aliasMatch = /resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\[/.exec(source);
1130
+ if (!aliasMatch) {
1131
+ throw new Error(`Could not find a resolve.alias array literal in ${viteConfigPath}. ` +
1132
+ `Refresh vite.config.ts from the starter (packages/sdk/src/starter_template.ts) and retry.`);
1133
+ }
1134
+ const insertAt = aliasMatch.index + aliasMatch[0].length;
1135
+ const updated = `${source.slice(0, insertAt)}\n ${aliasEntry}${source.slice(insertAt)}`;
1136
+ fs.writeFileSync(viteConfigPath, updated);
1137
+ console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
1138
+ console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1139
+ console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
1140
+ }
1141
+ /** Escape a string for literal use inside a RegExp. */
1142
+ function escapeRegExp(s) {
1143
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1144
+ }