@lotics/cli 0.60.1 → 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 CHANGED
@@ -173,9 +173,10 @@ lotics app workflow run issueInvoice '{...}' --cleanup # also deletes created
173
173
  # Edit workflow bodies as files. `app pull` writes src/workflows/<alias>.ts (the
174
174
  # faithful server source, wrapped + referencing its .lotics/workflows/<alias>.globals.d.ts);
175
175
  # edit the body, then push it back through set_app_workflow — the server verifies it
176
- # (deploy still never authors workflows). Bodies are locally typecheckable:
177
- # tsc -p tsconfig.workflows.json
176
+ # (deploy still never authors workflows). Bodies are locally typecheckable with
177
+ # `app workflow check` (one isolated program per alias = the same verdict as `set`):
178
178
  lotics app workflow pull # rewrite src/workflows/*.ts + globals from the server
179
+ lotics app workflow check # typecheck every body locally ([alias] for one)
179
180
  lotics app workflow set issueInvoice # push the edited src/workflows/issueInvoice.ts
180
181
 
181
182
  # Dev-link @lotics/ui to the monorepo's packages/ui/src for live HMR (monorepo only)
@@ -86,6 +86,17 @@ export declare function writeWorkflowFile(projectDir: string, alias: string, sou
86
86
  * real source, not bookkeeping, and must not be silently eaten.
87
87
  */
88
88
  export declare function stripWorkflowHeader(content: string): string;
89
+ /**
90
+ * Add the workflow-body globs to the main `tsconfig.json`'s `exclude` if missing,
91
+ * preserving every other exclude. The starter ships these excludes already, but
92
+ * an app SCAFFOLDED before this CLI release (or one with a hand-written tsconfig)
93
+ * doesn't — and pulling bodies into it would otherwise break its
94
+ * `npm run typecheck`: the bodies pull the app's DOM lib (the server doesn't) and
95
+ * the per-alias ambient globals collide on `trigger`. Idempotent — a second pull
96
+ * is a no-op. Warns exactly what it added. A missing/unparseable tsconfig is a
97
+ * non-fatal warn (the pull itself still succeeds); the author fixes the config.
98
+ */
99
+ export declare function ensureWorkflowTsconfigExcludes(projectDir: string): void;
89
100
  /**
90
101
  * `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
91
102
  * manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
@@ -185,6 +196,14 @@ export declare function appSetSubdomain(client: LoticsClient, args: {
185
196
  export declare function appRename(client: LoticsClient, args: {
186
197
  name: string;
187
198
  }): Promise<void>;
199
+ /**
200
+ * Where `lotics app pull <app_id>` lands when given NO explicit path. If the cwd
201
+ * IS already this app's own project (its manifest `app_id` matches), refresh in
202
+ * place — the documented `cd <app> && lotics app pull` flow. Otherwise a fresh
203
+ * clone goes to an `appDirName(name)` subdir. Without this, pulling from inside
204
+ * the app dropped a stray `./<name>/` subdir instead of refreshing the project.
205
+ */
206
+ export declare function defaultPullTarget(appId: string, appName: string): string;
188
207
  export declare function appPull(client: LoticsClient, args: {
189
208
  app_id: string;
190
209
  targetPath?: string;
@@ -273,6 +292,28 @@ export declare function appWorkflowSet(client: LoticsClient, args: {
273
292
  * uses); a legacy alias with no rendered source warns and is skipped.
274
293
  */
275
294
  export declare function appWorkflowPull(client: LoticsClient): Promise<void>;
295
+ /**
296
+ * `lotics app workflow check [alias]` — local TypeScript type check of the
297
+ * editable workflow bodies, ONE isolated program per bound alias (GAP-59 fix).
298
+ *
299
+ * The dedicated `tsconfig.workflows.json` that GAP-59 first shipped compiled ALL
300
+ * aliases' bodies + per-alias ambient globals into a SINGLE program, so the N
301
+ * `declare const trigger: AppWorkflowTrigger` declarations (each with THAT
302
+ * alias's `app_workflow.inputs`) collided — tsc resolved one and every body
303
+ * checked `trigger.app_workflow.inputs` against the wrong alias. This command
304
+ * replaces that config: it builds a separate `ts.Program` per alias from exactly
305
+ * that alias's `{body, globals}` pair (mirroring the SERVER, which verifies one
306
+ * body at a time), so the ambient `trigger` is unambiguous and the verdict
307
+ * matches set-time. All aliases run in ONE process.
308
+ *
309
+ * `[alias]` checks one alias; omitted, checks every bound alias that has a body
310
+ * file. Exits non-zero if ANY alias has a type error. A bound alias with no body
311
+ * file yet (never pulled) is warned and skipped; an alias missing its globals
312
+ * file is an error (the body can't be checked without its types).
313
+ */
314
+ export declare function appWorkflowCheck(args: {
315
+ alias?: string;
316
+ }): Promise<void>;
276
317
  /**
277
318
  * `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
278
319
  * dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
@@ -23,6 +23,7 @@ import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
23
23
  import { generateAppQueriesDts } from "./generate_app_queries_dts.js";
24
24
  import { collectQueryTableIds } from "@lotics/shared/app_query_ast";
25
25
  import { generateAppFields } from "./generate_app_fields.js";
26
+ import { loadProjectTypescript, checkWorkflowBodies, } from "./app_workflow_check.js";
26
27
  /**
27
28
  * Resolve the latest published version of a package from the npm registry.
28
29
  * Returns null on any failure (network error, 404, malformed payload) so
@@ -53,6 +54,16 @@ async function fetchLatestNpmVersion(packageName) {
53
54
  const WORKFLOWS_DIR = path.join("src", "workflows");
54
55
  /** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
55
56
  const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
57
+ /**
58
+ * The two globs the MAIN tsconfig must `exclude`: the editable workflow bodies AND
59
+ * their per-alias ambient globals. The bodies use the app's DOM lib (the server
60
+ * doesn't) and each globals file declares its own ambient `trigger` — loading 22
61
+ * of them into the app's program collides those declarations and poisons
62
+ * `npm run typecheck`. Bodies are type-checked separately by
63
+ * `lotics app workflow check` (one isolated program per alias). Always written
64
+ * with `/` separators — tsconfig globs are POSIX even on Windows.
65
+ */
66
+ const WORKFLOW_TSCONFIG_EXCLUDES = ["src/workflows", ".lotics/workflows"];
56
67
  /**
57
68
  * The `async function __workflow(...)` wrapper a workflow body sits inside —
58
69
  * the SAME envelope the server compiles the body within at `set_app_workflow`
@@ -68,9 +79,10 @@ export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
68
79
  * Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
69
80
  * reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
70
81
  * calls — server-generated), and the body sits inside the SAME `__workflow`
71
- * wrapper the server compiles within, so a local `tsc` now mirrors the set-time
72
- * verdict (GAP-59). The wrapper + reference + comment lines are CLI bookkeeping,
73
- * stripped on `set`; the filename IS the alias — renaming it orphans the body.
82
+ * wrapper the server compiles within, so a local `lotics app workflow check`
83
+ * mirrors the set-time verdict (GAP-59). The wrapper + reference + comment lines
84
+ * are CLI bookkeeping, stripped on `set`; the filename IS the alias — renaming it
85
+ * orphans the body.
74
86
  */
75
87
  function workflowFileHeader(alias) {
76
88
  const refPath = path
@@ -79,17 +91,18 @@ function workflowFileHeader(alias) {
79
91
  .join("/");
80
92
  return (`/// <reference path="${refPath}" />\n` +
81
93
  `// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
82
- `// lines below, then push with:\n` +
94
+ `// lines below, then check + push with:\n` +
95
+ `// lotics app workflow check ${alias}\n` +
83
96
  `// lotics app workflow set ${alias}\n` +
84
97
  `// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
85
98
  `// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
86
99
  `// set) — they only make the body typecheck locally against the workspace types.\n` +
87
100
  `// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
88
- // `export {};` makes the file a MODULE so the per-file `__workflow` wrapper
89
- // doesn't collide across the bodies the dedicated tsconfig globs together.
90
- // The server compiles each body in isolation (script mode), so this is a
91
- // local-only adaptation with no effect on the body's type-checking; it is
92
- // CLI bookkeeping, stripped on `set`.
101
+ // `export {};` makes the file a MODULE. `lotics app workflow check` compiles
102
+ // each body in its OWN isolated program (just this body + its globals), so the
103
+ // `__workflow` function never collides across bodies; the marker is retained as
104
+ // stable CLI bookkeeping (stripWorkflowHeader anchors on it) and is stripped on
105
+ // `set`, so it has no effect on what the server verifies.
93
106
  `export {};\n`);
94
107
  }
95
108
  /** Absolute path of one workflow body file, given the project root + alias. */
@@ -290,6 +303,47 @@ function writeAppMeta(projectDir, meta) {
290
303
  pkg.lotics = meta;
291
304
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
292
305
  }
306
+ /**
307
+ * Add the workflow-body globs to the main `tsconfig.json`'s `exclude` if missing,
308
+ * preserving every other exclude. The starter ships these excludes already, but
309
+ * an app SCAFFOLDED before this CLI release (or one with a hand-written tsconfig)
310
+ * doesn't — and pulling bodies into it would otherwise break its
311
+ * `npm run typecheck`: the bodies pull the app's DOM lib (the server doesn't) and
312
+ * the per-alias ambient globals collide on `trigger`. Idempotent — a second pull
313
+ * is a no-op. Warns exactly what it added. A missing/unparseable tsconfig is a
314
+ * non-fatal warn (the pull itself still succeeds); the author fixes the config.
315
+ */
316
+ export function ensureWorkflowTsconfigExcludes(projectDir) {
317
+ const tsconfigPath = path.join(projectDir, "tsconfig.json");
318
+ if (!fs.existsSync(tsconfigPath)) {
319
+ console.error(`⚠ No tsconfig.json at ${projectDir} — could not ensure ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} are excluded. ` +
320
+ `Add them to your tsconfig's "exclude" so npm run typecheck skips the workflow bodies.`);
321
+ return;
322
+ }
323
+ let parsed;
324
+ try {
325
+ parsed = JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
326
+ }
327
+ catch (err) {
328
+ console.error(`⚠ Could not parse tsconfig.json (${err instanceof Error ? err.message : String(err)}) — ` +
329
+ `add ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} to its "exclude" manually so npm run typecheck skips the workflow bodies.`);
330
+ return;
331
+ }
332
+ if (!parsed || typeof parsed !== "object")
333
+ return;
334
+ // `exclude` is a TOP-LEVEL tsconfig field — tsc ignores `compilerOptions.exclude`,
335
+ // so the workflow globs must land at the top level or `npm run typecheck` still
336
+ // loads the bodies + their colliding per-alias globals.
337
+ const current = Array.isArray(parsed.exclude) ? parsed.exclude : [];
338
+ const currentStrings = current.filter((e) => typeof e === "string");
339
+ const toAdd = WORKFLOW_TSCONFIG_EXCLUDES.filter((g) => !currentStrings.includes(g));
340
+ if (toAdd.length === 0)
341
+ return;
342
+ parsed.exclude = [...currentStrings, ...toAdd];
343
+ fs.writeFileSync(tsconfigPath, JSON.stringify(parsed, null, 2) + "\n");
344
+ console.error(`Patched tsconfig.json: added ${toAdd.join(", ")} to "exclude" so npm run typecheck skips the workflow bodies ` +
345
+ `(check them with: lotics app workflow check).`);
346
+ }
293
347
  /**
294
348
  * Write `.lotics/app_workflows.d.ts` + `.lotics/app_queries.d.ts` from the
295
349
  * manifest's `workflows` / `queries` maps. Called from `app create / pull /
@@ -396,6 +450,10 @@ export async function appCodegen(args) {
396
450
  // it, so the local typecheck tracks the current workspace schema. Aliases
397
451
  // never pulled (no body file yet) are skipped — codegen isn't a pull.
398
452
  await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
453
+ // Keep the main tsconfig excluding the workflow-body globs (idempotent) so
454
+ // npm run typecheck never loads the bodies or their colliding per-alias globals.
455
+ if (Object.keys(meta.workflows ?? {}).length > 0)
456
+ ensureWorkflowTsconfigExcludes(projectDir);
399
457
  }
400
458
  /**
401
459
  * For each bound alias that already has a local body file, fetch its current
@@ -572,6 +630,31 @@ export async function appRename(client, args) {
572
630
  throw new Error(res.error);
573
631
  console.error(`App renamed: "${args.name}" (${meta.app_id})`);
574
632
  }
633
+ /**
634
+ * Where `lotics app pull <app_id>` lands when given NO explicit path. If the cwd
635
+ * IS already this app's own project (its manifest `app_id` matches), refresh in
636
+ * place — the documented `cd <app> && lotics app pull` flow. Otherwise a fresh
637
+ * clone goes to an `appDirName(name)` subdir. Without this, pulling from inside
638
+ * the app dropped a stray `./<name>/` subdir instead of refreshing the project.
639
+ */
640
+ export function defaultPullTarget(appId, appName) {
641
+ const cwdPkgPath = path.join(process.cwd(), "package.json");
642
+ if (fs.existsSync(cwdPkgPath)) {
643
+ let pkg = null;
644
+ try {
645
+ pkg = JSON.parse(fs.readFileSync(cwdPkgPath, "utf-8"));
646
+ }
647
+ catch (err) {
648
+ // An unparseable cwd package.json means we can't confirm this is the app's
649
+ // own project — warn and fall through to a fresh clone rather than crash.
650
+ console.error(`⚠ Could not parse ${cwdPkgPath} (${err instanceof Error ? err.message : String(err)}) — ` +
651
+ `pulling into a fresh ${appDirName(appName)}/ subdir.`);
652
+ }
653
+ if (pkg?.lotics?.app_id === appId)
654
+ return process.cwd();
655
+ }
656
+ return appDirName(appName);
657
+ }
575
658
  export async function appPull(client, args) {
576
659
  const app = await client.getApp(args.app_id);
577
660
  if (!app.current_version_id) {
@@ -579,7 +662,7 @@ export async function appPull(client, args) {
579
662
  }
580
663
  const version = await client.getAppVersion(app.id, app.current_version_id);
581
664
  const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
582
- const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
665
+ const targetPath = path.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
583
666
  fs.mkdirSync(targetPath, { recursive: true });
584
667
  // Download to a temp file because `tar -xz` reads from a real path.
585
668
  const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
@@ -618,6 +701,9 @@ export async function appPull(client, args) {
618
701
  if (written.length > 0) {
619
702
  console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
620
703
  }
704
+ // A pre-existing app's tsconfig may predate the workflow-body excludes; pulling
705
+ // bodies into it would break its npm run typecheck. Patch it idempotently.
706
+ ensureWorkflowTsconfigExcludes(targetPath);
621
707
  }
622
708
  console.error(`Installing npm dependencies...`);
623
709
  await runNpm(["install"], targetPath);
@@ -1053,6 +1139,109 @@ export async function appWorkflowPull(client) {
1053
1139
  const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
1054
1140
  console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
1055
1141
  (written.length > 0 ? ` (${written.join(", ")})` : ""));
1142
+ // A pre-existing app's tsconfig may predate the workflow-body excludes; pulling
1143
+ // bodies into it would break its npm run typecheck. Patch it idempotently.
1144
+ ensureWorkflowTsconfigExcludes(projectDir);
1145
+ }
1146
+ /**
1147
+ * `lotics app workflow check [alias]` — local TypeScript type check of the
1148
+ * editable workflow bodies, ONE isolated program per bound alias (GAP-59 fix).
1149
+ *
1150
+ * The dedicated `tsconfig.workflows.json` that GAP-59 first shipped compiled ALL
1151
+ * aliases' bodies + per-alias ambient globals into a SINGLE program, so the N
1152
+ * `declare const trigger: AppWorkflowTrigger` declarations (each with THAT
1153
+ * alias's `app_workflow.inputs`) collided — tsc resolved one and every body
1154
+ * checked `trigger.app_workflow.inputs` against the wrong alias. This command
1155
+ * replaces that config: it builds a separate `ts.Program` per alias from exactly
1156
+ * that alias's `{body, globals}` pair (mirroring the SERVER, which verifies one
1157
+ * body at a time), so the ambient `trigger` is unambiguous and the verdict
1158
+ * matches set-time. All aliases run in ONE process.
1159
+ *
1160
+ * `[alias]` checks one alias; omitted, checks every bound alias that has a body
1161
+ * file. Exits non-zero if ANY alias has a type error. A bound alias with no body
1162
+ * file yet (never pulled) is warned and skipped; an alias missing its globals
1163
+ * file is an error (the body can't be checked without its types).
1164
+ */
1165
+ export async function appWorkflowCheck(args) {
1166
+ const projectDir = process.cwd();
1167
+ const meta = readAppMeta(projectDir);
1168
+ const bound = Object.keys(meta.workflows ?? {});
1169
+ let aliases;
1170
+ if (args.alias) {
1171
+ if (!bound.includes(args.alias)) {
1172
+ console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
1173
+ `Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`);
1174
+ process.exit(1);
1175
+ }
1176
+ aliases = [args.alias];
1177
+ }
1178
+ else {
1179
+ aliases = bound;
1180
+ }
1181
+ if (aliases.length === 0) {
1182
+ console.error(`App ${meta.app_id} has no bound workflows to check.`);
1183
+ return;
1184
+ }
1185
+ // Each alias contributes its OWN body + globals. A bound alias never pulled has
1186
+ // no body file — warn + skip (not an error; the author hasn't pulled it). A
1187
+ // body with no globals can't be checked — that IS an error (run a pull).
1188
+ const toCheck = [];
1189
+ for (const alias of aliases) {
1190
+ const bodyPath = workflowFilePath(projectDir, alias);
1191
+ const globalsPath = workflowGlobalsPath(projectDir, alias);
1192
+ if (!fs.existsSync(bodyPath)) {
1193
+ console.error(`⚠ Skipped "${alias}" — no body at ${path.relative(projectDir, bodyPath)}. ` +
1194
+ `Run 'lotics app workflow pull' to write it.`);
1195
+ continue;
1196
+ }
1197
+ if (!fs.existsSync(globalsPath)) {
1198
+ console.error(`Cannot check "${alias}" — missing types at ${path.relative(projectDir, globalsPath)}. ` +
1199
+ `Run 'lotics app workflow pull' (or 'lotics app codegen') to fetch them.`);
1200
+ process.exit(1);
1201
+ }
1202
+ toCheck.push({ alias, input: { bodyPath, globalsPath } });
1203
+ }
1204
+ if (toCheck.length === 0) {
1205
+ console.error("No workflow bodies to check (every bound alias was skipped).");
1206
+ return;
1207
+ }
1208
+ const tsApi = await loadProjectTypescript(projectDir);
1209
+ const results = checkWorkflowBodies(tsApi, toCheck);
1210
+ printWorkflowCheckResults(projectDir, results);
1211
+ const failed = results.filter((r) => r.issues.length > 0);
1212
+ if (failed.length > 0)
1213
+ process.exit(1);
1214
+ }
1215
+ /**
1216
+ * Render the per-alias verdict to stderr (status) — a clean line per passing
1217
+ * alias, then `<file>:<line>:<col> - TS####: message` per error, grouped by
1218
+ * alias, with a final tally. Lines point at the author's body (the envelope
1219
+ * offset already removed in `checkOneWorkflowBody`).
1220
+ */
1221
+ function printWorkflowCheckResults(projectDir, results) {
1222
+ let totalErrors = 0;
1223
+ for (const r of results) {
1224
+ const rel = path.relative(projectDir, r.bodyPath);
1225
+ if (r.issues.length === 0) {
1226
+ console.error(`✓ ${r.alias} (${rel}) — no type errors`);
1227
+ continue;
1228
+ }
1229
+ totalErrors += r.issues.length;
1230
+ console.error(`✗ ${r.alias} (${rel}) — ${r.issues.length} error${r.issues.length === 1 ? "" : "s"}:`);
1231
+ for (const issue of r.issues) {
1232
+ // TS multi-line messages indent every continuation under the location line.
1233
+ const [first, ...rest] = issue.message.split("\n");
1234
+ console.error(` ${rel}:${issue.line}:${issue.col} - ${issue.code}: ${first}`);
1235
+ for (const line of rest)
1236
+ console.error(` ${line}`);
1237
+ }
1238
+ }
1239
+ const passed = results.length - results.filter((r) => r.issues.length > 0).length;
1240
+ console.error(totalErrors === 0
1241
+ ? results.length === 1
1242
+ ? `\nThe workflow body type-checks clean.`
1243
+ : `\nAll ${results.length} workflow bodies type-check clean.`
1244
+ : `\n${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - passed} of ${results.length} ${results.length === 1 ? "body" : "bodies"}.`);
1056
1245
  }
1057
1246
  /**
1058
1247
  * Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
@@ -2,7 +2,7 @@ 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, appCodegen, appUiLink, appWorkflowSet, appWorkflowPull, appExecuteWorkflow, writeWorkflowFile, writeWorkflowGlobals, stripWorkflowHeader, FALLBACK_ENVELOPE_PREFIX, FALLBACK_ENVELOPE_SUFFIX, } 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
@@ -656,3 +656,71 @@ describe("appExecuteWorkflow (--print-created / --cleanup harvest)", () => {
656
656
  expect(deleteCalls).toHaveLength(0);
657
657
  });
658
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[];