@lotics/cli 0.57.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/app_commands.d.ts +165 -2
- package/dist/app_commands.js +803 -7
- package/dist/app_commands.test.js +569 -2
- package/dist/app_workflow_check.d.ts +77 -0
- package/dist/app_workflow_check.js +169 -0
- package/dist/app_workflow_check.test.d.ts +1 -0
- package/dist/app_workflow_check.test.js +166 -0
- package/dist/args.d.ts +4 -0
- package/dist/args.js +9 -0
- package/dist/args.test.js +12 -0
- package/dist/child_env.d.ts +13 -0
- package/dist/child_env.js +24 -0
- package/dist/cli.js +144 -30
- package/dist/client.d.ts +58 -0
- package/dist/client.js +85 -0
- package/dist/dev/server.js +2 -1
- package/dist/generate_app_fields.d.ts +54 -0
- package/dist/generate_app_fields.js +148 -0
- package/dist/generate_app_fields.test.d.ts +1 -0
- package/dist/generate_app_fields.test.js +108 -0
- package/dist/inputs.d.ts +38 -0
- package/dist/inputs.js +50 -0
- package/dist/inputs.test.d.ts +1 -0
- package/dist/inputs.test.js +89 -0
- package/dist/src/cli.js +1208 -187
- package/dist/starter_template.js +72 -2
- package/dist/starter_template.test.js +15 -0
- package/package.json +3 -1
|
@@ -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, defaultPullTarget, ensureWorkflowTsconfigExcludes, 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,570 @@ 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
|
+
});
|
|
659
|
+
describe("defaultPullTarget", () => {
|
|
660
|
+
let dir;
|
|
661
|
+
let prevCwd;
|
|
662
|
+
beforeEach(() => {
|
|
663
|
+
prevCwd = process.cwd();
|
|
664
|
+
dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pull-target-"));
|
|
665
|
+
process.chdir(dir);
|
|
666
|
+
});
|
|
667
|
+
afterEach(() => {
|
|
668
|
+
process.chdir(prevCwd);
|
|
669
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
670
|
+
});
|
|
671
|
+
it("refreshes in place when the cwd IS this app's own project (no stray subdir)", () => {
|
|
672
|
+
fs.writeFileSync("package.json", JSON.stringify({ lotics: { app_id: "app_X" } }));
|
|
673
|
+
expect(defaultPullTarget("app_X", "Sales Tracker")).toBe(process.cwd());
|
|
674
|
+
});
|
|
675
|
+
it("clones into a name subdir when the cwd is a DIFFERENT app", () => {
|
|
676
|
+
fs.writeFileSync("package.json", JSON.stringify({ lotics: { app_id: "app_OTHER" } }));
|
|
677
|
+
expect(defaultPullTarget("app_X", "Sales Tracker")).toBe("Sales Tracker");
|
|
678
|
+
});
|
|
679
|
+
it("clones into a name subdir when the cwd is not an app at all", () => {
|
|
680
|
+
expect(defaultPullTarget("app_X", "Sales Tracker")).toBe("Sales Tracker");
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
describe("ensureWorkflowTsconfigExcludes", () => {
|
|
684
|
+
let dir;
|
|
685
|
+
beforeEach(() => {
|
|
686
|
+
dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tsconfig-"));
|
|
687
|
+
});
|
|
688
|
+
afterEach(() => {
|
|
689
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
690
|
+
});
|
|
691
|
+
const tsconfigPath = () => path.join(dir, "tsconfig.json");
|
|
692
|
+
const read = () => JSON.parse(fs.readFileSync(tsconfigPath(), "utf-8"));
|
|
693
|
+
it("adds both workflow globs to the TOP-LEVEL exclude", () => {
|
|
694
|
+
fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: {}, exclude: ["node_modules"] }));
|
|
695
|
+
ensureWorkflowTsconfigExcludes(dir);
|
|
696
|
+
expect(read().exclude).toEqual(["node_modules", "src/workflows", ".lotics/workflows"]);
|
|
697
|
+
});
|
|
698
|
+
it("writes TOP-LEVEL even when a compilerOptions.exclude exists (tsc ignores the nested key)", () => {
|
|
699
|
+
fs.writeFileSync(tsconfigPath(), JSON.stringify({ compilerOptions: { exclude: ["x"] } }));
|
|
700
|
+
ensureWorkflowTsconfigExcludes(dir);
|
|
701
|
+
const cfg = read();
|
|
702
|
+
expect(cfg.exclude).toEqual(["src/workflows", ".lotics/workflows"]);
|
|
703
|
+
expect(cfg.compilerOptions.exclude).toEqual(["x"]); // left untouched (and tsc-ignored)
|
|
704
|
+
});
|
|
705
|
+
it("preserves pre-existing top-level excludes", () => {
|
|
706
|
+
fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules", "dist"] }));
|
|
707
|
+
ensureWorkflowTsconfigExcludes(dir);
|
|
708
|
+
expect(read().exclude).toEqual(["node_modules", "dist", "src/workflows", ".lotics/workflows"]);
|
|
709
|
+
});
|
|
710
|
+
it("is idempotent — a second call writes nothing new", () => {
|
|
711
|
+
fs.writeFileSync(tsconfigPath(), JSON.stringify({ exclude: ["node_modules"] }));
|
|
712
|
+
ensureWorkflowTsconfigExcludes(dir);
|
|
713
|
+
const afterFirst = fs.readFileSync(tsconfigPath(), "utf-8");
|
|
714
|
+
ensureWorkflowTsconfigExcludes(dir);
|
|
715
|
+
expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe(afterFirst);
|
|
716
|
+
});
|
|
717
|
+
it("warns + does not throw or create a file when there is no tsconfig", () => {
|
|
718
|
+
expect(() => ensureWorkflowTsconfigExcludes(dir)).not.toThrow();
|
|
719
|
+
expect(fs.existsSync(tsconfigPath())).toBe(false);
|
|
720
|
+
});
|
|
721
|
+
it("warns + does not throw on an unparseable tsconfig (left as-is)", () => {
|
|
722
|
+
fs.writeFileSync(tsconfigPath(), "{ not json,, }");
|
|
723
|
+
expect(() => ensureWorkflowTsconfigExcludes(dir)).not.toThrow();
|
|
724
|
+
expect(fs.readFileSync(tsconfigPath(), "utf-8")).toBe("{ not json,, }");
|
|
725
|
+
});
|
|
726
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* The compiler options the SERVER uses at set-time verify — a copy of
|
|
4
|
+
* `backend/features/workflows/typecheck_with_typescript.ts:COMPILER_OPTIONS`,
|
|
5
|
+
* expressed against the project-resolved `ts` namespace so the local verdict
|
|
6
|
+
* matches the server's. The CLI can't import backend code, so this is duplicated
|
|
7
|
+
* and pinned to the same literal by tests on BOTH sides (this package's
|
|
8
|
+
* `app_workflow_check.test.ts` + the backend's `typecheck_with_typescript.test.ts`
|
|
9
|
+
* — same pattern as the `__workflow` envelope). If the server changes a flag, both
|
|
10
|
+
* pins break and force this to follow. lib `es2022` with NO DOM (a body runs on
|
|
11
|
+
* the server, not a browser); `types: []` so no `@types/*` ambient leaks in;
|
|
12
|
+
* `skipLibCheck` keeps lib-typecheck off the hot path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function workflowCheckCompilerOptions(tsApi: typeof ts): ts.CompilerOptions;
|
|
15
|
+
/** One type error, mapped back to the author's body coordinates. */
|
|
16
|
+
export interface WorkflowCheckIssue {
|
|
17
|
+
/** 1-indexed line in the author's body (the envelope-prefix offset removed). */
|
|
18
|
+
line: number;
|
|
19
|
+
/** 1-indexed column in the author's body. */
|
|
20
|
+
col: number;
|
|
21
|
+
/** TS-prefixed diagnostic code, e.g. "TS2339". */
|
|
22
|
+
code: string;
|
|
23
|
+
/** Human-readable message; multi-line TS messages joined with `\n`. */
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
/** The verdict for one alias's body. `issues: []` ⇒ clean. */
|
|
27
|
+
export interface WorkflowCheckAliasResult {
|
|
28
|
+
alias: string;
|
|
29
|
+
bodyPath: string;
|
|
30
|
+
issues: WorkflowCheckIssue[];
|
|
31
|
+
}
|
|
32
|
+
export interface WorkflowCheckInput {
|
|
33
|
+
/** Absolute path to the wrapped body file (`src/workflows/<alias>.ts`). */
|
|
34
|
+
bodyPath: string;
|
|
35
|
+
/** Absolute path to the per-alias ambient globals (`.lotics/workflows/<alias>.globals.d.ts`). */
|
|
36
|
+
globalsPath: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Count the lines the envelope prefix adds ABOVE the author's body, so a
|
|
40
|
+
* diagnostic on body line K reports as the author's line K, not K+offset. The
|
|
41
|
+
* wrapper opener (`async function __workflow(...) {`) and the header above it
|
|
42
|
+
* (the `/// <reference>`, the `//` comments, the `export {};` marker, blank
|
|
43
|
+
* lines) all sit before the first author line. The author's body is everything
|
|
44
|
+
* between the wrapper opener line and the trailing `}` — so the offset is the
|
|
45
|
+
* count of lines up to and including the opener.
|
|
46
|
+
*
|
|
47
|
+
* Mirrors the body shape `writeWorkflowFile` produces; recognized structurally
|
|
48
|
+
* (the `__workflow` opener) so a future header tweak can't silently desync the
|
|
49
|
+
* line mapping. A file with no recognizable opener (degraded) maps with a zero
|
|
50
|
+
* offset rather than guessing.
|
|
51
|
+
*/
|
|
52
|
+
export declare function bodyLineOffset(wrappedSource: string): number;
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the app's own `typescript` from `projectDir`. Loud, actionable error
|
|
55
|
+
* when the app has no compiler installed (never a silent skip — a skipped check
|
|
56
|
+
* reads as a clean check). The dynamic import is the boundary adapter the file
|
|
57
|
+
* header explains: the only place we load a project-local peer tool.
|
|
58
|
+
*/
|
|
59
|
+
export declare function loadProjectTypescript(projectDir: string): Promise<typeof ts>;
|
|
60
|
+
/**
|
|
61
|
+
* Type-check one alias's body in an ISOLATED program built from exactly that
|
|
62
|
+
* alias's `{body, globals}` pair — so the ambient `trigger` is unambiguous and
|
|
63
|
+
* `trigger.app_workflow.inputs` is checked against THIS alias's inputs. Returns
|
|
64
|
+
* every diagnostic that lands in the body file, mapped back to the author's
|
|
65
|
+
* coordinates.
|
|
66
|
+
*/
|
|
67
|
+
export declare function checkOneWorkflowBody(tsApi: typeof ts, input: WorkflowCheckInput): WorkflowCheckIssue[];
|
|
68
|
+
/**
|
|
69
|
+
* Type-check every requested alias in ONE process (one isolated program each).
|
|
70
|
+
* Pure over its inputs (the resolved `ts` + the `{body, globals}` paths) so it's
|
|
71
|
+
* unit-testable without the CLI/manifest plumbing. Aliases are returned in the
|
|
72
|
+
* order given.
|
|
73
|
+
*/
|
|
74
|
+
export declare function checkWorkflowBodies(tsApi: typeof ts, aliases: {
|
|
75
|
+
alias: string;
|
|
76
|
+
input: WorkflowCheckInput;
|
|
77
|
+
}[]): WorkflowCheckAliasResult[];
|