@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.
@@ -1,8 +1,8 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { stampPulledManifest, undeclaredCapabilities, appDirName } from "./app_commands.js";
5
+ import { stampPulledManifest, undeclaredCapabilities, appDirName, appCodegen, appUiLink, appWorkflowSet, appWorkflowPull, appExecuteWorkflow, writeWorkflowFile, writeWorkflowGlobals, stripWorkflowHeader, FALLBACK_ENVELOPE_PREFIX, FALLBACK_ENVELOPE_SUFFIX, } from "./app_commands.js";
6
6
  /**
7
7
  * `appPull` reads workflows from the live App row (server response), NOT from
8
8
  * the manifest embedded in the extracted source archive. The frozen archive
@@ -157,3 +157,502 @@ describe("appDirName", () => {
157
157
  expect(appDirName("///")).toBe("app"); // separators → "-", a dir of only "-" falls back
158
158
  });
159
159
  });
160
+ /**
161
+ * `appCodegen` (no client) always regenerates the `.lotics/*.d.ts` companions
162
+ * from the manifest. It throws the clear not-an-app error when there's no
163
+ * manifest, and produces byte-identical output across runs (idempotent).
164
+ */
165
+ describe("appCodegen (.d.ts-only path)", () => {
166
+ let workDir;
167
+ beforeEach(() => {
168
+ workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-codegen-test-"));
169
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
170
+ });
171
+ afterEach(() => {
172
+ fs.rmSync(workDir, { recursive: true, force: true });
173
+ vi.restoreAllMocks();
174
+ });
175
+ function writeManifest() {
176
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
177
+ name: "sample-app",
178
+ lotics: {
179
+ app_id: "app_1",
180
+ workspace_id: "wks_1",
181
+ current_version_id: "apv_1",
182
+ version_number: 1,
183
+ workflows: { issue: { workflow_id: "wfl_x" } },
184
+ queries: { all: { ast: { kind: "from_table", table_id: "tbl_1" } } },
185
+ },
186
+ }, null, 2));
187
+ }
188
+ it("writes the three .d.ts companions from the manifest", async () => {
189
+ writeManifest();
190
+ await appCodegen({ projectDir: workDir });
191
+ const dir = path.join(workDir, ".lotics");
192
+ expect(fs.existsSync(path.join(dir, "app_workflows.d.ts"))).toBe(true);
193
+ expect(fs.existsSync(path.join(dir, "app_queries.d.ts"))).toBe(true);
194
+ expect(fs.existsSync(path.join(dir, "app_agents.d.ts"))).toBe(true);
195
+ expect(fs.readFileSync(path.join(dir, "app_workflows.d.ts"), "utf-8")).toContain("issue");
196
+ });
197
+ it("throws the not-an-app error when there's no manifest", async () => {
198
+ await expect(appCodegen({ projectDir: workDir })).rejects.toThrow(/package\.json/);
199
+ });
200
+ it("produces byte-identical output across runs (idempotent)", async () => {
201
+ writeManifest();
202
+ await appCodegen({ projectDir: workDir });
203
+ const first = fs.readFileSync(path.join(workDir, ".lotics", "app_queries.d.ts"));
204
+ await appCodegen({ projectDir: workDir });
205
+ const second = fs.readFileSync(path.join(workDir, ".lotics", "app_queries.d.ts"));
206
+ expect(second.equals(first)).toBe(true);
207
+ });
208
+ });
209
+ /**
210
+ * `appUiLink` edits the app's vite.config.ts resolve.alias to dev-link
211
+ * @lotics/ui at the monorepo's packages/ui/src. It fails loud outside a
212
+ * monorepo, validates the component exists, and insert/remove is idempotent.
213
+ */
214
+ describe("appUiLink", () => {
215
+ let root;
216
+ let appDir;
217
+ let uiSrc;
218
+ beforeEach(() => {
219
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
220
+ // A fake monorepo: <root>/packages/ui/src/<component>, <root>/apps/myapp.
221
+ root = fs.mkdtempSync(path.join(tmpdir(), "lotics-uilink-test-"));
222
+ uiSrc = path.join(root, "packages", "ui", "src");
223
+ fs.mkdirSync(uiSrc, { recursive: true });
224
+ fs.writeFileSync(path.join(uiSrc, "card.tsx"), "export const Card = () => null;");
225
+ appDir = path.join(root, "apps", "myapp");
226
+ fs.mkdirSync(appDir, { recursive: true });
227
+ fs.writeFileSync(path.join(appDir, "vite.config.ts"), [
228
+ "export default {",
229
+ " resolve: {",
230
+ " alias: [",
231
+ ' { find: "react-native", replacement: "react-native-web" },',
232
+ " ],",
233
+ " },",
234
+ "};",
235
+ "",
236
+ ].join("\n"));
237
+ });
238
+ afterEach(() => {
239
+ fs.rmSync(root, { recursive: true, force: true });
240
+ vi.restoreAllMocks();
241
+ });
242
+ const config = () => fs.readFileSync(path.join(appDir, "vite.config.ts"), "utf-8");
243
+ it("inserts the dev-link alias pointing at packages/ui/src", () => {
244
+ appUiLink({ projectDir: appDir, component: "card" });
245
+ const out = config();
246
+ expect(out).toContain(String.raw `/^@lotics\/ui\/(.+)$/`);
247
+ expect(out).toContain(uiSrc);
248
+ // The pre-existing alias is preserved.
249
+ expect(out).toContain("react-native-web");
250
+ });
251
+ it("is idempotent — linking twice does not duplicate the alias", () => {
252
+ appUiLink({ projectDir: appDir, component: "card" });
253
+ const once = config();
254
+ appUiLink({ projectDir: appDir, component: "card" });
255
+ expect(config()).toBe(once);
256
+ });
257
+ it("removes the alias and leaves the rest of resolve.alias intact", () => {
258
+ appUiLink({ projectDir: appDir, component: "card" });
259
+ appUiLink({ projectDir: appDir, component: "card", remove: true });
260
+ const out = config();
261
+ expect(out).not.toContain(String.raw `@lotics\/ui`);
262
+ expect(out).toContain("react-native-web");
263
+ });
264
+ it("fails loud when the named component does not exist in packages/ui/src", () => {
265
+ expect(() => appUiLink({ projectDir: appDir, component: "nonexistent" })).toThrow(/nonexistent/);
266
+ });
267
+ it("fails loud when there is no monorepo packages/ui/src above the project", () => {
268
+ const lonely = fs.mkdtempSync(path.join(tmpdir(), "lotics-lonely-app-"));
269
+ fs.writeFileSync(path.join(lonely, "vite.config.ts"), "export default { resolve: { alias: [] } };");
270
+ try {
271
+ expect(() => appUiLink({ projectDir: lonely, component: "card" })).toThrow(/monorepo checkout/);
272
+ }
273
+ finally {
274
+ fs.rmSync(lonely, { recursive: true, force: true });
275
+ }
276
+ });
277
+ });
278
+ /**
279
+ * The workflow body files are how AA-1 Option B becomes "open a file → edit →
280
+ * push". `writeWorkflowFile` writes the verbatim server source under an
281
+ * auto-pulled header; `stripWorkflowHeader` undoes the header so `set` round-
282
+ * trips only the body. The two must compose so a pull-then-set is a no-op on the
283
+ * source itself.
284
+ */
285
+ describe("workflow body files (writeWorkflowFile / stripWorkflowHeader)", () => {
286
+ let workDir;
287
+ beforeEach(() => {
288
+ workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-file-test-"));
289
+ });
290
+ afterEach(() => {
291
+ fs.rmSync(workDir, { recursive: true, force: true });
292
+ });
293
+ const body = `return({ data: { ok: tool_create_records({ table_id: "tbl_1" }) } })`;
294
+ it("writes src/workflows/<alias>.ts with a header + reference + wrapped body", () => {
295
+ const file = writeWorkflowFile(workDir, "issueInvoice", body);
296
+ expect(file).toBe(path.join(workDir, "src", "workflows", "issueInvoice.ts"));
297
+ const content = fs.readFileSync(file, "utf-8");
298
+ // Header: the loop command + the do-not-rename warning, all in // comments.
299
+ expect(content).toContain("lotics app workflow set issueInvoice");
300
+ expect(content).toContain("Do NOT rename");
301
+ // The triple-slash reference points at the per-alias ambient globals.
302
+ expect(content).toContain(`/// <reference path="../../.lotics/workflows/issueInvoice.globals.d.ts" />`);
303
+ // The body sits inside the server's __workflow envelope.
304
+ expect(content).toContain("async function __workflow()");
305
+ expect(content).toContain(body);
306
+ });
307
+ it("round-trips: stripping the header + wrapper recovers exactly the written body", () => {
308
+ const file = writeWorkflowFile(workDir, "wf", body);
309
+ const recovered = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
310
+ expect(recovered).toBe(body);
311
+ });
312
+ it("round-trips a multi-line body (wrapper peel is structural, not line-counted)", () => {
313
+ const multi = ["let_total = 0;", "return({ data: { total: let_total } })"].join("\n");
314
+ const file = writeWorkflowFile(workDir, "multi", multi);
315
+ expect(stripWorkflowHeader(fs.readFileSync(file, "utf-8"))).toBe(multi);
316
+ });
317
+ it("uses the server-returned envelope verbatim when provided", () => {
318
+ const file = writeWorkflowFile(workDir, "srv", body, {
319
+ prefix: "async function __workflow(): Promise<__WorkflowReturn | void> {\n",
320
+ suffix: "\n}",
321
+ });
322
+ const content = fs.readFileSync(file, "utf-8");
323
+ expect(content).toContain("async function __workflow(): Promise<__WorkflowReturn | void> {");
324
+ expect(stripWorkflowHeader(content)).toBe(body);
325
+ });
326
+ it("writes the per-alias ambient globals under .lotics/workflows", () => {
327
+ const file = writeWorkflowGlobals(workDir, "issueInvoice", "declare const trigger: never;");
328
+ expect(file).toBe(path.join(workDir, ".lotics", "workflows", "issueInvoice.globals.d.ts"));
329
+ expect(fs.readFileSync(file, "utf-8")).toContain("declare const trigger: never;");
330
+ });
331
+ it("passes a header-less hand-written body through unchanged", () => {
332
+ expect(stripWorkflowHeader(body)).toBe(body);
333
+ });
334
+ // GAP-59 pin: the CLI's fallback envelope must equal the SERVER's compile
335
+ // envelope (the canonical SOURCE_PREFIX/SOURCE_SUFFIX, pinned on the backend
336
+ // by `apps.test.ts`). If either side changes the wrapper, this literal breaks
337
+ // here and the backend pin breaks there — they can't silently diverge.
338
+ it("the CLI fallback envelope matches the server's __workflow wrapper byte-for-byte", () => {
339
+ expect(FALLBACK_ENVELOPE_PREFIX).toBe("async function __workflow(): Promise<__WorkflowReturn | void> {\n");
340
+ expect(FALLBACK_ENVELOPE_SUFFIX).toBe("\n}");
341
+ });
342
+ it("keeps an in-body leading comment when stripping a real generated file (header + wrapper)", () => {
343
+ // A body whose own first line is a comment: written through the real writer
344
+ // (header + __workflow wrapper), stripping recovers the body verbatim — the
345
+ // header peel is anchored on the wrapper opener that follows it, so the
346
+ // body's own comment is never mistaken for bookkeeping.
347
+ const commentBody = ["// real body comment", body].join("\n");
348
+ const file = writeWorkflowFile(workDir, "withcomment", commentBody);
349
+ expect(stripWorkflowHeader(fs.readFileSync(file, "utf-8"))).toBe(commentBody);
350
+ });
351
+ it("passes a wrapper-LESS hand-written body through unchanged even when it opens with comments", () => {
352
+ // No __workflow wrapper present (an author removed it, or hand-created the
353
+ // file before any pull): the leading comments are real source, not the
354
+ // generated header, so nothing is stripped. The earlier bug ate them.
355
+ const handWritten = [
356
+ "// a deliberate leading comment",
357
+ "// and a second one",
358
+ "",
359
+ body,
360
+ ].join("\n");
361
+ expect(stripWorkflowHeader(handWritten)).toBe(handWritten);
362
+ });
363
+ });
364
+ /**
365
+ * `appWorkflowSet` is the push half of the file flow: resolve app_id from the
366
+ * manifest, read src/workflows/<alias>.ts (header stripped), attach the typed
367
+ * inputs/outputs from the manifest, and call client.setAppWorkflow. Tested as a
368
+ * pure assembly against a captured mock client — no HTTP. process.cwd() is the
369
+ * resolution base (like deploy/dev), so each test runs chdir'd into its workDir.
370
+ */
371
+ describe("appWorkflowSet", () => {
372
+ let workDir;
373
+ let prevCwd;
374
+ let exitErr;
375
+ beforeEach(() => {
376
+ prevCwd = process.cwd();
377
+ workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-set-test-"));
378
+ process.chdir(workDir);
379
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
380
+ // appWorkflowSet calls process.exit(1) on the error paths; turn that into a
381
+ // throwable so a test can assert "it bailed" without killing the runner.
382
+ exitErr = undefined;
383
+ vi.spyOn(process, "exit").mockImplementation(((code) => {
384
+ exitErr = new Error(`process.exit(${code})`);
385
+ throw exitErr;
386
+ }));
387
+ });
388
+ afterEach(() => {
389
+ process.chdir(prevCwd);
390
+ fs.rmSync(workDir, { recursive: true, force: true });
391
+ vi.restoreAllMocks();
392
+ });
393
+ function writeManifest(workflows) {
394
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
395
+ name: "sample-app",
396
+ lotics: {
397
+ app_id: "app_set",
398
+ workspace_id: "wks_set",
399
+ current_version_id: "apv_1",
400
+ version_number: 1,
401
+ workflows,
402
+ },
403
+ }, null, 2));
404
+ }
405
+ /** A mock client that captures the setAppWorkflow call and echoes a result. */
406
+ function mockClient(echo) {
407
+ const calls = [];
408
+ const client = {
409
+ setAppWorkflow: async (app_id, alias, body) => {
410
+ calls.push({ app_id, alias, body });
411
+ return { result: echo ?? { app_id, alias, workflow_id: "wfl_new" } };
412
+ },
413
+ };
414
+ return { client, calls };
415
+ }
416
+ it("reads app_id from the manifest, strips the file header, and forwards inputs/outputs", async () => {
417
+ writeManifest({
418
+ issueInvoice: {
419
+ workflow_id: "wfl_x",
420
+ inputs: { record_id: { type: "text", required: true } },
421
+ outputs: { invoice_id: { type: "text" } },
422
+ },
423
+ });
424
+ const source = `return({ data: { invoice_id: "x" } })`;
425
+ writeWorkflowFile(workDir, "issueInvoice", source);
426
+ const { client, calls } = mockClient({ workflow_id: "wfl_x", outputs: { invoice_id: { type: "text" } } });
427
+ await appWorkflowSet(client, { alias: "issueInvoice" });
428
+ expect(calls).toHaveLength(1);
429
+ expect(calls[0].app_id).toBe("app_set");
430
+ expect(calls[0].alias).toBe("issueInvoice");
431
+ // The body is the stripped source — no header comment crosses the boundary.
432
+ expect(calls[0].body.source).toBe(source);
433
+ expect(calls[0].body.source).not.toContain("Auto-pulled");
434
+ // The typed schemas ride along from the manifest declaration.
435
+ expect(calls[0].body.inputs).toEqual({ record_id: { type: "text", required: true } });
436
+ expect(calls[0].body.outputs).toEqual({ invoice_id: { type: "text" } });
437
+ });
438
+ it("forwards no inputs/outputs when the manifest declares none", async () => {
439
+ writeManifest({ ping: { workflow_id: "wfl_ping" } });
440
+ writeWorkflowFile(workDir, "ping", `return({ data: {} })`);
441
+ const { client, calls } = mockClient();
442
+ await appWorkflowSet(client, { alias: "ping" });
443
+ expect(calls[0].body.inputs).toBeUndefined();
444
+ expect(calls[0].body.outputs).toBeUndefined();
445
+ });
446
+ it("bails when the alias is absent from the manifest", async () => {
447
+ writeManifest({ other: { workflow_id: "wfl_o" } });
448
+ writeWorkflowFile(workDir, "missing", `return({})`);
449
+ const { client, calls } = mockClient();
450
+ await expect(appWorkflowSet(client, { alias: "missing" })).rejects.toThrow(/process\.exit/);
451
+ expect(calls).toHaveLength(0);
452
+ });
453
+ it("bails when the body file does not exist", async () => {
454
+ writeManifest({ declared: { workflow_id: "wfl_d" } });
455
+ // No src/workflows/declared.ts written.
456
+ const { client, calls } = mockClient();
457
+ await expect(appWorkflowSet(client, { alias: "declared" })).rejects.toThrow(/process\.exit/);
458
+ expect(calls).toHaveLength(0);
459
+ });
460
+ it("bails on a server verify error (non-zero exit, no swallowed failure)", async () => {
461
+ writeManifest({ bad: { workflow_id: "wfl_b" } });
462
+ writeWorkflowFile(workDir, "bad", `return(nope)`);
463
+ const calls = [];
464
+ const client = {
465
+ setAppWorkflow: async () => {
466
+ calls.push(1);
467
+ return { result: null, error: "source failed verification: unknown identifier 'nope'" };
468
+ },
469
+ };
470
+ await expect(appWorkflowSet(client, { alias: "bad" })).rejects.toThrow(/process\.exit/);
471
+ expect(calls).toHaveLength(1); // it DID attempt the push, then surfaced the error
472
+ });
473
+ });
474
+ /**
475
+ * `appWorkflowPull` is the pull half: write one src/workflows/<alias>.ts per
476
+ * bound alias from the live App row, using get_app_workflow for each faithful
477
+ * body. A legacy alias whose source can't be rendered is warned and SKIPPED, not
478
+ * a failure — so a partially-migrated app still pulls its readable bodies. (This
479
+ * is the same writer appPull uses; appPull itself shells out to tar/npm.)
480
+ */
481
+ describe("appWorkflowPull", () => {
482
+ let workDir;
483
+ let prevCwd;
484
+ beforeEach(() => {
485
+ prevCwd = process.cwd();
486
+ workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-pull-test-"));
487
+ process.chdir(workDir);
488
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
489
+ });
490
+ afterEach(() => {
491
+ process.chdir(prevCwd);
492
+ fs.rmSync(workDir, { recursive: true, force: true });
493
+ vi.restoreAllMocks();
494
+ });
495
+ function writeManifest() {
496
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({ name: "sample-app", lotics: { app_id: "app_p", workspace_id: "wks_p", current_version_id: "apv_1", version_number: 1 } }, null, 2));
497
+ }
498
+ /**
499
+ * Mock client: getApp returns the bound alias set; getAppWorkflow returns a
500
+ * body per alias; getAppWorkflowDts returns the per-alias globals + envelope.
501
+ */
502
+ function mockClient(aliases, sources) {
503
+ return {
504
+ getApp: async () => ({ workflows: Object.fromEntries(aliases.map((a) => [a, { workflow_id: `wfl_${a}` }])) }),
505
+ getAppWorkflow: async (_app_id, alias) => {
506
+ const source = sources[alias];
507
+ return source === null || source === undefined
508
+ ? { result: { app_id: "app_p", alias, workflow_id: `wfl_${alias}` }, error: undefined }
509
+ : { result: { app_id: "app_p", alias, workflow_id: `wfl_${alias}`, source } };
510
+ },
511
+ getAppWorkflowDts: async (_app_id, alias) => ({
512
+ dts: `// globals for ${alias}\ndeclare const trigger: { type: "app_workflow" };`,
513
+ envelope_prefix: "async function __workflow(): Promise<__WorkflowReturn | void> {\n",
514
+ envelope_suffix: "\n}",
515
+ }),
516
+ };
517
+ }
518
+ it("writes one src/workflows/<alias>.ts per bound alias, wrapped + referencing its globals", async () => {
519
+ writeManifest();
520
+ const client = mockClient(["a", "b"], {
521
+ a: `return({ data: { x: 1 } })`,
522
+ b: `return({ data: { y: 2 } })`,
523
+ });
524
+ await appWorkflowPull(client);
525
+ const a = fs.readFileSync(path.join(workDir, "src", "workflows", "a.ts"), "utf-8");
526
+ const b = fs.readFileSync(path.join(workDir, "src", "workflows", "b.ts"), "utf-8");
527
+ expect(a).toContain("lotics app workflow set a");
528
+ expect(a).toContain(`return({ data: { x: 1 } })`);
529
+ // Wrapped in the server's __workflow envelope and references its globals.
530
+ expect(a).toContain("async function __workflow()");
531
+ expect(a).toContain(`/// <reference path="../../.lotics/workflows/a.globals.d.ts" />`);
532
+ expect(b).toContain(`return({ data: { y: 2 } })`);
533
+ // The per-alias ambient globals landed under .lotics/workflows/.
534
+ const globalsA = fs.readFileSync(path.join(workDir, ".lotics", "workflows", "a.globals.d.ts"), "utf-8");
535
+ expect(globalsA).toContain("globals for a");
536
+ // The body still round-trips: stripping recovers exactly what set will push.
537
+ expect(stripWorkflowHeader(a)).toBe(`return({ data: { x: 1 } })`);
538
+ });
539
+ it("skips (does not fail) a legacy alias with no readable source", async () => {
540
+ writeManifest();
541
+ const client = mockClient(["good", "legacy"], {
542
+ good: `return({ data: {} })`,
543
+ legacy: null, // no source field → skipped with a warning
544
+ });
545
+ await appWorkflowPull(client);
546
+ expect(fs.existsSync(path.join(workDir, "src", "workflows", "good.ts"))).toBe(true);
547
+ expect(fs.existsSync(path.join(workDir, "src", "workflows", "legacy.ts"))).toBe(false);
548
+ });
549
+ it("writes the body with a fallback wrapper when the dts fetch fails (non-fatal)", async () => {
550
+ writeManifest();
551
+ const client = {
552
+ getApp: async () => ({ workflows: { c: { workflow_id: "wfl_c" } } }),
553
+ getAppWorkflow: async (_a, alias) => ({
554
+ result: { app_id: "app_p", alias, workflow_id: "wfl_c", source: `return({ data: {} })` },
555
+ }),
556
+ getAppWorkflowDts: async () => {
557
+ throw new Error("network down");
558
+ },
559
+ };
560
+ await appWorkflowPull(client);
561
+ const c = fs.readFileSync(path.join(workDir, "src", "workflows", "c.ts"), "utf-8");
562
+ // Body still written, still wrapped, still round-trips — only the globals
563
+ // file is absent (typecheck degraded, not the file).
564
+ expect(c).toContain("async function __workflow()");
565
+ expect(stripWorkflowHeader(c)).toBe(`return({ data: {} })`);
566
+ expect(fs.existsSync(path.join(workDir, ".lotics", "workflows", "c.globals.d.ts"))).toBe(false);
567
+ });
568
+ });
569
+ /**
570
+ * `appExecuteWorkflow` runs a bound workflow and, with `--print-created` /
571
+ * `--cleanup`, harvests the run's side effects (GAP-58). The harvest is an
572
+ * honest report, never a rollback: it prints created records + a paste-ready
573
+ * delete command + the mandatory caveat, and `--cleanup` deletes ONLY the
574
+ * harvested records (never files/external/notifications).
575
+ */
576
+ describe("appExecuteWorkflow (--print-created / --cleanup harvest)", () => {
577
+ let workDir;
578
+ let prevCwd;
579
+ let stderr;
580
+ let exitCode;
581
+ beforeEach(() => {
582
+ prevCwd = process.cwd();
583
+ workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-run-test-"));
584
+ process.chdir(workDir);
585
+ fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({ name: "a", lotics: { app_id: "app_r", workspace_id: "wks_r" } }));
586
+ stderr = [];
587
+ exitCode = undefined;
588
+ vi.spyOn(console, "error").mockImplementation((...args) => {
589
+ stderr.push(args.map(String).join(" "));
590
+ });
591
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
592
+ vi.spyOn(process, "exit").mockImplementation(((code) => {
593
+ exitCode = code;
594
+ throw new Error(`exit(${code})`);
595
+ }));
596
+ });
597
+ afterEach(() => {
598
+ process.chdir(prevCwd);
599
+ fs.rmSync(workDir, { recursive: true, force: true });
600
+ vi.restoreAllMocks();
601
+ });
602
+ const summary = {
603
+ created_records: [{ table_id: "tbl_x", record_ids: ["rec_1", "rec_2"] }],
604
+ created_files: ["fil_1"],
605
+ irreversible_tool_calls: [{ tool_name: "misa_save_sales_voucher", step_id: "s1" }],
606
+ sub_workflows_possible: true,
607
+ };
608
+ function mockClient(deleteCalls) {
609
+ return {
610
+ appWorkflow: async () => ({ status: "success", message: "ok", side_effects: summary }),
611
+ execute: async (_tool, args) => {
612
+ deleteCalls.push(args);
613
+ return { result: { deleted: 2 } };
614
+ },
615
+ };
616
+ }
617
+ it("--print-created reports created records + a paste-ready delete + the caveat; deletes nothing", async () => {
618
+ const deleteCalls = [];
619
+ await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {}, printCreated: true });
620
+ const out = stderr.join("\n");
621
+ expect(out).toContain("tbl_x: 2 record(s)");
622
+ expect(out).toContain(`lotics run delete_records '{"table_id":"tbl_x","record_ids":["rec_1","rec_2"]}'`);
623
+ // The mandatory caveat names the irreversible call + the sub-workflow risk.
624
+ expect(out).toContain("Could NOT auto-undo (clean up manually): misa_save_sales_voucher.");
625
+ expect(out).toContain("Sub-workflows may have run");
626
+ // Report-only: no delete executed.
627
+ expect(deleteCalls).toHaveLength(0);
628
+ });
629
+ it("--cleanup deletes ONLY the harvested records (never files/external)", async () => {
630
+ const deleteCalls = [];
631
+ await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {}, cleanup: true });
632
+ expect(deleteCalls).toEqual([{ table_id: "tbl_x", record_ids: ["rec_1", "rec_2"] }]);
633
+ });
634
+ it("--cleanup exits non-zero when a delete fails (a CI script must not read partial cleanup as success)", async () => {
635
+ const client = {
636
+ appWorkflow: async () => ({ status: "success", message: "ok", side_effects: summary }),
637
+ execute: async () => ({ result: null, error: "permission denied" }),
638
+ };
639
+ await expect(appExecuteWorkflow(client, { alias: "run", inputs: {}, cleanup: true })).rejects.toThrow(/exit\(1\)/);
640
+ expect(exitCode).toBe(1);
641
+ expect(stderr.join("\n")).toContain("✗ tbl_x: permission denied");
642
+ });
643
+ it("exits non-zero on an error status even with --print-created", async () => {
644
+ const client = {
645
+ appWorkflow: async () => ({ status: "error", message: "boom", side_effects: summary }),
646
+ execute: async () => ({ result: {} }),
647
+ };
648
+ await expect(appExecuteWorkflow(client, { alias: "run", inputs: {}, printCreated: true })).rejects.toThrow(/exit\(1\)/);
649
+ expect(exitCode).toBe(1);
650
+ });
651
+ it("without the flags, prints no harvest and performs no delete", async () => {
652
+ const deleteCalls = [];
653
+ await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {} });
654
+ const out = stderr.join("\n");
655
+ expect(out).not.toContain("Could NOT auto-undo");
656
+ expect(deleteCalls).toHaveLength(0);
657
+ });
658
+ });
package/dist/args.d.ts CHANGED
@@ -28,6 +28,10 @@ export declare function parseArgs(argv: string[]): {
28
28
  message?: string;
29
29
  local: boolean;
30
30
  all: boolean;
31
+ /** `--print-created` (alias `--report-effects`): print the honest post-run side-effect harvest. */
32
+ printCreated: boolean;
33
+ /** `--cleanup`: also delete the harvested created records (records only). */
34
+ cleanup: boolean;
31
35
  version: boolean;
32
36
  help: boolean;
33
37
  };
