@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 +3 -2
- package/dist/app_commands.d.ts +41 -0
- package/dist/app_commands.js +199 -10
- package/dist/app_commands.test.js +69 -1
- 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/cli.js +13 -1
- package/dist/src/cli.js +432 -233
- package/dist/starter_template.js +22 -41
- package/dist/starter_template.test.js +14 -10
- package/package.json +2 -1
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
|
-
#
|
|
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)
|
package/dist/app_commands.d.ts
CHANGED
|
@@ -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
|
package/dist/app_commands.js
CHANGED
|
@@ -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 `
|
|
72
|
-
* verdict (GAP-59). The wrapper + reference + comment lines
|
|
73
|
-
* stripped on `set`; the filename IS the alias — renaming it
|
|
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
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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 ??
|
|
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[];
|