@lotics/cli 0.204.0 → 0.205.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/AGENTS.md +35 -0
- package/README.md +17 -1
- package/dist/src/cli.js +340 -53
- package/dist/src/client.d.ts +88 -3
- package/dist/src/client.js +63 -13
- package/docs/cli_reference.md +9 -7
- package/package.json +1 -1
package/dist/src/client.d.ts
CHANGED
|
@@ -92,10 +92,27 @@ export interface ReportFrame {
|
|
|
92
92
|
tried?: string;
|
|
93
93
|
wanted?: string;
|
|
94
94
|
}
|
|
95
|
+
/** One promise a refused write would break, as the tool transport states it. */
|
|
96
|
+
export interface ToolBreakingChange {
|
|
97
|
+
kind: string;
|
|
98
|
+
alias: string;
|
|
99
|
+
path: string;
|
|
100
|
+
rule?: string;
|
|
101
|
+
message?: string;
|
|
102
|
+
app_id?: string;
|
|
103
|
+
}
|
|
95
104
|
export interface ToolExecuteResult {
|
|
96
105
|
result: unknown;
|
|
97
106
|
model_output?: string;
|
|
98
107
|
error?: string;
|
|
108
|
+
/** The refusal's `code` — the same discriminator the HTTP envelope carries,
|
|
109
|
+
* and the only part of a failure a caller may branch on. Absent when the
|
|
110
|
+
* failure was not a typed error, and from any server older than it. */
|
|
111
|
+
error_code?: string;
|
|
112
|
+
/** A refused input addressed per field: one sentence from a typed alias, a
|
|
113
|
+
* list from a rejecting table workflow. */
|
|
114
|
+
field_errors?: Record<string, string> | Record<string, string[]>;
|
|
115
|
+
breaking_changes?: readonly ToolBreakingChange[];
|
|
99
116
|
}
|
|
100
117
|
/**
|
|
101
118
|
* A settled (or in-flight) app-agent run, the transcript-excluded projection
|
|
@@ -632,12 +649,28 @@ export declare class LoticsClient {
|
|
|
632
649
|
*/
|
|
633
650
|
private buildHeaders;
|
|
634
651
|
private request;
|
|
652
|
+
/** `email` is null for a credential no person signs in as; `account_type` is absent from an older server. */
|
|
653
|
+
/** `credential_origin` describes the CREDENTIAL this call carried, not the
|
|
654
|
+
* member above: `sign_in` for a terminal somebody signed in from, `api_key`
|
|
655
|
+
* for one an admin issued. Null for a row minted before it was recorded, and
|
|
656
|
+
* absent from an older server — both mean not told. */
|
|
635
657
|
whoami(): Promise<{
|
|
636
658
|
member_id: string;
|
|
637
|
-
email: string;
|
|
659
|
+
email: string | null;
|
|
638
660
|
name: string;
|
|
661
|
+
account_type?: string;
|
|
639
662
|
organization_id: string;
|
|
640
663
|
organization_name: string;
|
|
664
|
+
credential_origin?: "api_key" | "sign_in" | null;
|
|
665
|
+
}>;
|
|
666
|
+
/**
|
|
667
|
+
* Hand this client's own credential back — what `lotics auth logout` calls
|
|
668
|
+
* before it forgets the key, so signing out ends the credential instead of
|
|
669
|
+
* leaving a live one on a server nobody can see it from. Takes no id: the
|
|
670
|
+
* only thing it can revoke is the key these requests carry.
|
|
671
|
+
*/
|
|
672
|
+
revokeSelfCredential(): Promise<{
|
|
673
|
+
id: string;
|
|
641
674
|
}>;
|
|
642
675
|
/**
|
|
643
676
|
* File a hand-written report, or a list of them in one call. Unlike a
|
|
@@ -952,7 +985,12 @@ export declare class LoticsClient {
|
|
|
952
985
|
* provenance is a 400, and one already on the latest release a 409 — both
|
|
953
986
|
* refusals, both before any write. Admin-only.
|
|
954
987
|
*/
|
|
955
|
-
upgradeApp(app_id: string
|
|
988
|
+
upgradeApp(app_id: string,
|
|
989
|
+
/** Apply the new release even though it breaks what this app's published API
|
|
990
|
+
* promises, snapshotting the broken contract as a new version. */
|
|
991
|
+
opts?: {
|
|
992
|
+
acknowledge_breaking_api_change?: boolean;
|
|
993
|
+
}): Promise<AppUpgradeResult>;
|
|
956
994
|
/**
|
|
957
995
|
* Capture live records from this workspace as a starter's sample data.
|
|
958
996
|
*
|
|
@@ -1123,6 +1161,40 @@ export declare class LoticsClient {
|
|
|
1123
1161
|
public_subdomain: string;
|
|
1124
1162
|
origin: string;
|
|
1125
1163
|
}>;
|
|
1164
|
+
/**
|
|
1165
|
+
* Publish the app's API — the owner's promise about what its declared queries
|
|
1166
|
+
* and workflows return to a caller outside the app.
|
|
1167
|
+
*
|
|
1168
|
+
* Snapshots the contract and answers the version it took, plus what the
|
|
1169
|
+
* published surface exposes that the owner may not have intended. A query
|
|
1170
|
+
* that does not name its columns is refused (400) before anything is written:
|
|
1171
|
+
* the app cannot promise field names it never stated.
|
|
1172
|
+
*/
|
|
1173
|
+
publishAppApi(app_id: string): Promise<{
|
|
1174
|
+
app_id: string;
|
|
1175
|
+
contract_version: number;
|
|
1176
|
+
published_at: string;
|
|
1177
|
+
warnings: string[];
|
|
1178
|
+
}>;
|
|
1179
|
+
/** End the promise. `unpublished: false` means the app was publishing nothing,
|
|
1180
|
+
* which this leaves unchanged. The superseded snapshot stays, so a later
|
|
1181
|
+
* publish continues the numbering rather than reusing a version. */
|
|
1182
|
+
unpublishAppApi(app_id: string): Promise<{
|
|
1183
|
+
app_id: string;
|
|
1184
|
+
unpublished: boolean;
|
|
1185
|
+
}>;
|
|
1186
|
+
/** Whether the app is publishing an API, and which contract version. */
|
|
1187
|
+
getAppApiPublication(app_id: string): Promise<{
|
|
1188
|
+
published: boolean;
|
|
1189
|
+
contract_version: number | null;
|
|
1190
|
+
published_at: string | null;
|
|
1191
|
+
}>;
|
|
1192
|
+
/**
|
|
1193
|
+
* The published API as an OpenAPI 3.1 document — what a consumer's own
|
|
1194
|
+
* generator reads. Rendered from the live SNAPSHOT rather than the manifest,
|
|
1195
|
+
* so it describes what the app has promised; 404 while nothing is published.
|
|
1196
|
+
*/
|
|
1197
|
+
getAppOpenApiDocument(app_id: string): Promise<Record<string, unknown>>;
|
|
1126
1198
|
getAppVersion(app_id: string, version_id: string): Promise<{
|
|
1127
1199
|
id: string;
|
|
1128
1200
|
app_id: string;
|
|
@@ -1202,6 +1274,9 @@ export declare class LoticsClient {
|
|
|
1202
1274
|
* server refuses it when the live body has moved since, rather than
|
|
1203
1275
|
* letting a stale copy overwrite an edit its author never saw. */
|
|
1204
1276
|
expected_body_sha?: string;
|
|
1277
|
+
/** Carry out this write even though it breaks what the app's published API
|
|
1278
|
+
* promises, snapshotting the broken contract as a new version. */
|
|
1279
|
+
acknowledge_breaking_api_change?: boolean;
|
|
1205
1280
|
}): Promise<ToolExecuteResult>;
|
|
1206
1281
|
/**
|
|
1207
1282
|
* Bind (create or replace) an app query by alias via the `set_app_query` tool
|
|
@@ -1217,7 +1292,10 @@ export declare class LoticsClient {
|
|
|
1217
1292
|
description?: string;
|
|
1218
1293
|
},
|
|
1219
1294
|
/** The fingerprint this edit was based on — makes the write conditional. */
|
|
1220
|
-
expected_sha?: string
|
|
1295
|
+
expected_sha?: string,
|
|
1296
|
+
/** Carry out this write even though it breaks what the app's published API
|
|
1297
|
+
* promises, snapshotting the broken contract as a new version. */
|
|
1298
|
+
acknowledge_breaking_api_change?: boolean): Promise<ToolExecuteResult>;
|
|
1221
1299
|
/**
|
|
1222
1300
|
* Bind (create or replace) an app agent by alias via the `set_app_agent` tool
|
|
1223
1301
|
* — the deploy-free authoring path for `apps.agents`, parallel to
|
|
@@ -1395,6 +1473,13 @@ export declare class LoticsClient {
|
|
|
1395
1473
|
workflow_aliases?: string[];
|
|
1396
1474
|
agent_aliases?: string[];
|
|
1397
1475
|
query_aliases?: string[];
|
|
1476
|
+
/**
|
|
1477
|
+
* Ship this version even though its manifest breaks what the app's published
|
|
1478
|
+
* API promises, snapshotting the broken contract as a new version. A
|
|
1479
|
+
* multipart field is text, so it goes over as the two spellings the server's
|
|
1480
|
+
* own schema admits.
|
|
1481
|
+
*/
|
|
1482
|
+
acknowledge_breaking_api_change?: boolean;
|
|
1398
1483
|
}): Promise<{
|
|
1399
1484
|
version_id: string;
|
|
1400
1485
|
version_number: number;
|
package/dist/src/client.js
CHANGED
|
@@ -266,10 +266,12 @@ var LoticsClient = class {
|
|
|
266
266
|
body = {};
|
|
267
267
|
}
|
|
268
268
|
const message = typeof body.message === "string" ? body.message : text;
|
|
269
|
+
const hint = typeof body.hint === "string" ? `
|
|
270
|
+
${body.hint}` : "";
|
|
269
271
|
const trace = requestId === void 0 ? "" : `
|
|
270
272
|
|
|
271
273
|
Request id: ${requestId} \u2014 quote this to Lotics support.`;
|
|
272
|
-
throw new LoticsRequestError(`${response.status}: ${message}${trace}`, response.status, body);
|
|
274
|
+
throw new LoticsRequestError(`${response.status}: ${message}${hint}${trace}`, response.status, body);
|
|
273
275
|
}
|
|
274
276
|
/**
|
|
275
277
|
* The backend's `log()` middleware registers `user-agent`,
|
|
@@ -319,9 +321,23 @@ var LoticsClient = class {
|
|
|
319
321
|
if (!response.ok) await this.throwResponseError(response, headers["x-request-id"]);
|
|
320
322
|
return response.json();
|
|
321
323
|
}
|
|
324
|
+
/** `email` is null for a credential no person signs in as; `account_type` is absent from an older server. */
|
|
325
|
+
/** `credential_origin` describes the CREDENTIAL this call carried, not the
|
|
326
|
+
* member above: `sign_in` for a terminal somebody signed in from, `api_key`
|
|
327
|
+
* for one an admin issued. Null for a row minted before it was recorded, and
|
|
328
|
+
* absent from an older server — both mean not told. */
|
|
322
329
|
async whoami() {
|
|
323
330
|
return this.request("GET", "/v1/cli/whoami");
|
|
324
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Hand this client's own credential back — what `lotics auth logout` calls
|
|
334
|
+
* before it forgets the key, so signing out ends the credential instead of
|
|
335
|
+
* leaving a live one on a server nobody can see it from. Takes no id: the
|
|
336
|
+
* only thing it can revoke is the key these requests carry.
|
|
337
|
+
*/
|
|
338
|
+
async revokeSelfCredential() {
|
|
339
|
+
return this.request("DELETE", "/v1/api_keys/self");
|
|
340
|
+
}
|
|
325
341
|
/**
|
|
326
342
|
* File a hand-written report, or a list of them in one call. Unlike a
|
|
327
343
|
* telemetry flush, the headers this stamps are CORRECT: the invocation making
|
|
@@ -517,8 +533,12 @@ var LoticsClient = class {
|
|
|
517
533
|
* provenance is a 400, and one already on the latest release a 409 — both
|
|
518
534
|
* refusals, both before any write. Admin-only.
|
|
519
535
|
*/
|
|
520
|
-
async upgradeApp(app_id) {
|
|
521
|
-
return this.request(
|
|
536
|
+
async upgradeApp(app_id, opts = {}) {
|
|
537
|
+
return this.request(
|
|
538
|
+
"POST",
|
|
539
|
+
`/v1/apps/${encodeURIComponent(app_id)}/upgrade`,
|
|
540
|
+
opts.acknowledge_breaking_api_change ? { acknowledge_breaking_api_change: true } : {}
|
|
541
|
+
);
|
|
522
542
|
}
|
|
523
543
|
/**
|
|
524
544
|
* Capture live records from this workspace as a starter's sample data.
|
|
@@ -712,6 +732,36 @@ var LoticsClient = class {
|
|
|
712
732
|
{ public_subdomain }
|
|
713
733
|
);
|
|
714
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Publish the app's API — the owner's promise about what its declared queries
|
|
737
|
+
* and workflows return to a caller outside the app.
|
|
738
|
+
*
|
|
739
|
+
* Snapshots the contract and answers the version it took, plus what the
|
|
740
|
+
* published surface exposes that the owner may not have intended. A query
|
|
741
|
+
* that does not name its columns is refused (400) before anything is written:
|
|
742
|
+
* the app cannot promise field names it never stated.
|
|
743
|
+
*/
|
|
744
|
+
async publishAppApi(app_id) {
|
|
745
|
+
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/api/publish`, {});
|
|
746
|
+
}
|
|
747
|
+
/** End the promise. `unpublished: false` means the app was publishing nothing,
|
|
748
|
+
* which this leaves unchanged. The superseded snapshot stays, so a later
|
|
749
|
+
* publish continues the numbering rather than reusing a version. */
|
|
750
|
+
async unpublishAppApi(app_id) {
|
|
751
|
+
return this.request("DELETE", `/v1/apps/${encodeURIComponent(app_id)}/api/publish`);
|
|
752
|
+
}
|
|
753
|
+
/** Whether the app is publishing an API, and which contract version. */
|
|
754
|
+
async getAppApiPublication(app_id) {
|
|
755
|
+
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/api/publication`);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The published API as an OpenAPI 3.1 document — what a consumer's own
|
|
759
|
+
* generator reads. Rendered from the live SNAPSHOT rather than the manifest,
|
|
760
|
+
* so it describes what the app has promised; 404 while nothing is published.
|
|
761
|
+
*/
|
|
762
|
+
async getAppOpenApiDocument(app_id) {
|
|
763
|
+
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/openapi.json`);
|
|
764
|
+
}
|
|
715
765
|
async getAppVersion(app_id, version_id) {
|
|
716
766
|
return this.request(
|
|
717
767
|
"GET",
|
|
@@ -801,7 +851,10 @@ var LoticsClient = class {
|
|
|
801
851
|
...body.outputs ? { outputs: body.outputs } : {},
|
|
802
852
|
...body.name ? { name: body.name } : {},
|
|
803
853
|
...body.description ? { description: body.description } : {},
|
|
804
|
-
...body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {}
|
|
854
|
+
...body.expected_body_sha ? { expected_body_sha: body.expected_body_sha } : {},
|
|
855
|
+
// Sent only when the caller asked for it: absent means "refuse a break",
|
|
856
|
+
// which is the answer a caller who said nothing gave.
|
|
857
|
+
...body.acknowledge_breaking_api_change ? { acknowledge_breaking_api_change: true } : {}
|
|
805
858
|
});
|
|
806
859
|
}
|
|
807
860
|
/**
|
|
@@ -812,12 +865,13 @@ var LoticsClient = class {
|
|
|
812
865
|
* deploy validates the manifest. Note: `apps.queries` is manifest-authoritative,
|
|
813
866
|
* so the next `lotics app deploy` overwrites this from the manifest.
|
|
814
867
|
*/
|
|
815
|
-
async setAppQuery(app_id, alias, declaration, expected_sha) {
|
|
868
|
+
async setAppQuery(app_id, alias, declaration, expected_sha, acknowledge_breaking_api_change) {
|
|
816
869
|
return this.execute("set_app_query", {
|
|
817
870
|
app_id,
|
|
818
871
|
alias,
|
|
819
872
|
declaration,
|
|
820
|
-
...expected_sha ? { expected_sha } : {}
|
|
873
|
+
...expected_sha ? { expected_sha } : {},
|
|
874
|
+
...acknowledge_breaking_api_change ? { acknowledge_breaking_api_change: true } : {}
|
|
821
875
|
});
|
|
822
876
|
}
|
|
823
877
|
/**
|
|
@@ -1020,16 +1074,12 @@ var LoticsClient = class {
|
|
|
1020
1074
|
formData.append("workflow_aliases", JSON.stringify(args.workflow_aliases ?? []));
|
|
1021
1075
|
formData.append("agent_aliases", JSON.stringify(args.agent_aliases ?? []));
|
|
1022
1076
|
formData.append("query_aliases", JSON.stringify(args.query_aliases ?? []));
|
|
1077
|
+
if (args.acknowledge_breaking_api_change) {
|
|
1078
|
+
formData.append("acknowledge_breaking_api_change", "true");
|
|
1079
|
+
}
|
|
1023
1080
|
const url = `${this.baseUrl}/v1/apps/${encodeURIComponent(args.app_id)}/versions`;
|
|
1024
1081
|
const headers = this.buildHeaders();
|
|
1025
1082
|
const response = await fetch(url, { method: "POST", headers, body: formData });
|
|
1026
|
-
if (response.status === 409) {
|
|
1027
|
-
const conflict = await response.json();
|
|
1028
|
-
const err = new Error(conflict.message ?? "Deploy conflict \u2014 pull required");
|
|
1029
|
-
err.code = "VERSION_CONFLICT";
|
|
1030
|
-
err.current_version_id = conflict.current_version_id ?? null;
|
|
1031
|
-
throw err;
|
|
1032
|
-
}
|
|
1033
1083
|
if (!response.ok) {
|
|
1034
1084
|
await this.throwResponseError(response, headers["x-request-id"]);
|
|
1035
1085
|
}
|
package/docs/cli_reference.md
CHANGED
|
@@ -9,8 +9,9 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
9
9
|
| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open — `https://lotics.ai/cli_login/<request_id>`, also mailed — and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential — `library list`/`show`, `scaffold docs`/`check`, `app codegen`, `app workflow check` — claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |
|
|
10
10
|
| `lotics auth api-key [key]` | `whoami` → **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |
|
|
11
11
|
| `lotics auth web` | Send a magic link email to access the web app (requires auth) |
|
|
12
|
-
| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url` + `source`. |
|
|
13
|
-
| `lotics auth logout [<name\|id>]` | In a pinned dir: delete the local pin. Else: remove
|
|
12
|
+
| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key — read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |
|
|
13
|
+
| `lotics auth logout [<name\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked — logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings → Security → *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here — an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings → API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings → Security → *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |
|
|
14
|
+
| — | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \`lotics auth login <email>\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings → API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart — so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` — deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |
|
|
14
15
|
| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |
|
|
15
16
|
| `LOTICS_ORG=<name\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence — a read, a write, and a local check that needs no credential alike — and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under — a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |
|
|
16
17
|
| `lotics org use <name\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous → error) or id. No flag → global `active_org`; `--local` → a `.lotics/config.json` pointer in the current dir. |
|
|
@@ -44,9 +45,10 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
44
45
|
| `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
|
|
45
46
|
| `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1. The generated `package.json#name` is the app name FOLDED to ASCII (`Đơn hàng` → `don-hang`), never stripped of it — dropping the marks would treat each accented vowel as a separator and can slug a name away to nothing. The app's display name is unaffected; this is the npm field only. **`--from <model.json>#<app>`** scaffolds the app a PLAN describes: the file is checked as `scaffold check` checks it, the named app (or the only one) is taken, every screen's entity is found as a live table BY LABEL and every field the model declares on it — the record shows the ones the list leaves out — and, for each child entity a record section is over, its table and the fields that section draws — a table the workspace lacks is refused first, naming every missing one and `scaffold apply`; then a field a table lacks, a label two tables or two fields share, or a field whose live type is not the model's — all before anything is created. Each screen is written as its registry shape from `@lotics/ui` (`LifecycleDesk`, `PartyRegister`, …) over `useQuery(<screen alias>)`, the slots reading the bound fields through `F`; a `custom` screen arrives as its rows and the slot list. The RECORD that shape opens is written too, off the same roles (`recordSections`): a `RecordFacts` of every field no other section owns, a `RecordProgress` over the lifecycle, a `RecordExpectedSet` per required set — the entity's own multi-select, or a child entity whose rows each file one entry — a `RecordChildren` per child entity — one whose `parent` link names this record, or whose `party` link does, which is how a party's record holds its history — the identity, the figures and two more roles as columns, ranked so a narrow register sheds the contact before the money, with the row's whole projection in the drawer behind it — and a `RecordFiles` per files field. A `page` record is one `RecordPage` whose facts take the aside beside that work, or the main column itself where the record has none; a `drawer` record is the same sections stacked. The manifest declares one `project` query per screen (every column the screen and its record read, a files cell whole, a dated book newest first) plus one per child entity, filtered to the record through whichever of those links names it and taking it as a declared `{{params.<entity>_id}}`; the `.lotics` companions and `app_fields.ts` are written for them before the first build, and the deploy pushes the queries as it pushes any. **And it declares what the app WRITES** — `package.json#lotics.writes`, table alias → the field aliases each record surface's editor changes, derived from the same `update_<entity>` declarations it emits beside the bodies. Seeded once: from then on the declaration is the app's, and `app check` refuses a body that writes outside it. A screen the plan marks `writes: false` contributes none. |
|
|
46
47
|
| `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, install dependencies (`npm ci --ignore-scripts` when a lockfile is present, else `npm install --ignore-scripts`), stamp package.json's `lotics` field. With no `[path]`: refresh the cwd IN PLACE when it's already this app's own project (its manifest `app_id` matches — the documented `cd <app> && lotics app pull` flow), else clone into an `<name>/` subdir. **A pull never overwrites a file that differs from what it is about to write** — it writes only what is ABSENT or already identical, keeps the rest, and reports which files it kept plus the commands that close the gap. The same rule covers `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, so an unpushed body or prompt survives too. Those two are written from the LIVE App row (`apps.workflows` / `apps.agents`), which owns them, and the archive's own copy of them is deliberately SKIPPED on extract: a deploy tars the whole source directory, so the tarball holds a deploy-time snapshot that is stale for anything authored since. The comparison is against the app's own content, not git, so it holds for a project that was never a repo. `--force` takes the app's copy and DISCARDS local edits; there is no other way to lose them. **For a KEPT workflow body or agent prompt the pull records the server's fingerprint only when the server has not moved** — that token is `set_app_workflow`/`set_app_agent`'s lost-update precondition, so recording one for text the author has not seen would clear the next push's refusal by disarming the guard, and silently overwrite whoever edited it. When the live text HAS moved, the pull writes it beside the checkout (`.lotics/agents/<alias>.live.md`, `.lotics/workflows/<alias>.live.ts`), names it, and leaves the token stale: the next deploy is refused, which is correct, and the text to merge is now on disk. A file whose prose/body already reads back AS the live text is never "kept" at all — the baselines are healed from it, so a checkout whose prose was pushed out of band (chat, `lotics run set_app_agent`) converges instead of latching. **A pull also REPORTS the files it restored** when refreshing a tree that already claimed a version: a pull mirrors the last DEPLOYED source, so a file deleted locally comes back until the deletion itself ships, and saying so is the only honest fix — nothing can read a deletion off the disk. **When a pull ACROSS versions keeps files, the manifest is left on the OLDER of the two versions** — the tree is then part one and part the other, and claiming the newer would make `deploy`'s `prev_version_id` check pass and ship a half-and-half bundle. Older, not "the one it had": `--from-version` pulls a deliberately old revision, so the version it had is the NEWER side, and holding that would match what the server serves and let the old source ship. Either way a deploy from that tree is refused until you reconcile the listed files by hand and pull again, or take the app's copy with `--force`. The report says which version each side is on, because a kept file is your unshipped work when the project was already current and merely the OLD version when it was behind — and nothing in a byte comparison can tell those apart. **`--from-version <apv_…>`** pulls an OLDER revision instead of the current one (`lotics app versions` lists the ids) — point it at a NEW path to read a previous revision without disturbing the project you are in. The manifest records the version actually written, never the live pointer, so a deploy from that checkout is refused by the version guard rather than shipping old source over newer. — `workflows` and `agents` are sourced from the live App row (NOT the archived manifest), so `set_app_workflow` / `set_app_agent` authoring survives the pull. Regenerates `.lotics/app_{workflows,queries,agents}.d.ts` so `useWorkflow` / `useQuery` / `useAgentRun` stay typed, AND — AFTER `npm install`, so `node_modules/@lotics/ui` actually exists to read — `.lotics/tsconfig.link.json`'s peer pins and, for a kit old enough to ship one, its `react-native` augmentation (a kit that ships none has the previously-written copy deleted); a pulled project's own `tsc` used to fail until `app codegen` was run by hand, because nothing had regenerated either one after install populated node_modules. AND the runtime `.lotics/app_fields.ts` (the same generation `app codegen` runs, off the app row already fetched). That one is not optional: `app deploy` tars source with `--exclude=.lotics`, so no archive can carry it, and a pulled project whose `src/` imports `F`/`OPT` would fail to build with `Could not resolve "../../.lotics/app_fields"` until `app codegen` was run by hand. Skipped under `--view-as` (the schema is read as that member and silently drops tables they cannot see — a narrowed `F` map compiles and then throws at runtime, worse than the missing module). A schema fetch failure is non-fatal and names the right recovery for what is on disk: an existing file is kept, an ABSENT one warns about the build error and points at `app codegen`. Pull GENERATES but never RECONCILES `.lotics/` — deleting a companion whose alias the manifest no longer declares is `app codegen`'s alone, since pull's authority is the server's alias set and a declared-but-not-yet-`set` alias is supported. Also writes one `src/workflows/<alias>.ts` per bound workflow (faithful body from `get_app_workflow`) and one `src/agents/<alias>.md` per bound agent (its instructions, straight off the live row) — so the prose an author actually edits lives in a file. A pull writes it from live UNLESS the local file holds unpushed work, in which case it is kept and the live text is parked beside the checkout — the same rule the rest of this row describes. A legacy workflow alias with no rendered source, or an agent with no instructions, warns and is skipped. The stamped `lotics.agents` map carries the TYPED half only (`inputs`/`outputs`/`tool_names`/`model_tier`/…) — an agent's prose lives solely in its `.md`, so there is never a second local copy to desync; a stale `instructions` left by an older CLI is inert and disappears on the next pull |
|
|
47
|
-
| `lotics app deploy [--prune] [--prune-invoked <alias>] [-m <message>]` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it pushed. Runs the app's `npm run typecheck` and `npm run build`, tars source + dist, POST /v1/apps/{id}/versions multipart. **One command ships everything.** Before the bundle moves it pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and FAILS the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` rides that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. Editing `lotics.agents.<alias>.inputs`/`outputs` is pushed the same way, and only those two fields (`set_app_agent` merges, so anything the manifest does not model is left untouched). The deploy never AUTHORS a binding itself, and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. It regenerates the `.lotics/*.d.ts` companions and `.lotics/app_fields.ts` before building — the build INLINES the latter — and then typechecks against them; a `package.json` with no `typecheck` script is warned about, never passed in silence. `lotics app check` reports the same set without pushing; neither has a `--strict`. The aliases the version RECORDS as called — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — are read by the SERVER out of the uploaded source archive, never reported by the client that is also what unbinds. **After the ship it reports two things and removes nothing.** Aliases the source CALLS that nothing bound. And bindings this project has RETIRED, which is two transitions, each with its own evidence: an alias the previous bundle called and this one does not (`package.json#lotics.bundle_calls`, recorded by each deploy), and an alias still bound live that this checkout holds a `lotics.synced.<kind>.<alias>` baseline for and no longer DECLARES. Neither piece of evidence present is an alias this checkout has never seen — bound by chat, by another operator, or after this tree was pulled — which is not a removal and is never a prune target. With no `bundle_calls` the first transition reports nothing; that deploy records it and the next can compare. The `bundle_calls` baseline is STICKY: it advances only once the call-site half is settled, so the `--prune` a warning names still finds the transition on a later run. **`--prune` unbinds them, and only when passed.** It runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares. When the source computes an alias at run time, the call-site half is left in place with a warning (the scan cannot tell which binding that call reaches); the declaration-removed half is unbound anyway, since deleting a declaration here states the removal outright. A removal DELETES the local declaration too — `package.json#lotics.<kind>.<alias>` and its `synced` baseline — or the next plain deploy would push it straight back; what it deleted is written to `.lotics/pruned/<kind>/<alias>.json` and the ✓ names that file plus the `set` verb that re-binds it. (These trees are never committed, so `lotics app pull --from-version <apv_…>` is the only other route back.) The generated companions are then regenerated from the narrowed manifest; a table named ONLY by a pruned query leaves `F`/`OPT`, which is reported — a workflow that still writes it keeps it, since the codegen set is the queries' tables plus every bound workflow's own `table_ids`. A binding that will not unbind is reported and never fails the release, and neither does a local write that fails: the version is already live, and the report names which aliases were unbound server-side. The server refuses to unbind a WORKFLOW this workspace has actually run — a recorded execution means a caller the source cannot name — printed as `✗ could not unbind …` with the date it last ran. **`--prune-invoked <alias>` lifts that guard for the alias you name** (repeatable, comma-separated; needs `--prune`, and is refused as a no-op without it), keeping the prune's report, undo file and manifest cleanup that a raw `lotics run remove_app_workflow` loses. Finally it refreshes `.lotics/workflows/<alias>.globals.d.ts` for any alias whose `// lotics:declaration` stamp says this deploy moved its declaration — from the manifest, re-wrapping the SAME on-disk body, so local edits survive. Non-fatal: the release has shipped, and stale types never fail it. |
|
|
48
|
-
| `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it.
|
|
49
|
-
| `lotics app upgrade [app_id]` | `POST /v1/apps/{id}/upgrade` — apply the latest version of the package this app was COPIED from. A copy records its provenance (`apps.origin`: package, version, app alias and the `bind` it was made under) and this is the only thing that reads it — a hand-built app, or one copied before the column existed, has no package to offer one and answers 400. Run it once per app: a package's apps each carry their own provenance. **The schema is additive** — fields, options and views the new version declares are created under the recorded bind, so they land on the same tables the copy did; nothing is renamed, retyped or deleted, and a field the new version stopped declaring keeps its column and its data and is REPORTED. **An artifact is replaced only while it is still byte-for-byte what was delivered**: a workflow or agent you have edited here is kept as it is and named, so the offer is partial by design and every part it declined to touch is printed. Queries are replaced outright (generated from the contract, no edit to lose) and only a knowledge doc the new version ADDS is created. The app is then redeployed from the new version's prebuilt dist — **nothing local is read or sent**, so a checkout on this machine is behind afterwards and the report ends at `lotics app pull <app_id>`. app_id from the local manifest, or pass one to upgrade any app without pulling it. **Already on the latest version prints that one line and exits 0** — it is a refusal before the first write, not a failure, and re-applying the version it is on would re-stamp your edits as delivered. Every other refusal (an unpublished package, a contract that no longer validates, a bind the new version broke) is a package that cannot be applied: the app is untouched, the server's sentence is printed, and the exit is 1. Anything else — no provenance to read, not an admin, no such app — exits 1. Admin-only. Audited as `app.upgrade`. |
|
|
48
|
+
| `lotics app deploy [--prune] [--prune-invoked <alias>] [-m <message>] [--acknowledge-breaking-api]` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it pushed. Runs the app's `npm run typecheck` and `npm run build`, tars source + dist, POST /v1/apps/{id}/versions multipart. **One command ships everything.** Before the bundle moves it pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and FAILS the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` rides that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. Editing `lotics.agents.<alias>.inputs`/`outputs` is pushed the same way, and only those two fields (`set_app_agent` merges, so anything the manifest does not model is left untouched). The deploy never AUTHORS a binding itself, and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. It regenerates the `.lotics/*.d.ts` companions and `.lotics/app_fields.ts` before building — the build INLINES the latter — and then typechecks against them; a `package.json` with no `typecheck` script is warned about, never passed in silence. `lotics app check` reports the same set without pushing; neither has a `--strict`. The aliases the version RECORDS as called — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — are read by the SERVER out of the uploaded source archive, never reported by the client that is also what unbinds. **After the ship it reports two things and removes nothing.** Aliases the source CALLS that nothing bound. And bindings this project has RETIRED, which is two transitions, each with its own evidence: an alias the previous bundle called and this one does not (`package.json#lotics.bundle_calls`, recorded by each deploy), and an alias still bound live that this checkout holds a `lotics.synced.<kind>.<alias>` baseline for and no longer DECLARES. Neither piece of evidence present is an alias this checkout has never seen — bound by chat, by another operator, or after this tree was pulled — which is not a removal and is never a prune target. With no `bundle_calls` the first transition reports nothing; that deploy records it and the next can compare. The `bundle_calls` baseline is STICKY: it advances only once the call-site half is settled, so the `--prune` a warning names still finds the transition on a later run. **`--prune` unbinds them, and only when passed.** It runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares. When the source computes an alias at run time, the call-site half is left in place with a warning (the scan cannot tell which binding that call reaches); the declaration-removed half is unbound anyway, since deleting a declaration here states the removal outright. A removal DELETES the local declaration too — `package.json#lotics.<kind>.<alias>` and its `synced` baseline — or the next plain deploy would push it straight back; what it deleted is written to `.lotics/pruned/<kind>/<alias>.json` and the ✓ names that file plus the `set` verb that re-binds it. (These trees are never committed, so `lotics app pull --from-version <apv_…>` is the only other route back.) The generated companions are then regenerated from the narrowed manifest; a table named ONLY by a pruned query leaves `F`/`OPT`, which is reported — a workflow that still writes it keeps it, since the codegen set is the queries' tables plus every bound workflow's own `table_ids`. A binding that will not unbind is reported and never fails the release, and neither does a local write that fails: the version is already live, and the report names which aliases were unbound server-side. The server refuses to unbind a WORKFLOW this workspace has actually run — a recorded execution means a caller the source cannot name — printed as `✗ could not unbind …` with the date it last ran. **`--prune-invoked <alias>` lifts that guard for the alias you name** (repeatable, comma-separated; needs `--prune`, and is refused as a no-op without it), keeping the prune's report, undo file and manifest cleanup that a raw `lotics run remove_app_workflow` loses. Finally it refreshes `.lotics/workflows/<alias>.globals.d.ts` for any alias whose `// lotics:declaration` stamp says this deploy moved its declaration — from the manifest, re-wrapping the SAME on-disk body, so local edits survive. Non-fatal: the release has shipped, and stale types never fail it. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). It rides every write the release makes — the bindings pushed ahead of the bundle, the version itself, and a `--prune`'s unbinds — because the answer is about the RELEASE. |
|
|
49
|
+
| `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it. Server-side it is the app's owner or an org admin, the same gate deploy and source download take — a `manager` share on somebody else's app does not reach it. Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. Title → stderr, table → stdout (pipeable). |
|
|
50
|
+
| `lotics app upgrade [app_id]` | `POST /v1/apps/{id}/upgrade` — apply the latest version of the package this app was COPIED from. A copy records its provenance (`apps.origin`: package, version, app alias and the `bind` it was made under) and this is the only thing that reads it — a hand-built app, or one copied before the column existed, has no package to offer one and answers 400. Run it once per app: a package's apps each carry their own provenance. **The schema is additive** — fields, options and views the new version declares are created under the recorded bind, so they land on the same tables the copy did; nothing is renamed, retyped or deleted, and a field the new version stopped declaring keeps its column and its data and is REPORTED. **An artifact is replaced only while it is still byte-for-byte what was delivered**: a workflow or agent you have edited here is kept as it is and named, so the offer is partial by design and every part it declined to touch is printed. Queries are replaced outright (generated from the contract, no edit to lose) and only a knowledge doc the new version ADDS is created. The app is then redeployed from the new version's prebuilt dist — **nothing local is read or sent**, so a checkout on this machine is behind afterwards and the report ends at `lotics app pull <app_id>`. app_id from the local manifest, or pass one to upgrade any app without pulling it. **Already on the latest version prints that one line and exits 0** — it is a refusal before the first write, not a failure, and re-applying the version it is on would re-stamp your edits as delivered. Every other refusal (an unpublished package, a contract that no longer validates, a bind the new version broke) is a package that cannot be applied: the app is untouched, the server's sentence is printed, and the exit is 1. Anything else — no provenance to read, not an admin, no such app — exits 1. Admin-only. Audited as `app.upgrade`. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). |
|
|
51
|
+
| `lotics app api publish [app_id]` \| `unpublish` \| `status` \| `spec [-o <file.json>]` | **The app's API — what its declared queries and workflows promise to a caller OUTSIDE it** (a customer's own site or server, an integration, another system). `publish` (`POST /v1/apps/{id}/api/publish`) snapshots that promise as a numbered contract version and prints the version, when it was taken, and every warning about what the published surface exposes — a query anyone holding the public link can reach, a field an owner may not have meant to hand out. It is REFUSED (400) while a query does not name the columns it returns: those field names come from the table and would change under the consumer whenever the table does, so they are not the app's to promise — the refusal names each such alias and the `project` that fixes it, and nothing is written. **From the publish onward a manifest write is a release**: additive changes re-snapshot silently, and one that breaks what is promised is refused with every breaking change named, unless the write carries the acknowledgment (`--acknowledge-breaking-api` on `app deploy` / `app query set` / `app workflow set` / `app upgrade`). `unpublish` ends the promise; the superseded snapshot stays, so a later publish continues the numbering rather than reusing a version. `status` says whether one is published and which version its callers hold. `spec` prints the OpenAPI 3.1 document — rendered by the server from the SNAPSHOT rather than from the manifest, so it describes what the app has promised — to stdout, or to the file `-o` names; 404 while nothing is published. The app_id comes from the local manifest, or pass one to act on any app in the workspace. **`--json` answers `publish` / `unpublish` / `status` with one object on stdout and nothing else**, every warning carried in it rather than printed away; `spec` already prints a document there. **Who may run which**: starting and ending the promise is an organization ADMIN's — what an app hands outside the workspace is the same capability that declared it. `status` is the app's AUTHOR's (its owner, or an admin): whether their own app publishes anything is theirs to see. `spec` is reachable by whoever may USE the app, which on a publicly-shared app is anyone holding the link — it is the document a consumer generates their client from. |
|
|
50
52
|
| `lotics app codegen [path]` | Regenerate `.lotics/*` from the manifest + workspace schema **without a deploy**. The three `.d.ts` companions (`app_{workflows,queries,agents}.d.ts`) are always rewritten (synchronous, no network). When credentials resolve, also rewrites the **runtime** `.lotics/app_fields.ts` — a real `.ts` exporting `F` (table→field→`"fld_…"`), `OPT` (table→select-field→option→`"opt_…"`), `TBL` (table→`"tbl_…"`) and `GRP` (member group→`"grp_…"`) keyed by display-name aliases, for every table the app's queries READ plus every table its bound workflows WRITE (each binding's recorded `table_ids`, so a table no screen reads is still addressable by alias and a rename fails `tsc` instead of the body), plus every member group in the organization — which one a screen names is not knowable from the manifest, and a `GRP` narrowed by a failed read is a map that is wrong rather than absent, so a failure writes nothing. **There is one form, and that is what makes a starter's source portable**: the keys are slugified DISPLAY NAMES and a starter carries its labels verbatim, so running codegen in a copy's own workspace emits the same keys pointing at that workspace's ids — no binding fetched at load, no prebuilt bundle to keep in step. Also refreshes each bound workflow's `.lotics/workflows/<alias>.globals.d.ts` + re-wraps its EXISTING `src/workflows/<alias>.ts` body in the current envelope (strips + re-wraps; never re-fetches the body, so local edits survive). **`.lotics/` is reconciled to the manifest, not merely added to** — a `<alias>.globals.d.ts` whose alias the manifest no longer declares is DELETED. Only that exact filename shape is removed; anything else in the directory is left alone. The reconcile runs before the credential branch, so it happens offline too. The authored counterpart is never deleted — a `src/workflows/<alias>.ts` the manifest does not declare is NAMED instead (`check` and `set` both take their alias set from the manifest, so editing an undeclared body is a silent no-op). A getApp / binding / schema / dts-fetch failure is non-fatal (warns, keeps the last-generated files). **Re-silvers `package.json#lotics.agents`** from the live app row whenever its `inputs`/`outputs` disagree, then rewrites the agent `.d.ts` from the refreshed block: that block is a mirror AND the offline seed for `useAgentRun` typings, so a stale copy types the app against an agent that does not exist. The write is surgical and order-preserving, so it changes only the fields that actually differ. A hand edit to that block is therefore reverted — it never changed the agent anyway; to change one, `set_app_agent`. **Types are written for every DECLARED alias, body file or not** — the dts is rendered from the declaration, which is the whole point of the declare → codegen → write → check loop — and codegen NAMES each alias it wrote types for without a body, with the next step. It used to return early on a missing or blank `src/workflows/<alias>.ts` and say nothing, which left `workflow check` pointing at `app workflow pull` (which writes nothing for an alias the server has never bound) on one path and at `app codegen` — the command that had just declined to write them — on the other. **A body whose helper sits ABOVE the `__workflow` wrapper is refused by name and line** rather than wrapped a second time: the strip peels a wrapper only when it is the first line after the header, so a top-level declaration between the two used to leave the whole file read as the body and the re-wrap nested an envelope per run. Move the helper inside the wrapper — the body is one expression sequence. |
|
|
51
53
|
| `lotics setup <apg_id \| model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes — `--name` and `--timezone` apply), then fills its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** — it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the copy or the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as — that address IS the account it holds, not a second one. **The argument decides which of the two forms this is, by SHAPE**: a `*.json` file is a workspace MODEL — in either of ITS two forms, spelled out or `{"from": "<preset-slug>", …}` — and anything else is a package id copied through `library init`. The suffix decides it alone — asking the filesystem would answer a long library id with `ENAMETOOLONG` instead of with a verdict — and a model is checked OFFLINE before an account is created, because a file with a typo in it must not leave an organization behind. The model form creates no apps, so its sign-in link lands on the first table it made. It sends no `adopt`: an entity whose `label` already names a table in the workspace is REFUSED with every collision named, and the refusal adds the line the server cannot — `lotics scaffold apply <model.json>`, the verb that adds to the workspace you already have. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently copies a package into an org the caller did not name — the message says how to do each thing on purpose. Without it, `setup` copies into the account you already have and is a pure alias for `library init`. A path positional is accepted and IGNORED with a warning — nothing is written to disk any more — so a prompt written for an older CLI still runs. **`--json` prints one object on stdout and nothing else** — `organization_id`, `workspace_id`, `app_ids` (alias → id), `apps` (each app's `version_number`, or its `error`), `signin_url`, and `created` — which NAMES what landed (`tables`, `templates` and `knowledge_docs` are alias arrays; `sample_records` is a row count, since rows are not named things). Aliases rather than counts because the next question is about a particular artifact: a copied template carries the publisher's wording and a copied knowledge doc describes how they work, so "which of these should be mine?" is the conversation a copy starts, and a count cannot begin it. **The model form emits `entities`, `roles`, `record_ids` and `rows_skipped`** in place of `app_ids` / `apps` / `created` — a model creates no apps and nothing named for a copier to review. **The model form also runs the file's `apply` list** — each named package copied in after the tables exist, with that entry's `bind`, in order, stopping at a refusal with everything before it kept — and emits `applied: [{package, apps}]` beside them; **the sign-in link then lands on the FIRST app any applied package created**, falling back to the first table when the model applied none. Plus a `warnings` array carrying everything the prose form would have said out of band — an unbindable knowledge doc, a sign-in link that could not be minted, the publisher's-code disclosure, an app that landed without a version. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup …`. |
|
|
52
54
|
| `lotics scaffold docs` | **The model reference, from inside the binary.** Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<entity-alias>:<ref>"`), the rules, the `apply` list (packages copied in after the model's own tables, each with an optional `bind` onto them), the `preset` block (a published model's branches and its at-most-two questions), the **`from` form** — `{from, variants, rename, entities, rows, field_roles, apps, apply}`, which names a preset by SLUG instead of restating it — and one complete worked example. **Offline, no account**, and not part of `lotics docs`. |
|
|
@@ -63,9 +65,9 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
63
65
|
| `lotics docs` \| `lotics docs <area>[/<section>]` | The index of the reference docs, **resolved out of the packages installed beside this project** — never carried by this CLI. **Both levels are discovered by looking**: every `@lotics/*` package carrying an `AGENTS.md` or a `docs/` in any `node_modules/@lotics` from the current directory UPWARD (nearest wins, so a hoisted root copy never shadows the one a project's own imports resolve to), and within each, every area it actually ships. Titles come from each file's own `# heading` and the version from the installed `package.json`, so a doc OR a whole package added upstream appears with no change to this CLI, and a skewed install is visible rather than reassuring. A package's index is named after the package (`lotics docs ui`), never `index`. `@lotics/app-sdk`, `@lotics/ui` and `@lotics/cli` sort first as a reading ORDER, not a filter. **Output is ONE PAGE, 16 KB, navigation included**: a doc that does not fit prints its opening and the addresses that reach into it — its sections with their sizes, or, for a reference that is one table (this file, the kit's catalog), the name of every row. `lotics docs <area>/<section>` prints that section and `lotics docs ui/catalog/Button` that one row; a unique prefix is enough, and an address matching two parts is refused with both. Both levels print to **stdout** — the index is the payload of a bare `lotics docs`, so `lotics docs | grep -i excel` works — with only the provenance line on stderr; a name two packages share is refused with both qualified forms (`lotics docs ui/templates`) rather than resolved silently. Needs no auth. Outside a project only `@lotics/cli`'s own resolve, and it says so. |
|
|
64
66
|
| `lotics report '<json>'` \| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** — `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` — invoking it IS the consent that passive collection needs an opt-in for — but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** — a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |
|
|
65
67
|
| `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion off the app row it already fetched, so a stale tree fails before it pushes a binding or builds, instead of after the upload arrives and the server 409s. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__` **in a project that still ships react-native** (read off its own `package.json` — an app on the React DOM kit bundles none of it and the check would be advice to define two globals nothing reads), a `window.open` in the app's own source, and an INSTALLED `@lotics/app-sdk` below the version that understands the host's realtime push — read from `node_modules`, not the dependency range, because a caret is minor-locked below 1.0 so `^0.79.x` can never resolve `0.80` and `npm update` does nothing (all three fail ONLY in the deployed app — dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green while react-native-web reads `global.cancelAnimationFrame` as a free variable and the sandboxed iframe drops a popup silently), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the kit this app builds against has fallen behind what is published** — `@lotics/ui` and `@lotics/app-sdk`, read from `node_modules` for the same reason as the floor check above: a range keeps accepting, so an app pinned `^44.x` reads healthy for a year, and even an in-range one sits on the lockfile's older patch until `npm update` (never `npm install`, which honours the lock). A MAJOR behind is loud and names the packages actually behind — plus `@lotics/ui`'s `MIGRATION.md`, when ui is one of them, since it is the only half that keeps one; anything smaller is one quiet line, because a warning that fires on every deploy is one the reader stops seeing. The registry lookup is bounded and every failure — offline, slow, private — is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, the second of two rules `check` runs that a deploy does not** (the first is the undeclared call site below) — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `library publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there, and it cannot invert a fake — so without this a stand-in survives until the publish resolves it against the app's footprint, on somebody else's machine. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **And every bound workflow body, type-checked locally** — the same isolated per-alias program `app workflow check` builds, against the pulled `.lotics/workflows/<alias>.globals.d.ts`. The server verifies a body once, at the save that wrote it, so a helper whose declared signature has since moved (`toNumber` returning `number | null`) leaves it stored, matching what is live, and refused by the next writer — a starter copy, in somebody else's workspace. The verdict is as fresh as those types, which `pull`, `workflow pull` and `codegen` refresh. **And the app's own `npm run typecheck`**, after regenerating the `.lotics/*.d.ts` companions from the manifest — the same run a deploy makes before building, so a filter or sort key the query does not project fails here rather than at the first member's request. **Exits 1 on that, on a body the types refuse, on a failing typecheck, and on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query), and a query or workflow `description` over the 300-character capability cap, which is a binding no `set` and no deploy will take (the rule is `@lotics/shared`'s, the same one the server refuses with, and `workflow set` / `query set` ask it before sending anything — so a long line costs one edit rather than a failed push per alias). **And every manifest query declared with NO description**, named in one line: that sentence is what a chat or MCP caller chooses between aliases by, and an alias is a JS identifier. `app create --from` deliberately writes none — a template over a shape's own English reads like a line about the business while saying nothing — so a generated app is told once, here, which lines are the author's to write. Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. **`--screens` adds the rendered surface**: the app is served the way `app dev` serves it (its real data, this key), rendered headless in Chrome (`CHROME_PATH`/`LOTICS_CHROME`, then Playwright's, then system) at 1280 and 375. **The screens are its navigation's destinations** — a `nav` landmark's `a[href]` or `role="link"` (an app's route lives in its router, so the kit's shell renders each screen as a button carrying the link role and no `href`), else the first tab strip, else the root — in that order, because a screen's own lifecycle desk draws a tablist too and reaching for a tablist first walks one screen's STAGES believing they are the app's screens. **Each screen is then followed into one RECORD**: a register whose rows open a record page stamps each row `data-opens="page"` and one whose rows open a drawer stamps nothing, so the first stamped row out of the screen's content — or a real `a[href]`, for a hand-written screen that renders one — is exactly the `record: "page"` surface, measured under `<screen> · record`; a screen whose rows open a drawer has nothing there to walk. Each surface is measured once no request is in flight, and the measurable probes of `@lotics/ui` docs/reviewing.md run over the DOM — money strings on more than two right edges in one column of three or more figures, eight or more values as bare text with nothing drawn, an internal id in the text, more than one NAVIGATION strip, text cut or clamped at 375, the ` · ` glyph, compact money in the other currency's words, a stacked pair demoted twice, two identity marks at two rungs on one row. Four of those read OUT what no app authored, each on a fact of the DOM rather than on a kit selector. The register's ordinal gutter is not a reported value (a column counting 1, 2, 3 … to the row count is the shape's numbering, which no app authored and none can treat), and a strip whose list carries `data-order="sequence"` is a lifecycle rail rather than navigation, which composition.md permits under a screen's tabs. A text leaf a hairline wide or tall, or clipped away by its own `clip-path`, is the visually-hidden node a control plants for a screen reader — it holds no language a reader could read, and reported verbatim it was 638 of 787 findings on one app, once per meter; a leaf whose own computed line clamp states a count has DECLARED what it does with the rest, and is not a cut either (a leaf a LAYOUT crushed to nothing is reviewing.md 8g-bis, a different rule with a different fix). **And a meter counts as an encoding only where it draws a POSITION** — `aria-valuenow` inside a range that still has room. One pinned at its own maximum draws the same full track for its value and for every value above it, which is what a meter whose maximum IS the bound it is alarmed against draws on every alarmed row, and one with no maximum is indeterminate; both are counted in the census's `devices` and out of its `encoded`, so "nothing drawn" and "drawn and saying nothing" never read alike. The bare values that remain are grouped into columns and each is named by the heading over it, so a finding says WHICH slot draws its figures as words rather than only how many do. A census per screen (text runs, money strings, bare values against the devices reading, tab strips) prints first, so a clean verdict over a screen that rendered nothing cannot pass; a screen that renders no text is itself a finding, and one still changing after fifteen seconds is measured as it is and reported. **A width is measured again, once, if the page reloads out from under the probe** — Vite optimising a newly-imported dependency does exactly that, and left alone it was indistinguishable from a real failure; a second reload is a page that keeps moving and fails. **`--screen <label>` and `--width <n>` narrow a run** (repeatable, comma-separated; the label matches case-insensitively as a substring), for the author iterating on one screen who would otherwise pay a typecheck plus a Vite boot plus every screen at both widths on every edit; the clean verdict then names only the widths actually covered, and a `--screen` matching nothing is refused rather than passing over nothing. **`--shots <dir>` writes what the run measured**: one PNG of each screen and of each record it opened, at each width, taken from the SAME settled frame the probes read — so a shot and a finding can never describe different pixels — as the viewport box exactly (1280×900 / 375×900), never the page below it — `app dev`'s own header band is in the shot, above the app, because it is the band the app was laid out under. Looking at an app otherwise costs a dev server plus a browser pass per screen, paid again on every look. Named `<nn>-<slug>@<width>.png`, and `<nn>-<slug>__record@<width>.png` for the record, where `<nn>` is the walk's own index: it lists the directory in the order the screens were read, and it is what keeps two Vietnamese labels that fold to one ASCII slug apart. The row a record shot was opened from is printed on that screen's census line, never written into a file name — it is a person's data. The directory is created if it is missing, and refused before the dev server boots when it cannot be; `--shots` without `--screens` is refused outright rather than running a pass that writes nothing. Findings exit 1 like the rest. Runs after the typecheck and only when it passed — a type error renders nothing worth measuring. **It also refuses a call site the manifest no longer declares.** `useQuery`/`useWorkflow` keep a bare-string overload for a computed alias, so deleting or renaming an alias leaves every call site compiling and failing only when the screen renders — the one edit most likely to orphan a call site is the one the generated types cannot catch. The alias literals in `src/` are matched against `package.json#lotics.queries`/`.workflows` (the manifest, not the live row: the server still SERVES an alias whose declaration was just deleted, because a deploy never unbinds), and an undeclared one exits 1. `app codegen` prints the same finding as a warning, since it is the command an author runs right after editing the manifest. **A failing body that is byte-identical to the one the server is running is labelled as such**: its `lotics.synced.workflows.<alias>.content` baseline proves the file has not been edited since it was pushed or pulled, so the failure is a grammar migration the stored body is owed rather than a stale checkout — a link field reads as an id array, so drop `.id` or descend with `linked(…)`. Until the body is edited and `set`, the stored one keeps running as it always has. **And a query that reads past a table's ROW RULE.** A table's `private_filters` bind the CALLER, and an app query's caller is the app's OWNER — the viewer needs no table access, `app:use` is the grant — so the rule passes and every row is served. For each table a declared query reads (`GET /v1/tables/{id}`, the one surface that serves the rule), the viewer predicate — `current_member in_any_group`, or a member field's `is_current_member` / `is_not_current_member` — is attributed to the SCAN it guards: a `from_table`'s own `filter`, or an enclosing `filter` node's predicate, whose rows are the ones that scan produced. So a clause written over one table never silences the finding for the ruled table joined beside it, and every scan no clause covers is named with its table. A table this credential may not read (403, or 404 for one that is gone) is dropped — a rule that cannot be read is not evidence of one — while any other failure of that read fails the command, since "I could not ask" must never render as "there is no rule". Advisory, never part of the exit code: an app that deliberately serves the whole table to a desk of people who may all read it is legitimate, and nothing here can tell the two apart. **And it names the workflows a chat or MCP caller is offered with no description** (one `get_app_capabilities` read, the reader's own view of the app): that text is what those callers choose between aliases by, and without it they choose by the alias. It cannot be counted from the manifest — a workflow bound out of band is not declared there at all. Advisory, never part of the exit code. **It regenerates `.lotics/app_fields.ts` from the live schema before typechecking**, as a deploy does — a gitignored map from a moved schema otherwise passes. **And three claims the app makes**: a bound body's writes against `package.json#lotics.writes`, read as the step tree the server would store, exit 1 on a field no entry covers, and declaring none only warns; a description carrying `<placeholder>` syntax exits 1, since the catalogue escapes angle brackets — state the format as an example; so does one naming a desk `query_apps` does not list. |
|
|
66
|
-
| `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. A deploy runs this same verb for every alias whose declaration or body is ahead of the app, so this command is the one-alias spelling of what a release does, not a step a release leaves to a person. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. A push also prints any non-blocking verify warnings, including an input the alias declares that the body never reads. A first bind MINTS the workflow row, and the id it echoes is written back into `package.json#lotics.workflows.<alias>.workflow_id` — the same surgical write the derived `outputs` gets. Without it a hand-declared alias ended up shaped unlike its siblings, so anything reading the manifest (an audit, a port to another workspace, a person comparing two blocks) had to treat a missing id as normal, which is exactly how a genuinely missing one stops being visible. |
|
|
68
|
+
| `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. A deploy runs this same verb for every alias whose declaration or body is ahead of the app, so this command is the one-alias spelling of what a release does, not a step a release leaves to a person. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. A push also prints any non-blocking verify warnings, including an input the alias declares that the body never reads. A first bind MINTS the workflow row, and the id it echoes is written back into `package.json#lotics.workflows.<alias>.workflow_id` — the same surgical write the derived `outputs` gets. Without it a hand-declared alias ended up shaped unlike its siblings, so anything reading the manifest (an audit, a port to another workspace, a person comparing two blocks) had to treat a missing id as normal, which is exactly how a genuinely missing one stops being visible. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). |
|
|
67
69
|
| `lotics app agent set <alias>` | Push `src/agents/<alias>.md` — plus `inputs`/`outputs` when `package.json#lotics.agents.<alias>` declares them — through `set_app_agent`. The agent mirror of `app workflow set`, and the deploy-free authoring path for an agent's prose and its typed edges. **It sends only those fields.** Everything else is absent, and absent means unchanged, so a declaration this CLI does not model cannot be reverted by a push from a checkout that predates it — the chat authoring agent's `knowledge_doc_ids`, another operator's `query_aliases` grant. To change one of those, call `set_app_agent` with just that field (`lotics run set_app_agent '{"app_id":…,"alias":…,"tool_names":[…]}'` — it merges), then `app pull` to bring the manifest back in step. **CREATES the alias when the app has not bound one yet**, so a new agent is authored the same way a new workflow is: write the prose, declare the typed half, push. A create needs the prose file (an agent without instructions is not an agent); it is gated on nothing else, because what keeps a binding alive is a `useAppAgentRun("<alias>")` call site in the shipped bundle — a deploy prunes an agent the bundle never names, manifest entry or not. The prose push is a conditional write against the fingerprint this project last saw, so it is refused rather than allowed to overwrite prose someone else changed. Clear error + non-zero exit when there is no prose file and nothing declared to push instead, when a create has no prose to create from, or when the file is empty once the header is stripped. |
|
|
68
|
-
| `lotics app query set <alias>` \| `--all` | Push `package.json#lotics.queries` (`{ ast, params? }` per alias) to `apps.queries` through `set_app_query` — **the only author of a query binding**, the mirror of `app workflow set`. A deploy pushes a DRIFTED declaration through this same verb before it ships (see `app deploy`), so this is the explicit single-alias path, not the only way a query reaches the app. The **server** validates each one exactly as it always did (alias identifier, workspace-only tables, resolvable fields, declared params). `--all` pushes every declared alias, alias-sorted, stopping at the first failure and naming what already landed. Clear error + non-zero exit on an alias absent from the manifest or a validation failure. **The declaration's fields MERGE**, so the manifest is not a snapshot: deleting `params` from an alias and pushing leaves the live params exactly where they were, because an absent key means "unchanged". Clear one with `params: null`, or replace the map with the set you want. After the push it regenerates `.lotics/app_queries.d.ts` from the manifest, so the types the next `npm run typecheck` reads match what was just pushed. |
|
|
70
|
+
| `lotics app query set <alias>` \| `--all` | Push `package.json#lotics.queries` (`{ ast, params? }` per alias) to `apps.queries` through `set_app_query` — **the only author of a query binding**, the mirror of `app workflow set`. A deploy pushes a DRIFTED declaration through this same verb before it ships (see `app deploy`), so this is the explicit single-alias path, not the only way a query reaches the app. The **server** validates each one exactly as it always did (alias identifier, workspace-only tables, resolvable fields, declared params). `--all` pushes every declared alias, alias-sorted, stopping at the first failure and naming what already landed. Clear error + non-zero exit on an alias absent from the manifest or a validation failure. **The declaration's fields MERGE**, so the manifest is not a snapshot: deleting `params` from an alias and pushing leaves the live params exactly where they were, because an absent key means "unchanged". Clear one with `params: null`, or replace the map with the set you want. After the push it regenerates `.lotics/app_queries.d.ts` from the manifest, so the types the next `npm run typecheck` reads match what was just pushed. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). |
|
|
69
71
|
| `lotics app workflow pull` | Rewrite every `src/workflows/<alias>.ts` from the server (faithful body per bound alias via `get_app_workflow`) **+ its `.lotics/workflows/<alias>.globals.d.ts`** (via `getAppWorkflowDts`, so the body is locally typecheckable via `lotics app workflow check`) without a full `app pull` (no source archive, no npm install). A legacy alias with no rendered source warns and is skipped; a dts-fetch failure is non-fatal (body still written with the fallback wrapper, typecheck degraded). Each alias's `description` is folded back into `package.json#lotics.workflows.<alias>` from the same read — the alias binding the manifest is otherwise stamped from carries `inputs`/`outputs` but not the description, which lives on the workflow ROW, so without this a pull would erase an authored one. The server's GENERATED default is skipped, so an app that never described its workflows gains no manifest noise. Also idempotently patches the main `tsconfig.json` `exclude` to cover `src/workflows` + `.lotics/workflows` so a pre-existing app's `npm run typecheck` never loads the bodies or the colliding per-alias globals. **A body the app's row has not moved on is left exactly as it is**, and the aliases skipped are named. A pull writes the server's RE-RENDER of a stored step tree, which is not the text that made it — a comment inside an object literal does not survive the round trip — and the local hash cannot catch that, because `workflow set` recorded this checkout's own text (comments included) as `synced.workflows.<alias>.content`, so nothing reads as unpushed. The fact that can is the server's own fingerprint: when `synced.workflows.<alias>.live` still equals the `body_sha` the read returns, there is nothing to deliver and the baseline is left where it is. `--force` takes the app's rendering anyway. |
|
|
70
72
|
| `lotics app workflow diff [alias...]` | Print how `src/workflows/<alias>.ts` differs from the body the SERVER is running, line by line (`-` is live, `+` is the file, three lines of context, the unchanged middle elided). Name the aliases to diff them whether or not they read as drifted; name none and it diffs every alias the baseline says has moved. Exits 1 when anything differs, so a script can gate on it. It is the companion the drift signal never had: `workflow set` pushes the file and `workflow pull --force` takes the server's, but nothing could say what the difference WAS short of pulling into a throwaway directory. **The two hashes under `package.json#lotics.synced` are not a comparison**: `content` hashes the local text and `live` is the server's own fingerprint of a body stored as steps, so they can never be equal and nothing compares them — reading `content != live` as drift is a misreading the block's shape invites. |
|
|
71
73
|
| `lotics app workflow check [alias...]` | Check the editable workflow bodies locally — **every alias you name**, or all of them when you name none; an alias that is not bound is refused BEFORE any body is checked, so a green ✓ never sits under an exit 1 — no auth / no network, in the **server's own order** — parse, then type-check. **Parse** runs `parseWorkflowJs` from `@lotics/shared` (the SAME module `verifyWorkflow` calls, never a second implementation) over the stripped body `set` would upload, with `toolNames: undefined` (the CLI ships no tool registry, so tool-name resolution stays a server check while every shape/scope rule runs here). A body the subset rejects reports **that error alone** and skips the compiler — it never reaches the server's compiler either, so tsc's opinion of it is noise. **Type-check** then builds an **isolated** `ts.Program` per alias from exactly that alias's `{body, globals}` pair — mirroring the server, which verifies one body at a time — so the per-alias ambient `trigger` never collides and `trigger.app_workflow.inputs` is checked against the right alias. All aliases run in ONE node process (N programs, not N `tsc` spawns), with the SAME compile options the server uses at set-time verify (lib `es2022` with no DOM, target ES2022, strict, NodeNext, `types:[]`, skipLibCheck) and the app's OWN `typescript` (resolved from its `node_modules`, never bundled into the CLI). What the compiler sees is the **checked source**, not the file: `rewriteAccumulatorAppends` from `@lotics/shared` — the SAME transform the server applies before its set-time compile — is applied in memory, so a pulled body's canonical `out = concat(out, [item])` accumulator checks green here exactly as it saves there, and the body on disk is never rewritten. Reports `<file>:<line>:<col> - <TS####\|subset>` at the **physical** line in `src/workflows/<alias>.ts`, so an editor jump lands on the offending code (these are deliberately NOT `set`'s body-relative numbers — `set` prints no file path, so there is no format to agree with); exits non-zero if any alias fails. Green is honest but not total: `set` additionally resolves names, lints and structurally validates against the live workspace — passes that need its tables and tool schemas, so they cannot run offline, and the success line says so. A bound alias with no body file yet warns + skips; a body with no globals errors (naming `lotics app codegen`, which refreshes types WITHOUT touching the body — a pull would overwrite it). **It also keeps the types honest.** Each alias's `.lotics/workflows/<alias>.globals.d.ts` carries a `// lotics:declaration <hash>` stamp of the manifest declaration it was rendered from; `check` compares it to `package.json#lotics.workflows.<alias>` and, when they differ, re-renders that alias's dts from the LOCAL declaration before compiling. Without it the verdict was confidently wrong in the exact case an author needs it — declare an input, run `check`, and get `TS2339: Property 'x' does not exist` pointing at your body for a schema the types have never been told about. The server renders a dts from a SUPPLIED declaration, so this works before the manifest has ever been deployed, which is when it matters (the order is edit → check → set). This is the ONE thing `check` uses the API for: it is skipped entirely when the stamps match (the common case, so `check` stays instant and offline), and with no credentials or a failed fetch it WARNS and checks against the older types rather than blocking. A file written before the stamp existed reads as unknown, never as matching, so a pre-existing checkout heals on its first run. |
|