package/dist/args.js CHANGED
@@ -24,6 +24,8 @@ export function parseArgs(argv) {
24
24
  message: undefined,
25
25
  local: false,
26
26
  all: false,
27
+ printCreated: false,
28
+ cleanup: false,
27
29
  version: false,
28
30
  help: false,
29
31
  };
@@ -74,6 +76,13 @@ export function parseArgs(argv) {
74
76
  case "--all":
75
77
  flags.all = true;
76
78
  break;
79
+ case "--print-created":
80
+ case "--report-effects":
81
+ flags.printCreated = true;
82
+ break;
83
+ case "--cleanup":
84
+ flags.cleanup = true;
85
+ break;
77
86
  case "--version":
78
87
  case "-v":
79
88
  flags.version = true;
package/dist/args.test.js CHANGED
@@ -51,6 +51,18 @@ describe("parseArgs", () => {
51
51
  expect(r.flags.workspace).toBeUndefined();
52
52
  expect(r.flags.all).toBe(false);
53
53
  });
54
+ it("parses --print-created and --report-effects to the same boolean flag", () => {
55
+ expect(parseArgs(["app", "workflow", "run", "wf", "--print-created"]).flags.printCreated).toBe(true);
56
+ expect(parseArgs(["app", "workflow", "run", "wf", "--report-effects"]).flags.printCreated).toBe(true);
57
+ // The alias does not consume the next token (it's a boolean flag).
58
+ const r = parseArgs(["app", "workflow", "run", "wf", "--print-created"]);
59
+ expect(r.restArgs).toEqual(["wf"]);
60
+ });
61
+ it("parses --cleanup as a boolean flag (default false)", () => {
62
+ expect(parseArgs(["app", "workflow", "run", "wf", "--cleanup"]).flags.cleanup).toBe(true);
63
+ expect(parseArgs(["app", "workflow", "run", "wf"]).flags.cleanup).toBe(false);
64
+ expect(parseArgs(["app", "workflow", "run", "wf"]).flags.printCreated).toBe(false);
65
+ });
54
66
  it("treats `org use <name>` as command / subcommand / positional", () => {
55
67
  const r = parseArgs(["org", "use", "Acme Corp"]);
56
68
  expect(r.command).toBe("org");
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `NODE_OPTIONS` for a spawned Node child (npm, vite), forcing IPv4-first DNS +
3
+ * a fast Happy-Eyeballs family timeout. The CLI bin applies the equivalent as
4
+ * runtime API calls (cli.ts top-of-module), but a child Node process is a fresh
5
+ * runtime that won't inherit them — so they must travel via the env. Preserves
6
+ * any existing `NODE_OPTIONS`.
7
+ *
8
+ * Why: WSL2 + Node 24's Happy-Eyeballs races IPv4/IPv6 and intermittently stalls
9
+ * on a dead IPv6 route to api.lotics.ai. IPv4-first + a 2s per-family cap fails a
10
+ * bad IPv6 path fast to IPv4. Its own module so `app_commands` and `dev/server`
11
+ * share it without a circular import.
12
+ */
13
+ export declare function ipv4ChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `NODE_OPTIONS` for a spawned Node child (npm, vite), forcing IPv4-first DNS +
3
+ * a fast Happy-Eyeballs family timeout. The CLI bin applies the equivalent as
4
+ * runtime API calls (cli.ts top-of-module), but a child Node process is a fresh
5
+ * runtime that won't inherit them — so they must travel via the env. Preserves
6
+ * any existing `NODE_OPTIONS`.
7
+ *
8
+ * Why: WSL2 + Node 24's Happy-Eyeballs races IPv4/IPv6 and intermittently stalls
9
+ * on a dead IPv6 route to api.lotics.ai. IPv4-first + a 2s per-family cap fails a
10
+ * bad IPv6 path fast to IPv4. Its own module so `app_commands` and `dev/server`
11
+ * share it without a circular import.
12
+ */
13
+ export function ipv4ChildEnv(env) {
14
+ return {
15
+ ...env,
16
+ NODE_OPTIONS: [
17
+ env.NODE_OPTIONS,
18
+ "--dns-result-order=ipv4first",
19
+ "--network-family-autoselection-attempt-timeout=2000",
20
+ ]
21
+ .filter(Boolean)
22
+ .join(" "),
23
+ };
24
+ }