@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,898 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { stampPulledManifest, undeclaredCapabilities, appDirName, defaultPullTarget, ensureAppTsconfig, appCodegen, appUiLink, appVersions, appWorkflowSet, appWorkflowPull, appExecuteWorkflow, writeWorkflowFile, writeWorkflowGlobals, stripWorkflowHeader, FALLBACK_ENVELOPE_PREFIX, FALLBACK_ENVELOPE_SUFFIX, } from "./app_commands.js";
6
- /**
7
- * `appPull` reads workflows from the live App row (server response), NOT from
8
- * the manifest embedded in the extracted source archive. The frozen archive
9
- * snapshot would silently overwrite any agent-authored bindings (via
10
- * `set_app_workflow`) that landed on the App row after the last deploy.
11
- *
12
- * Tests `stampPulledManifest` directly — the part of `appPull` that decides
13
- * which workflows source wins. The rest of `appPull` shells out to
14
- * `tar`/`npm`, which is integration territory.
15
- */
16
- describe("stampPulledManifest", () => {
17
- let workDir;
18
- beforeEach(() => {
19
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-sdk-test-"));
20
- });
21
- afterEach(() => {
22
- fs.rmSync(workDir, { recursive: true, force: true });
23
- });
24
- function writeExtractedPackageJson(workflows) {
25
- fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
26
- name: "sample-app",
27
- lotics: {
28
- // Stale archived map — must NOT survive the stamp.
29
- app_id: "app_old",
30
- workspace_id: "wks_old",
31
- current_version_id: "apv_old",
32
- version_number: 1,
33
- workflows,
34
- },
35
- }, null, 2));
36
- }
37
- function readStampedManifest() {
38
- const pkg = JSON.parse(fs.readFileSync(path.join(workDir, "package.json"), "utf-8"));
39
- return pkg.lotics;
40
- }
41
- it("writes workflows from the live argument, overriding the archive-embedded manifest", () => {
42
- // Simulate the source archive's embedded manifest containing a stale
43
- // alias — the kind of thing that ships when an agent-authored workflow
44
- // hasn't yet been propagated to a developer's local checkout.
45
- writeExtractedPackageJson({
46
- stale_archive_alias: { workflow_id: "wfl_archived_at_deploy_time" },
47
- });
48
- // Live state from the server — what `client.getApp()` returns. This
49
- // includes the agent-authored alias added since the last deploy.
50
- stampPulledManifest(workDir, {
51
- app_id: "app_live",
52
- workspace_id: "wks_live",
53
- current_version_id: "apv_live",
54
- version_number: 7,
55
- workflows: {
56
- agent_authored: { workflow_id: "wfl_authored_by_agent" },
57
- renamed: { workflow_id: "wfl_renamed_on_server" },
58
- },
59
- queries: {},
60
- agents: {},
61
- });
62
- const stamped = readStampedManifest();
63
- expect(stamped.app_id).toBe("app_live");
64
- expect(stamped.workspace_id).toBe("wks_live");
65
- expect(stamped.current_version_id).toBe("apv_live");
66
- expect(stamped.version_number).toBe(7);
67
- // Only the live workflows are present. The archive's stale_archive_alias
68
- // does not survive — proving we're not reading from the extracted manifest.
69
- expect(stamped.workflows).toEqual({
70
- agent_authored: { workflow_id: "wfl_authored_by_agent" },
71
- renamed: { workflow_id: "wfl_renamed_on_server" },
72
- });
73
- expect(stamped.workflows).not.toHaveProperty("stale_archive_alias");
74
- });
75
- it("clears the local workflows when the server has none", () => {
76
- writeExtractedPackageJson({
77
- stale_archive_alias: { workflow_id: "wfl_should_disappear" },
78
- });
79
- stampPulledManifest(workDir, {
80
- app_id: "app_x",
81
- workspace_id: "wks_x",
82
- current_version_id: "apv_x",
83
- version_number: 2,
84
- workflows: {},
85
- queries: {},
86
- agents: {},
87
- });
88
- const stamped = readStampedManifest();
89
- expect(stamped.workflows).toEqual({});
90
- });
91
- it("emits .lotics/app_workflows.d.ts from the live workflows", () => {
92
- writeExtractedPackageJson({});
93
- stampPulledManifest(workDir, {
94
- app_id: "app_y",
95
- workspace_id: "wks_y",
96
- current_version_id: "apv_y",
97
- version_number: 3,
98
- workflows: {
99
- only_live: { workflow_id: "wfl_only_live" },
100
- },
101
- queries: {},
102
- agents: {},
103
- });
104
- const dtsPath = path.join(workDir, ".lotics", "app_workflows.d.ts");
105
- expect(fs.existsSync(dtsPath)).toBe(true);
106
- const dts = fs.readFileSync(dtsPath, "utf-8");
107
- expect(dts).toContain("only_live");
108
- });
109
- });
110
- /**
111
- * The deploy pre-flight that catches GAP-29: source that CALLS a capability-gated
112
- * SDK function while the manifest omits that capability would silently 403 at
113
- * runtime. `undeclaredCapabilities` is the pure decision the warning is built on.
114
- */
115
- describe("undeclaredCapabilities", () => {
116
- const commentsSource = `import { useComments } from "@lotics/app-sdk";\nconst { available } = useComments({ record_id });`;
117
- it("flags a capability that's called but not declared", () => {
118
- expect(undeclaredCapabilities(commentsSource, undefined)).toEqual(["comments"]);
119
- expect(undeclaredCapabilities(commentsSource, {})).toEqual(["comments"]);
120
- expect(undeclaredCapabilities(commentsSource, { comments: false })).toEqual(["comments"]);
121
- });
122
- it("stays quiet when the called capability is declared", () => {
123
- expect(undeclaredCapabilities(commentsSource, { comments: true })).toEqual([]);
124
- });
125
- it("stays quiet when the capability isn't used at all", () => {
126
- expect(undeclaredCapabilities(`const x = createInvoice();`, undefined)).toEqual([]);
127
- expect(undeclaredCapabilities("", { comments: true })).toEqual([]);
128
- });
129
- it("matches on whole identifiers, not substrings", () => {
130
- // `myUseCommentsHelper` is not a call to the gated `useComments`.
131
- expect(undeclaredCapabilities(`const x = myUseCommentsHelper();`, undefined)).toEqual([]);
132
- });
133
- it("detects any of a capability's gated calls", () => {
134
- expect(undeclaredCapabilities(`await createComment({ body });`, {})).toEqual(["comments"]);
135
- expect(undeclaredCapabilities(`await deleteComment(id);`, {})).toEqual(["comments"]);
136
- });
137
- });
138
- describe("appDirName", () => {
139
- it("collapses a slash in the name to a single segment (the create/pull nesting bug)", () => {
140
- // A "/" in the name used to split the create/pull target into nested folders
141
- // ("Nhập" → "Xuất Cont") and compound on each pull.
142
- expect(appDirName("Nhập/Xuất Cont")).toBe("Nhập-Xuất Cont");
143
- expect(appDirName("a/b/c")).toBe("a-b-c");
144
- expect(appDirName("a\\b")).toBe("a-b");
145
- });
146
- it("leaves an ordinary name untouched (no regression for the common case)", () => {
147
- expect(appDirName("Sales Tracker")).toBe("Sales Tracker");
148
- expect(appDirName("crm")).toBe("crm");
149
- });
150
- it("strips reserved characters and collapses whitespace", () => {
151
- expect(appDirName('Report: "Q1" <draft>')).toBe("Report Q1 draft");
152
- });
153
- it("never yields a traversal, hidden, or empty directory", () => {
154
- expect(appDirName("../etc")).toBe("etc"); // "/"→"-", then leading dot/dash run stripped
155
- expect(appDirName(".hidden")).toBe("hidden");
156
- expect(appDirName(" ")).toBe("app");
157
- expect(appDirName("///")).toBe("app"); // separators → "-", a dir of only "-" falls back
158
- });
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
- * `appCodegen` (with a client) branches the runtime `app_fields.ts` on whether
211
- * the app is a package installation: a LINKED app (`getApp().package_id` set)
212
- * emits the binding-resolved form (portable — resolves through `getAppBinding()`
213
- * at load); a BESPOKE app bakes this workspace's concrete ids from the schema.
214
- */
215
- describe("appCodegen app_fields branch (linked vs bespoke)", () => {
216
- let workDir;
217
- beforeEach(() => {
218
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-codegen-branch-"));
219
- vi.spyOn(console, "error").mockImplementation(() => undefined);
220
- });
221
- afterEach(() => {
222
- fs.rmSync(workDir, { recursive: true, force: true });
223
- vi.restoreAllMocks();
224
- });
225
- function writeManifest() {
226
- fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
227
- name: "sample-app",
228
- lotics: {
229
- app_id: "app_1",
230
- workspace_id: "wks_1",
231
- current_version_id: "apv_1",
232
- version_number: 1,
233
- queries: { all: { ast: { kind: "from_table", table_id: "tbl_1" } } },
234
- },
235
- }, null, 2));
236
- }
237
- const readFields = () => fs.readFileSync(path.join(workDir, ".lotics", "app_fields.ts"), "utf-8");
238
- it("emits the BAKED form for a bespoke app (no package_id)", async () => {
239
- writeManifest();
240
- const client = {
241
- getApp: async () => ({ package_id: null }),
242
- getWorkspaceSchema: async () => [
243
- { id: "tbl_1", name: "Tasks", fields: [{ id: "fld_title", name: "Title" }] },
244
- ],
245
- };
246
- await appCodegen({ projectDir: workDir, client });
247
- const src = readFields();
248
- expect(src).not.toContain("getAppBinding");
249
- // Concrete id baked into the value position.
250
- expect(src).toContain('"fld_title"');
251
- expect(src).toMatch(/TASKS: \{/);
252
- });
253
- it("emits the BINDING form for a linked/published app (package_id set)", async () => {
254
- writeManifest();
255
- const client = {
256
- getApp: async () => ({ package_id: "apg_1" }),
257
- appBinding: async () => ({
258
- fields: { "tasks.title": "fld_title" },
259
- options: {},
260
- roles: { approver: "grp_x" },
261
- }),
262
- // Must NOT be consulted on the linked branch — a call would fail the type
263
- // shape it returns, and the assertions below prove the baked path is dead.
264
- getWorkspaceSchema: async () => {
265
- throw new Error("getWorkspaceSchema must not run for a linked app");
266
- },
267
- };
268
- await appCodegen({ projectDir: workDir, client });
269
- const src = readFields();
270
- expect(src).toContain('import { getAppBinding } from "@lotics/app-sdk";');
271
- expect(src).toContain("const binding = await getAppBinding();");
272
- expect(src).toContain('title: bound(binding.fields, "tasks.title", "field")');
273
- expect(src).toContain('approver: bound(binding.roles, "approver", "role")');
274
- // No concrete id baked into any value position.
275
- expect(src).not.toMatch(/"(fld|opt|tbl|grp)_/);
276
- });
277
- });
278
- /**
279
- * `appUiLink` edits the app's vite.config.ts resolve.alias to dev-link
280
- * @lotics/ui at packages/ui/src — auto-found in a monorepo checkout, or given
281
- * explicitly via --ui-src for an external app. It validates the src + component,
282
- * fails loud when neither resolves, and insert/remove is idempotent.
283
- */
284
- describe("appUiLink", () => {
285
- let root;
286
- let appDir;
287
- let uiSrc;
288
- beforeEach(() => {
289
- vi.spyOn(console, "error").mockImplementation(() => undefined);
290
- // A fake monorepo: <root>/packages/ui/src/<component>, <root>/apps/myapp.
291
- root = fs.mkdtempSync(path.join(tmpdir(), "lotics-uilink-test-"));
292
- uiSrc = path.join(root, "packages", "ui", "src");
293
- fs.mkdirSync(uiSrc, { recursive: true });
294
- fs.writeFileSync(path.join(uiSrc, "card.tsx"), "export const Card = () => null;");
295
- appDir = path.join(root, "apps", "myapp");
296
- fs.mkdirSync(appDir, { recursive: true });
297
- fs.writeFileSync(path.join(appDir, "vite.config.ts"), [
298
- "export default {",
299
- " resolve: {",
300
- " alias: [",
301
- ' { find: "react-native", replacement: "react-native-web" },',
302
- " ],",
303
- " },",
304
- "};",
305
- "",
306
- ].join("\n"));
307
- });
308
- afterEach(() => {
309
- fs.rmSync(root, { recursive: true, force: true });
310
- vi.restoreAllMocks();
311
- });
312
- const config = () => fs.readFileSync(path.join(appDir, "vite.config.ts"), "utf-8");
313
- it("inserts the dev-link alias pointing at packages/ui/src", () => {
314
- appUiLink({ projectDir: appDir, component: "card" });
315
- const out = config();
316
- expect(out).toContain(String.raw `/^@lotics\/ui\/(.+)$/`);
317
- expect(out).toContain(uiSrc);
318
- // The pre-existing alias is preserved.
319
- expect(out).toContain("react-native-web");
320
- });
321
- it("is idempotent — linking twice does not duplicate the alias", () => {
322
- appUiLink({ projectDir: appDir, component: "card" });
323
- const once = config();
324
- appUiLink({ projectDir: appDir, component: "card" });
325
- expect(config()).toBe(once);
326
- });
327
- it("removes the alias and leaves the rest of resolve.alias intact", () => {
328
- appUiLink({ projectDir: appDir, component: "card" });
329
- appUiLink({ projectDir: appDir, component: "card", remove: true });
330
- const out = config();
331
- expect(out).not.toContain(String.raw `@lotics\/ui`);
332
- expect(out).toContain("react-native-web");
333
- });
334
- it("fails loud when the named component does not exist in packages/ui/src", () => {
335
- expect(() => appUiLink({ projectDir: appDir, component: "nonexistent" })).toThrow(/nonexistent/);
336
- });
337
- it("fails loud when there is no packages/ui/src and no --ui-src override", () => {
338
- const lonely = fs.mkdtempSync(path.join(tmpdir(), "lotics-lonely-app-"));
339
- fs.writeFileSync(path.join(lonely, "vite.config.ts"), "export default { resolve: { alias: [] } };");
340
- try {
341
- expect(() => appUiLink({ projectDir: lonely, component: "card" })).toThrow(/--ui-src/);
342
- }
343
- finally {
344
- fs.rmSync(lonely, { recursive: true, force: true });
345
- }
346
- });
347
- it("links an EXTERNAL app (no monorepo above) via an explicit --ui-src", () => {
348
- const ext = fs.mkdtempSync(path.join(tmpdir(), "lotics-external-app-"));
349
- fs.writeFileSync(path.join(ext, "vite.config.ts"), "export default { resolve: { alias: [] } };");
350
- try {
351
- appUiLink({ projectDir: ext, component: "card", uiSrc });
352
- expect(fs.readFileSync(path.join(ext, "vite.config.ts"), "utf-8")).toContain(uiSrc);
353
- }
354
- finally {
355
- fs.rmSync(ext, { recursive: true, force: true });
356
- }
357
- });
358
- it("fails loud when --ui-src is not a directory", () => {
359
- expect(() => appUiLink({ projectDir: appDir, component: "card", uiSrc: path.join(root, "nope") })).toThrow(/not a directory/);
360
- });
361
- });
362
- /**
363
- * The workflow body files are how AA-1 Option B becomes "open a file → edit →
364
- * push". `writeWorkflowFile` writes the verbatim server source under an
365
- * auto-pulled header; `stripWorkflowHeader` undoes the header so `set` round-
366
- * trips only the body. The two must compose so a pull-then-set is a no-op on the
367
- * source itself.
368
- */
369
- describe("workflow body files (writeWorkflowFile / stripWorkflowHeader)", () => {
370
- let workDir;
371
- beforeEach(() => {
372
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-file-test-"));
373
- });
374
- afterEach(() => {
375
- fs.rmSync(workDir, { recursive: true, force: true });
376
- });
377
- const body = `return({ data: { ok: tool_create_records({ table_id: "tbl_1" }) } })`;
378
- it("writes src/workflows/<alias>.ts with a header + reference + wrapped body", () => {
379
- const file = writeWorkflowFile(workDir, "issueInvoice", body);
380
- expect(file).toBe(path.join(workDir, "src", "workflows", "issueInvoice.ts"));
381
- const content = fs.readFileSync(file, "utf-8");
382
- // Header: the loop command + the do-not-rename warning, all in // comments.
383
- expect(content).toContain("lotics app workflow set issueInvoice");
384
- expect(content).toContain("Do NOT rename");
385
- // The triple-slash reference points at the per-alias ambient globals.
386
- expect(content).toContain(`/// <reference path="../../.lotics/workflows/issueInvoice.globals.d.ts" />`);
387
- // The body sits inside the server's __workflow envelope.
388
- expect(content).toContain("async function __workflow()");
389
- expect(content).toContain(body);
390
- });
391
- it("round-trips: stripping the header + wrapper recovers exactly the written body", () => {
392
- const file = writeWorkflowFile(workDir, "wf", body);
393
- const recovered = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
394
- expect(recovered).toBe(body);
395
- });
396
- it("round-trips a multi-line body (wrapper peel is structural, not line-counted)", () => {
397
- const multi = ["let_total = 0;", "return({ data: { total: let_total } })"].join("\n");
398
- const file = writeWorkflowFile(workDir, "multi", multi);
399
- expect(stripWorkflowHeader(fs.readFileSync(file, "utf-8"))).toBe(multi);
400
- });
401
- it("uses the server-returned envelope verbatim when provided", () => {
402
- const file = writeWorkflowFile(workDir, "srv", body, {
403
- prefix: "async function __workflow(): Promise<__WorkflowReturn | void> {\n",
404
- suffix: "\n}",
405
- });
406
- const content = fs.readFileSync(file, "utf-8");
407
- expect(content).toContain("async function __workflow(): Promise<__WorkflowReturn | void> {");
408
- expect(stripWorkflowHeader(content)).toBe(body);
409
- });
410
- it("writes the per-alias ambient globals under .lotics/workflows", () => {
411
- const file = writeWorkflowGlobals(workDir, "issueInvoice", "declare const trigger: never;");
412
- expect(file).toBe(path.join(workDir, ".lotics", "workflows", "issueInvoice.globals.d.ts"));
413
- expect(fs.readFileSync(file, "utf-8")).toContain("declare const trigger: never;");
414
- });
415
- it("passes a header-less hand-written body through unchanged", () => {
416
- expect(stripWorkflowHeader(body)).toBe(body);
417
- });
418
- // GAP-59 pin: the CLI's fallback envelope must equal the SERVER's compile
419
- // envelope (the canonical SOURCE_PREFIX/SOURCE_SUFFIX, pinned on the backend
420
- // by `apps.test.ts`). If either side changes the wrapper, this literal breaks
421
- // here and the backend pin breaks there — they can't silently diverge.
422
- it("the CLI fallback envelope matches the server's __workflow wrapper byte-for-byte", () => {
423
- expect(FALLBACK_ENVELOPE_PREFIX).toBe("async function __workflow(): Promise<__WorkflowReturn | void> {\n");
424
- expect(FALLBACK_ENVELOPE_SUFFIX).toBe("\n}");
425
- });
426
- it("keeps an in-body leading comment when stripping a real generated file (header + wrapper)", () => {
427
- // A body whose own first line is a comment: written through the real writer
428
- // (header + __workflow wrapper), stripping recovers the body verbatim — the
429
- // header peel is anchored on the wrapper opener that follows it, so the
430
- // body's own comment is never mistaken for bookkeeping.
431
- const commentBody = ["// real body comment", body].join("\n");
432
- const file = writeWorkflowFile(workDir, "withcomment", commentBody);
433
- expect(stripWorkflowHeader(fs.readFileSync(file, "utf-8"))).toBe(commentBody);
434
- });
435
- it("passes a wrapper-LESS hand-written body through unchanged even when it opens with comments", () => {
436
- // No __workflow wrapper present (an author removed it, or hand-created the
437
- // file before any pull): the leading comments are real source, not the
438
- // generated header, so nothing is stripped. The earlier bug ate them.
439
- const handWritten = [
440
- "// a deliberate leading comment",
441
- "// and a second one",
442
- "",
443
- body,
444
- ].join("\n");
445
- expect(stripWorkflowHeader(handWritten)).toBe(handWritten);
446
- });
447
- });
448
- /**
449
- * `appWorkflowSet` is the push half of the file flow: resolve app_id from the
450
- * manifest, read src/workflows/<alias>.ts (header stripped), attach the typed
451
- * inputs/outputs from the manifest, and call client.setAppWorkflow. Tested as a
452
- * pure assembly against a captured mock client — no HTTP. process.cwd() is the
453
- * resolution base (like deploy/dev), so each test runs chdir'd into its workDir.
454
- */
455
- describe("appWorkflowSet", () => {
456
- let workDir;
457
- let prevCwd;
458
- let exitErr;
459
- beforeEach(() => {
460
- prevCwd = process.cwd();
461
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-set-test-"));
462
- process.chdir(workDir);
463
- vi.spyOn(console, "error").mockImplementation(() => undefined);
464
- // appWorkflowSet calls process.exit(1) on the error paths; turn that into a
465
- // throwable so a test can assert "it bailed" without killing the runner.
466
- exitErr = undefined;
467
- vi.spyOn(process, "exit").mockImplementation(((code) => {
468
- exitErr = new Error(`process.exit(${code})`);
469
- throw exitErr;
470
- }));
471
- });
472
- afterEach(() => {
473
- process.chdir(prevCwd);
474
- fs.rmSync(workDir, { recursive: true, force: true });
475
- vi.restoreAllMocks();
476
- });
477
- function writeManifest(workflows) {
478
- fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({
479
- name: "sample-app",
480
- lotics: {
481
- app_id: "app_set",
482
- workspace_id: "wks_set",
483
- current_version_id: "apv_1",
484
- version_number: 1,
485
- workflows,
486
- },
487
- }, null, 2));
488
- }
489
- /** A mock client that captures the setAppWorkflow call and echoes a result. */
490
- function mockClient(echo) {
491
- const calls = [];
492
- const client = {
493
- setAppWorkflow: async (app_id, alias, body) => {
494
- calls.push({ app_id, alias, body });
495
- return { result: echo ?? { app_id, alias, workflow_id: "wfl_new" } };
496
- },
497
- };
498
- return { client, calls };
499
- }
500
- it("reads app_id from the manifest, strips the file header, and forwards inputs/outputs", async () => {
501
- writeManifest({
502
- issueInvoice: {
503
- workflow_id: "wfl_x",
504
- inputs: { record_id: { type: "text", required: true } },
505
- outputs: { invoice_id: { type: "text" } },
506
- },
507
- });
508
- const source = `return({ data: { invoice_id: "x" } })`;
509
- writeWorkflowFile(workDir, "issueInvoice", source);
510
- const { client, calls } = mockClient({ workflow_id: "wfl_x", outputs: { invoice_id: { type: "text" } } });
511
- await appWorkflowSet(client, { alias: "issueInvoice" });
512
- expect(calls).toHaveLength(1);
513
- expect(calls[0].app_id).toBe("app_set");
514
- expect(calls[0].alias).toBe("issueInvoice");
515
- // The body is the stripped source — no header comment crosses the boundary.
516
- expect(calls[0].body.source).toBe(source);
517
- expect(calls[0].body.source).not.toContain("Auto-pulled");
518
- // The typed schemas ride along from the manifest declaration.
519
- expect(calls[0].body.inputs).toEqual({ record_id: { type: "text", required: true } });
520
- expect(calls[0].body.outputs).toEqual({ invoice_id: { type: "text" } });
521
- });
522
- it("forwards no inputs/outputs when the manifest declares none", async () => {
523
- writeManifest({ ping: { workflow_id: "wfl_ping" } });
524
- writeWorkflowFile(workDir, "ping", `return({ data: {} })`);
525
- const { client, calls } = mockClient();
526
- await appWorkflowSet(client, { alias: "ping" });
527
- expect(calls[0].body.inputs).toBeUndefined();
528
- expect(calls[0].body.outputs).toBeUndefined();
529
- });
530
- it("bails when the alias is absent from the manifest", async () => {
531
- writeManifest({ other: { workflow_id: "wfl_o" } });
532
- writeWorkflowFile(workDir, "missing", `return({})`);
533
- const { client, calls } = mockClient();
534
- await expect(appWorkflowSet(client, { alias: "missing" })).rejects.toThrow(/process\.exit/);
535
- expect(calls).toHaveLength(0);
536
- });
537
- it("bails when the body file does not exist", async () => {
538
- writeManifest({ declared: { workflow_id: "wfl_d" } });
539
- // No src/workflows/declared.ts written.
540
- const { client, calls } = mockClient();
541
- await expect(appWorkflowSet(client, { alias: "declared" })).rejects.toThrow(/process\.exit/);
542
- expect(calls).toHaveLength(0);
543
- });
544
- it("bails on a server verify error (non-zero exit, no swallowed failure)", async () => {
545
- writeManifest({ bad: { workflow_id: "wfl_b" } });
546
- writeWorkflowFile(workDir, "bad", `return(nope)`);
547
- const calls = [];
548
- const client = {
549
- setAppWorkflow: async () => {
550
- calls.push(1);
551
- return { result: null, error: "source failed verification: unknown identifier 'nope'" };
552
- },
553
- };
554
- await expect(appWorkflowSet(client, { alias: "bad" })).rejects.toThrow(/process\.exit/);
555
- expect(calls).toHaveLength(1); // it DID attempt the push, then surfaced the error
556
- });
557
- });
558
- /**
559
- * `appWorkflowPull` is the pull half: write one src/workflows/<alias>.ts per
560
- * bound alias from the live App row, using get_app_workflow for each faithful
561
- * body. A legacy alias whose source can't be rendered is warned and SKIPPED, not
562
- * a failure — so a partially-migrated app still pulls its readable bodies. (This
563
- * is the same writer appPull uses; appPull itself shells out to tar/npm.)
564
- */
565
- describe("appWorkflowPull", () => {
566
- let workDir;
567
- let prevCwd;
568
- beforeEach(() => {
569
- prevCwd = process.cwd();
570
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-pull-test-"));
571
- process.chdir(workDir);
572
- vi.spyOn(console, "error").mockImplementation(() => undefined);
573
- });
574
- afterEach(() => {
575
- process.chdir(prevCwd);
576
- fs.rmSync(workDir, { recursive: true, force: true });
577
- vi.restoreAllMocks();
578
- });
579
- function writeManifest() {
580
- 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));
581
- }
582
- /**
583
- * Mock client: getApp returns the bound alias set; getAppWorkflow returns a
584
- * body per alias; getAppWorkflowDts returns the per-alias globals + envelope.
585
- */
586
- function mockClient(aliases, sources) {
587
- return {
588
- getApp: async () => ({ workflows: Object.fromEntries(aliases.map((a) => [a, { workflow_id: `wfl_${a}` }])) }),
589
- getAppWorkflow: async (_app_id, alias) => {
590
- const source = sources[alias];
591
- return source === null || source === undefined
592
- ? { result: { app_id: "app_p", alias, workflow_id: `wfl_${alias}` }, error: undefined }
593
- : { result: { app_id: "app_p", alias, workflow_id: `wfl_${alias}`, source } };
594
- },
595
- getAppWorkflowDts: async (_app_id, alias) => ({
596
- dts: `// globals for ${alias}\ndeclare const trigger: { type: "app_workflow" };`,
597
- envelope_prefix: "async function __workflow(): Promise<__WorkflowReturn | void> {\n",
598
- envelope_suffix: "\n}",
599
- }),
600
- };
601
- }
602
- it("writes one src/workflows/<alias>.ts per bound alias, wrapped + referencing its globals", async () => {
603
- writeManifest();
604
- const client = mockClient(["a", "b"], {
605
- a: `return({ data: { x: 1 } })`,
606
- b: `return({ data: { y: 2 } })`,
607
- });
608
- await appWorkflowPull(client);
609
- const a = fs.readFileSync(path.join(workDir, "src", "workflows", "a.ts"), "utf-8");
610
- const b = fs.readFileSync(path.join(workDir, "src", "workflows", "b.ts"), "utf-8");
611
- expect(a).toContain("lotics app workflow set a");
612
- expect(a).toContain(`return({ data: { x: 1 } })`);
613
- // Wrapped in the server's __workflow envelope and references its globals.
614
- expect(a).toContain("async function __workflow()");
615
- expect(a).toContain(`/// <reference path="../../.lotics/workflows/a.globals.d.ts" />`);
616
- expect(b).toContain(`return({ data: { y: 2 } })`);
617
- // The per-alias ambient globals landed under .lotics/workflows/.
618
- const globalsA = fs.readFileSync(path.join(workDir, ".lotics", "workflows", "a.globals.d.ts"), "utf-8");
619
- expect(globalsA).toContain("globals for a");
620
- // The body still round-trips: stripping recovers exactly what set will push.
621
- expect(stripWorkflowHeader(a)).toBe(`return({ data: { x: 1 } })`);
622
- });
623
- it("skips (does not fail) a legacy alias with no readable source", async () => {
624
- writeManifest();
625
- const client = mockClient(["good", "legacy"], {
626
- good: `return({ data: {} })`,
627
- legacy: null, // no source field → skipped with a warning
628
- });
629
- await appWorkflowPull(client);
630
- expect(fs.existsSync(path.join(workDir, "src", "workflows", "good.ts"))).toBe(true);
631
- expect(fs.existsSync(path.join(workDir, "src", "workflows", "legacy.ts"))).toBe(false);
632
- });
633
- it("writes the body with a fallback wrapper when the dts fetch fails (non-fatal)", async () => {
634
- writeManifest();
635
- const client = {
636
- getApp: async () => ({ workflows: { c: { workflow_id: "wfl_c" } } }),
637
- getAppWorkflow: async (_a, alias) => ({
638
- result: { app_id: "app_p", alias, workflow_id: "wfl_c", source: `return({ data: {} })` },
639
- }),
640
- getAppWorkflowDts: async () => {
641
- throw new Error("network down");
642
- },
643
- };
644
- await appWorkflowPull(client);
645
- const c = fs.readFileSync(path.join(workDir, "src", "workflows", "c.ts"), "utf-8");
646
- // Body still written, still wrapped, still round-trips — only the globals
647
- // file is absent (typecheck degraded, not the file).
648
- expect(c).toContain("async function __workflow()");
649
- expect(stripWorkflowHeader(c)).toBe(`return({ data: {} })`);
650
- expect(fs.existsSync(path.join(workDir, ".lotics", "workflows", "c.globals.d.ts"))).toBe(false);
651
- });
652
- });
653
- /**
654
- * `appExecuteWorkflow` runs a bound workflow and, with `--print-created` /
655
- * `--cleanup`, harvests the run's side effects (GAP-58). The harvest is an
656
- * honest report, never a rollback: it prints created records + a paste-ready
657
- * delete command + the mandatory caveat, and `--cleanup` deletes ONLY the
658
- * harvested records (never files/external/notifications).
659
- */
660
- describe("appExecuteWorkflow (--print-created / --cleanup harvest)", () => {
661
- let workDir;
662
- let prevCwd;
663
- let stderr;
664
- let exitCode;
665
- beforeEach(() => {
666
- prevCwd = process.cwd();
667
- workDir = fs.mkdtempSync(path.join(tmpdir(), "lotics-wf-run-test-"));
668
- process.chdir(workDir);
669
- fs.writeFileSync(path.join(workDir, "package.json"), JSON.stringify({ name: "a", lotics: { app_id: "app_r", workspace_id: "wks_r" } }));
670
- stderr = [];
671
- exitCode = undefined;
672
- vi.spyOn(console, "error").mockImplementation((...args) => {
673
- stderr.push(args.map(String).join(" "));
674
- });
675
- vi.spyOn(console, "log").mockImplementation(() => undefined);
676
- vi.spyOn(process, "exit").mockImplementation(((code) => {
677
- exitCode = code;
678
- throw new Error(`exit(${code})`);
679
- }));
680
- });
681
- afterEach(() => {
682
- process.chdir(prevCwd);
683
- fs.rmSync(workDir, { recursive: true, force: true });
684
- vi.restoreAllMocks();
685
- });
686
- const summary = {
687
- created_records: [{ table_id: "tbl_x", record_ids: ["rec_1", "rec_2"] }],
688
- created_files: ["fil_1"],
689
- irreversible_tool_calls: [{ tool_name: "misa_save_sales_voucher", step_id: "s1" }],
690
- sub_workflows_possible: true,
691
- };
692
- function mockClient(deleteCalls) {
693
- return {
694
- appWorkflow: async () => ({ status: "success", message: "ok", side_effects: summary }),
695
- execute: async (_tool, args) => {
696
- deleteCalls.push(args);
697
- return { result: { deleted: 2 } };
698
- },
699
- };
700
- }
701
- it("--print-created reports created records + a paste-ready delete + the caveat; deletes nothing", async () => {
702
- const deleteCalls = [];
703
- await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {}, printCreated: true });
704
- const out = stderr.join("\n");
705
- expect(out).toContain("tbl_x: 2 record(s)");
706
- expect(out).toContain(`lotics run delete_records '{"table_id":"tbl_x","record_ids":["rec_1","rec_2"]}'`);
707
- // The mandatory caveat names the irreversible call + the sub-workflow risk.
708
- expect(out).toContain("Could NOT auto-undo (clean up manually): misa_save_sales_voucher.");
709
- expect(out).toContain("Sub-workflows may have run");
710
- // Report-only: no delete executed.
711
- expect(deleteCalls).toHaveLength(0);
712
- });
713
- it("--cleanup deletes ONLY the harvested records (never files/external)", async () => {
714
- const deleteCalls = [];
715
- await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {}, cleanup: true });
716
- expect(deleteCalls).toEqual([{ table_id: "tbl_x", record_ids: ["rec_1", "rec_2"] }]);
717
- });
718
- it("--cleanup exits non-zero when a delete fails (a CI script must not read partial cleanup as success)", async () => {
719
- const client = {
720
- appWorkflow: async () => ({ status: "success", message: "ok", side_effects: summary }),
721
- execute: async () => ({ result: null, error: "permission denied" }),
722
- };
723
- await expect(appExecuteWorkflow(client, { alias: "run", inputs: {}, cleanup: true })).rejects.toThrow(/exit\(1\)/);
724
- expect(exitCode).toBe(1);
725
- expect(stderr.join("\n")).toContain("✗ tbl_x: permission denied");
726
- });
727
- it("exits non-zero on an error status even with --print-created", async () => {
728
- const client = {
729
- appWorkflow: async () => ({ status: "error", message: "boom", side_effects: summary }),
730
- execute: async () => ({ result: {} }),
731
- };
732
- await expect(appExecuteWorkflow(client, { alias: "run", inputs: {}, printCreated: true })).rejects.toThrow(/exit\(1\)/);
733
- expect(exitCode).toBe(1);
734
- });
735
- it("without the flags, prints no harvest and performs no delete", async () => {
736
- const deleteCalls = [];
737
- await appExecuteWorkflow(mockClient(deleteCalls), { alias: "run", inputs: {} });
738
- const out = stderr.join("\n");
739
- expect(out).not.toContain("Could NOT auto-undo");
740
- expect(deleteCalls).toHaveLength(0);
741
- });
742
- });
743
- describe("defaultPullTarget", () => {
744
- let dir;
745
- let prevCwd;
746
- beforeEach(() => {
747
- prevCwd = process.cwd();
748
- dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pull-target-"));
749
- process.chdir(dir);
750
- });
751
- afterEach(() => {
752
- process.chdir(prevCwd);
753
- fs.rmSync(dir, { recursive: true, force: true });
754
- });
755
- it("refreshes in place when the cwd IS this app's own project (no stray subdir)", () => {
756
- fs.writeFileSync("package.json", JSON.stringify({ lotics: { app_id: "app_X" } }));
757
- expect(defaultPullTarget("app_X", "Sales Tracker")).toBe(process.cwd());
758
- });
759
- it("clones into a name subdir when the cwd is a DIFFERENT app", () => {
760
- fs.writeFileSync("package.json", JSON.stringify({ lotics: { app_id: "app_OTHER" } }));
761
- expect(defaultPullTarget("app_X", "Sales Tracker")).toBe("Sales Tracker");
762
- });
763
- it("clones into a name subdir when the cwd is not an app at all", () => {
764
- expect(defaultPullTarget("app_X", "Sales Tracker")).toBe("Sales Tracker");
765
- });
766
- });
767
- describe("ensureAppTsconfig", () => {
768
- let dir;
769
- beforeEach(() => {
770
- dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tsconfig-"));
771
- });
772
- afterEach(() => {
773
- fs.rmSync(dir, { recursive: true, force: true });
774
- });
775
- const tsconfigPath = () => path.join(dir, "tsconfig.json");
776
- const read = () => JSON.parse(fs.readFileSync(tsconfigPath(), "utf-8"));
777
- it("adds both workflow globs to the TOP-LEVEL exclude", () => {
778
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: {}, exclude: ["node_modules"] }));
779
- ensureAppTsconfig(dir);
780
- expect(read().exclude).toEqual(["node_modules", "src/workflows", ".lotics/workflows"]);
781
- });
782
- it("writes TOP-LEVEL even when a compilerOptions.exclude exists (tsc ignores the nested key)", () => {
783
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: { exclude: ["x"] } }));
784
- ensureAppTsconfig(dir);
785
- const cfg = read();
786
- expect(cfg.exclude).toEqual(["src/workflows", ".lotics/workflows"]);
787
- expect(cfg.compilerOptions.exclude).toEqual(["x"]); // left untouched (and tsc-ignored)
788
- });
789
- it("preserves pre-existing top-level excludes", () => {
790
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules", "dist"] }));
791
- ensureAppTsconfig(dir);
792
- expect(read().exclude).toEqual(["node_modules", "dist", "src/workflows", ".lotics/workflows"]);
793
- });
794
- it("is idempotent — a second call writes nothing new", () => {
795
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules"] }));
796
- ensureAppTsconfig(dir);
797
- const afterFirst = fs.readFileSync(tsconfigPath(), "utf-8");
798
- ensureAppTsconfig(dir);
799
- expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe(afterFirst);
800
- });
801
- it("warns + does not throw or create a file when there is no tsconfig", () => {
802
- expect(() => ensureAppTsconfig(dir)).not.toThrow();
803
- expect(fs.existsSync(tsconfigPath())).toBe(false);
804
- });
805
- it("warns + does not throw on an unparseable tsconfig (left as-is)", () => {
806
- fs.writeFileSync(tsconfigPath(), "{ not json,, }");
807
- expect(() => ensureAppTsconfig(dir)).not.toThrow();
808
- expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe("{ not json,, }");
809
- });
810
- it("rewrites a bare .lotics include to the glob (a dot-dir loads zero generated types)", () => {
811
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics"], exclude: ["node_modules"] }));
812
- ensureAppTsconfig(dir);
813
- expect(read().include).toEqual(["src", ".lotics/**/*"]);
814
- });
815
- it("leaves an already-correct .lotics/**/* include untouched (idempotent)", () => {
816
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics/**/*"], exclude: ["node_modules", "src/workflows", ".lotics/workflows"] }));
817
- const before = fs.readFileSync(tsconfigPath(), "utf-8");
818
- ensureAppTsconfig(dir);
819
- expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe(before);
820
- });
821
- it("dedupes when both a bare .lotics and the glob are present", () => {
822
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: [".lotics", ".lotics/**/*", "src"] }));
823
- ensureAppTsconfig(dir);
824
- expect(read().include).toEqual([".lotics/**/*", "src"]);
825
- });
826
- it("heals the include AND adds the excludes in a single write", () => {
827
- fs.writeFileSync(tsconfigPath(), JSON.stringify({ include: ["src", ".lotics"], exclude: ["node_modules"] }));
828
- ensureAppTsconfig(dir);
829
- const cfg = read();
830
- expect(cfg.include).toEqual(["src", ".lotics/**/*"]);
831
- expect(cfg.exclude).toEqual(["node_modules", "src/workflows", ".lotics/workflows"]);
832
- });
833
- });
834
- describe("appVersions", () => {
835
- let logLines;
836
- let errLines;
837
- beforeEach(() => {
838
- logLines = [];
839
- errLines = [];
840
- vi.spyOn(console, "log").mockImplementation((...args) => {
841
- logLines.push(String(args[0]));
842
- });
843
- vi.spyOn(console, "error").mockImplementation((...args) => {
844
- errLines.push(String(args[0]));
845
- });
846
- });
847
- afterEach(() => {
848
- vi.restoreAllMocks();
849
- });
850
- const clientWith = (payload) => ({ listAppVersions: vi.fn().mockResolvedValue(payload) });
851
- it("renders history newest-first, marks the current version, and prefers the deployer name", async () => {
852
- const client = clientWith({
853
- current_version_id: "apv_2",
854
- versions: [
855
- {
856
- id: "apv_2",
857
- version: 2,
858
- message: "add receipt gate",
859
- build_status: "success",
860
- bundle_size_bytes: 1234,
861
- created_at: "2026-06-24T03:04:41Z",
862
- created_by: "mem_a",
863
- created_by_name: "Minh Vu",
864
- },
865
- {
866
- id: "apv_1",
867
- version: 1,
868
- message: "initial deploy",
869
- build_status: "success",
870
- bundle_size_bytes: 1000,
871
- created_at: "2026-06-23T08:00:00Z",
872
- created_by: "mem_b",
873
- created_by_name: null,
874
- },
875
- ],
876
- });
877
- // Explicit app_id → no manifest/package.json in cwd required.
878
- await appVersions(client, { app_id: "app_x" });
879
- // One header row + one row per version, all on stdout.
880
- expect(logLines.length).toBe(3);
881
- const v2Row = logLines.find((l) => l.includes("v2"));
882
- const v1Row = logLines.find((l) => l.includes("v1"));
883
- // Currently-served version is marked with `*`; the other is not.
884
- expect(v2Row.startsWith("*")).toBe(true);
885
- expect(v1Row.startsWith("*")).toBe(false);
886
- // Messages render; the deployer name is preferred, falling back to the id.
887
- expect(v2Row).toContain("add receipt gate");
888
- expect(v2Row).toContain("Minh Vu");
889
- expect(v1Row).toContain("initial deploy");
890
- expect(v1Row).toContain("mem_b");
891
- });
892
- it("reports an empty history to stderr without printing a table", async () => {
893
- const client = clientWith({ current_version_id: null, versions: [] });
894
- await appVersions(client, { app_id: "app_empty" });
895
- expect(logLines).toEqual([]);
896
- expect(errLines.join("\n")).toContain("No versions found");
897
- });
898
- });