@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.
@@ -17,9 +17,13 @@ import { spawn } from "node:child_process";
17
17
  import { tmpdir } from "node:os";
18
18
  import { buildStarterTemplate } from "./starter_template.js";
19
19
  import { startDevServer, openBrowser } from "./dev/server.js";
20
+ import { ipv4ChildEnv } from "./child_env.js";
20
21
  import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
21
22
  import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
22
23
  import { generateAppQueriesDts } from "./generate_app_queries_dts.js";
24
+ import { collectQueryTableIds } from "@lotics/shared/app_query_ast";
25
+ import { generateAppFields } from "./generate_app_fields.js";
26
+ import { loadProjectTypescript, checkWorkflowBodies, } from "./app_workflow_check.js";
23
27
  /**
24
28
  * Resolve the latest published version of a package from the npm registry.
25
29
  * Returns null on any failure (network error, 404, malformed payload) so
@@ -46,6 +50,202 @@ async function fetchLatestNpmVersion(packageName) {
46
50
  return null;
47
51
  }
48
52
  }
53
+ /** The directory, relative to the project root, that holds editable workflow bodies. */
54
+ const WORKFLOWS_DIR = path.join("src", "workflows");
55
+ /** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
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"];
67
+ /**
68
+ * The `async function __workflow(...)` wrapper a workflow body sits inside —
69
+ * the SAME envelope the server compiles the body within at `set_app_workflow`
70
+ * verify time (GAP-59). Carried so a body that uses top-level `await` and ends
71
+ * with `return({...})` typechecks locally exactly as the server checks it. The
72
+ * server returns the canonical strings (`getAppWorkflowDts`); these are the
73
+ * offline fallback when the dts fetch fails so the file is still wrapped — a
74
+ * test pins them equal to the server's, so they can't drift.
75
+ */
76
+ export const FALLBACK_ENVELOPE_PREFIX = "async function __workflow(): Promise<__WorkflowReturn | void> {\n";
77
+ export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
78
+ /**
79
+ * Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
80
+ * reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
81
+ * calls — server-generated), and the body sits inside the SAME `__workflow`
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.
86
+ */
87
+ function workflowFileHeader(alias) {
88
+ const refPath = path
89
+ .join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`)
90
+ .split(path.sep)
91
+ .join("/");
92
+ return (`/// <reference path="${refPath}" />\n` +
93
+ `// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
94
+ `// lines below, then check + push with:\n` +
95
+ `// lotics app workflow check ${alias}\n` +
96
+ `// lotics app workflow set ${alias}\n` +
97
+ `// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
98
+ `// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
99
+ `// set) — they only make the body typecheck locally against the workspace types.\n` +
100
+ `// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
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.
106
+ `export {};\n`);
107
+ }
108
+ /** Absolute path of one workflow body file, given the project root + alias. */
109
+ function workflowFilePath(projectDir, alias) {
110
+ return path.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
111
+ }
112
+ /** Absolute path of one alias's ambient globals `.d.ts`. */
113
+ function workflowGlobalsPath(projectDir, alias) {
114
+ return path.join(projectDir, WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`);
115
+ }
116
+ /**
117
+ * Write `.lotics/workflows/<alias>.globals.d.ts` = the server-generated ambient
118
+ * declarations the body typechecks against. Idempotent. Returns the path.
119
+ */
120
+ export function writeWorkflowGlobals(projectDir, alias, dts) {
121
+ const dir = path.join(projectDir, WORKFLOW_GLOBALS_DIR);
122
+ fs.mkdirSync(dir, { recursive: true });
123
+ const file = workflowGlobalsPath(projectDir, alias);
124
+ fs.writeFileSync(file, `${dts.replace(/\s+$/, "")}\n`);
125
+ return file;
126
+ }
127
+ /**
128
+ * Write `src/workflows/<alias>.ts` = header (triple-slash reference + comments)
129
+ * + the body wrapped in the `__workflow` envelope. Idempotent (a re-pull
130
+ * overwrites with the current server body). The raw `source` round-trips on
131
+ * `set` (the header + wrapper are stripped). Exported for direct unit testing —
132
+ * the surrounding pull shells out to `tar`/`npm`.
133
+ */
134
+ export function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX }) {
135
+ const dir = path.join(projectDir, WORKFLOWS_DIR);
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ const file = workflowFilePath(projectDir, alias);
138
+ // Body verbatim inside the wrapper so a later `set` round-trips byte-for-byte
139
+ // (modulo the header + wrapper, which are stripped). The prefix already ends
140
+ // with a newline and the suffix begins with one (server envelope shape).
141
+ const body = source.replace(/\s+$/, "");
142
+ fs.writeFileSync(file, `${workflowFileHeader(alias)}\n${envelope.prefix}${body}${envelope.suffix}\n`);
143
+ return file;
144
+ }
145
+ /** Matches the `__workflow` envelope opener (the wrapper `writeWorkflowFile`
146
+ * prepends). Recognized structurally (not by exact return-type text) so a
147
+ * future envelope-signature tweak doesn't silently leave the wrapper in the
148
+ * pushed source. */
149
+ const WORKFLOW_WRAPPER_OPENER = /^\s*async\s+function\s+__workflow\s*\(/;
150
+ /**
151
+ * Strip the CLI bookkeeping back off a workflow body before pushing it: the
152
+ * triple-slash reference + the `//` header comments + the `export {};` marker +
153
+ * blank lines, then the `__workflow` envelope (the opening
154
+ * `async function __workflow(...) {` line and the matching trailing `}`). `set`
155
+ * sends ONLY the JS-subset body the author edited — the server stays the single
156
+ * verifier.
157
+ *
158
+ * The strip is anchored on the GENERATED bookkeeping, never on "the file happens
159
+ * to start with comments": the leading comment/marker/blank block is only peeled
160
+ * when it is immediately followed by the `__workflow` wrapper opener (the exact
161
+ * shape `writeWorkflowFile` produces). A hand-written, wrapper-LESS body whose
162
+ * first lines are comments therefore round-trips unchanged — its comments are
163
+ * real source, not bookkeeping, and must not be silently eaten.
164
+ */
165
+ export function stripWorkflowHeader(content) {
166
+ const lines = content.split("\n");
167
+ // Probe how far a generated header would extend: `//`/`///` lines, then the
168
+ // `export {};` module marker, then the blank line(s) before the wrapper.
169
+ let headerEnd = 0;
170
+ while (headerEnd < lines.length && /^\s*\/\//.test(lines[headerEnd]))
171
+ headerEnd++;
172
+ while (headerEnd < lines.length && /^\s*export\s*\{\s*\}\s*;?\s*$/.test(lines[headerEnd]))
173
+ headerEnd++;
174
+ while (headerEnd < lines.length && lines[headerEnd].trim() === "")
175
+ headerEnd++;
176
+ // Peel the header ONLY when the wrapper opener follows it — that's the proof
177
+ // the leading block was generated bookkeeping and not the author's own source.
178
+ // If the file opens with the wrapper directly (degraded pull with no header),
179
+ // peel from there. Otherwise leave the body verbatim.
180
+ let start;
181
+ if (headerEnd < lines.length && WORKFLOW_WRAPPER_OPENER.test(lines[headerEnd])) {
182
+ start = headerEnd;
183
+ }
184
+ else if (lines.length > 0 && WORKFLOW_WRAPPER_OPENER.test(lines[0])) {
185
+ start = 0;
186
+ }
187
+ else {
188
+ return content.replace(/\s+$/, "");
189
+ }
190
+ // `start` is the wrapper opener line — drop it and the matching trailing `}`.
191
+ let end = lines.length - 1;
192
+ while (end > start && lines[end].trim() === "")
193
+ end--;
194
+ if (lines[end]?.trim() === "}") {
195
+ return lines.slice(start + 1, end).join("\n").replace(/\s+$/, "");
196
+ }
197
+ // Opener present but no closing `}` — a malformed wrapper; push the body after
198
+ // the opener verbatim rather than guessing where the envelope ends.
199
+ return lines.slice(start + 1).join("\n").replace(/\s+$/, "");
200
+ }
201
+ /**
202
+ * Pull every workflow body the server can render into `src/workflows/<alias>.ts`,
203
+ * plus the per-alias ambient globals `.lotics/workflows/<alias>.globals.d.ts` that
204
+ * make the body locally typecheckable. The alias set comes from the live
205
+ * `apps.workflows` map; each body comes from `get_app_workflow` (faithful source
206
+ * re-rendered from the persisted step tree) and each globals + envelope from
207
+ * `getAppWorkflowDts`. A legacy alias whose source can't be read is WARNED and
208
+ * SKIPPED — never a failure — so a partially-migrated app still pulls. A dts
209
+ * fetch failure is non-fatal too: the body is still written (with the fallback
210
+ * envelope), only the local typecheck is degraded — mirrors `app_fields`.
211
+ * Returns the aliases written.
212
+ */
213
+ async function writeWorkflowFiles(client, projectDir, app_id, aliases) {
214
+ const written = [];
215
+ for (const alias of aliases) {
216
+ const res = await client.getAppWorkflow(app_id, alias);
217
+ const source = res.error || res.result === null || typeof res.result !== "object"
218
+ ? null
219
+ : res.result.source;
220
+ if (typeof source !== "string" || source.trim() === "") {
221
+ console.error(`⚠ Skipped src/workflows/${alias}.ts — the server returned no readable source ` +
222
+ `(${res.error ?? "legacy workflow with no rendered body"}).`);
223
+ continue;
224
+ }
225
+ const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
226
+ writeWorkflowFile(projectDir, alias, source, envelope);
227
+ written.push(alias);
228
+ }
229
+ return written;
230
+ }
231
+ /**
232
+ * Fetch + write one alias's ambient globals `.d.ts` and return the wrapper
233
+ * envelope to wrap the body in. Network failure is non-fatal (warn, keep the
234
+ * last-written globals if any) and the body is wrapped in the FALLBACK envelope
235
+ * so it still parses — mirrors `app_fields`'s offline tolerance.
236
+ */
237
+ async function fetchWorkflowGlobals(client, projectDir, app_id, alias) {
238
+ try {
239
+ const { dts, envelope_prefix, envelope_suffix } = await client.getAppWorkflowDts(app_id, alias);
240
+ writeWorkflowGlobals(projectDir, alias, dts);
241
+ return { prefix: envelope_prefix, suffix: envelope_suffix };
242
+ }
243
+ catch (err) {
244
+ console.error(`⚠ Could not fetch workflow types for "${alias}" (${err instanceof Error ? err.message : String(err)}). ` +
245
+ `Wrote the body with the fallback wrapper; its local typecheck may be degraded.`);
246
+ return { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX };
247
+ }
248
+ }
49
249
  /** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
50
250
  function runTar(args, cwd) {
51
251
  return new Promise((resolve, reject) => {
@@ -66,7 +266,7 @@ function runTar(args, cwd) {
66
266
  /** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
67
267
  function runNpm(args, cwd) {
68
268
  return new Promise((resolve, reject) => {
69
- const proc = spawn("npm", args, { cwd, stdio: "inherit" });
269
+ const proc = spawn("npm", args, { cwd, stdio: "inherit", env: ipv4ChildEnv(process.env) });
70
270
  proc.on("error", reject);
71
271
  proc.on("exit", (code) => {
72
272
  if (code === 0)
@@ -103,6 +303,47 @@ function writeAppMeta(projectDir, meta) {
103
303
  pkg.lotics = meta;
104
304
  fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
105
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
+ }
106
347
  /**
107
348
  * Write `.lotics/app_workflows.d.ts` + `.lotics/app_queries.d.ts` from the
108
349
  * manifest's `workflows` / `queries` maps. Called from `app create / pull /
@@ -113,9 +354,125 @@ function writeAppMeta(projectDir, meta) {
113
354
  function writeAppDts(projectDir, manifest) {
114
355
  const dotLotics = path.join(projectDir, ".lotics");
115
356
  fs.mkdirSync(dotLotics, { recursive: true });
116
- fs.writeFileSync(path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows));
117
- fs.writeFileSync(path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries));
118
- fs.writeFileSync(path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents));
357
+ const written = [
358
+ [path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
359
+ [path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
360
+ [path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)],
361
+ ];
362
+ for (const [file, content] of written)
363
+ fs.writeFileSync(file, content);
364
+ return written.map(([file]) => file);
365
+ }
366
+ /**
367
+ * The optional `package.json#lotics.codegen.tables` allowlist — extra table ids
368
+ * to include in `app_fields.ts` beyond those the named queries reference (e.g.
369
+ * tables an app only writes to via a workflow, never queries). Returns `[]` when
370
+ * absent or malformed (codegen falls back to the query-derived set).
371
+ */
372
+ function readCodegenTablesAllowlist(projectDir) {
373
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"));
374
+ const tables = pkg.lotics?.codegen?.tables;
375
+ return Array.isArray(tables) ? tables.filter((t) => typeof t === "string") : [];
376
+ }
377
+ /**
378
+ * The table ids `app_fields.ts` should cover: the union of every `from_table`
379
+ * referenced by a declared query AST plus the optional manifest allowlist. The
380
+ * query set is what the app can actually read; the allowlist widens it to tables
381
+ * only ever written (so their field/option aliases are still addressable).
382
+ */
383
+ function resolveCodegenTableIds(projectDir, queries) {
384
+ const ids = new Set();
385
+ for (const decl of Object.values(queries)) {
386
+ // The manifest stores the AST untyped (`unknown`) — it's the server's
387
+ // deploy-time concern to verify. Cast at this single boundary so codegen
388
+ // reuses the canonical exhaustive walker (a new QueryNode kind is a
389
+ // compile error there, never a silently-dropped table).
390
+ for (const id of collectQueryTableIds(decl.ast))
391
+ ids.add(id);
392
+ }
393
+ for (const id of readCodegenTablesAllowlist(projectDir))
394
+ ids.add(id);
395
+ return [...ids];
396
+ }
397
+ /**
398
+ * Write the runtime `.lotics/app_fields.ts` from a resolved workspace schema.
399
+ * Separate from `writeAppDts` (the type-only companions) because this one needs
400
+ * the client to fetch the schema. Returns the written path.
401
+ */
402
+ function writeAppFields(projectDir, tables) {
403
+ const dotLotics = path.join(projectDir, ".lotics");
404
+ fs.mkdirSync(dotLotics, { recursive: true });
405
+ const file = path.join(dotLotics, "app_fields.ts");
406
+ fs.writeFileSync(file, generateAppFields(tables));
407
+ return file;
408
+ }
409
+ /**
410
+ * `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
411
+ * manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
412
+ * always written (synchronous, no network). When a `client` is available, the
413
+ * runtime `app_fields.ts` is also regenerated from the live schema of the tables
414
+ * the app's queries reference (+ the allowlist); a network failure is non-fatal
415
+ * (warn, keep the last-generated file) — so codegen still does useful work
416
+ * offline, mirroring `app create`'s tolerance of an offline npm registry.
417
+ */
418
+ export async function appCodegen(args) {
419
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
420
+ // readAppMeta throws the clear not-an-app error when there's no manifest.
421
+ const meta = readAppMeta(projectDir);
422
+ const dtsPaths = writeAppDts(projectDir, {
423
+ workflows: meta.workflows,
424
+ queries: meta.queries,
425
+ agents: meta.agents,
426
+ });
427
+ for (const p of dtsPaths)
428
+ console.error(`Regenerated ${p}`);
429
+ if (!args.client) {
430
+ console.error("Skipped .lotics/app_fields.ts — no workspace credentials resolved. " +
431
+ "Run authenticated (or set LOTICS_API_KEY) to regenerate field/option ids.");
432
+ return;
433
+ }
434
+ const tableIds = resolveCodegenTableIds(projectDir, meta.queries ?? {});
435
+ try {
436
+ const tables = await args.client.getWorkspaceSchema(tableIds);
437
+ const fieldsPath = writeAppFields(projectDir, tables);
438
+ console.error(`Regenerated ${fieldsPath} (${tables.length} table${tables.length === 1 ? "" : "s"})`);
439
+ }
440
+ catch (err) {
441
+ // Non-fatal: keep the last-generated app_fields.ts so an offline/transient
442
+ // failure doesn't strip the app's field aliases (same tolerance as the npm
443
+ // registry lookup on `app create`).
444
+ console.error(`⚠ Could not fetch the workspace schema (${err instanceof Error ? err.message : String(err)}). ` +
445
+ `Kept the existing .lotics/app_fields.ts.`);
446
+ }
447
+ // Refresh each bound workflow's ambient globals + re-wrap its EXISTING local
448
+ // body in the current envelope (GAP-59). Codegen never re-fetches the body
449
+ // (that would clobber local edits) — it strips the on-disk body and re-wraps
450
+ // it, so the local typecheck tracks the current workspace schema. Aliases
451
+ // never pulled (no body file yet) are skipped — codegen isn't a pull.
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);
457
+ }
458
+ /**
459
+ * For each bound alias that already has a local body file, fetch its current
460
+ * ambient globals `.d.ts` + envelope and re-wrap the on-disk body. Preserves the
461
+ * author's edits (strips + re-wraps, never re-fetches the body). A dts fetch
462
+ * failure is non-fatal per alias (warned inside `fetchWorkflowGlobals`).
463
+ */
464
+ async function refreshWorkflowGlobals(client, projectDir, app_id, aliases) {
465
+ for (const alias of aliases) {
466
+ const file = workflowFilePath(projectDir, alias);
467
+ if (!fs.existsSync(file))
468
+ continue;
469
+ const body = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
470
+ if (body.trim() === "")
471
+ continue;
472
+ const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
473
+ writeWorkflowFile(projectDir, alias, body, envelope);
474
+ console.error(`Refreshed ${path.relative(projectDir, file)} + its workflow types`);
475
+ }
119
476
  }
120
477
  /**
121
478
  * Stamp the post-extraction manifest with server-authoritative meta. Called
@@ -245,8 +602,10 @@ export async function appCreate(client, args) {
245
602
  * 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
246
603
  * `useWorkflow<"alias">` is typed at pull time.
247
604
  * 5. Run `npm install`.
248
- * 6. (TODO) Generate `.lotics/types.ts` with workspace tables augmentation —
249
- * pending a `client.getWorkspaceSchema()` endpoint.
605
+ *
606
+ * Runtime field/option id aliases (`.lotics/app_fields.ts`) are generated on
607
+ * demand by `lotics app codegen`, which fetches the workspace schema — pull
608
+ * leaves it to that command so a schema fetch never blocks the bootstrap.
250
609
  */
251
610
  /**
252
611
  * `lotics app subdomain <new>` — rename the current app's public address.
@@ -271,6 +630,31 @@ export async function appRename(client, args) {
271
630
  throw new Error(res.error);
272
631
  console.error(`App renamed: "${args.name}" (${meta.app_id})`);
273
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
+ }
274
658
  export async function appPull(client, args) {
275
659
  const app = await client.getApp(args.app_id);
276
660
  if (!app.current_version_id) {
@@ -278,7 +662,7 @@ export async function appPull(client, args) {
278
662
  }
279
663
  const version = await client.getAppVersion(app.id, app.current_version_id);
280
664
  const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
281
- const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
665
+ const targetPath = path.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
282
666
  fs.mkdirSync(targetPath, { recursive: true });
283
667
  // Download to a temp file because `tar -xz` reads from a real path.
284
668
  const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
@@ -306,6 +690,21 @@ export async function appPull(client, args) {
306
690
  queries: app.queries ?? {},
307
691
  agents: app.agents ?? {},
308
692
  });
693
+ // Write each bound workflow's faithful body to src/workflows/<alias>.ts so the
694
+ // author edits a real file and pushes with `lotics app workflow set <alias>`
695
+ // — no more fetch/reconstruct/escape. Sourced from get_app_workflow (the live
696
+ // workflow row), like the manifest's `workflows` map, so agent-authored bodies
697
+ // survive the pull. A legacy alias with no rendered source warns and is skipped.
698
+ const aliases = Object.keys(app.workflows ?? {});
699
+ if (aliases.length > 0) {
700
+ const written = await writeWorkflowFiles(client, targetPath, app.id, aliases);
701
+ if (written.length > 0) {
702
+ console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
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);
707
+ }
309
708
  console.error(`Installing npm dependencies...`);
310
709
  await runNpm(["install"], targetPath);
311
710
  console.error(`\nReady. Next steps:`);
@@ -535,3 +934,400 @@ export async function appDev(client, args) {
535
934
  console.error("\nStopping…");
536
935
  await handle.stop();
537
936
  }
937
+ /** Narrow the loose envelope's `created_records` into typed groups. */
938
+ function parseCreatedRecords(value) {
939
+ if (!Array.isArray(value))
940
+ return [];
941
+ const groups = [];
942
+ for (const entry of value) {
943
+ if (!entry || typeof entry !== "object")
944
+ continue;
945
+ const tableId = entry.table_id;
946
+ const recordIds = entry.record_ids;
947
+ if (typeof tableId !== "string" || !Array.isArray(recordIds))
948
+ continue;
949
+ const ids = recordIds.filter((id) => typeof id === "string");
950
+ if (ids.length > 0)
951
+ groups.push({ table_id: tableId, record_ids: ids });
952
+ }
953
+ return groups;
954
+ }
955
+ /** Narrow `irreversible_tool_calls` into a list of tool names (deduped, ordered). */
956
+ function parseIrreversibleToolNames(value) {
957
+ if (!Array.isArray(value))
958
+ return [];
959
+ const names = [];
960
+ for (const entry of value) {
961
+ if (!entry || typeof entry !== "object")
962
+ continue;
963
+ const name = entry.tool_name;
964
+ if (typeof name === "string" && !names.includes(name))
965
+ names.push(name);
966
+ }
967
+ return names;
968
+ }
969
+ /**
970
+ * Print the honest harvest of a run's side effects to stderr (GAP-58): created
971
+ * records grouped by table, a ready-to-paste `lotics run delete_records …` per
972
+ * table, then the MANDATORY caveat naming what cannot be auto-undone. Never
973
+ * deletes anything — this only reports.
974
+ */
975
+ function printSideEffects(summary) {
976
+ const created = parseCreatedRecords(summary.created_records);
977
+ const irreversibleTools = parseIrreversibleToolNames(summary.irreversible_tool_calls);
978
+ const subWorkflows = summary.sub_workflows_possible === true;
979
+ console.error("\nCreated records:");
980
+ if (created.length === 0) {
981
+ console.error(" (none with ids to clean up)");
982
+ }
983
+ else {
984
+ for (const group of created) {
985
+ console.error(` ${group.table_id}: ${group.record_ids.length} record(s)`);
986
+ const payload = JSON.stringify({ table_id: group.table_id, record_ids: group.record_ids });
987
+ console.error(` lotics run delete_records '${payload}'`);
988
+ }
989
+ }
990
+ // The caveat is mandatory and unconditional — a clean run still owes the
991
+ // reader the explicit "this is not a rollback" framing so cleanup is never
992
+ // mistaken for complete.
993
+ const irreversiblePart = irreversibleTools.length > 0
994
+ ? `Could NOT auto-undo (clean up manually): ${irreversibleTools.join(", ")}.`
995
+ : "Could NOT auto-undo: none.";
996
+ const subPart = subWorkflows
997
+ ? " Sub-workflows may have run (after_* table workflows) — their effects are NOT in this list."
998
+ : "";
999
+ console.error(`\n${irreversiblePart}${subPart}`);
1000
+ }
1001
+ /**
1002
+ * Run the harvested deletes for created records ONLY (never files / external /
1003
+ * notifications — those are reported, never silently undone). Best-effort: a
1004
+ * failed delete is logged and the rest continue. Returns `false` when ANY delete
1005
+ * failed, so the command boundary can exit non-zero — a CI script branching on
1006
+ * the exit code must not read partial cleanup as success.
1007
+ */
1008
+ async function cleanupCreatedRecords(client, created) {
1009
+ if (created.length === 0) {
1010
+ console.error("\nNo created records to clean up.");
1011
+ return true;
1012
+ }
1013
+ console.error("\nCleaning up created records (delete_records — records only):");
1014
+ let allDeleted = true;
1015
+ for (const group of created) {
1016
+ const res = await client.execute("delete_records", {
1017
+ table_id: group.table_id,
1018
+ record_ids: group.record_ids,
1019
+ });
1020
+ if (res.error) {
1021
+ console.error(` ✗ ${group.table_id}: ${res.error}`);
1022
+ allDeleted = false;
1023
+ }
1024
+ else {
1025
+ console.error(` ✓ ${group.table_id}: deleted ${group.record_ids.length} record(s)`);
1026
+ }
1027
+ }
1028
+ return allDeleted;
1029
+ }
1030
+ /**
1031
+ * `lotics app workflow run <alias> '<json>'` — execute a bound app workflow
1032
+ * end-to-end against the live workspace. `app_id` comes from the local manifest
1033
+ * (like deploy/dev), the alias must be bound server-side via `set_app_workflow`.
1034
+ *
1035
+ * The full `{ status, message, data, files, side_effects }` JSON prints to
1036
+ * stdout (pipeable / assertable); a one-line human summary goes to stderr. A
1037
+ * `status: "error"` envelope exits non-zero so a script can branch on it — the
1038
+ * transport already normalizes a gateway/timeout failure into the same
1039
+ * `{ status: "error" }` shape, so a failed run is never a thrown HTML body.
1040
+ *
1041
+ * `--print-created` (alias `--report-effects`) renders the honest post-run
1042
+ * harvest (GAP-58): created records grouped by table, a paste-ready
1043
+ * `delete_records` per table, and the mandatory caveat about what cannot be
1044
+ * auto-undone. `--cleanup` (DEFAULT OFF) additionally runs the deletes for the
1045
+ * harvested records ONLY — never files, external integrations, or notifications.
1046
+ * Neither is a rollback; a rollback is structurally impossible here.
1047
+ */
1048
+ export async function appExecuteWorkflow(client, args) {
1049
+ const meta = readAppMeta(process.cwd());
1050
+ const result = (await client.appWorkflow(meta.app_id, args.alias, args.inputs));
1051
+ console.log(JSON.stringify(result, null, 2));
1052
+ const status = typeof result.status === "string" ? result.status : "unknown";
1053
+ const message = typeof result.message === "string" ? result.message : "";
1054
+ console.error(`Workflow "${args.alias}" → ${status}${message ? `: ${message}` : ""}`);
1055
+ // --cleanup implies the report (you should always see what's being undone).
1056
+ let cleanupFailed = false;
1057
+ if ((args.printCreated || args.cleanup) && result.side_effects) {
1058
+ printSideEffects(result.side_effects);
1059
+ if (args.cleanup) {
1060
+ const allDeleted = await cleanupCreatedRecords(client, parseCreatedRecords(result.side_effects.created_records));
1061
+ cleanupFailed = !allDeleted;
1062
+ }
1063
+ }
1064
+ else if (args.printCreated || args.cleanup) {
1065
+ console.error("\n(no side-effect summary returned by the server)");
1066
+ }
1067
+ // Exit non-zero on an error run OR a partial cleanup — a script must not read
1068
+ // either as success.
1069
+ if (status === "error" || cleanupFailed)
1070
+ process.exit(1);
1071
+ }
1072
+ /**
1073
+ * `lotics app workflow set <alias>` — push the edited `src/workflows/<alias>.ts`
1074
+ * body to the server through `set_app_workflow` (the single author of
1075
+ * `apps.workflows`). The body is read from disk (header stripped); the typed
1076
+ * `inputs`/`outputs` schemas come from `package.json#lotics.workflows.<alias>`,
1077
+ * so a pulled-then-edited app keeps its declared contract. The server re-verifies
1078
+ * the body and echoes the bound `outputs` (declared, else DERIVED from
1079
+ * `return({ data })`) — the same guarantee as calling `set_app_workflow` by hand,
1080
+ * with no fetch/reconstruct/escape. Errors (missing file, unbound alias, verify
1081
+ * failure) print to stderr and exit non-zero.
1082
+ *
1083
+ * This is a CLI convenience over the existing tool — `lotics app deploy` is still
1084
+ * NOT an author of workflows; the single-author invariant holds.
1085
+ */
1086
+ export async function appWorkflowSet(client, args) {
1087
+ const projectDir = process.cwd();
1088
+ const meta = readAppMeta(projectDir);
1089
+ const declaration = meta.workflows?.[args.alias];
1090
+ if (!declaration) {
1091
+ console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
1092
+ `Bind it first (set_app_workflow), then 'lotics app pull' to write its body and manifest entry.`);
1093
+ process.exit(1);
1094
+ }
1095
+ const file = workflowFilePath(projectDir, args.alias);
1096
+ if (!fs.existsSync(file)) {
1097
+ console.error(`No workflow body at ${path.relative(projectDir, file)}. ` +
1098
+ `Run 'lotics app pull ${meta.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`);
1099
+ process.exit(1);
1100
+ }
1101
+ const source = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
1102
+ if (source.trim() === "") {
1103
+ console.error(`Workflow body ${path.relative(projectDir, file)} is empty after stripping the header.`);
1104
+ process.exit(1);
1105
+ }
1106
+ const res = await client.setAppWorkflow(meta.app_id, args.alias, {
1107
+ source,
1108
+ inputs: declaration.inputs,
1109
+ outputs: declaration.outputs,
1110
+ });
1111
+ if (res.error) {
1112
+ console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
1113
+ process.exit(1);
1114
+ }
1115
+ // set_app_workflow echoes { app_id, alias, workflow_id, outputs? } — outputs is
1116
+ // declared-wins-else-DERIVED from return({ data }), the shape result.data carries.
1117
+ const result = (res.result ?? {});
1118
+ const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
1119
+ console.error(`Set workflow "${args.alias}" → ${workflowId}`);
1120
+ if (result.outputs && typeof result.outputs === "object") {
1121
+ console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
1122
+ }
1123
+ }
1124
+ /**
1125
+ * `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
1126
+ * server without a full `lotics app pull` (no source archive, no npm install).
1127
+ * The alias set + bodies come from the live App row (the same source `app pull`
1128
+ * uses); a legacy alias with no rendered source warns and is skipped.
1129
+ */
1130
+ export async function appWorkflowPull(client) {
1131
+ const projectDir = process.cwd();
1132
+ const meta = readAppMeta(projectDir);
1133
+ const app = await client.getApp(meta.app_id);
1134
+ const aliases = Object.keys(app.workflows ?? {});
1135
+ if (aliases.length === 0) {
1136
+ console.error(`App ${meta.app_id} has no bound workflows.`);
1137
+ return;
1138
+ }
1139
+ const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
1140
+ console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
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"}.`);
1245
+ }
1246
+ /**
1247
+ * Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
1248
+ * not found — an external npm app author has no monorepo checkout, so `ui link`
1249
+ * must fail loud rather than write a broken alias.
1250
+ */
1251
+ function findUiSrcDir(start) {
1252
+ let dir = path.resolve(start);
1253
+ for (;;) {
1254
+ const candidate = path.join(dir, "packages", "ui", "src");
1255
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
1256
+ return candidate;
1257
+ const parent = path.dirname(dir);
1258
+ if (parent === dir)
1259
+ return null;
1260
+ dir = parent;
1261
+ }
1262
+ }
1263
+ // The dev-link alias entry — a package-wide `@lotics/ui/<subpath>` → local
1264
+ // `packages/ui/src/<subpath>` redirect. Matched/removed by the literal `find`
1265
+ // regex source so insert/remove is idempotent regardless of the replacement.
1266
+ const UI_ALIAS_FIND_SOURCE = String.raw `/^@lotics\/ui\/(.+)$/`;
1267
+ /**
1268
+ * `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
1269
+ * dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
1270
+ * `packages/ui/src` go live (HMR) without a publish round-trip. `component` is
1271
+ * advisory only — the alias is package-wide (one subpath regex covers every
1272
+ * import); it's validated to exist under `packages/ui/src` so a typo fails here.
1273
+ *
1274
+ * Idempotent: linking twice is a no-op; `--remove` strips the one inserted
1275
+ * entry and leaves the rest of `resolve.alias` intact.
1276
+ */
1277
+ export function appUiLink(args) {
1278
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
1279
+ const viteConfigPath = path.join(projectDir, "vite.config.ts");
1280
+ if (!fs.existsSync(viteConfigPath)) {
1281
+ throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
1282
+ }
1283
+ const uiSrc = findUiSrcDir(projectDir);
1284
+ if (!uiSrc) {
1285
+ throw new Error("Cannot find packages/ui/src by walking up from this directory — `lotics ui link` " +
1286
+ "requires a monorepo checkout. External apps consume @lotics/ui from npm; bump the " +
1287
+ "package version and widen the app's dependency range instead.");
1288
+ }
1289
+ // Validate the named component exists in src so a typo fails loud (the alias
1290
+ // itself stays package-wide — this is the advisory check the spec calls for).
1291
+ const hasComponent = fs.existsSync(path.join(uiSrc, `${args.component}.tsx`)) ||
1292
+ fs.existsSync(path.join(uiSrc, `${args.component}.ts`)) ||
1293
+ fs.existsSync(path.join(uiSrc, args.component));
1294
+ if (!hasComponent) {
1295
+ throw new Error(`No '@lotics/ui/${args.component}' under ${uiSrc} (expected ${args.component}.tsx/.ts). ` +
1296
+ `Check the component name.`);
1297
+ }
1298
+ const source = fs.readFileSync(viteConfigPath, "utf-8");
1299
+ const aliasEntry = `{ find: ${UI_ALIAS_FIND_SOURCE}, replacement: ${JSON.stringify(`${uiSrc}/$1`)} },`;
1300
+ const alreadyLinked = source.includes(UI_ALIAS_FIND_SOURCE);
1301
+ if (args.remove) {
1302
+ if (!alreadyLinked) {
1303
+ console.error("No @lotics/ui dev-link alias present — nothing to remove.");
1304
+ return;
1305
+ }
1306
+ // Drop the whole alias line (the entry + its own line), leaving the rest of
1307
+ // resolve.alias untouched.
1308
+ const stripped = source.replace(new RegExp(`^\\s*\\{ find: ${escapeRegExp(UI_ALIAS_FIND_SOURCE)}.*$\\n?`, "m"), "");
1309
+ fs.writeFileSync(viteConfigPath, stripped);
1310
+ console.error(`Removed the @lotics/ui dev-link alias from ${viteConfigPath}.`);
1311
+ console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1312
+ return;
1313
+ }
1314
+ if (alreadyLinked) {
1315
+ console.error(`@lotics/ui is already dev-linked in ${viteConfigPath}.`);
1316
+ return;
1317
+ }
1318
+ const aliasMatch = /resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\[/.exec(source);
1319
+ if (!aliasMatch) {
1320
+ throw new Error(`Could not find a resolve.alias array literal in ${viteConfigPath}. ` +
1321
+ `Refresh vite.config.ts from the starter (packages/sdk/src/starter_template.ts) and retry.`);
1322
+ }
1323
+ const insertAt = aliasMatch.index + aliasMatch[0].length;
1324
+ const updated = `${source.slice(0, insertAt)}\n ${aliasEntry}${source.slice(insertAt)}`;
1325
+ fs.writeFileSync(viteConfigPath, updated);
1326
+ console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
1327
+ console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
1328
+ console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
1329
+ }
1330
+ /** Escape a string for literal use inside a RegExp. */
1331
+ function escapeRegExp(s) {
1332
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1333
+ }