@lotics/cli 0.88.0 → 0.88.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/render_page.js +100 -100
- package/dist/src/cli.js +100 -100
- package/dist/{client.d.ts → src/client.d.ts} +1 -1
- package/dist/src/package_content_types.d.ts +70 -0
- package/dist/src/package_content_types.js +12 -0
- package/package.json +2 -2
- package/dist/app_commands.d.ts +0 -401
- package/dist/app_commands.js +0 -1520
- package/dist/app_commands.test.d.ts +0 -1
- package/dist/app_commands.test.js +0 -898
- package/dist/app_workflow_check.d.ts +0 -77
- package/dist/app_workflow_check.js +0 -169
- package/dist/app_workflow_check.test.d.ts +0 -1
- package/dist/app_workflow_check.test.js +0 -166
- package/dist/args.d.ts +0 -42
- package/dist/args.js +0 -121
- package/dist/args.test.d.ts +0 -1
- package/dist/args.test.js +0 -88
- package/dist/child_env.d.ts +0 -13
- package/dist/child_env.js +0 -24
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -1095
- package/dist/cli_dispatch.test.d.ts +0 -1
- package/dist/cli_dispatch.test.js +0 -89
- package/dist/client.test.d.ts +0 -1
- package/dist/client.test.js +0 -47
- package/dist/config.d.ts +0 -124
- package/dist/config.js +0 -315
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -319
- package/dist/dev/file_relay.d.ts +0 -39
- package/dist/dev/file_relay.js +0 -87
- package/dist/dev/file_relay.test.d.ts +0 -1
- package/dist/dev/file_relay.test.js +0 -87
- package/dist/dev/rpc_handler.d.ts +0 -21
- package/dist/dev/rpc_handler.js +0 -166
- package/dist/dev/rpc_handler.test.d.ts +0 -1
- package/dist/dev/rpc_handler.test.js +0 -107
- package/dist/dev/server.d.ts +0 -75
- package/dist/dev/server.js +0 -443
- package/dist/dev/upload_relay.d.ts +0 -36
- package/dist/dev/upload_relay.js +0 -61
- package/dist/dev/upload_relay.test.d.ts +0 -1
- package/dist/dev/upload_relay.test.js +0 -76
- package/dist/dev/wrapper_page.d.ts +0 -40
- package/dist/dev/wrapper_page.js +0 -335
- package/dist/docx.d.ts +0 -2
- package/dist/docx.js +0 -341
- package/dist/docx.test.d.ts +0 -1
- package/dist/docx.test.js +0 -145
- package/dist/file_command_io.d.ts +0 -12
- package/dist/file_command_io.js +0 -34
- package/dist/generate_app_agents_dts.d.ts +0 -16
- package/dist/generate_app_agents_dts.js +0 -62
- package/dist/generate_app_agents_dts.test.d.ts +0 -1
- package/dist/generate_app_agents_dts.test.js +0 -45
- package/dist/generate_app_fields.d.ts +0 -56
- package/dist/generate_app_fields.js +0 -148
- package/dist/generate_app_fields.test.d.ts +0 -1
- package/dist/generate_app_fields.test.js +0 -108
- package/dist/generate_app_queries_dts.d.ts +0 -21
- package/dist/generate_app_queries_dts.js +0 -57
- package/dist/generate_app_workflows_dts.d.ts +0 -39
- package/dist/generate_app_workflows_dts.js +0 -229
- package/dist/generate_app_workflows_dts.test.d.ts +0 -1
- package/dist/generate_app_workflows_dts.test.js +0 -109
- package/dist/generate_package_fields.d.ts +0 -49
- package/dist/generate_package_fields.js +0 -157
- package/dist/generate_package_fields.test.d.ts +0 -1
- package/dist/generate_package_fields.test.js +0 -59
- package/dist/inputs.d.ts +0 -38
- package/dist/inputs.js +0 -50
- package/dist/inputs.test.d.ts +0 -1
- package/dist/inputs.test.js +0 -89
- package/dist/preview.d.ts +0 -3
- package/dist/preview.js +0 -233
- package/dist/starter_template.d.ts +0 -64
- package/dist/starter_template.js +0 -753
- package/dist/starter_template.test.d.ts +0 -1
- package/dist/starter_template.test.js +0 -115
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -6
- package/dist/xlsx.d.ts +0 -2
- package/dist/xlsx.js +0 -489
- package/dist/xlsx.test.d.ts +0 -1
- package/dist/xlsx.test.js +0 -131
- /package/dist/{client.js → src/client.js} +0 -0
package/dist/app_commands.js
DELETED
|
@@ -1,1520 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `lotics app *` subcommands — create / pull / deploy.
|
|
3
|
-
*
|
|
4
|
-
* Apps are user-owned npm projects. Source-of-truth during editing is the
|
|
5
|
-
* local filesystem; the server stores deployed bundles (source.tar.gz + dist)
|
|
6
|
-
* in R2 keyed per version. These commands are the only sanctioned lifecycle
|
|
7
|
-
* surface — everything else (validate, logs, watch) folds into a flag on
|
|
8
|
-
* deploy or lives in the web UI.
|
|
9
|
-
*
|
|
10
|
-
* Tarball handling shells out to the system `tar` command. CLI users are
|
|
11
|
-
* developers; macOS/Linux/WSL all ship a working tar. Avoids adding a
|
|
12
|
-
* runtime dep to the published @lotics/cli package.
|
|
13
|
-
*/
|
|
14
|
-
import fs from "node:fs";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
18
|
-
import { buildStarterTemplate } from "./starter_template.js";
|
|
19
|
-
import { startDevServer, openBrowser } from "./dev/server.js";
|
|
20
|
-
import { ipv4ChildEnv } from "./child_env.js";
|
|
21
|
-
import { generateAppWorkflowsDts } from "./generate_app_workflows_dts.js";
|
|
22
|
-
import { generateAppAgentsDts } from "./generate_app_agents_dts.js";
|
|
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 { generatePackageAppFields } from "./generate_package_fields.js";
|
|
27
|
-
import { loadProjectTypescript, checkWorkflowBodies, } from "./app_workflow_check.js";
|
|
28
|
-
/**
|
|
29
|
-
* Resolve the latest published version of a package from the npm registry.
|
|
30
|
-
* Returns null on any failure (network error, 404, malformed payload) so
|
|
31
|
-
* callers can fall back to a static pin rather than crashing `app create`.
|
|
32
|
-
*
|
|
33
|
-
* 1.5s timeout — npm registry is fast when reachable; the fallback is fine
|
|
34
|
-
* the rare times it isn't, and we don't want to block scaffold on a hang.
|
|
35
|
-
*/
|
|
36
|
-
export async function fetchLatestNpmVersion(packageName) {
|
|
37
|
-
try {
|
|
38
|
-
const controller = new AbortController();
|
|
39
|
-
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
40
|
-
const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
|
|
41
|
-
signal: controller.signal,
|
|
42
|
-
headers: { accept: "application/json" },
|
|
43
|
-
});
|
|
44
|
-
clearTimeout(timeout);
|
|
45
|
-
if (!response.ok)
|
|
46
|
-
return null;
|
|
47
|
-
const body = (await response.json());
|
|
48
|
-
return typeof body.version === "string" ? body.version : null;
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/** The directory, relative to the project root, that holds editable workflow bodies. */
|
|
55
|
-
const WORKFLOWS_DIR = path.join("src", "workflows");
|
|
56
|
-
/** The dot-dir that holds the per-alias ambient globals `.d.ts` (server-generated). */
|
|
57
|
-
const WORKFLOW_GLOBALS_DIR = path.join(".lotics", "workflows");
|
|
58
|
-
/**
|
|
59
|
-
* The two globs the MAIN tsconfig must `exclude`: the editable workflow bodies AND
|
|
60
|
-
* their per-alias ambient globals. The bodies use the app's DOM lib (the server
|
|
61
|
-
* doesn't) and each globals file declares its own ambient `trigger` — loading 22
|
|
62
|
-
* of them into the app's program collides those declarations and poisons
|
|
63
|
-
* `npm run typecheck`. Bodies are type-checked separately by
|
|
64
|
-
* `lotics app workflow check` (one isolated program per alias). Always written
|
|
65
|
-
* with `/` separators — tsconfig globs are POSIX even on Windows.
|
|
66
|
-
*/
|
|
67
|
-
const WORKFLOW_TSCONFIG_EXCLUDES = ["src/workflows", ".lotics/workflows"];
|
|
68
|
-
/**
|
|
69
|
-
* The include glob that actually loads the generated `.lotics/*.d.ts` companions.
|
|
70
|
-
* A BARE `.lotics` entry loads NONE of them: TypeScript's include-glob walk skips
|
|
71
|
-
* dot-directories, so `useQuery` / `useWorkflow` / `useAgentRun` fall back to their
|
|
72
|
-
* untyped string overloads and every param / input / result silently becomes
|
|
73
|
-
* `unknown`. The current starter emits the glob (`starter_template.ts`); apps
|
|
74
|
-
* scaffolded before that fix shipped the bare form and need healing.
|
|
75
|
-
*/
|
|
76
|
-
const LOTICS_INCLUDE_GLOB = ".lotics/**/*";
|
|
77
|
-
/** The bare `.lotics` forms an older starter emitted — all skipped by the glob walk. */
|
|
78
|
-
const STALE_LOTICS_INCLUDES = new Set([".lotics", "./.lotics", ".lotics/"]);
|
|
79
|
-
/**
|
|
80
|
-
* The `async function __workflow(...)` wrapper a workflow body sits inside —
|
|
81
|
-
* the SAME envelope the server compiles the body within at `set_app_workflow`
|
|
82
|
-
* verify time (GAP-59). Carried so a body that uses top-level `await` and ends
|
|
83
|
-
* with `return({...})` typechecks locally exactly as the server checks it. The
|
|
84
|
-
* server returns the canonical strings (`getAppWorkflowDts`); these are the
|
|
85
|
-
* offline fallback when the dts fetch fails so the file is still wrapped — a
|
|
86
|
-
* test pins them equal to the server's, so they can't drift.
|
|
87
|
-
*/
|
|
88
|
-
export const FALLBACK_ENVELOPE_PREFIX = "async function __workflow(): Promise<__WorkflowReturn | void> {\n";
|
|
89
|
-
export const FALLBACK_ENVELOPE_SUFFIX = "\n}";
|
|
90
|
-
/**
|
|
91
|
-
* Header prepended to every pulled `src/workflows/<alias>.ts`. A triple-slash
|
|
92
|
-
* reference pulls in the per-alias ambient globals (`trigger` / `runtime` / tool
|
|
93
|
-
* calls — server-generated), and the body sits inside the SAME `__workflow`
|
|
94
|
-
* wrapper the server compiles within, so a local `lotics app workflow check`
|
|
95
|
-
* mirrors the set-time verdict (GAP-59). The wrapper + reference + comment lines
|
|
96
|
-
* are CLI bookkeeping, stripped on `set`; the filename IS the alias — renaming it
|
|
97
|
-
* orphans the body.
|
|
98
|
-
*/
|
|
99
|
-
function workflowFileHeader(alias) {
|
|
100
|
-
const refPath = path
|
|
101
|
-
.join("..", "..", WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`)
|
|
102
|
-
.split(path.sep)
|
|
103
|
-
.join("/");
|
|
104
|
-
return (`/// <reference path="${refPath}" />\n` +
|
|
105
|
-
`// Auto-pulled workflow body for "${alias}". Edit the BODY between the wrapper\n` +
|
|
106
|
-
`// lines below, then check + push with:\n` +
|
|
107
|
-
`// lotics app workflow check ${alias}\n` +
|
|
108
|
-
`// lotics app workflow set ${alias}\n` +
|
|
109
|
-
`// The push goes through set_app_workflow, where the SERVER verifies the body.\n` +
|
|
110
|
-
`// The __workflow wrapper + the reference above are CLI bookkeeping (stripped on\n` +
|
|
111
|
-
`// set) — they only make the body typecheck locally against the workspace types.\n` +
|
|
112
|
-
`// Do NOT rename this file — the filename is the alias the binding is keyed by.\n` +
|
|
113
|
-
// `export {};` makes the file a MODULE. `lotics app workflow check` compiles
|
|
114
|
-
// each body in its OWN isolated program (just this body + its globals), so the
|
|
115
|
-
// `__workflow` function never collides across bodies; the marker is retained as
|
|
116
|
-
// stable CLI bookkeeping (stripWorkflowHeader anchors on it) and is stripped on
|
|
117
|
-
// `set`, so it has no effect on what the server verifies.
|
|
118
|
-
`export {};\n`);
|
|
119
|
-
}
|
|
120
|
-
/** Absolute path of one workflow body file, given the project root + alias. */
|
|
121
|
-
function workflowFilePath(projectDir, alias) {
|
|
122
|
-
return path.join(projectDir, WORKFLOWS_DIR, `${alias}.ts`);
|
|
123
|
-
}
|
|
124
|
-
/** Absolute path of one alias's ambient globals `.d.ts`. */
|
|
125
|
-
function workflowGlobalsPath(projectDir, alias) {
|
|
126
|
-
return path.join(projectDir, WORKFLOW_GLOBALS_DIR, `${alias}.globals.d.ts`);
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Write `.lotics/workflows/<alias>.globals.d.ts` = the server-generated ambient
|
|
130
|
-
* declarations the body typechecks against. Idempotent. Returns the path.
|
|
131
|
-
*/
|
|
132
|
-
export function writeWorkflowGlobals(projectDir, alias, dts) {
|
|
133
|
-
const dir = path.join(projectDir, WORKFLOW_GLOBALS_DIR);
|
|
134
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
135
|
-
const file = workflowGlobalsPath(projectDir, alias);
|
|
136
|
-
fs.writeFileSync(file, `${dts.replace(/\s+$/, "")}\n`);
|
|
137
|
-
return file;
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Write `src/workflows/<alias>.ts` = header (triple-slash reference + comments)
|
|
141
|
-
* + the body wrapped in the `__workflow` envelope. Idempotent (a re-pull
|
|
142
|
-
* overwrites with the current server body). The raw `source` round-trips on
|
|
143
|
-
* `set` (the header + wrapper are stripped). Exported for direct unit testing —
|
|
144
|
-
* the surrounding pull shells out to `tar`/`npm`.
|
|
145
|
-
*/
|
|
146
|
-
export function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX }) {
|
|
147
|
-
const dir = path.join(projectDir, WORKFLOWS_DIR);
|
|
148
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
-
const file = workflowFilePath(projectDir, alias);
|
|
150
|
-
// Body verbatim inside the wrapper so a later `set` round-trips byte-for-byte
|
|
151
|
-
// (modulo the header + wrapper, which are stripped). The prefix already ends
|
|
152
|
-
// with a newline and the suffix begins with one (server envelope shape).
|
|
153
|
-
const body = source.replace(/\s+$/, "");
|
|
154
|
-
fs.writeFileSync(file, `${workflowFileHeader(alias)}\n${envelope.prefix}${body}${envelope.suffix}\n`);
|
|
155
|
-
return file;
|
|
156
|
-
}
|
|
157
|
-
/** Matches the `__workflow` envelope opener (the wrapper `writeWorkflowFile`
|
|
158
|
-
* prepends). Recognized structurally (not by exact return-type text) so a
|
|
159
|
-
* future envelope-signature tweak doesn't silently leave the wrapper in the
|
|
160
|
-
* pushed source. */
|
|
161
|
-
const WORKFLOW_WRAPPER_OPENER = /^\s*async\s+function\s+__workflow\s*\(/;
|
|
162
|
-
/**
|
|
163
|
-
* Strip the CLI bookkeeping back off a workflow body before pushing it: the
|
|
164
|
-
* triple-slash reference + the `//` header comments + the `export {};` marker +
|
|
165
|
-
* blank lines, then the `__workflow` envelope (the opening
|
|
166
|
-
* `async function __workflow(...) {` line and the matching trailing `}`). `set`
|
|
167
|
-
* sends ONLY the JS-subset body the author edited — the server stays the single
|
|
168
|
-
* verifier.
|
|
169
|
-
*
|
|
170
|
-
* The strip is anchored on the GENERATED bookkeeping, never on "the file happens
|
|
171
|
-
* to start with comments": the leading comment/marker/blank block is only peeled
|
|
172
|
-
* when it is immediately followed by the `__workflow` wrapper opener (the exact
|
|
173
|
-
* shape `writeWorkflowFile` produces). A hand-written, wrapper-LESS body whose
|
|
174
|
-
* first lines are comments therefore round-trips unchanged — its comments are
|
|
175
|
-
* real source, not bookkeeping, and must not be silently eaten.
|
|
176
|
-
*/
|
|
177
|
-
export function stripWorkflowHeader(content) {
|
|
178
|
-
const lines = content.split("\n");
|
|
179
|
-
// Probe how far a generated header would extend: `//`/`///` lines, then the
|
|
180
|
-
// `export {};` module marker, then the blank line(s) before the wrapper.
|
|
181
|
-
let headerEnd = 0;
|
|
182
|
-
while (headerEnd < lines.length && /^\s*\/\//.test(lines[headerEnd]))
|
|
183
|
-
headerEnd++;
|
|
184
|
-
while (headerEnd < lines.length && /^\s*export\s*\{\s*\}\s*;?\s*$/.test(lines[headerEnd]))
|
|
185
|
-
headerEnd++;
|
|
186
|
-
while (headerEnd < lines.length && lines[headerEnd].trim() === "")
|
|
187
|
-
headerEnd++;
|
|
188
|
-
// Peel the header ONLY when the wrapper opener follows it — that's the proof
|
|
189
|
-
// the leading block was generated bookkeeping and not the author's own source.
|
|
190
|
-
// If the file opens with the wrapper directly (degraded pull with no header),
|
|
191
|
-
// peel from there. Otherwise leave the body verbatim.
|
|
192
|
-
let start;
|
|
193
|
-
if (headerEnd < lines.length && WORKFLOW_WRAPPER_OPENER.test(lines[headerEnd])) {
|
|
194
|
-
start = headerEnd;
|
|
195
|
-
}
|
|
196
|
-
else if (lines.length > 0 && WORKFLOW_WRAPPER_OPENER.test(lines[0])) {
|
|
197
|
-
start = 0;
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
return content.replace(/\s+$/, "");
|
|
201
|
-
}
|
|
202
|
-
// `start` is the wrapper opener line — drop it and the matching trailing `}`.
|
|
203
|
-
let end = lines.length - 1;
|
|
204
|
-
while (end > start && lines[end].trim() === "")
|
|
205
|
-
end--;
|
|
206
|
-
if (lines[end]?.trim() === "}") {
|
|
207
|
-
return lines.slice(start + 1, end).join("\n").replace(/\s+$/, "");
|
|
208
|
-
}
|
|
209
|
-
// Opener present but no closing `}` — a malformed wrapper; push the body after
|
|
210
|
-
// the opener verbatim rather than guessing where the envelope ends.
|
|
211
|
-
return lines.slice(start + 1).join("\n").replace(/\s+$/, "");
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Pull every workflow body the server can render into `src/workflows/<alias>.ts`,
|
|
215
|
-
* plus the per-alias ambient globals `.lotics/workflows/<alias>.globals.d.ts` that
|
|
216
|
-
* make the body locally typecheckable. The alias set comes from the live
|
|
217
|
-
* `apps.workflows` map; each body comes from `get_app_workflow` (faithful source
|
|
218
|
-
* re-rendered from the persisted step tree) and each globals + envelope from
|
|
219
|
-
* `getAppWorkflowDts`. A legacy alias whose source can't be read is WARNED and
|
|
220
|
-
* SKIPPED — never a failure — so a partially-migrated app still pulls. A dts
|
|
221
|
-
* fetch failure is non-fatal too: the body is still written (with the fallback
|
|
222
|
-
* envelope), only the local typecheck is degraded — mirrors `app_fields`.
|
|
223
|
-
* Returns the aliases written.
|
|
224
|
-
*/
|
|
225
|
-
async function writeWorkflowFiles(client, projectDir, app_id, aliases) {
|
|
226
|
-
const written = [];
|
|
227
|
-
for (const alias of aliases) {
|
|
228
|
-
const res = await client.getAppWorkflow(app_id, alias);
|
|
229
|
-
const source = res.error || res.result === null || typeof res.result !== "object"
|
|
230
|
-
? null
|
|
231
|
-
: res.result.source;
|
|
232
|
-
if (typeof source !== "string" || source.trim() === "") {
|
|
233
|
-
console.error(`⚠ Skipped src/workflows/${alias}.ts — the server returned no readable source ` +
|
|
234
|
-
`(${res.error ?? "legacy workflow with no rendered body"}).`);
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
|
|
238
|
-
writeWorkflowFile(projectDir, alias, source, envelope);
|
|
239
|
-
written.push(alias);
|
|
240
|
-
}
|
|
241
|
-
return written;
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Fetch + write one alias's ambient globals `.d.ts` and return the wrapper
|
|
245
|
-
* envelope to wrap the body in. Network failure is non-fatal (warn, keep the
|
|
246
|
-
* last-written globals if any) and the body is wrapped in the FALLBACK envelope
|
|
247
|
-
* so it still parses — mirrors `app_fields`'s offline tolerance.
|
|
248
|
-
*/
|
|
249
|
-
async function fetchWorkflowGlobals(client, projectDir, app_id, alias) {
|
|
250
|
-
try {
|
|
251
|
-
const { dts, envelope_prefix, envelope_suffix } = await client.getAppWorkflowDts(app_id, alias);
|
|
252
|
-
writeWorkflowGlobals(projectDir, alias, dts);
|
|
253
|
-
return { prefix: envelope_prefix, suffix: envelope_suffix };
|
|
254
|
-
}
|
|
255
|
-
catch (err) {
|
|
256
|
-
console.error(`⚠ Could not fetch workflow types for "${alias}" (${err instanceof Error ? err.message : String(err)}). ` +
|
|
257
|
-
`Wrote the body with the fallback wrapper; its local typecheck may be degraded.`);
|
|
258
|
-
return { prefix: FALLBACK_ENVELOPE_PREFIX, suffix: FALLBACK_ENVELOPE_SUFFIX };
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
/** Run `tar` and resolve when it exits cleanly. Throws with stderr on failure. */
|
|
262
|
-
export function runTar(args, cwd) {
|
|
263
|
-
return new Promise((resolve, reject) => {
|
|
264
|
-
const proc = spawn("tar", args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
265
|
-
let stderr = "";
|
|
266
|
-
proc.stderr.on("data", (chunk) => {
|
|
267
|
-
stderr += chunk.toString();
|
|
268
|
-
});
|
|
269
|
-
proc.on("error", reject);
|
|
270
|
-
proc.on("exit", (code) => {
|
|
271
|
-
if (code === 0)
|
|
272
|
-
resolve();
|
|
273
|
-
else
|
|
274
|
-
reject(new Error(`tar ${args.join(" ")} failed with exit ${code}: ${stderr}`));
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
/** Run `npm` (run/install/etc.) inheriting stdio so the user sees progress. */
|
|
279
|
-
export function runNpm(args, cwd) {
|
|
280
|
-
return new Promise((resolve, reject) => {
|
|
281
|
-
const proc = spawn("npm", args, { cwd, stdio: "inherit", env: ipv4ChildEnv(process.env) });
|
|
282
|
-
proc.on("error", reject);
|
|
283
|
-
proc.on("exit", (code) => {
|
|
284
|
-
if (code === 0)
|
|
285
|
-
resolve();
|
|
286
|
-
else
|
|
287
|
-
reject(new Error(`npm ${args.join(" ")} exited with code ${code}`));
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
function readAppMeta(projectDir) {
|
|
292
|
-
const pkgPath = path.join(projectDir, "package.json");
|
|
293
|
-
if (!fs.existsSync(pkgPath)) {
|
|
294
|
-
throw new Error(`No package.json found at ${projectDir}. Run 'lotics app create' first.`);
|
|
295
|
-
}
|
|
296
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
297
|
-
if (!pkg.lotics?.app_id || !pkg.lotics.workspace_id) {
|
|
298
|
-
throw new Error(`package.json is missing the 'lotics' field with app_id and workspace_id. ` +
|
|
299
|
-
`Was this folder created with 'lotics app create'?`);
|
|
300
|
-
}
|
|
301
|
-
return {
|
|
302
|
-
app_id: pkg.lotics.app_id,
|
|
303
|
-
workspace_id: pkg.lotics.workspace_id,
|
|
304
|
-
current_version_id: pkg.lotics.current_version_id ?? null,
|
|
305
|
-
version_number: pkg.lotics.version_number ?? null,
|
|
306
|
-
workflows: pkg.lotics.workflows ?? {},
|
|
307
|
-
queries: pkg.lotics.queries ?? {},
|
|
308
|
-
agents: pkg.lotics.agents ?? {},
|
|
309
|
-
capabilities: pkg.lotics.capabilities,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
function writeAppMeta(projectDir, meta) {
|
|
313
|
-
const pkgPath = path.join(projectDir, "package.json");
|
|
314
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
315
|
-
pkg.lotics = meta;
|
|
316
|
-
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Heal a pre-existing app's `tsconfig.json` so its generated types load and its
|
|
320
|
-
* `npm run typecheck` stays honest. Idempotent, run on every pull / codegen /
|
|
321
|
-
* deploy. Two things the current starter ships but an app SCAFFOLDED before those
|
|
322
|
-
* releases (or with a hand-written tsconfig) can lack:
|
|
323
|
-
*
|
|
324
|
-
* 1. `include`: a bare `.lotics` is rewritten to the recursive `LOTICS_INCLUDE_GLOB`.
|
|
325
|
-
* A bare dot-dir is skipped by TypeScript's include-glob walk, so the codegen'd
|
|
326
|
-
* `.d.ts` never enter the program — every `useQuery`/`useWorkflow`/`useAgentRun`
|
|
327
|
-
* param and result silently falls back to `unknown`.
|
|
328
|
-
* 2. `exclude`: the workflow-body globs — the bodies use the app's DOM lib (the
|
|
329
|
-
* server doesn't) and the per-alias ambient globals collide on `trigger`,
|
|
330
|
-
* poisoning typecheck. Checked separately by `lotics app workflow check`.
|
|
331
|
-
*
|
|
332
|
-
* A missing / unparseable tsconfig is a non-fatal warn (the caller still succeeds);
|
|
333
|
-
* the author fixes the config.
|
|
334
|
-
*/
|
|
335
|
-
export function ensureAppTsconfig(projectDir) {
|
|
336
|
-
const tsconfigPath = path.join(projectDir, "tsconfig.json");
|
|
337
|
-
if (!fs.existsSync(tsconfigPath)) {
|
|
338
|
-
console.error(`⚠ No tsconfig.json at ${projectDir} — could not ensure "include" has "${LOTICS_INCLUDE_GLOB}" ` +
|
|
339
|
-
`(a bare .lotics loads none of the generated types) or "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")}.`);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
let parsed;
|
|
343
|
-
try {
|
|
344
|
-
parsed = JSON.parse(fs.readFileSync(tsconfigPath, "utf-8"));
|
|
345
|
-
}
|
|
346
|
-
catch (err) {
|
|
347
|
-
console.error(`⚠ Could not parse tsconfig.json (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
348
|
-
`ensure "include" has "${LOTICS_INCLUDE_GLOB}" and "exclude" has ${WORKFLOW_TSCONFIG_EXCLUDES.join(", ")} manually.`);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (!parsed || typeof parsed !== "object")
|
|
352
|
-
return;
|
|
353
|
-
const changes = [];
|
|
354
|
-
// 1. Heal a stale bare `.lotics` include so the generated `.d.ts` actually load
|
|
355
|
-
// (a bare dot-dir is invisible to TypeScript's include-glob walk).
|
|
356
|
-
if (Array.isArray(parsed.include)) {
|
|
357
|
-
const inc = parsed.include.filter((e) => typeof e === "string");
|
|
358
|
-
if (inc.some((e) => STALE_LOTICS_INCLUDES.has(e))) {
|
|
359
|
-
const healed = inc.map((e) => (STALE_LOTICS_INCLUDES.has(e) ? LOTICS_INCLUDE_GLOB : e));
|
|
360
|
-
parsed.include = healed.filter((e, i) => healed.indexOf(e) === i); // dedupe if the glob was already present
|
|
361
|
-
changes.push(`rewrote a bare ".lotics" to "${LOTICS_INCLUDE_GLOB}" in "include" (a dot-dir loads zero generated types)`);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
// 2. Ensure the workflow-body excludes. `exclude` is a TOP-LEVEL tsconfig field —
|
|
365
|
-
// tsc ignores `compilerOptions.exclude`, so they must land at the top level.
|
|
366
|
-
const currentEx = Array.isArray(parsed.exclude)
|
|
367
|
-
? parsed.exclude.filter((e) => typeof e === "string")
|
|
368
|
-
: [];
|
|
369
|
-
const toAdd = WORKFLOW_TSCONFIG_EXCLUDES.filter((g) => !currentEx.includes(g));
|
|
370
|
-
if (toAdd.length > 0) {
|
|
371
|
-
parsed.exclude = [...currentEx, ...toAdd];
|
|
372
|
-
changes.push(`added ${toAdd.join(", ")} to "exclude"`);
|
|
373
|
-
}
|
|
374
|
-
if (changes.length === 0)
|
|
375
|
-
return;
|
|
376
|
-
fs.writeFileSync(tsconfigPath, JSON.stringify(parsed, null, 2) + "\n");
|
|
377
|
-
console.error(`Patched tsconfig.json: ${changes.join("; ")}.`);
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Write the three `.lotics/app_{workflows,queries,agents}.d.ts` companions from
|
|
381
|
-
* the manifest's maps, then heal the app's tsconfig so they actually load. Called
|
|
382
|
-
* from `app create / pull / dev / deploy / codegen`, so the augmented `AppWorkflows`
|
|
383
|
-
* / `AppQueries` / `AppAgents` types stay in sync with the manifest.
|
|
384
|
-
*
|
|
385
|
-
* The heal is at the write boundary on purpose: a `.d.ts` written but not loaded is
|
|
386
|
-
* useless (a bare `.lotics` include is skipped by TypeScript's include-glob walk and
|
|
387
|
-
* loads zero of them), so `ensureAppTsconfig` couples "wrote the types" with "the
|
|
388
|
-
* program can see them" — no caller can do one without the other.
|
|
389
|
-
*/
|
|
390
|
-
export function writeAppDts(projectDir, manifest) {
|
|
391
|
-
const dotLotics = path.join(projectDir, ".lotics");
|
|
392
|
-
fs.mkdirSync(dotLotics, { recursive: true });
|
|
393
|
-
const written = [
|
|
394
|
-
[path.join(dotLotics, "app_workflows.d.ts"), generateAppWorkflowsDts(manifest.workflows)],
|
|
395
|
-
[path.join(dotLotics, "app_queries.d.ts"), generateAppQueriesDts(manifest.queries)],
|
|
396
|
-
[path.join(dotLotics, "app_agents.d.ts"), generateAppAgentsDts(manifest.agents)],
|
|
397
|
-
];
|
|
398
|
-
for (const [file, content] of written)
|
|
399
|
-
fs.writeFileSync(file, content);
|
|
400
|
-
ensureAppTsconfig(projectDir);
|
|
401
|
-
return written.map(([file]) => file);
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* The optional `package.json#lotics.codegen.tables` allowlist — extra table ids
|
|
405
|
-
* to include in `app_fields.ts` beyond those the named queries reference (e.g.
|
|
406
|
-
* tables an app only writes to via a workflow, never queries). Returns `[]` when
|
|
407
|
-
* absent or malformed (codegen falls back to the query-derived set).
|
|
408
|
-
*/
|
|
409
|
-
function readCodegenTablesAllowlist(projectDir) {
|
|
410
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"));
|
|
411
|
-
const tables = pkg.lotics?.codegen?.tables;
|
|
412
|
-
return Array.isArray(tables) ? tables.filter((t) => typeof t === "string") : [];
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* The table ids `app_fields.ts` should cover: the union of every `from_table`
|
|
416
|
-
* referenced by a declared query AST plus the optional manifest allowlist. The
|
|
417
|
-
* query set is what the app can actually read; the allowlist widens it to tables
|
|
418
|
-
* only ever written (so their field/option aliases are still addressable).
|
|
419
|
-
*/
|
|
420
|
-
function resolveCodegenTableIds(projectDir, queries) {
|
|
421
|
-
const ids = new Set();
|
|
422
|
-
for (const decl of Object.values(queries)) {
|
|
423
|
-
// The manifest stores the AST untyped (`unknown`) — it's the server's
|
|
424
|
-
// deploy-time concern to verify. Cast at this single boundary so codegen
|
|
425
|
-
// reuses the canonical exhaustive walker (a new QueryNode kind is a
|
|
426
|
-
// compile error there, never a silently-dropped table).
|
|
427
|
-
for (const id of collectQueryTableIds(decl.ast))
|
|
428
|
-
ids.add(id);
|
|
429
|
-
}
|
|
430
|
-
for (const id of readCodegenTablesAllowlist(projectDir))
|
|
431
|
-
ids.add(id);
|
|
432
|
-
return [...ids];
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Write the runtime `.lotics/app_fields.ts` from a resolved workspace schema.
|
|
436
|
-
* Separate from `writeAppDts` (the type-only companions) because this one needs
|
|
437
|
-
* the client to fetch the schema. Returns the written path.
|
|
438
|
-
*/
|
|
439
|
-
function writeAppFields(projectDir, tables) {
|
|
440
|
-
const dotLotics = path.join(projectDir, ".lotics");
|
|
441
|
-
fs.mkdirSync(dotLotics, { recursive: true });
|
|
442
|
-
const file = path.join(dotLotics, "app_fields.ts");
|
|
443
|
-
fs.writeFileSync(file, generateAppFields(tables));
|
|
444
|
-
return file;
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Write the BINDING-resolved `.lotics/app_fields.ts` for a package installation
|
|
448
|
-
* — the portable twin of `writeAppFields`. Instead of baking this workspace's
|
|
449
|
-
* concrete ids, the module resolves `F`/`OPT`/`ROLE` from the installation's
|
|
450
|
-
* live binding at load (`getAppBinding()`), so a published origin's source runs
|
|
451
|
-
* unchanged in every workspace that installs the package. Returns the path.
|
|
452
|
-
*/
|
|
453
|
-
function writeBindingAppFields(projectDir, binding) {
|
|
454
|
-
const dotLotics = path.join(projectDir, ".lotics");
|
|
455
|
-
fs.mkdirSync(dotLotics, { recursive: true });
|
|
456
|
-
const file = path.join(dotLotics, "app_fields.ts");
|
|
457
|
-
fs.writeFileSync(file, generatePackageAppFields(binding));
|
|
458
|
-
return file;
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* `lotics app codegen [path]` — regenerate every `.lotics/` artifact from the
|
|
462
|
-
* manifest + workspace schema, WITHOUT a deploy. The `.d.ts` companions are
|
|
463
|
-
* always written (synchronous, no network). When a `client` is available, the
|
|
464
|
-
* runtime `app_fields.ts` is also regenerated — **branched on whether the app is
|
|
465
|
-
* a package installation** (`getApp().package_id` set): a **linked/published**
|
|
466
|
-
* app emits the BINDING-resolved form (`F`/`OPT`/`ROLE` resolved from the live
|
|
467
|
-
* binding at module load via `getAppBinding()`, so the source stays portable
|
|
468
|
-
* across every install), a **bespoke** app emits the baked form (this
|
|
469
|
-
* workspace's concrete `fld_…`/`opt_…` ids, from the live schema of the tables
|
|
470
|
-
* its queries reference + the allowlist). A network failure is non-fatal (warn,
|
|
471
|
-
* keep the last-generated file) — so codegen still does useful work offline,
|
|
472
|
-
* mirroring `app create`'s tolerance of an offline npm registry.
|
|
473
|
-
*/
|
|
474
|
-
export async function appCodegen(args) {
|
|
475
|
-
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
476
|
-
// readAppMeta throws the clear not-an-app error when there's no manifest.
|
|
477
|
-
const meta = readAppMeta(projectDir);
|
|
478
|
-
const dtsPaths = writeAppDts(projectDir, {
|
|
479
|
-
workflows: meta.workflows,
|
|
480
|
-
queries: meta.queries,
|
|
481
|
-
agents: meta.agents,
|
|
482
|
-
});
|
|
483
|
-
for (const p of dtsPaths)
|
|
484
|
-
console.error(`Regenerated ${p}`);
|
|
485
|
-
if (!args.client) {
|
|
486
|
-
console.error("Skipped .lotics/app_fields.ts — no workspace credentials resolved. " +
|
|
487
|
-
"Run authenticated (or set LOTICS_API_KEY) to regenerate field/option ids.");
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
try {
|
|
491
|
-
// Branch on linked-ness: a package installation (its own published origin OR
|
|
492
|
-
// an install of another org's package) must ship PORTABLE source, so
|
|
493
|
-
// app_fields resolves F/OPT/ROLE from the live binding at module load; a
|
|
494
|
-
// bespoke app bakes this workspace's concrete ids. getApp is the authority —
|
|
495
|
-
// `package_id` is set iff the app is a package installation.
|
|
496
|
-
const app = await args.client.getApp(meta.app_id);
|
|
497
|
-
if (app.package_id) {
|
|
498
|
-
const binding = await args.client.appBinding(meta.app_id);
|
|
499
|
-
const fieldsPath = writeBindingAppFields(projectDir, binding);
|
|
500
|
-
const count = Object.keys(binding.fields).length;
|
|
501
|
-
console.error(`Regenerated ${fieldsPath} (binding form — ${count} field alias${count === 1 ? "" : "es"} resolved per-install)`);
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
const tableIds = resolveCodegenTableIds(projectDir, meta.queries ?? {});
|
|
505
|
-
const tables = await args.client.getWorkspaceSchema(tableIds);
|
|
506
|
-
const fieldsPath = writeAppFields(projectDir, tables);
|
|
507
|
-
console.error(`Regenerated ${fieldsPath} (${tables.length} table${tables.length === 1 ? "" : "s"})`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
catch (err) {
|
|
511
|
-
// Non-fatal: keep the last-generated app_fields.ts so an offline/transient
|
|
512
|
-
// failure doesn't strip the app's field aliases (same tolerance as the npm
|
|
513
|
-
// registry lookup on `app create`).
|
|
514
|
-
console.error(`⚠ Could not regenerate .lotics/app_fields.ts (${err instanceof Error ? err.message : String(err)}). ` +
|
|
515
|
-
`Kept the existing file.`);
|
|
516
|
-
}
|
|
517
|
-
// Refresh each bound workflow's ambient globals + re-wrap its EXISTING local
|
|
518
|
-
// body in the current envelope (GAP-59). Codegen never re-fetches the body
|
|
519
|
-
// (that would clobber local edits) — it strips the on-disk body and re-wraps
|
|
520
|
-
// it, so the local typecheck tracks the current workspace schema. Aliases
|
|
521
|
-
// never pulled (no body file yet) are skipped — codegen isn't a pull.
|
|
522
|
-
await refreshWorkflowGlobals(args.client, projectDir, meta.app_id, Object.keys(meta.workflows ?? {}));
|
|
523
|
-
}
|
|
524
|
-
/**
|
|
525
|
-
* For each bound alias that already has a local body file, fetch its current
|
|
526
|
-
* ambient globals `.d.ts` + envelope and re-wrap the on-disk body. Preserves the
|
|
527
|
-
* author's edits (strips + re-wraps, never re-fetches the body). A dts fetch
|
|
528
|
-
* failure is non-fatal per alias (warned inside `fetchWorkflowGlobals`).
|
|
529
|
-
*/
|
|
530
|
-
async function refreshWorkflowGlobals(client, projectDir, app_id, aliases) {
|
|
531
|
-
for (const alias of aliases) {
|
|
532
|
-
const file = workflowFilePath(projectDir, alias);
|
|
533
|
-
if (!fs.existsSync(file))
|
|
534
|
-
continue;
|
|
535
|
-
const body = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
|
|
536
|
-
if (body.trim() === "")
|
|
537
|
-
continue;
|
|
538
|
-
const envelope = await fetchWorkflowGlobals(client, projectDir, app_id, alias);
|
|
539
|
-
writeWorkflowFile(projectDir, alias, body, envelope);
|
|
540
|
-
console.error(`Refreshed ${path.relative(projectDir, file)} + its workflow types`);
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
/**
|
|
544
|
-
* Stamp the post-extraction manifest with server-authoritative meta. Called
|
|
545
|
-
* by `appPull` after the source archive lands on disk.
|
|
546
|
-
*
|
|
547
|
-
* Crucially, `workflows` is sourced from `app.workflows` (live DB state),
|
|
548
|
-
* NOT from whatever the extracted archive's package.json contains. The
|
|
549
|
-
* archive's embedded `lotics.workflows` is a frozen snapshot from deploy
|
|
550
|
-
* time and would silently overwrite any agent-authored bindings made via
|
|
551
|
-
* `set_app_workflow` since the last deploy. Exported for direct unit
|
|
552
|
-
* testing — the rest of `appPull` involves shelling out to `tar`/`npm`,
|
|
553
|
-
* which doesn't reward integration coverage.
|
|
554
|
-
*/
|
|
555
|
-
export function stampPulledManifest(projectDir, args) {
|
|
556
|
-
writeAppMeta(projectDir, {
|
|
557
|
-
app_id: args.app_id,
|
|
558
|
-
workspace_id: args.workspace_id,
|
|
559
|
-
current_version_id: args.current_version_id,
|
|
560
|
-
version_number: args.version_number,
|
|
561
|
-
workflows: args.workflows,
|
|
562
|
-
queries: args.queries,
|
|
563
|
-
agents: args.agents,
|
|
564
|
-
});
|
|
565
|
-
writeAppDts(projectDir, { workflows: args.workflows, queries: args.queries, agents: args.agents });
|
|
566
|
-
}
|
|
567
|
-
async function downloadToFile(url, destPath) {
|
|
568
|
-
const response = await fetch(url);
|
|
569
|
-
if (!response.ok) {
|
|
570
|
-
throw new Error(`Failed to download ${url}: ${response.status}`);
|
|
571
|
-
}
|
|
572
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
573
|
-
fs.writeFileSync(destPath, buffer);
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* A filesystem-safe SINGLE directory segment from an app's free-form display
|
|
577
|
-
* name, for the default create/pull target. App names may contain "/" (and
|
|
578
|
-
* other path-hostile characters); used verbatim as a directory, a name like
|
|
579
|
-
* "Nhập/Xuất Cont" splits into NESTED folders ("Nhập" → "Xuất Cont") — and a
|
|
580
|
-
* deploy then packages that nested copy into the source archive, so each later
|
|
581
|
-
* pull re-extracts and compounds it. Neutralize path separators + reserved
|
|
582
|
-
* characters to one segment, keeping spaces and unicode so the folder stays
|
|
583
|
-
* recognizable. An explicit `targetPath` still overrides this entirely.
|
|
584
|
-
*/
|
|
585
|
-
export function appDirName(name) {
|
|
586
|
-
const cleaned = name
|
|
587
|
-
.replace(/[/\\]+/g, "-") // path separators — the core bug
|
|
588
|
-
.replace(/[<>:"|?*]/g, "") // other filesystem-reserved characters
|
|
589
|
-
.replace(/\s+/g, " ") // collapse whitespace runs
|
|
590
|
-
.trim()
|
|
591
|
-
.replace(/^[.\s-]+|[.\s-]+$/g, ""); // no leading/trailing dot/space/dash → never "."/".."/hidden/blank
|
|
592
|
-
return cleaned || "app";
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* `lotics app create <name> [path]`
|
|
596
|
-
*
|
|
597
|
-
* Creates the app server-side, scaffolds a Vite+React+TS project locally
|
|
598
|
-
* from the embedded starter template, runs `npm install` + `npm run build`,
|
|
599
|
-
* then deploys the result as v1. After this returns, `lotics app deploy`
|
|
600
|
-
* works end-to-end on the same directory.
|
|
601
|
-
*
|
|
602
|
-
* Why scaffold + deploy rather than pull-from-server-starter: the deploy
|
|
603
|
-
* pipeline requires a built dist tree, and v1 doesn't run builds server-side.
|
|
604
|
-
* Doing the build locally on first create keeps the platform infrastructure
|
|
605
|
-
* minimal — the starter template is embedded in the CLI binary and bumps
|
|
606
|
-
* with CLI releases. (Server-side build is the v2 path.)
|
|
607
|
-
*/
|
|
608
|
-
export async function appCreate(client, args) {
|
|
609
|
-
const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
|
|
610
|
-
if (fs.existsSync(targetPath)) {
|
|
611
|
-
const entries = fs.readdirSync(targetPath);
|
|
612
|
-
if (entries.length > 0) {
|
|
613
|
-
throw new Error(`Target directory ${targetPath} is not empty.`);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
fs.mkdirSync(targetPath, { recursive: true });
|
|
617
|
-
// Server-side row first — if this fails, no local files have been touched.
|
|
618
|
-
const app = await client.createApp({ name: args.name });
|
|
619
|
-
console.error(`Created app: ${app.name} (${app.id})`);
|
|
620
|
-
// Resolve latest versions in parallel; null falls back to the static pin
|
|
621
|
-
// baked into the starter template. Lookups time out at 1.5s so an offline
|
|
622
|
-
// create still succeeds (with the fallback) instead of hanging.
|
|
623
|
-
const [uiLatest, sdkLatest] = await Promise.all([
|
|
624
|
-
fetchLatestNpmVersion("@lotics/ui"),
|
|
625
|
-
fetchLatestNpmVersion("@lotics/app-sdk"),
|
|
626
|
-
]);
|
|
627
|
-
// Scaffold the starter into the target directory.
|
|
628
|
-
const files = buildStarterTemplate({
|
|
629
|
-
app_name: args.name,
|
|
630
|
-
app_id: app.id,
|
|
631
|
-
workspace_id: app.workspace_id,
|
|
632
|
-
ui_version: uiLatest ? `^${uiLatest}` : undefined,
|
|
633
|
-
sdk_version: sdkLatest ? `^${sdkLatest}` : undefined,
|
|
634
|
-
});
|
|
635
|
-
for (const file of files) {
|
|
636
|
-
const fullPath = path.join(targetPath, file.path);
|
|
637
|
-
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
638
|
-
fs.writeFileSync(fullPath, file.content);
|
|
639
|
-
}
|
|
640
|
-
console.error(`Scaffolded ${files.length} files into ${targetPath}`);
|
|
641
|
-
// Codegen pass for AppWorkflows typing. Empty manifest on first create —
|
|
642
|
-
// emits a base augmentation file so future deploys produce idempotent diffs
|
|
643
|
-
// rather than introducing a new tracked file later.
|
|
644
|
-
writeAppDts(targetPath, {});
|
|
645
|
-
console.error("Installing npm dependencies...");
|
|
646
|
-
await runNpm(["install"], targetPath);
|
|
647
|
-
console.error("Building initial version...");
|
|
648
|
-
await appDeploy(client, {
|
|
649
|
-
projectDir: targetPath,
|
|
650
|
-
message: "Initial version",
|
|
651
|
-
});
|
|
652
|
-
console.error(`\nReady. Next steps:`);
|
|
653
|
-
console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
|
|
654
|
-
console.error(` # edit src/App.tsx, then:`);
|
|
655
|
-
console.error(` lotics app deploy`);
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* `lotics app pull <app_id> [path]`
|
|
659
|
-
*
|
|
660
|
-
* Bootstraps a full local dev environment for an existing app:
|
|
661
|
-
* 1. Fetch the current version's source archive from R2 (presigned URL).
|
|
662
|
-
* 2. Extract into the target directory.
|
|
663
|
-
* 3. Stamp package.json's `lotics` field with app_id + version metadata,
|
|
664
|
-
* and the live `apps.workflows` map from the server (NOT the manifest
|
|
665
|
-
* embedded in the source archive — that's a frozen snapshot from deploy
|
|
666
|
-
* time and would silently overwrite any agent-authored bindings made via
|
|
667
|
-
* `set_app_workflow` since the last deploy).
|
|
668
|
-
* 4. Regenerate `.lotics/app_workflows.d.ts` from the live workflows so
|
|
669
|
-
* `useWorkflow<"alias">` is typed at pull time.
|
|
670
|
-
* 5. Run `npm install`.
|
|
671
|
-
*
|
|
672
|
-
* Runtime field/option id aliases (`.lotics/app_fields.ts`) are generated on
|
|
673
|
-
* demand by `lotics app codegen`, which fetches the workspace schema — pull
|
|
674
|
-
* leaves it to that command so a schema fetch never blocks the bootstrap.
|
|
675
|
-
*/
|
|
676
|
-
/**
|
|
677
|
-
* `lotics app subdomain <new>` — rename the current app's public address.
|
|
678
|
-
* The app_id comes from the local `package.json` manifest. The old
|
|
679
|
-
* `<slug>.lotics.app` stops resolving once the change lands.
|
|
680
|
-
*/
|
|
681
|
-
export async function appSetSubdomain(client, args) {
|
|
682
|
-
const meta = readAppMeta(process.cwd());
|
|
683
|
-
const result = await client.setAppSubdomain(meta.app_id, args.subdomain);
|
|
684
|
-
console.error(`Public address set: https://${result.public_subdomain}.lotics.app`);
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* `lotics app rename <name>` — change the current app's display name (the
|
|
688
|
-
* launcher/title shown in the workspace). The app_id comes from the local
|
|
689
|
-
* `package.json` manifest. Wraps the `update_app` tool — the public address
|
|
690
|
-
* (`lotics app subdomain`) and the code (`lotics app deploy`) are unchanged.
|
|
691
|
-
*/
|
|
692
|
-
export async function appRename(client, args) {
|
|
693
|
-
const meta = readAppMeta(process.cwd());
|
|
694
|
-
const res = await client.execute("update_app", { app_id: meta.app_id, name: args.name });
|
|
695
|
-
if (res.error)
|
|
696
|
-
throw new Error(res.error);
|
|
697
|
-
console.error(`App renamed: "${args.name}" (${meta.app_id})`);
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* `lotics app versions [app_id]` — print the app's deploy history, newest
|
|
701
|
-
* first: version number, when, deployer, build status, and the `-m` message.
|
|
702
|
-
* With no `app_id`, reads it from the local `package.json` manifest (run inside
|
|
703
|
-
* the app dir); pass an explicit `app_id` to inspect any app without pulling it.
|
|
704
|
-
* The currently served version is marked with `*`. Admin-only server-side.
|
|
705
|
-
*/
|
|
706
|
-
export async function appVersions(client, args) {
|
|
707
|
-
const appId = args.app_id ?? readAppMeta(process.cwd()).app_id;
|
|
708
|
-
const { current_version_id, versions } = await client.listAppVersions(appId, {
|
|
709
|
-
limit: args.limit,
|
|
710
|
-
});
|
|
711
|
-
if (versions.length === 0) {
|
|
712
|
-
console.error(`No versions found for ${appId}.`);
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
const fmtWhen = (iso) => {
|
|
716
|
-
const d = new Date(iso);
|
|
717
|
-
if (Number.isNaN(d.getTime()))
|
|
718
|
-
return iso;
|
|
719
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
720
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
721
|
-
};
|
|
722
|
-
const rows = versions.map((v) => ({
|
|
723
|
-
mark: v.id === current_version_id ? "*" : " ",
|
|
724
|
-
ver: `v${v.version}`,
|
|
725
|
-
when: fmtWhen(v.created_at),
|
|
726
|
-
by: v.created_by_name ?? v.created_by ?? "—",
|
|
727
|
-
status: v.build_status,
|
|
728
|
-
message: v.message ?? "",
|
|
729
|
-
}));
|
|
730
|
-
const colWidth = (key, header) => Math.max(header.length, ...rows.map((r) => r[key].length));
|
|
731
|
-
const wVer = colWidth("ver", "VER");
|
|
732
|
-
const wWhen = colWidth("when", "WHEN");
|
|
733
|
-
const wBy = colWidth("by", "DEPLOYER");
|
|
734
|
-
const wStatus = colWidth("status", "STATUS");
|
|
735
|
-
const pad = (s, n) => s.padEnd(n);
|
|
736
|
-
// Title → stderr (status); the table → stdout (data), so it stays pipeable.
|
|
737
|
-
console.error(`Deploy history — ${appId} (${versions.length} version${versions.length === 1 ? "" : "s"}, newest first; * = currently served)`);
|
|
738
|
-
console.log(` ${pad("VER", wVer)} ${pad("WHEN", wWhen)} ${pad("DEPLOYER", wBy)} ${pad("STATUS", wStatus)} MESSAGE`);
|
|
739
|
-
for (const r of rows) {
|
|
740
|
-
console.log(`${r.mark} ${pad(r.ver, wVer)} ${pad(r.when, wWhen)} ${pad(r.by, wBy)} ${pad(r.status, wStatus)} ${r.message}`.trimEnd());
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Where `lotics app pull <app_id>` lands when given NO explicit path. If the cwd
|
|
745
|
-
* IS already this app's own project (its manifest `app_id` matches), refresh in
|
|
746
|
-
* place — the documented `cd <app> && lotics app pull` flow. Otherwise a fresh
|
|
747
|
-
* clone goes to an `appDirName(name)` subdir. Without this, pulling from inside
|
|
748
|
-
* the app dropped a stray `./<name>/` subdir instead of refreshing the project.
|
|
749
|
-
*/
|
|
750
|
-
export function defaultPullTarget(appId, appName) {
|
|
751
|
-
const cwdPkgPath = path.join(process.cwd(), "package.json");
|
|
752
|
-
if (fs.existsSync(cwdPkgPath)) {
|
|
753
|
-
let pkg = null;
|
|
754
|
-
try {
|
|
755
|
-
pkg = JSON.parse(fs.readFileSync(cwdPkgPath, "utf-8"));
|
|
756
|
-
}
|
|
757
|
-
catch (err) {
|
|
758
|
-
// An unparseable cwd package.json means we can't confirm this is the app's
|
|
759
|
-
// own project — warn and fall through to a fresh clone rather than crash.
|
|
760
|
-
console.error(`⚠ Could not parse ${cwdPkgPath} (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
761
|
-
`pulling into a fresh ${appDirName(appName)}/ subdir.`);
|
|
762
|
-
}
|
|
763
|
-
if (pkg?.lotics?.app_id === appId)
|
|
764
|
-
return process.cwd();
|
|
765
|
-
}
|
|
766
|
-
return appDirName(appName);
|
|
767
|
-
}
|
|
768
|
-
export async function appPull(client, args) {
|
|
769
|
-
const app = await client.getApp(args.app_id);
|
|
770
|
-
if (!app.current_version_id) {
|
|
771
|
-
throw new Error(`App ${app.id} has no published version yet. Deploy from another machine first, or use 'lotics app create' to scaffold a new app.`);
|
|
772
|
-
}
|
|
773
|
-
const version = await client.getAppVersion(app.id, app.current_version_id);
|
|
774
|
-
const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
|
|
775
|
-
const targetPath = path.resolve(args.targetPath ?? defaultPullTarget(app.id, app.name));
|
|
776
|
-
fs.mkdirSync(targetPath, { recursive: true });
|
|
777
|
-
// Download to a temp file because `tar -xz` reads from a real path.
|
|
778
|
-
const tmpFile = path.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
|
|
779
|
-
console.error(`Downloading source archive...`);
|
|
780
|
-
await downloadToFile(sourceUrl, tmpFile);
|
|
781
|
-
try {
|
|
782
|
-
console.error(`Extracting to ${targetPath}...`);
|
|
783
|
-
await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
|
|
784
|
-
}
|
|
785
|
-
finally {
|
|
786
|
-
if (fs.existsSync(tmpFile))
|
|
787
|
-
fs.unlinkSync(tmpFile);
|
|
788
|
-
}
|
|
789
|
-
// Workflows live on the App row, not in the source archive. The R2 archive
|
|
790
|
-
// is for code; pull the workflows live from `apps.workflows` so agent-
|
|
791
|
-
// authored bindings (via set_app_workflow) land in the local manifest. The
|
|
792
|
-
// archive's embedded `lotics.workflows` is a deploy-time snapshot and would
|
|
793
|
-
// be stale relative to anything authored since.
|
|
794
|
-
stampPulledManifest(targetPath, {
|
|
795
|
-
app_id: app.id,
|
|
796
|
-
workspace_id: app.workspace_id,
|
|
797
|
-
current_version_id: app.current_version_id,
|
|
798
|
-
version_number: version.version,
|
|
799
|
-
workflows: app.workflows ?? {},
|
|
800
|
-
queries: app.queries ?? {},
|
|
801
|
-
agents: app.agents ?? {},
|
|
802
|
-
});
|
|
803
|
-
// Write each bound workflow's faithful body to src/workflows/<alias>.ts so the
|
|
804
|
-
// author edits a real file and pushes with `lotics app workflow set <alias>`
|
|
805
|
-
// — no more fetch/reconstruct/escape. Sourced from get_app_workflow (the live
|
|
806
|
-
// workflow row), like the manifest's `workflows` map, so agent-authored bodies
|
|
807
|
-
// survive the pull. A legacy alias with no rendered source warns and is skipped.
|
|
808
|
-
const aliases = Object.keys(app.workflows ?? {});
|
|
809
|
-
if (aliases.length > 0) {
|
|
810
|
-
const written = await writeWorkflowFiles(client, targetPath, app.id, aliases);
|
|
811
|
-
if (written.length > 0) {
|
|
812
|
-
console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
console.error(`Installing npm dependencies...`);
|
|
816
|
-
await runNpm(["install"], targetPath);
|
|
817
|
-
console.error(`\nReady. Next steps:`);
|
|
818
|
-
console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
|
|
819
|
-
console.error(` # edit src/App.tsx`);
|
|
820
|
-
console.error(` lotics app deploy`);
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* SDK calls that only work when the matching capability is declared in
|
|
824
|
-
* `package.json#lotics.capabilities`. Used by the deploy pre-flight (GAP-29):
|
|
825
|
-
* code that calls one of these but omits the capability silently 403s at
|
|
826
|
-
* runtime, with no build or deploy error. Extend this map when a new capability
|
|
827
|
-
* gate ships in `@lotics/app-sdk`.
|
|
828
|
-
*/
|
|
829
|
-
const CAPABILITY_GATED_CALLS = {
|
|
830
|
-
comments: ["useComments", "createComment", "updateComment", "deleteComment"],
|
|
831
|
-
};
|
|
832
|
-
/**
|
|
833
|
-
* Capabilities the source CALLS but the manifest does not DECLARE — each one
|
|
834
|
-
* silently 403s at runtime (GAP-29). Pure over the concatenated source text so
|
|
835
|
-
* it's unit-testable; the deploy warns (non-blocking) on a non-empty result.
|
|
836
|
-
*/
|
|
837
|
-
export function undeclaredCapabilities(sourceText, declared) {
|
|
838
|
-
const used = [];
|
|
839
|
-
for (const [capability, calls] of Object.entries(CAPABILITY_GATED_CALLS)) {
|
|
840
|
-
const isCalled = calls.some((call) => new RegExp(`\\b${call}\\b`).test(sourceText));
|
|
841
|
-
if (isCalled && declared?.[capability] !== true)
|
|
842
|
-
used.push(capability);
|
|
843
|
-
}
|
|
844
|
-
return used;
|
|
845
|
-
}
|
|
846
|
-
/** Concatenated text of the app's `src/` files — the input to the capability pre-flight. */
|
|
847
|
-
function readAppSourceText(projectDir) {
|
|
848
|
-
const srcDir = path.join(projectDir, "src");
|
|
849
|
-
if (!fs.existsSync(srcDir))
|
|
850
|
-
return "";
|
|
851
|
-
const parts = [];
|
|
852
|
-
const walk = (dir) => {
|
|
853
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
854
|
-
const full = path.join(dir, entry.name);
|
|
855
|
-
if (entry.isDirectory())
|
|
856
|
-
walk(full);
|
|
857
|
-
else if (/\.(ts|tsx|js|jsx)$/.test(entry.name))
|
|
858
|
-
parts.push(fs.readFileSync(full, "utf8"));
|
|
859
|
-
}
|
|
860
|
-
};
|
|
861
|
-
walk(srcDir);
|
|
862
|
-
return parts.join("\n");
|
|
863
|
-
}
|
|
864
|
-
/**
|
|
865
|
-
* `lotics app deploy [-m <message>]`
|
|
866
|
-
*
|
|
867
|
-
* Builds the project locally with `npm run build`, tars source + dist, and
|
|
868
|
-
* uploads to the deploy endpoint. The endpoint validates structure, stores
|
|
869
|
-
* both archives in R2, creates an app_versions row, and atomically advances
|
|
870
|
-
* the app's current_version pointer.
|
|
871
|
-
*/
|
|
872
|
-
export async function appDeploy(client,
|
|
873
|
-
// `message` is required — each deploy is a version row read back by
|
|
874
|
-
// `lotics app versions`; a blank message loses the audit trail. The CLI
|
|
875
|
-
// enforces non-empty at the dispatch; the type enforces it for every caller.
|
|
876
|
-
args) {
|
|
877
|
-
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
878
|
-
const meta = readAppMeta(projectDir);
|
|
879
|
-
// Pre-flight (GAP-29): a capability the code calls but the manifest doesn't
|
|
880
|
-
// declare silently 403s at runtime with no other signal — warn before shipping
|
|
881
|
-
// it. Non-blocking, like the unbranded nudge below.
|
|
882
|
-
const undeclared = undeclaredCapabilities(readAppSourceText(projectDir), meta.capabilities);
|
|
883
|
-
if (undeclared.length > 0) {
|
|
884
|
-
const block = JSON.stringify(Object.fromEntries(undeclared.map((c) => [c, true])));
|
|
885
|
-
console.error(`\n⚠ This app calls capability-gated SDK functions for ${undeclared.join(", ")} but the ` +
|
|
886
|
-
`manifest doesn't declare ${undeclared.length > 1 ? "them" : "it"} — those calls will 403 at runtime.\n` +
|
|
887
|
-
` Add to package.json#lotics.capabilities: ${block}`);
|
|
888
|
-
}
|
|
889
|
-
// Regenerate AppWorkflows typing before the build picks up source. Keeps
|
|
890
|
-
// .lotics/app_workflows.d.ts in sync with the manifest's workflows map
|
|
891
|
-
// every time the developer ships.
|
|
892
|
-
writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries, agents: meta.agents });
|
|
893
|
-
// Build locally so the server doesn't need a build sandbox in v1.
|
|
894
|
-
console.error("Building...");
|
|
895
|
-
await runNpm(["run", "build"], projectDir);
|
|
896
|
-
const distDir = path.join(projectDir, "dist");
|
|
897
|
-
if (!fs.existsSync(distDir)) {
|
|
898
|
-
throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
|
|
899
|
-
`Check that 'npm run build' is configured correctly (Vite/etc. defaults to dist/).`);
|
|
900
|
-
}
|
|
901
|
-
const tmpSource = path.join(tmpdir(), `lotics-source-${Date.now()}.tar.gz`);
|
|
902
|
-
const tmpDist = path.join(tmpdir(), `lotics-dist-${Date.now()}.tar.gz`);
|
|
903
|
-
try {
|
|
904
|
-
console.error("Packaging source...");
|
|
905
|
-
await runTar([
|
|
906
|
-
"-czf",
|
|
907
|
-
tmpSource,
|
|
908
|
-
"--exclude=node_modules",
|
|
909
|
-
"--exclude=dist",
|
|
910
|
-
"--exclude=.lotics",
|
|
911
|
-
"--exclude=*.tsbuildinfo",
|
|
912
|
-
"--exclude=.git",
|
|
913
|
-
".",
|
|
914
|
-
], projectDir);
|
|
915
|
-
console.error("Packaging dist...");
|
|
916
|
-
await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
|
|
917
|
-
console.error("Uploading...");
|
|
918
|
-
try {
|
|
919
|
-
const result = await client.deployAppVersion({
|
|
920
|
-
app_id: meta.app_id,
|
|
921
|
-
source_archive: fs.readFileSync(tmpSource),
|
|
922
|
-
dist_archive: fs.readFileSync(tmpDist),
|
|
923
|
-
prev_version_id: meta.current_version_id,
|
|
924
|
-
message: args.message,
|
|
925
|
-
// Sync apps.queries from the manifest. Server validates each query
|
|
926
|
-
// template (parseQueryNode, table access, param coverage).
|
|
927
|
-
queries: meta.queries ?? {},
|
|
928
|
-
// Capabilities are manifest-authoritative (like queries): always send,
|
|
929
|
-
// defaulting to `{}` when the manifest declares none — so deleting the
|
|
930
|
-
// `capabilities` block turns every capability OFF on the next deploy
|
|
931
|
-
// (fail-safe; the declaration is the grant).
|
|
932
|
-
capabilities: meta.capabilities ?? {},
|
|
933
|
-
// Workflow BINDINGS are NOT a deploy concern — set_app_workflow /
|
|
934
|
-
// remove_app_workflow own apps.workflows. But the alias KEYS of the
|
|
935
|
-
// manifest's `workflows` map ARE sent (never the bindings): they record
|
|
936
|
-
// which aliases this bundle declares, so remove_app_workflow can refuse
|
|
937
|
-
// to unbind an alias the served version still calls. Drop an alias from
|
|
938
|
-
// the manifest + redeploy to lift that guard before removing its binding.
|
|
939
|
-
workflow_aliases: Object.keys(meta.workflows ?? {}),
|
|
940
|
-
});
|
|
941
|
-
writeAppMeta(projectDir, {
|
|
942
|
-
...meta,
|
|
943
|
-
current_version_id: result.version_id,
|
|
944
|
-
version_number: result.version_number,
|
|
945
|
-
});
|
|
946
|
-
console.error(`Deployed v${result.version_number} (${result.version_id})`);
|
|
947
|
-
console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
|
|
948
|
-
// One post-deploy getApp feeds both advisory nudges (branding, unbound
|
|
949
|
-
// aliases). Deploy already succeeded — a failed check must not mask that.
|
|
950
|
-
try {
|
|
951
|
-
const app = await client.getApp(meta.app_id);
|
|
952
|
-
warnIfUnbranded(app);
|
|
953
|
-
warnIfUnboundAliases(app, meta.workflows ?? {}, meta.agents ?? {});
|
|
954
|
-
}
|
|
955
|
-
catch (err) {
|
|
956
|
-
console.error(`(skipped post-deploy checks: ${err.message})`);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
catch (err) {
|
|
960
|
-
const e = err;
|
|
961
|
-
if (e.code === "VERSION_CONFLICT") {
|
|
962
|
-
throw new Error(`Deploy conflict: server is at version ${e.current_version_id ?? "unknown"} ` +
|
|
963
|
-
`but local copy is based on ${meta.current_version_id ?? "no version"}. ` +
|
|
964
|
-
`Pull the latest before redeploying:\n lotics app pull ${meta.app_id}`);
|
|
965
|
-
}
|
|
966
|
-
throw err;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
finally {
|
|
970
|
-
if (fs.existsSync(tmpSource))
|
|
971
|
-
fs.unlinkSync(tmpSource);
|
|
972
|
-
if (fs.existsSync(tmpDist))
|
|
973
|
-
fs.unlinkSync(tmpDist);
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
/**
|
|
977
|
-
* Non-blocking nudge after a successful deploy: an app with no icon/color shows a
|
|
978
|
-
* generic tile in the launcher. Branding is set via `update_app` (the single
|
|
979
|
-
* setter) — this only reminds; it never fails the deploy.
|
|
980
|
-
*/
|
|
981
|
-
function warnIfUnbranded(app) {
|
|
982
|
-
const missing = [];
|
|
983
|
-
if (!app.icon)
|
|
984
|
-
missing.push("icon");
|
|
985
|
-
if (!app.theme?.color)
|
|
986
|
-
missing.push("color");
|
|
987
|
-
if (missing.length === 0)
|
|
988
|
-
return;
|
|
989
|
-
console.error(`\n⚠ This app has no ${missing.join(" or ")} set — it shows a generic tile in the launcher.\n` +
|
|
990
|
-
` Set it: lotics run update_app '{"app_id":"${app.id}","icon":"<lucide-name>","theme":{"color":"blue"}}'\n` +
|
|
991
|
-
` Find an icon: lotics run search_app_icons '{"query":"<word>"}'`);
|
|
992
|
-
}
|
|
993
|
-
/**
|
|
994
|
-
* Non-blocking nudge after a successful deploy: catch manifest workflow/agent aliases
|
|
995
|
-
* that are NOT bound on the server. Deploy ships code + queries only — it never binds
|
|
996
|
-
* workflows/agents (`set_app_workflow` / `set_app_agent` own `apps.workflows`/`apps.agents`;
|
|
997
|
-
* the manifest maps are a pulled reflection). So an alias hand-added to `lotics.workflows`
|
|
998
|
-
* / `lotics.agents` (or bound then removed) compiles + deploys clean and only throws when
|
|
999
|
-
* the app first calls `useWorkflow` / `useAgentRun`. Surface that divergence HERE — at
|
|
1000
|
-
* deploy time — instead of at the user's first click. Advisory only; never fails the deploy.
|
|
1001
|
-
*/
|
|
1002
|
-
function warnIfUnboundAliases(app, declaredWorkflows, declaredAgents) {
|
|
1003
|
-
const boundWorkflows = new Set(Object.keys(app.workflows ?? {}));
|
|
1004
|
-
const boundAgents = new Set(Object.keys(app.agents ?? {}));
|
|
1005
|
-
const unboundWorkflows = Object.keys(declaredWorkflows).filter((a) => !boundWorkflows.has(a));
|
|
1006
|
-
const unboundAgents = Object.keys(declaredAgents).filter((a) => !boundAgents.has(a));
|
|
1007
|
-
if (unboundWorkflows.length === 0 && unboundAgents.length === 0)
|
|
1008
|
-
return;
|
|
1009
|
-
const lines = [
|
|
1010
|
-
"\n⚠ The manifest declares aliases that are NOT bound on the server. Deploy ships code +",
|
|
1011
|
-
" queries only — it does NOT bind workflows/agents, so the app will throw \"has no … alias\"",
|
|
1012
|
-
" the first time it calls them. Bind each one:",
|
|
1013
|
-
];
|
|
1014
|
-
for (const alias of unboundWorkflows)
|
|
1015
|
-
lines.push(` • workflow "${alias}" → lotics app workflow set ${alias}`);
|
|
1016
|
-
for (const alias of unboundAgents)
|
|
1017
|
-
lines.push(` • agent "${alias}" → bind with set_app_agent (lotics run set_app_agent …)`);
|
|
1018
|
-
console.error(lines.join("\n"));
|
|
1019
|
-
}
|
|
1020
|
-
/**
|
|
1021
|
-
* `lotics app dev [path] [--port=5174] [--vite-port=5173]`
|
|
1022
|
-
*
|
|
1023
|
-
* Local dev mode for iframe apps. Spawns Vite + a postMessage RPC forwarder
|
|
1024
|
-
* that bridges the iframe's hooks (useQuery, useWorkflow) to api.lotics.ai
|
|
1025
|
-
* using the CLI's stored API key. Wrapper
|
|
1026
|
-
* iframe matches the production sandbox attributes exactly — null origin,
|
|
1027
|
-
* allow-scripts — so prod-equivalent runtime behavior surfaces in dev.
|
|
1028
|
-
*
|
|
1029
|
-
* Lifecycle: Vite as a child process (HMR over WebSocket), HTTP server on a
|
|
1030
|
-
* sibling port serving the wrapper HTML + the /_rpc dispatcher. SIGINT kills
|
|
1031
|
-
* both cleanly.
|
|
1032
|
-
*/
|
|
1033
|
-
export async function appDev(client, args) {
|
|
1034
|
-
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
1035
|
-
const meta = readAppMeta(projectDir);
|
|
1036
|
-
// Regenerate AppWorkflows typing before Vite spins up so the dev typecheck
|
|
1037
|
-
// sees the current shape. Doesn't watch for manifest changes mid-session —
|
|
1038
|
-
// re-running `lotics app dev` after editing the manifest is the loop.
|
|
1039
|
-
writeAppDts(projectDir, { workflows: meta.workflows, queries: meta.queries, agents: meta.agents });
|
|
1040
|
-
// Sanity: confirm the app exists in the workspace the CLI is auth'd into.
|
|
1041
|
-
// Surfaces a clear error if the project's app_id has been deleted or the
|
|
1042
|
-
// CLI is pointed at the wrong workspace.
|
|
1043
|
-
const app = await client.getApp(meta.app_id);
|
|
1044
|
-
const handle = await startDevServer({
|
|
1045
|
-
projectDir,
|
|
1046
|
-
app_id: meta.app_id,
|
|
1047
|
-
app_name: app.name,
|
|
1048
|
-
workspace_id: meta.workspace_id,
|
|
1049
|
-
api_url: client.baseUrl,
|
|
1050
|
-
port: args.port,
|
|
1051
|
-
vitePort: args.vitePort,
|
|
1052
|
-
client,
|
|
1053
|
-
commentsEnabled: meta.capabilities?.comments,
|
|
1054
|
-
});
|
|
1055
|
-
await handle.ready;
|
|
1056
|
-
const url = `http://localhost:${handle.port}`;
|
|
1057
|
-
console.error(`\n lotics app dev`);
|
|
1058
|
-
console.error(` app: ${app.name} (${meta.app_id})`);
|
|
1059
|
-
console.error(` workspace: ${meta.workspace_id}`);
|
|
1060
|
-
if (client.viewAsMemberId) {
|
|
1061
|
-
console.error(` view as: ${client.viewAsMemberId} (is_current_member resolves to this member)`);
|
|
1062
|
-
}
|
|
1063
|
-
console.error(` vite: http://localhost:${handle.vitePort}/`);
|
|
1064
|
-
console.error(` open: ${url}`);
|
|
1065
|
-
console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
|
|
1066
|
-
console.error(` Ctrl-C to stop.\n`);
|
|
1067
|
-
openBrowser(url);
|
|
1068
|
-
// Block until SIGINT.
|
|
1069
|
-
await new Promise((resolve) => {
|
|
1070
|
-
const onSig = () => {
|
|
1071
|
-
process.off("SIGINT", onSig);
|
|
1072
|
-
process.off("SIGTERM", onSig);
|
|
1073
|
-
resolve();
|
|
1074
|
-
};
|
|
1075
|
-
process.on("SIGINT", onSig);
|
|
1076
|
-
process.on("SIGTERM", onSig);
|
|
1077
|
-
});
|
|
1078
|
-
console.error("\nStopping…");
|
|
1079
|
-
await handle.stop();
|
|
1080
|
-
}
|
|
1081
|
-
/** Narrow the loose envelope's `created_records` into typed groups. */
|
|
1082
|
-
function parseCreatedRecords(value) {
|
|
1083
|
-
if (!Array.isArray(value))
|
|
1084
|
-
return [];
|
|
1085
|
-
const groups = [];
|
|
1086
|
-
for (const entry of value) {
|
|
1087
|
-
if (!entry || typeof entry !== "object")
|
|
1088
|
-
continue;
|
|
1089
|
-
const tableId = entry.table_id;
|
|
1090
|
-
const recordIds = entry.record_ids;
|
|
1091
|
-
if (typeof tableId !== "string" || !Array.isArray(recordIds))
|
|
1092
|
-
continue;
|
|
1093
|
-
const ids = recordIds.filter((id) => typeof id === "string");
|
|
1094
|
-
if (ids.length > 0)
|
|
1095
|
-
groups.push({ table_id: tableId, record_ids: ids });
|
|
1096
|
-
}
|
|
1097
|
-
return groups;
|
|
1098
|
-
}
|
|
1099
|
-
/** Narrow `irreversible_tool_calls` into a list of tool names (deduped, ordered). */
|
|
1100
|
-
function parseIrreversibleToolNames(value) {
|
|
1101
|
-
if (!Array.isArray(value))
|
|
1102
|
-
return [];
|
|
1103
|
-
const names = [];
|
|
1104
|
-
for (const entry of value) {
|
|
1105
|
-
if (!entry || typeof entry !== "object")
|
|
1106
|
-
continue;
|
|
1107
|
-
const name = entry.tool_name;
|
|
1108
|
-
if (typeof name === "string" && !names.includes(name))
|
|
1109
|
-
names.push(name);
|
|
1110
|
-
}
|
|
1111
|
-
return names;
|
|
1112
|
-
}
|
|
1113
|
-
/**
|
|
1114
|
-
* Print the honest harvest of a run's side effects to stderr (GAP-58): created
|
|
1115
|
-
* records grouped by table, a ready-to-paste `lotics run delete_records …` per
|
|
1116
|
-
* table, then the MANDATORY caveat naming what cannot be auto-undone. Never
|
|
1117
|
-
* deletes anything — this only reports.
|
|
1118
|
-
*/
|
|
1119
|
-
function printSideEffects(summary) {
|
|
1120
|
-
const created = parseCreatedRecords(summary.created_records);
|
|
1121
|
-
const irreversibleTools = parseIrreversibleToolNames(summary.irreversible_tool_calls);
|
|
1122
|
-
const subWorkflows = summary.sub_workflows_possible === true;
|
|
1123
|
-
console.error("\nCreated records:");
|
|
1124
|
-
if (created.length === 0) {
|
|
1125
|
-
console.error(" (none with ids to clean up)");
|
|
1126
|
-
}
|
|
1127
|
-
else {
|
|
1128
|
-
for (const group of created) {
|
|
1129
|
-
console.error(` ${group.table_id}: ${group.record_ids.length} record(s)`);
|
|
1130
|
-
const payload = JSON.stringify({ table_id: group.table_id, record_ids: group.record_ids });
|
|
1131
|
-
console.error(` lotics run delete_records '${payload}'`);
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
// The caveat is mandatory and unconditional — a clean run still owes the
|
|
1135
|
-
// reader the explicit "this is not a rollback" framing so cleanup is never
|
|
1136
|
-
// mistaken for complete.
|
|
1137
|
-
const irreversiblePart = irreversibleTools.length > 0
|
|
1138
|
-
? `Could NOT auto-undo (clean up manually): ${irreversibleTools.join(", ")}.`
|
|
1139
|
-
: "Could NOT auto-undo: none.";
|
|
1140
|
-
const subPart = subWorkflows
|
|
1141
|
-
? " Sub-workflows may have run (after_* table workflows) — their effects are NOT in this list."
|
|
1142
|
-
: "";
|
|
1143
|
-
console.error(`\n${irreversiblePart}${subPart}`);
|
|
1144
|
-
}
|
|
1145
|
-
/**
|
|
1146
|
-
* Run the harvested deletes for created records ONLY (never files / external /
|
|
1147
|
-
* notifications — those are reported, never silently undone). Best-effort: a
|
|
1148
|
-
* failed delete is logged and the rest continue. Returns `false` when ANY delete
|
|
1149
|
-
* failed, so the command boundary can exit non-zero — a CI script branching on
|
|
1150
|
-
* the exit code must not read partial cleanup as success.
|
|
1151
|
-
*/
|
|
1152
|
-
async function cleanupCreatedRecords(client, created) {
|
|
1153
|
-
if (created.length === 0) {
|
|
1154
|
-
console.error("\nNo created records to clean up.");
|
|
1155
|
-
return true;
|
|
1156
|
-
}
|
|
1157
|
-
console.error("\nCleaning up created records (delete_records — records only):");
|
|
1158
|
-
let allDeleted = true;
|
|
1159
|
-
for (const group of created) {
|
|
1160
|
-
const res = await client.execute("delete_records", {
|
|
1161
|
-
table_id: group.table_id,
|
|
1162
|
-
record_ids: group.record_ids,
|
|
1163
|
-
});
|
|
1164
|
-
if (res.error) {
|
|
1165
|
-
console.error(` ✗ ${group.table_id}: ${res.error}`);
|
|
1166
|
-
allDeleted = false;
|
|
1167
|
-
}
|
|
1168
|
-
else {
|
|
1169
|
-
console.error(` ✓ ${group.table_id}: deleted ${group.record_ids.length} record(s)`);
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
return allDeleted;
|
|
1173
|
-
}
|
|
1174
|
-
/**
|
|
1175
|
-
* `lotics app workflow run <alias> '<json>'` — execute a bound app workflow
|
|
1176
|
-
* end-to-end against the live workspace. `app_id` comes from the local manifest
|
|
1177
|
-
* (like deploy/dev), the alias must be bound server-side via `set_app_workflow`.
|
|
1178
|
-
*
|
|
1179
|
-
* The full `{ status, message, data, files, side_effects }` JSON prints to
|
|
1180
|
-
* stdout (pipeable / assertable); a one-line human summary goes to stderr. A
|
|
1181
|
-
* `status: "error"` envelope exits non-zero so a script can branch on it — the
|
|
1182
|
-
* transport already normalizes a gateway/timeout failure into the same
|
|
1183
|
-
* `{ status: "error" }` shape, so a failed run is never a thrown HTML body.
|
|
1184
|
-
*
|
|
1185
|
-
* `--print-created` (alias `--report-effects`) renders the honest post-run
|
|
1186
|
-
* harvest (GAP-58): created records grouped by table, a paste-ready
|
|
1187
|
-
* `delete_records` per table, and the mandatory caveat about what cannot be
|
|
1188
|
-
* auto-undone. `--cleanup` (DEFAULT OFF) additionally runs the deletes for the
|
|
1189
|
-
* harvested records ONLY — never files, external integrations, or notifications.
|
|
1190
|
-
* Neither is a rollback; a rollback is structurally impossible here.
|
|
1191
|
-
*/
|
|
1192
|
-
export async function appExecuteWorkflow(client, args) {
|
|
1193
|
-
const meta = readAppMeta(process.cwd());
|
|
1194
|
-
const result = (await client.appWorkflow(meta.app_id, args.alias, args.inputs));
|
|
1195
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1196
|
-
const status = typeof result.status === "string" ? result.status : "unknown";
|
|
1197
|
-
const message = typeof result.message === "string" ? result.message : "";
|
|
1198
|
-
console.error(`Workflow "${args.alias}" → ${status}${message ? `: ${message}` : ""}`);
|
|
1199
|
-
// --cleanup implies the report (you should always see what's being undone).
|
|
1200
|
-
let cleanupFailed = false;
|
|
1201
|
-
if ((args.printCreated || args.cleanup) && result.side_effects) {
|
|
1202
|
-
printSideEffects(result.side_effects);
|
|
1203
|
-
if (args.cleanup) {
|
|
1204
|
-
const allDeleted = await cleanupCreatedRecords(client, parseCreatedRecords(result.side_effects.created_records));
|
|
1205
|
-
cleanupFailed = !allDeleted;
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
else if (args.printCreated || args.cleanup) {
|
|
1209
|
-
console.error("\n(no side-effect summary returned by the server)");
|
|
1210
|
-
}
|
|
1211
|
-
// Exit non-zero on an error run OR a partial cleanup — a script must not read
|
|
1212
|
-
// either as success.
|
|
1213
|
-
if (status === "error" || cleanupFailed)
|
|
1214
|
-
process.exit(1);
|
|
1215
|
-
}
|
|
1216
|
-
/**
|
|
1217
|
-
* `lotics app workflow set <alias>` — push the edited `src/workflows/<alias>.ts`
|
|
1218
|
-
* body to the server through `set_app_workflow` (the single author of
|
|
1219
|
-
* `apps.workflows`). The body is read from disk (header stripped); the typed
|
|
1220
|
-
* `inputs`/`outputs` schemas come from `package.json#lotics.workflows.<alias>`,
|
|
1221
|
-
* so a pulled-then-edited app keeps its declared contract. The server re-verifies
|
|
1222
|
-
* the body and echoes the bound `outputs` (declared, else DERIVED from
|
|
1223
|
-
* `return({ data })`) — the same guarantee as calling `set_app_workflow` by hand,
|
|
1224
|
-
* with no fetch/reconstruct/escape. Errors (missing file, unbound alias, verify
|
|
1225
|
-
* failure) print to stderr and exit non-zero.
|
|
1226
|
-
*
|
|
1227
|
-
* This is a CLI convenience over the existing tool — `lotics app deploy` is still
|
|
1228
|
-
* NOT an author of workflows; the single-author invariant holds.
|
|
1229
|
-
*/
|
|
1230
|
-
export async function appWorkflowSet(client, args) {
|
|
1231
|
-
const projectDir = process.cwd();
|
|
1232
|
-
const meta = readAppMeta(projectDir);
|
|
1233
|
-
const declaration = meta.workflows?.[args.alias];
|
|
1234
|
-
if (!declaration) {
|
|
1235
|
-
console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
|
|
1236
|
-
`Bind it first (set_app_workflow), then 'lotics app pull' to write its body and manifest entry.`);
|
|
1237
|
-
process.exit(1);
|
|
1238
|
-
}
|
|
1239
|
-
const file = workflowFilePath(projectDir, args.alias);
|
|
1240
|
-
if (!fs.existsSync(file)) {
|
|
1241
|
-
console.error(`No workflow body at ${path.relative(projectDir, file)}. ` +
|
|
1242
|
-
`Run 'lotics app pull ${meta.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`);
|
|
1243
|
-
process.exit(1);
|
|
1244
|
-
}
|
|
1245
|
-
const source = stripWorkflowHeader(fs.readFileSync(file, "utf-8"));
|
|
1246
|
-
if (source.trim() === "") {
|
|
1247
|
-
console.error(`Workflow body ${path.relative(projectDir, file)} is empty after stripping the header.`);
|
|
1248
|
-
process.exit(1);
|
|
1249
|
-
}
|
|
1250
|
-
const res = await client.setAppWorkflow(meta.app_id, args.alias, {
|
|
1251
|
-
source,
|
|
1252
|
-
inputs: declaration.inputs,
|
|
1253
|
-
outputs: declaration.outputs,
|
|
1254
|
-
});
|
|
1255
|
-
if (res.error) {
|
|
1256
|
-
console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
|
|
1257
|
-
process.exit(1);
|
|
1258
|
-
}
|
|
1259
|
-
// set_app_workflow echoes { app_id, alias, workflow_id, outputs? } — outputs is
|
|
1260
|
-
// declared-wins-else-DERIVED from return({ data }), the shape result.data carries.
|
|
1261
|
-
const result = (res.result ?? {});
|
|
1262
|
-
const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
|
|
1263
|
-
console.error(`Set workflow "${args.alias}" → ${workflowId}`);
|
|
1264
|
-
if (result.outputs && typeof result.outputs === "object") {
|
|
1265
|
-
console.error(` result.data schema: ${JSON.stringify(result.outputs)}`);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
/**
|
|
1269
|
-
* `lotics app query set <alias>` — push `package.json#lotics.queries.<alias>` to
|
|
1270
|
-
* `apps.queries` through `set_app_query`, WITHOUT a deploy. The deploy-free inner
|
|
1271
|
-
* loop for named queries, parallel to `lotics app workflow set` for workflows.
|
|
1272
|
-
*
|
|
1273
|
-
* The declaration (`{ ast, params? }`) is read from the manifest — the same map
|
|
1274
|
-
* `useQuery` codegen reads and `lotics app deploy` syncs authoritatively. The
|
|
1275
|
-
* server validates it exactly as a deploy does (alias identifier, workspace-only
|
|
1276
|
-
* tables, resolvable fields, declared params). Because `apps.queries` is
|
|
1277
|
-
* manifest-authoritative, the next `lotics app deploy` overwrites this from the
|
|
1278
|
-
* manifest — so keep the manifest as the source of truth; this only skips the
|
|
1279
|
-
* build/upload round-trip while iterating. Errors (unbound alias, validation
|
|
1280
|
-
* failure) print to stderr and exit non-zero.
|
|
1281
|
-
*/
|
|
1282
|
-
export async function appQuerySet(client, args) {
|
|
1283
|
-
const projectDir = process.cwd();
|
|
1284
|
-
const meta = readAppMeta(projectDir);
|
|
1285
|
-
const declaration = meta.queries?.[args.alias];
|
|
1286
|
-
if (!declaration) {
|
|
1287
|
-
console.error(`No query "${args.alias}" in package.json#lotics.queries. ` +
|
|
1288
|
-
`Declare it there (alias → { ast, params? }) first.`);
|
|
1289
|
-
process.exit(1);
|
|
1290
|
-
}
|
|
1291
|
-
const res = await client.setAppQuery(meta.app_id, args.alias, declaration);
|
|
1292
|
-
if (res.error) {
|
|
1293
|
-
console.error(`Failed to set query "${args.alias}": ${res.error}`);
|
|
1294
|
-
process.exit(1);
|
|
1295
|
-
}
|
|
1296
|
-
console.error(`Set query "${args.alias}" on ${meta.app_id}. ` +
|
|
1297
|
-
`(apps.queries is manifest-authoritative — the next 'lotics app deploy' re-syncs it.)`);
|
|
1298
|
-
}
|
|
1299
|
-
/**
|
|
1300
|
-
* `lotics app workflow pull` — rewrite every `src/workflows/<alias>.ts` from the
|
|
1301
|
-
* server without a full `lotics app pull` (no source archive, no npm install).
|
|
1302
|
-
* The alias set + bodies come from the live App row (the same source `app pull`
|
|
1303
|
-
* uses); a legacy alias with no rendered source warns and is skipped.
|
|
1304
|
-
*/
|
|
1305
|
-
export async function appWorkflowPull(client) {
|
|
1306
|
-
const projectDir = process.cwd();
|
|
1307
|
-
const meta = readAppMeta(projectDir);
|
|
1308
|
-
const app = await client.getApp(meta.app_id);
|
|
1309
|
-
const aliases = Object.keys(app.workflows ?? {});
|
|
1310
|
-
if (aliases.length === 0) {
|
|
1311
|
-
console.error(`App ${meta.app_id} has no bound workflows.`);
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
const written = await writeWorkflowFiles(client, projectDir, meta.app_id, aliases);
|
|
1315
|
-
console.error(`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` +
|
|
1316
|
-
(written.length > 0 ? ` (${written.join(", ")})` : ""));
|
|
1317
|
-
// This command writes workflow BODIES, not the `.d.ts` — so it doesn't reach
|
|
1318
|
-
// `writeAppDts`'s heal. Heal here so the bodies land excluded and a stale
|
|
1319
|
-
// `.lotics` include doesn't leave the app's other generated types dead.
|
|
1320
|
-
ensureAppTsconfig(projectDir);
|
|
1321
|
-
}
|
|
1322
|
-
/**
|
|
1323
|
-
* `lotics app workflow check [alias]` — local TypeScript type check of the
|
|
1324
|
-
* editable workflow bodies, ONE isolated program per bound alias (GAP-59 fix).
|
|
1325
|
-
*
|
|
1326
|
-
* The dedicated `tsconfig.workflows.json` that GAP-59 first shipped compiled ALL
|
|
1327
|
-
* aliases' bodies + per-alias ambient globals into a SINGLE program, so the N
|
|
1328
|
-
* `declare const trigger: AppWorkflowTrigger` declarations (each with THAT
|
|
1329
|
-
* alias's `app_workflow.inputs`) collided — tsc resolved one and every body
|
|
1330
|
-
* checked `trigger.app_workflow.inputs` against the wrong alias. This command
|
|
1331
|
-
* replaces that config: it builds a separate `ts.Program` per alias from exactly
|
|
1332
|
-
* that alias's `{body, globals}` pair (mirroring the SERVER, which verifies one
|
|
1333
|
-
* body at a time), so the ambient `trigger` is unambiguous and the verdict
|
|
1334
|
-
* matches set-time. All aliases run in ONE process.
|
|
1335
|
-
*
|
|
1336
|
-
* `[alias]` checks one alias; omitted, checks every bound alias that has a body
|
|
1337
|
-
* file. Exits non-zero if ANY alias has a type error. A bound alias with no body
|
|
1338
|
-
* file yet (never pulled) is warned and skipped; an alias missing its globals
|
|
1339
|
-
* file is an error (the body can't be checked without its types).
|
|
1340
|
-
*/
|
|
1341
|
-
export async function appWorkflowCheck(args) {
|
|
1342
|
-
const projectDir = process.cwd();
|
|
1343
|
-
const meta = readAppMeta(projectDir);
|
|
1344
|
-
const bound = Object.keys(meta.workflows ?? {});
|
|
1345
|
-
let aliases;
|
|
1346
|
-
if (args.alias) {
|
|
1347
|
-
if (!bound.includes(args.alias)) {
|
|
1348
|
-
console.error(`No workflow "${args.alias}" in package.json#lotics.workflows. ` +
|
|
1349
|
-
`Bound aliases: ${bound.length > 0 ? bound.join(", ") : "(none)"}.`);
|
|
1350
|
-
process.exit(1);
|
|
1351
|
-
}
|
|
1352
|
-
aliases = [args.alias];
|
|
1353
|
-
}
|
|
1354
|
-
else {
|
|
1355
|
-
aliases = bound;
|
|
1356
|
-
}
|
|
1357
|
-
if (aliases.length === 0) {
|
|
1358
|
-
console.error(`App ${meta.app_id} has no bound workflows to check.`);
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
// Each alias contributes its OWN body + globals. A bound alias never pulled has
|
|
1362
|
-
// no body file — warn + skip (not an error; the author hasn't pulled it). A
|
|
1363
|
-
// body with no globals can't be checked — that IS an error (run a pull).
|
|
1364
|
-
const toCheck = [];
|
|
1365
|
-
for (const alias of aliases) {
|
|
1366
|
-
const bodyPath = workflowFilePath(projectDir, alias);
|
|
1367
|
-
const globalsPath = workflowGlobalsPath(projectDir, alias);
|
|
1368
|
-
if (!fs.existsSync(bodyPath)) {
|
|
1369
|
-
console.error(`⚠ Skipped "${alias}" — no body at ${path.relative(projectDir, bodyPath)}. ` +
|
|
1370
|
-
`Run 'lotics app workflow pull' to write it.`);
|
|
1371
|
-
continue;
|
|
1372
|
-
}
|
|
1373
|
-
if (!fs.existsSync(globalsPath)) {
|
|
1374
|
-
console.error(`Cannot check "${alias}" — missing types at ${path.relative(projectDir, globalsPath)}. ` +
|
|
1375
|
-
`Run 'lotics app workflow pull' (or 'lotics app codegen') to fetch them.`);
|
|
1376
|
-
process.exit(1);
|
|
1377
|
-
}
|
|
1378
|
-
toCheck.push({ alias, input: { bodyPath, globalsPath } });
|
|
1379
|
-
}
|
|
1380
|
-
if (toCheck.length === 0) {
|
|
1381
|
-
console.error("No workflow bodies to check (every bound alias was skipped).");
|
|
1382
|
-
return;
|
|
1383
|
-
}
|
|
1384
|
-
const tsApi = await loadProjectTypescript(projectDir);
|
|
1385
|
-
const results = checkWorkflowBodies(tsApi, toCheck);
|
|
1386
|
-
printWorkflowCheckResults(projectDir, results);
|
|
1387
|
-
const failed = results.filter((r) => r.issues.length > 0);
|
|
1388
|
-
if (failed.length > 0)
|
|
1389
|
-
process.exit(1);
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Render the per-alias verdict to stderr (status) — a clean line per passing
|
|
1393
|
-
* alias, then `<file>:<line>:<col> - TS####: message` per error, grouped by
|
|
1394
|
-
* alias, with a final tally. Lines point at the author's body (the envelope
|
|
1395
|
-
* offset already removed in `checkOneWorkflowBody`).
|
|
1396
|
-
*/
|
|
1397
|
-
function printWorkflowCheckResults(projectDir, results) {
|
|
1398
|
-
let totalErrors = 0;
|
|
1399
|
-
for (const r of results) {
|
|
1400
|
-
const rel = path.relative(projectDir, r.bodyPath);
|
|
1401
|
-
if (r.issues.length === 0) {
|
|
1402
|
-
console.error(`✓ ${r.alias} (${rel}) — no type errors`);
|
|
1403
|
-
continue;
|
|
1404
|
-
}
|
|
1405
|
-
totalErrors += r.issues.length;
|
|
1406
|
-
console.error(`✗ ${r.alias} (${rel}) — ${r.issues.length} error${r.issues.length === 1 ? "" : "s"}:`);
|
|
1407
|
-
for (const issue of r.issues) {
|
|
1408
|
-
// TS multi-line messages indent every continuation under the location line.
|
|
1409
|
-
const [first, ...rest] = issue.message.split("\n");
|
|
1410
|
-
console.error(` ${rel}:${issue.line}:${issue.col} - ${issue.code}: ${first}`);
|
|
1411
|
-
for (const line of rest)
|
|
1412
|
-
console.error(` ${line}`);
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
const passed = results.length - results.filter((r) => r.issues.length > 0).length;
|
|
1416
|
-
console.error(totalErrors === 0
|
|
1417
|
-
? results.length === 1
|
|
1418
|
-
? `\nThe workflow body type-checks clean.`
|
|
1419
|
-
: `\nAll ${results.length} workflow bodies type-check clean.`
|
|
1420
|
-
: `\n${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - passed} of ${results.length} ${results.length === 1 ? "body" : "bodies"}.`);
|
|
1421
|
-
}
|
|
1422
|
-
/**
|
|
1423
|
-
* Walk up from `start` to the monorepo's `packages/ui/src`. Returns null when
|
|
1424
|
-
* not found — an external npm app author has no monorepo checkout, so `ui link`
|
|
1425
|
-
* must fail loud rather than write a broken alias.
|
|
1426
|
-
*/
|
|
1427
|
-
function findUiSrcDir(start) {
|
|
1428
|
-
let dir = path.resolve(start);
|
|
1429
|
-
for (;;) {
|
|
1430
|
-
const candidate = path.join(dir, "packages", "ui", "src");
|
|
1431
|
-
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
|
|
1432
|
-
return candidate;
|
|
1433
|
-
const parent = path.dirname(dir);
|
|
1434
|
-
if (parent === dir)
|
|
1435
|
-
return null;
|
|
1436
|
-
dir = parent;
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
// The dev-link alias entry — a package-wide `@lotics/ui/<subpath>` → local
|
|
1440
|
-
// `packages/ui/src/<subpath>` redirect. Matched/removed by the literal `find`
|
|
1441
|
-
// regex source so insert/remove is idempotent regardless of the replacement.
|
|
1442
|
-
const UI_ALIAS_FIND_SOURCE = String.raw `/^@lotics\/ui\/(.+)$/`;
|
|
1443
|
-
/**
|
|
1444
|
-
* `lotics ui link <component> [--remove]` — add or remove the `@lotics/ui`
|
|
1445
|
-
* dev-link alias in the app's `vite.config.ts`, so edits to the monorepo's
|
|
1446
|
-
* `packages/ui/src` go live (HMR) without a publish round-trip. `component` is
|
|
1447
|
-
* advisory only — the alias is package-wide (one subpath regex covers every
|
|
1448
|
-
* import); it's validated to exist under `packages/ui/src` so a typo fails here.
|
|
1449
|
-
*
|
|
1450
|
-
* Idempotent: linking twice is a no-op; `--remove` strips the one inserted
|
|
1451
|
-
* entry and leaves the rest of `resolve.alias` intact.
|
|
1452
|
-
*/
|
|
1453
|
-
export function appUiLink(args) {
|
|
1454
|
-
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
1455
|
-
const viteConfigPath = path.join(projectDir, "vite.config.ts");
|
|
1456
|
-
if (!fs.existsSync(viteConfigPath)) {
|
|
1457
|
-
throw new Error(`No vite.config.ts at ${projectDir}. Run inside a 'lotics app' project directory.`);
|
|
1458
|
-
}
|
|
1459
|
-
// Resolve packages/ui/src. An explicit --ui-src / LOTICS_UI_SRC wins — that's how
|
|
1460
|
-
// an EXTERNAL app (one that consumes @lotics/ui from npm, with no monorepo above
|
|
1461
|
-
// it) links the local kit; otherwise walk up for a monorepo checkout.
|
|
1462
|
-
const explicit = args.uiSrc ?? process.env.LOTICS_UI_SRC;
|
|
1463
|
-
const uiSrc = explicit ? path.resolve(explicit) : findUiSrcDir(projectDir);
|
|
1464
|
-
if (!uiSrc || !fs.existsSync(uiSrc) || !fs.statSync(uiSrc).isDirectory()) {
|
|
1465
|
-
throw new Error(explicit
|
|
1466
|
-
? `--ui-src / LOTICS_UI_SRC points at '${explicit}', which is not a directory. ` +
|
|
1467
|
-
`Pass the absolute path to the monorepo's packages/ui/src.`
|
|
1468
|
-
: "Cannot find packages/ui/src by walking up from this directory. For an EXTERNAL app " +
|
|
1469
|
-
"(consuming @lotics/ui from npm), pass --ui-src=<abs path to packages/ui/src> or set " +
|
|
1470
|
-
"LOTICS_UI_SRC; inside a monorepo checkout it is found automatically.");
|
|
1471
|
-
}
|
|
1472
|
-
// Validate the named component exists in src so a typo fails loud (the alias
|
|
1473
|
-
// itself stays package-wide — this is the advisory check the spec calls for).
|
|
1474
|
-
const hasComponent = fs.existsSync(path.join(uiSrc, `${args.component}.tsx`)) ||
|
|
1475
|
-
fs.existsSync(path.join(uiSrc, `${args.component}.ts`)) ||
|
|
1476
|
-
fs.existsSync(path.join(uiSrc, args.component));
|
|
1477
|
-
if (!hasComponent) {
|
|
1478
|
-
throw new Error(`No '@lotics/ui/${args.component}' under ${uiSrc} (expected ${args.component}.tsx/.ts). ` +
|
|
1479
|
-
`Check the component name.`);
|
|
1480
|
-
}
|
|
1481
|
-
const source = fs.readFileSync(viteConfigPath, "utf-8");
|
|
1482
|
-
const aliasEntry = `{ find: ${UI_ALIAS_FIND_SOURCE}, replacement: ${JSON.stringify(`${uiSrc}/$1`)} },`;
|
|
1483
|
-
const alreadyLinked = source.includes(UI_ALIAS_FIND_SOURCE);
|
|
1484
|
-
if (args.remove) {
|
|
1485
|
-
if (!alreadyLinked) {
|
|
1486
|
-
console.error("No @lotics/ui dev-link alias present — nothing to remove.");
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
// Drop the whole alias line (the entry + its own line), leaving the rest of
|
|
1490
|
-
// resolve.alias untouched.
|
|
1491
|
-
const stripped = source.replace(new RegExp(`^\\s*\\{ find: ${escapeRegExp(UI_ALIAS_FIND_SOURCE)}.*$\\n?`, "m"), "");
|
|
1492
|
-
fs.writeFileSync(viteConfigPath, stripped);
|
|
1493
|
-
console.error(`Removed the @lotics/ui dev-link alias from ${viteConfigPath}.`);
|
|
1494
|
-
console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
|
|
1495
|
-
return;
|
|
1496
|
-
}
|
|
1497
|
-
if (alreadyLinked) {
|
|
1498
|
-
console.error(`@lotics/ui is already dev-linked in ${viteConfigPath}.`);
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
const aliasMatch = /resolve\s*:\s*\{[\s\S]*?alias\s*:\s*\[/.exec(source);
|
|
1502
|
-
if (!aliasMatch) {
|
|
1503
|
-
throw new Error(`Could not find a resolve.alias array literal in ${viteConfigPath}. ` +
|
|
1504
|
-
`Refresh vite.config.ts from the starter (packages/sdk/src/starter_template.ts) and retry.`);
|
|
1505
|
-
}
|
|
1506
|
-
const insertAt = aliasMatch.index + aliasMatch[0].length;
|
|
1507
|
-
const updated = `${source.slice(0, insertAt)}\n ${aliasEntry}${source.slice(insertAt)}`;
|
|
1508
|
-
fs.writeFileSync(viteConfigPath, updated);
|
|
1509
|
-
console.error(`Dev-linked @lotics/ui → ${uiSrc} in ${viteConfigPath}.`);
|
|
1510
|
-
console.error("Restart `lotics app dev` and rm -rf node_modules/.vite to clear cached modules.");
|
|
1511
|
-
// The app's tsc still resolves @lotics/ui from node_modules (the published .d.ts) —
|
|
1512
|
-
// the kit `src` can't be typechecked in an app because it's RN-Web (uses
|
|
1513
|
-
// react-native-web types the app resolves as base react-native). Typecheck the kit
|
|
1514
|
-
// in packages/ui; the finalize publish restores the app's own typecheck.
|
|
1515
|
-
console.error("Finalize: PR the packages/ui change → publish → `lotics ui link <component> --remove` + bump the app's dep.");
|
|
1516
|
-
}
|
|
1517
|
-
/** Escape a string for literal use inside a RegExp. */
|
|
1518
|
-
function escapeRegExp(s) {
|
|
1519
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1520
|
-
}
|