@lotics/cli 0.189.0 → 0.190.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -6
- package/README.md +13 -3
- package/dist/src/cli.js +267 -68
- package/dist/src/client.d.ts +35 -0
- package/dist/src/client.js +15 -0
- package/docs/cli_reference.md +4 -3
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -8,7 +8,7 @@ conventions are, and where the traps are.
|
|
|
8
8
|
| `lotics --help` | The verb inventory (§ COMMANDS) and global flags. The verb LIST is generated and never stale; the prose beside each verb is hand-written, so where it disagrees with `docs/cli_reference.md`, the reference wins. |
|
|
9
9
|
| `lotics tools` · `lotics tools <name>` | The agent tool registry and one tool's full JSON Schema. |
|
|
10
10
|
| `lotics docs` · `lotics docs <area>` | Every reference the packages installed beside the project actually ship — `@lotics/app-sdk`, `@lotics/ui` and the document engines each carry their own, and this index only covers THIS package. Discovered by looking, not by a list, so it reports the installed VERSION of each: a doc always describes the code that is really there. |
|
|
11
|
-
| `lotics scaffold docs` | How to write a `model.json` — the file a workspace is built from. Two forms: `{"from": "<preset-slug>", "variants", "rename", "entities", "rows", "apply"}`, which names a preset by slug and carries only what this business differs by, and the full one, spelled out, for when no preset is the trade: every top-level key, every field type with the config it needs, the row format, the rules, and a worked example. Offline, and not part of `lotics docs`. It also covers the `apply` list — published packages copied in after the model's own tables, each with an optional `bind` onto them — and the `preset` block a published model carries. `lotics scaffold check` then proves the file — offline, except for the one read a `from` file's preset needs — including every branch of a preset merged onto its base; `lotics setup` applies it and refuses a table name the workspace already has, `lotics scaffold apply` adopts that table and adds what is missing. |
|
|
11
|
+
| `lotics scaffold docs` | How to write a `model.json` — the file a workspace is built from. Two forms: `{"from": "<preset-slug>", "variants", "rename", "entities", "rows", "apply"}`, which names a preset by slug and carries only what this business differs by, and the full one, spelled out, for when no preset is the trade: every top-level key, every field type with the config it needs, the row format, the rules, and a worked example. Offline, and not part of `lotics docs`. It also covers the `apply` list — published packages copied in after the model's own tables, each with an optional `bind` onto them — and the `preset` block a published model carries. `lotics scaffold check` then proves the file — offline, except for the one read a `from` file's preset needs — including every branch of a preset merged onto its base; `lotics setup` applies it and refuses a table name the workspace already has, `lotics scaffold apply` adopts that table and adds what is missing. `lotics scaffold export` goes the other way — a workspace printed as one of these files, to edit into another business's model; a starting point, never a source of truth. |
|
|
12
12
|
| [docs/building_an_app.md](./docs/building_an_app.md) | The SEQUENCE — scaffold, model, types, queries, workflows, screens, ship — and the deploy-free inner loop. The other references describe contracts; this one is the order they go in and why. Read it once before starting an app. |
|
|
13
13
|
| [docs/cli_reference.md](./docs/cli_reference.md) | Per-command contracts, flags, exit codes, and gotchas — the detail `--help` compresses. Read it before hand-building a `set_app_*` payload: several tools REPLACE rather than patch, and a CLI verb already owns the safe assembly. |
|
|
14
14
|
| [docs/data_model.md](./docs/data_model.md) | How tables RELATE — one entity per table and the NAME-OVERLAP probe that says when a split has broken, one vocabulary wherever values are copied between tables, a copy boundary that accounts for every source field, provenance as a link rather than a flag, a declared natural key so find-or-create never compares rendered text, and why derived DEPTH costs more than row count. Separate from building_an_app because every workspace starts with tables and many never get an app. The within-table half (one fact, one column) is stated at `create_table` / `update_table`, where you meet it while deciding. |
|
|
@@ -42,11 +42,16 @@ whether something exists, read `lotics --help` § COMMANDS — the whole section
|
|
|
42
42
|
from a shell serving many. Every command that resolves a workspace echoes its target to **stderr**
|
|
43
43
|
(`lotics → <org> / <workspace>`); read it back before trusting a write. `file preview <fil_…>` is
|
|
44
44
|
the one credentialed command with no echo. Resolution precedence is in README § Organizations.
|
|
45
|
-
- **A machine with no key can still sign in
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
- **A machine with no key can still sign in, and the sign-in never blocks you.** `lotics auth
|
|
46
|
+
login <email>` prints the page a person opens (also mailed) and the code that page must show,
|
|
47
|
+
records the request, and EXITS. They press Confirm whenever they get to it; the next command that
|
|
48
|
+
NEEDS a credential collects the key before doing its own work, so "sign in" costs you one command
|
|
49
|
+
and then re-running what you wanted. Never wrap it in a timeout waiting for a human — `--wait`
|
|
50
|
+
exists if you really want one blocking command, and killing that one is safe too (the request
|
|
51
|
+
survives and the next command claims it). A command run before Confirm exits 1 naming the page and
|
|
52
|
+
code again; once the 15 minutes are up it says to ask again. `lotics setup` falls into the same
|
|
53
|
+
flow by itself when the email it was given already has an account: it stops having created
|
|
54
|
+
nothing, and the SAME command run again carries on.
|
|
50
55
|
- **Large payloads bypass `ARG_MAX`** — `lotics run <tool> @args.json` or piped stdin. A leading `@` is
|
|
51
56
|
unambiguously a file path (JSON args start with `{`).
|
|
52
57
|
- **stdout is the payload, stderr is the narration.** Progress, status lines, and the target echo go to
|
package/README.md
CHANGED
|
@@ -91,6 +91,10 @@ copied in — bound onto the tables the model just made — as part of the same
|
|
|
91
91
|
`setup` refuses a table name your workspace already has; `lotics scaffold apply model.json` is the
|
|
92
92
|
additive verb — it adopts that table, adds what the model declares beyond it, and deletes nothing.
|
|
93
93
|
|
|
94
|
+
The other direction, once a workspace works: `lotics scaffold export > model.json` prints its
|
|
95
|
+
tables (or only `--tables tbl_a,tbl_b`) as that same file, findings on stderr. It is a starting
|
|
96
|
+
point for the next business, never a source of truth — the labels are this one's.
|
|
97
|
+
|
|
94
98
|
## Install
|
|
95
99
|
|
|
96
100
|
```bash
|
|
@@ -118,13 +122,19 @@ lotics auth signup # interactive prompts
|
|
|
118
122
|
lotics auth signup a@b.com --name "Agent" # non-interactive
|
|
119
123
|
```
|
|
120
124
|
|
|
121
|
-
**`lotics auth login`** — Signs in an account that already exists, on a machine that holds no key.
|
|
125
|
+
**`lotics auth login`** — Signs in an account that already exists, on a machine that holds no key. **It does not wait for you.** It prints the page to open (also mailed to you) and the code that page must show, then exits. Sign in there if asked, check the code, press Confirm — and run whatever you wanted to run: the next command that needs a credential collects the key before doing its own work.
|
|
122
126
|
|
|
123
127
|
```bash
|
|
124
|
-
lotics auth login a@b.com #
|
|
125
|
-
|
|
128
|
+
lotics auth login a@b.com # prints the page + code, exits — nothing to keep open
|
|
129
|
+
# …press Confirm in the browser…
|
|
130
|
+
lotics auth whoami # picks up the key, then answers
|
|
131
|
+
|
|
132
|
+
lotics auth login a@b.com --wait # one command instead: holds the terminal until Confirm
|
|
133
|
+
lotics auth login a@b.com --local # pins this directory to that org (implies --wait)
|
|
126
134
|
```
|
|
127
135
|
|
|
136
|
+
A command run before you press Confirm names the page and the code again and exits 1; the request is good for 15 minutes, after which the next command says to ask again. Re-running `auth login` is always safe — it replaces the request, so only the newest code is the live one.
|
|
137
|
+
|
|
128
138
|
**`lotics auth web`** — Send a magic link email to access the web app (requires prior signup or setup).
|
|
129
139
|
|
|
130
140
|
```bash
|
package/dist/src/cli.js
CHANGED
|
@@ -44579,6 +44579,21 @@ var LoticsClient = class {
|
|
|
44579
44579
|
async scaffoldWorkspace(body) {
|
|
44580
44580
|
return this.request("POST", "/v1/workspaces/scaffold", body);
|
|
44581
44581
|
}
|
|
44582
|
+
/**
|
|
44583
|
+
* Read this workspace's schema back as a model — the tables it has (or only
|
|
44584
|
+
* the ones named), their fields, options and views, plus its roles.
|
|
44585
|
+
*
|
|
44586
|
+
* A pure read, and admin-only for the same reason the scaffold is: the whole
|
|
44587
|
+
* schema is what comes back.
|
|
44588
|
+
*/
|
|
44589
|
+
async exportWorkspaceModel(opts = {}) {
|
|
44590
|
+
const params = new URLSearchParams();
|
|
44591
|
+
if (opts.tables !== void 0 && opts.tables.length > 0) {
|
|
44592
|
+
params.set("tables", opts.tables.join(","));
|
|
44593
|
+
}
|
|
44594
|
+
const qs = params.toString();
|
|
44595
|
+
return this.request("GET", `/v1/workspaces/model${qs ? `?${qs}` : ""}`);
|
|
44596
|
+
}
|
|
44582
44597
|
/** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
|
|
44583
44598
|
* target from the request's workspace, never a path id. */
|
|
44584
44599
|
async updateWorkspace(body) {
|
|
@@ -45810,10 +45825,23 @@ function upsertProfile(orgId, fields) {
|
|
|
45810
45825
|
profiles: { ...config2.profiles, [orgId]: profile },
|
|
45811
45826
|
active_org: config2.active_org,
|
|
45812
45827
|
email: config2.email,
|
|
45828
|
+
pending_login: config2.pending_login,
|
|
45813
45829
|
last_update_check: config2.last_update_check,
|
|
45814
45830
|
latest_version: config2.latest_version
|
|
45815
45831
|
});
|
|
45816
45832
|
}
|
|
45833
|
+
function loadPendingLogin() {
|
|
45834
|
+
return loadGlobalConfig()?.pending_login ?? null;
|
|
45835
|
+
}
|
|
45836
|
+
function savePendingLogin(pending) {
|
|
45837
|
+
saveGlobalConfig({ ...loadGlobalConfig(), pending_login: pending });
|
|
45838
|
+
}
|
|
45839
|
+
function clearPendingLogin() {
|
|
45840
|
+
const config2 = loadGlobalConfig();
|
|
45841
|
+
if (!config2?.pending_login) return;
|
|
45842
|
+
const { pending_login: _collected, ...rest2 } = config2;
|
|
45843
|
+
saveGlobalConfig(rest2);
|
|
45844
|
+
}
|
|
45817
45845
|
function clearProfileWorkspace(orgId) {
|
|
45818
45846
|
const config2 = loadGlobalConfig();
|
|
45819
45847
|
const existing = config2?.profiles?.[orgId];
|
|
@@ -45973,7 +46001,7 @@ function resultSideEffects(result) {
|
|
|
45973
46001
|
}
|
|
45974
46002
|
|
|
45975
46003
|
// src/version.ts
|
|
45976
|
-
var VERSION = "0.
|
|
46004
|
+
var VERSION = "0.190.0";
|
|
45977
46005
|
|
|
45978
46006
|
// src/timezone.ts
|
|
45979
46007
|
function machineTimezone() {
|
|
@@ -46158,6 +46186,10 @@ var COMMANDS = [
|
|
|
46158
46186
|
" It REFUSES a name a table here already has; use",
|
|
46159
46187
|
" lotics scaffold apply to add to what you have.",
|
|
46160
46188
|
" Drop --email to use the account you already have.",
|
|
46189
|
+
" An email that ALREADY has an account: it prints a",
|
|
46190
|
+
" sign-in page to open and stops, having created",
|
|
46191
|
+
" nothing \u2014 press Confirm and run the same command",
|
|
46192
|
+
" again. --wait holds the terminal instead",
|
|
46161
46193
|
" --json prints one object and nothing else"
|
|
46162
46194
|
]
|
|
46163
46195
|
},
|
|
@@ -46181,7 +46213,13 @@ var COMMANDS = [
|
|
|
46181
46213
|
" where every bound table is empty. Renaming or",
|
|
46182
46214
|
" deleting is not the file's to do \u2014 after the",
|
|
46183
46215
|
" first run the workspace is the truth.",
|
|
46184
|
-
" --json prints one object and nothing else"
|
|
46216
|
+
" --json prints one object and nothing else",
|
|
46217
|
+
" lotics scaffold export [--tables] Print this workspace's tables \u2014 or only the ids",
|
|
46218
|
+
" named \u2014 as a model.json: the file scaffold check",
|
|
46219
|
+
" reads, and what a preset is written from. Edit the",
|
|
46220
|
+
" labels into the trade's words and add a preset block",
|
|
46221
|
+
" with its two questions and its variants. A starting",
|
|
46222
|
+
" point, never a source of truth"
|
|
46185
46223
|
]
|
|
46186
46224
|
},
|
|
46187
46225
|
{
|
|
@@ -75143,6 +75181,7 @@ function parseArgs(argv) {
|
|
|
75143
75181
|
port: void 0,
|
|
75144
75182
|
vitePort: void 0,
|
|
75145
75183
|
local: false,
|
|
75184
|
+
wait: false,
|
|
75146
75185
|
all: false,
|
|
75147
75186
|
yes: false,
|
|
75148
75187
|
printCreated: false,
|
|
@@ -75152,6 +75191,7 @@ function parseArgs(argv) {
|
|
|
75152
75191
|
adopt: false,
|
|
75153
75192
|
entity: [],
|
|
75154
75193
|
limit: void 0,
|
|
75194
|
+
tables: [],
|
|
75155
75195
|
bind: [],
|
|
75156
75196
|
version: false,
|
|
75157
75197
|
help: false
|
|
@@ -75169,6 +75209,11 @@ function parseArgs(argv) {
|
|
|
75169
75209
|
missingValue ??= { flag, expects };
|
|
75170
75210
|
return "";
|
|
75171
75211
|
};
|
|
75212
|
+
const takeList = (flag, expects) => {
|
|
75213
|
+
const parts = splitList(takeValue(flag, expects));
|
|
75214
|
+
if (parts.length === 0) missingValue ??= { flag, expects };
|
|
75215
|
+
return parts;
|
|
75216
|
+
};
|
|
75172
75217
|
while (i2 < argv.length) {
|
|
75173
75218
|
const arg = argv[i2];
|
|
75174
75219
|
if (subcommand !== void 0 && commandOwnsTail(command) && arg !== "--help" && arg !== "-h") {
|
|
@@ -75270,6 +75315,9 @@ function parseArgs(argv) {
|
|
|
75270
75315
|
case "--local":
|
|
75271
75316
|
flags.local = true;
|
|
75272
75317
|
break;
|
|
75318
|
+
case "--wait":
|
|
75319
|
+
flags.wait = true;
|
|
75320
|
+
break;
|
|
75273
75321
|
case "--all":
|
|
75274
75322
|
flags.all = true;
|
|
75275
75323
|
break;
|
|
@@ -75293,11 +75341,14 @@ function parseArgs(argv) {
|
|
|
75293
75341
|
// Comma-separated AND repeatable, like `--tags` above: an agent scripting
|
|
75294
75342
|
// this reaches for whichever form it knows, and both accumulate.
|
|
75295
75343
|
case "--entity":
|
|
75296
|
-
flags.entity = [...flags.entity, ...
|
|
75344
|
+
flags.entity = [...flags.entity, ...takeList(arg, "<alias>")];
|
|
75297
75345
|
break;
|
|
75298
75346
|
case "--limit":
|
|
75299
75347
|
flags.limit = takeValue(arg, "<n>");
|
|
75300
75348
|
break;
|
|
75349
|
+
case "--tables":
|
|
75350
|
+
flags.tables = [...flags.tables, ...takeList(arg, "<tbl_id,tbl_id>")];
|
|
75351
|
+
break;
|
|
75301
75352
|
// Repeatable and never split: the value's right half is a table or field
|
|
75302
75353
|
// NAME, which may hold a comma.
|
|
75303
75354
|
case "--bind":
|
|
@@ -76708,7 +76759,7 @@ var workspaceModelContractSchema = packageContractSchema.omit({ apps: true, fixt
|
|
|
76708
76759
|
templates: zod_default.array(contractInlineTemplateSchema).default([]).describe("Document templates whose content travels inline (html or email)")
|
|
76709
76760
|
}).strict();
|
|
76710
76761
|
var modelApplyEntrySchema = zod_default.object({
|
|
76711
|
-
|
|
76762
|
+
package: zod_default.string().min(1).describe("The package id to copy in"),
|
|
76712
76763
|
bind: packageBindSchema.optional().describe("Entity alias \u2192 the labels this workspace calls that entity and its fields"),
|
|
76713
76764
|
no_sample_data: zod_default.boolean().optional().describe("Decline the package's sample records")
|
|
76714
76765
|
}).strict();
|
|
@@ -77478,7 +77529,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
77478
77529
|
import fs10 from "node:fs";
|
|
77479
77530
|
|
|
77480
77531
|
// src/model_reference.md
|
|
77481
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are not a model\'s to declare \u2014 build one in the workspace the model\ncreated, then publish that workspace as a starter.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, and a value on a\n files or platform-computed field.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `apps`, `fixtures`, `knowledge` or `knowledge_expects`,\nand no `excel` / `word` / `pdf-form` template: each of those is content that\nlives in a published bundle, which a model has none of. An unknown top-level key is\nan error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model \u2014 a real data set belongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | not allowed |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "starter": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] }\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`** and **`apply`** mean exactly what they mean in the full form \u2014\n `"rows"` are this business\'s real first records, `"apply"` the packages copied\n in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows`.\n';
|
|
77532
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are not a model\'s to declare \u2014 build one in the workspace the model\ncreated, then publish that workspace as a starter.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, and a value on a\n files or platform-computed field.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `apps`, `fixtures`, `knowledge` or `knowledge_expects`,\nand no `excel` / `word` / `pdf-form` template: each of those is content that\nlives in a published bundle, which a model has none of. An unknown top-level key is\nan error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model \u2014 a real data set belongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | not allowed |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] }\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`** and **`apply`** mean exactly what they mean in the full form \u2014\n `"rows"` are this business\'s real first records, `"apply"` the packages copied\n in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows`.\n';
|
|
77482
77533
|
|
|
77483
77534
|
// src/scaffold_commands.ts
|
|
77484
77535
|
function printModelReference() {
|
|
@@ -77635,6 +77686,23 @@ function reportModelFindings(file2, findings) {
|
|
|
77635
77686
|
The model's shape, every field type and a worked example: lotics scaffold docs`
|
|
77636
77687
|
);
|
|
77637
77688
|
}
|
|
77689
|
+
async function scaffoldExport(client, opts = {}) {
|
|
77690
|
+
const { contract, findings } = await client.exportWorkspaceModel(opts);
|
|
77691
|
+
const file2 = { entities: contract.entities };
|
|
77692
|
+
if (contract.roles.length > 0) file2.roles = contract.roles;
|
|
77693
|
+
if (contract.templates.length > 0) file2.templates = contract.templates;
|
|
77694
|
+
process.stdout.write(`${JSON.stringify(file2, null, 2)}
|
|
77695
|
+
`);
|
|
77696
|
+
for (const finding of findings) {
|
|
77697
|
+
console.error(` \u2022 ${finding.severity} ${finding.area}: ${finding.message}`);
|
|
77698
|
+
}
|
|
77699
|
+
const errors2 = findings.filter((finding) => finding.severity === "error").length;
|
|
77700
|
+
if (errors2 > 0) {
|
|
77701
|
+
console.error(`
|
|
77702
|
+
${count(errors2, "error")} \u2014 the file above does not describe this workspace in full.`);
|
|
77703
|
+
process.exitCode = 1;
|
|
77704
|
+
}
|
|
77705
|
+
}
|
|
77638
77706
|
function scaffoldJson(target, result, applied, signinUrl) {
|
|
77639
77707
|
return {
|
|
77640
77708
|
...target,
|
|
@@ -77642,7 +77710,7 @@ function scaffoldJson(target, result, applied, signinUrl) {
|
|
|
77642
77710
|
// Always present, empty included: a caller reading this object indexes
|
|
77643
77711
|
// `applied` to find the apps, and a key that appears only sometimes makes
|
|
77644
77712
|
// "this model applied nothing" and "this CLI is too old" the same value.
|
|
77645
|
-
applied: applied.map((entry) => ({
|
|
77713
|
+
applied: applied.map((entry) => ({ package: entry.package, apps: entry.apps })),
|
|
77646
77714
|
...signinUrl === void 0 ? {} : { signin_url: signinUrl }
|
|
77647
77715
|
};
|
|
77648
77716
|
}
|
|
@@ -77656,22 +77724,22 @@ async function applyModelPackages(client, entries2) {
|
|
|
77656
77724
|
const applied = [];
|
|
77657
77725
|
for (const entry of entries2) {
|
|
77658
77726
|
note(`
|
|
77659
|
-
Applying ${entry.
|
|
77727
|
+
Applying ${entry.package}\u2026`);
|
|
77660
77728
|
try {
|
|
77661
77729
|
const copy = await copyPackage(client, {
|
|
77662
|
-
starter_id: entry.
|
|
77730
|
+
starter_id: entry.package,
|
|
77663
77731
|
adopt: true,
|
|
77664
77732
|
...entry.no_sample_data === true ? { noSampleData: true } : {},
|
|
77665
77733
|
...entry.bind !== void 0 ? { bind: entry.bind } : {}
|
|
77666
77734
|
});
|
|
77667
|
-
applied.push({
|
|
77735
|
+
applied.push({ package: entry.package, apps: copy.apps });
|
|
77668
77736
|
} catch (error52) {
|
|
77669
77737
|
throw new Error(
|
|
77670
77738
|
`${error52 instanceof Error ? error52.message : String(error52)}
|
|
77671
77739
|
|
|
77672
|
-
${entry.
|
|
77673
|
-
The tables${applied.length > 0 ? ` and ${applied.map((one) => one.
|
|
77674
|
-
and copy it on its own: lotics library init ${entry.
|
|
77740
|
+
${entry.package} was refused, so the rest of this model's apply list did not run.
|
|
77741
|
+
The tables${applied.length > 0 ? ` and ${applied.map((one) => one.package).join(", ")}` : ""} already landed and must not be applied again \u2014 fix this entry
|
|
77742
|
+
and copy it on its own: lotics library init ${entry.package}`
|
|
77675
77743
|
);
|
|
77676
77744
|
}
|
|
77677
77745
|
}
|
|
@@ -77741,39 +77809,93 @@ function requireNonEmpty(value2, field) {
|
|
|
77741
77809
|
);
|
|
77742
77810
|
}
|
|
77743
77811
|
}
|
|
77744
|
-
|
|
77812
|
+
function signInPageUrl(pending) {
|
|
77813
|
+
return `${WEB_APP_URL}/cli_login/${pending.request_id}`;
|
|
77814
|
+
}
|
|
77815
|
+
function expirySentence(pending) {
|
|
77816
|
+
const remaining = Date.parse(pending.expires_at) - Date.now();
|
|
77817
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "";
|
|
77818
|
+
return ` The request expires in ${Math.max(1, Math.round(remaining / 6e4))} minutes.`;
|
|
77819
|
+
}
|
|
77820
|
+
function signInInstructions(pending, options) {
|
|
77821
|
+
const ask = `Sign in from your browser: open ${signInPageUrl(pending)} (also in a mail to ${pending.email}), sign in if asked, check that the page shows code ${pending.code}, and press Confirm.`;
|
|
77822
|
+
const then = options.waiting ? `Waiting here until you press Confirm.${expirySentence(pending)}` : `Then run your command again \u2014 this terminal picks up the key on its next command.${expirySentence(pending)}`;
|
|
77823
|
+
return `${ask}
|
|
77824
|
+
${then}`;
|
|
77825
|
+
}
|
|
77826
|
+
async function startPendingLogin(email3) {
|
|
77745
77827
|
const request = await startCliLogin(email3);
|
|
77746
|
-
|
|
77747
|
-
|
|
77748
|
-
|
|
77749
|
-
|
|
77828
|
+
const pending = {
|
|
77829
|
+
request_id: request.request_id,
|
|
77830
|
+
secret: request.secret,
|
|
77831
|
+
code: request.code,
|
|
77832
|
+
expires_at: request.expires_at,
|
|
77833
|
+
email: email3,
|
|
77834
|
+
api_url: API_BASE_URL
|
|
77835
|
+
};
|
|
77836
|
+
savePendingLogin(pending);
|
|
77837
|
+
return pending;
|
|
77838
|
+
}
|
|
77839
|
+
function saveApproval(state, email3, options) {
|
|
77840
|
+
requireNonEmpty(state.api_key, "api_key");
|
|
77841
|
+
requireNonEmpty(state.organization_id, "organization_id");
|
|
77842
|
+
requireNonEmpty(state.workspace_id, "workspace_id");
|
|
77843
|
+
upsertProfile(state.organization_id, {
|
|
77844
|
+
api_key: state.api_key,
|
|
77845
|
+
org_name: state.organization_name,
|
|
77846
|
+
workspace_id: state.workspace_id
|
|
77847
|
+
});
|
|
77848
|
+
const existing = loadGlobalConfig() ?? {};
|
|
77849
|
+
saveGlobalConfig({ ...existing, email: email3 });
|
|
77850
|
+
setActiveOrg(state.organization_id, options?.local === true ? "local" : "global");
|
|
77851
|
+
clearPendingLogin();
|
|
77852
|
+
return {
|
|
77853
|
+
email: email3,
|
|
77854
|
+
orgId: state.organization_id,
|
|
77855
|
+
orgName: state.organization_name,
|
|
77856
|
+
workspaceId: state.workspace_id
|
|
77857
|
+
};
|
|
77858
|
+
}
|
|
77859
|
+
async function claimPendingLogin() {
|
|
77860
|
+
const pending = loadPendingLogin();
|
|
77861
|
+
if (!pending) return { status: "none" };
|
|
77862
|
+
const expired = `The sign-in request expired. Run again: lotics auth login ${pending.email}`;
|
|
77863
|
+
if (Date.parse(pending.expires_at) <= Date.now()) {
|
|
77864
|
+
clearPendingLogin();
|
|
77865
|
+
return pending.api_url === API_BASE_URL ? { status: "gone", message: expired } : { status: "none" };
|
|
77866
|
+
}
|
|
77867
|
+
if (pending.api_url !== API_BASE_URL) return { status: "none" };
|
|
77868
|
+
const state = await pollCliLogin(pending.request_id, pending.secret);
|
|
77869
|
+
if (state.status === "approved") {
|
|
77870
|
+
return { status: "signed_in", account: saveApproval(state, pending.email) };
|
|
77871
|
+
}
|
|
77872
|
+
if (state.status === "pending") {
|
|
77873
|
+
return {
|
|
77874
|
+
status: "waiting",
|
|
77875
|
+
message: `Still waiting for Confirm on ${signInPageUrl(pending)} (code ${pending.code}). Press it, then run this again \u2014 or ask again for a new one: lotics auth login ${pending.email}`
|
|
77876
|
+
};
|
|
77877
|
+
}
|
|
77878
|
+
clearPendingLogin();
|
|
77879
|
+
return {
|
|
77880
|
+
status: "gone",
|
|
77881
|
+
message: state.status === "claimed" ? `The sign-in request was already used. Run again: lotics auth login ${pending.email}` : expired
|
|
77882
|
+
};
|
|
77883
|
+
}
|
|
77884
|
+
async function awaitCliLogin(email3, options) {
|
|
77885
|
+
const pending = await startPendingLogin(email3);
|
|
77886
|
+
console.error(signInInstructions(pending, { waiting: true }));
|
|
77887
|
+
const expiresAt = Date.parse(pending.expires_at);
|
|
77750
77888
|
for (; ; ) {
|
|
77751
77889
|
let state;
|
|
77752
77890
|
try {
|
|
77753
|
-
state = await pollCliLogin(
|
|
77891
|
+
state = await pollCliLogin(pending.request_id, pending.secret);
|
|
77754
77892
|
} catch (error52) {
|
|
77755
77893
|
if (!(Date.now() < expiresAt)) throw error52;
|
|
77756
77894
|
await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
|
|
77757
77895
|
continue;
|
|
77758
77896
|
}
|
|
77759
77897
|
if (state.status === "approved") {
|
|
77760
|
-
|
|
77761
|
-
requireNonEmpty(state.organization_id, "organization_id");
|
|
77762
|
-
requireNonEmpty(state.workspace_id, "workspace_id");
|
|
77763
|
-
upsertProfile(state.organization_id, {
|
|
77764
|
-
api_key: state.api_key,
|
|
77765
|
-
org_name: state.organization_name,
|
|
77766
|
-
workspace_id: state.workspace_id
|
|
77767
|
-
});
|
|
77768
|
-
const existing = loadGlobalConfig() ?? {};
|
|
77769
|
-
saveGlobalConfig({ ...existing, email: email3 });
|
|
77770
|
-
setActiveOrg(state.organization_id, options?.local === true ? "local" : "global");
|
|
77771
|
-
return {
|
|
77772
|
-
email: email3,
|
|
77773
|
-
orgId: state.organization_id,
|
|
77774
|
-
orgName: state.organization_name,
|
|
77775
|
-
workspaceId: state.workspace_id
|
|
77776
|
-
};
|
|
77898
|
+
return saveApproval(state, email3, options);
|
|
77777
77899
|
}
|
|
77778
77900
|
if (state.status === "expired") {
|
|
77779
77901
|
throw new Error(`That sign-in link expired. Run "lotics auth login ${email3}" again.`);
|
|
@@ -106702,8 +106824,8 @@ Lotics is an AI-powered operations platform. Through this CLI you can:
|
|
|
106702
106824
|
AUTHENTICATION
|
|
106703
106825
|
lotics auth Show auth help
|
|
106704
106826
|
lotics auth signup <email> Create account (run "lotics auth" for details)
|
|
106705
|
-
lotics auth login <email> Sign in on this machine \u2014
|
|
106706
|
-
|
|
106827
|
+
lotics auth login <email> Sign in on this machine \u2014 open the page it names,
|
|
106828
|
+
press Confirm, then run your command again
|
|
106707
106829
|
lotics auth api-key <key> Save a key \u2014 registers its org as a profile
|
|
106708
106830
|
|
|
106709
106831
|
ORGANIZATIONS
|
|
@@ -106745,7 +106867,11 @@ FLAGS
|
|
|
106745
106867
|
--view-as <id> Admin "View as": run every request as this member, so
|
|
106746
106868
|
is_current_member / row-scoping resolve to them (also
|
|
106747
106869
|
LOTICS_VIEW_AS env; admin key only; writes stay yours)
|
|
106748
|
-
--local Pin the current directory (lotics org use / auth login / auth api-key)
|
|
106870
|
+
--local Pin the current directory (lotics org use / auth login / auth api-key).
|
|
106871
|
+
On auth login / setup it implies --wait: only the terminal that
|
|
106872
|
+
stays in this directory can write its pin
|
|
106873
|
+
--wait (auth login / setup) Hold this terminal until Confirm is pressed,
|
|
106874
|
+
instead of picking the key up on the next command
|
|
106749
106875
|
--all (lotics auth logout) Remove every saved credential
|
|
106750
106876
|
--version Show version
|
|
106751
106877
|
|
|
@@ -106788,10 +106914,12 @@ function printAuthHelp() {
|
|
|
106788
106914
|
console.log(`Authentication commands:
|
|
106789
106915
|
|
|
106790
106916
|
lotics auth signup <email> Create account, org, workspace, and API key
|
|
106791
|
-
lotics auth login <email> Sign in an account that already exists
|
|
106792
|
-
|
|
106793
|
-
|
|
106794
|
-
|
|
106917
|
+
lotics auth login <email> Sign in an account that already exists. It does NOT
|
|
106918
|
+
wait: it prints the page to open (also mailed) and the
|
|
106919
|
+
code that page must show, and exits. Press Confirm,
|
|
106920
|
+
then run your command again \u2014 this terminal picks the
|
|
106921
|
+
key up on its next command. --wait holds the terminal
|
|
106922
|
+
instead. --json prints one object and nothing else
|
|
106795
106923
|
lotics auth api-key [key] Save an API key \u2014 registers its org as a profile
|
|
106796
106924
|
lotics auth web Send a magic link email to access the web app
|
|
106797
106925
|
lotics auth whoami Show the active account, org, workspace, and source
|
|
@@ -106802,7 +106930,11 @@ function printAuthHelp() {
|
|
|
106802
106930
|
Auth flags:
|
|
106803
106931
|
--local Pin the current directory instead of the global store.
|
|
106804
106932
|
signup/login/api-key write a ./.lotics/config.json pointer
|
|
106805
|
-
\u2014 the key itself stays in the global store.
|
|
106933
|
+
\u2014 the key itself stays in the global store. On login it
|
|
106934
|
+
implies --wait: a pin names THIS directory, and only the
|
|
106935
|
+
terminal that stays in it can write one.
|
|
106936
|
+
--wait (login) Hold this terminal until Confirm is pressed, rather
|
|
106937
|
+
than picking the key up on the next command.
|
|
106806
106938
|
--name <name> (signup) Display name (defaults to email prefix)
|
|
106807
106939
|
--timezone <timezone> (signup) Workspace timezone (defaults to this machine's, e.g. Asia/Ho_Chi_Minh)
|
|
106808
106940
|
|
|
@@ -106939,15 +107071,38 @@ async function handleSignup(positionalEmail, flags) {
|
|
|
106939
107071
|
Check your email for a magic link to access the Lotics web app.`);
|
|
106940
107072
|
console.error(`Run "lotics auth web" to request a new link at any time.`);
|
|
106941
107073
|
}
|
|
107074
|
+
function waitsForConfirm(flags) {
|
|
107075
|
+
return flags.wait || flags.local;
|
|
107076
|
+
}
|
|
106942
107077
|
async function handleLogin(positionalEmail, flags) {
|
|
106943
107078
|
const email3 = positionalEmail ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
|
|
106944
107079
|
if (!email3) {
|
|
106945
107080
|
console.error("Email is required. Usage: lotics auth login <email>");
|
|
106946
107081
|
process.exit(1);
|
|
106947
107082
|
}
|
|
107083
|
+
if (!waitsForConfirm(flags)) {
|
|
107084
|
+
const pending = await startPendingLogin(email3);
|
|
107085
|
+
console.error(signInInstructions(pending, { waiting: false }));
|
|
107086
|
+
if (flags.json) {
|
|
107087
|
+
console.log(
|
|
107088
|
+
JSON.stringify(
|
|
107089
|
+
{
|
|
107090
|
+
request_id: pending.request_id,
|
|
107091
|
+
confirm_url: signInPageUrl(pending),
|
|
107092
|
+
code: pending.code,
|
|
107093
|
+
email: pending.email,
|
|
107094
|
+
expires_at: pending.expires_at
|
|
107095
|
+
},
|
|
107096
|
+
null,
|
|
107097
|
+
2
|
|
107098
|
+
)
|
|
107099
|
+
);
|
|
107100
|
+
}
|
|
107101
|
+
return;
|
|
107102
|
+
}
|
|
106948
107103
|
let account;
|
|
106949
107104
|
try {
|
|
106950
|
-
account = await awaitCliLogin(email3, flags.local
|
|
107105
|
+
account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
|
|
106951
107106
|
} catch (error52) {
|
|
106952
107107
|
console.error(error52 instanceof Error ? error52.message : String(error52));
|
|
106953
107108
|
process.exit(1);
|
|
@@ -107004,6 +107159,7 @@ Multiple workspaces in ${info.organization_name}. Run "lotics workspace select <
|
|
|
107004
107159
|
const existing = loadGlobalConfig() ?? {};
|
|
107005
107160
|
saveGlobalConfig({ ...existing, email: info.email });
|
|
107006
107161
|
setActiveOrg(info.organization_id, local ? "local" : "global");
|
|
107162
|
+
clearPendingLogin();
|
|
107007
107163
|
if (local) {
|
|
107008
107164
|
console.error(`Authenticated as ${info.email} in ${info.organization_name}.`);
|
|
107009
107165
|
console.error(` Pinned this directory: ${getConfigPath("local")}`);
|
|
@@ -107012,8 +107168,23 @@ Multiple workspaces in ${info.organization_name}. Run "lotics workspace select <
|
|
|
107012
107168
|
console.error(` Saved to the "${info.organization_name}" profile in ~/.lotics`);
|
|
107013
107169
|
}
|
|
107014
107170
|
}
|
|
107015
|
-
function
|
|
107016
|
-
const
|
|
107171
|
+
async function resolveContextOrClaim(flags, options) {
|
|
107172
|
+
const appWorkspaceId = options?.appWorkspaceId;
|
|
107173
|
+
const resolved = resolveContext(flags, appWorkspaceId);
|
|
107174
|
+
if (resolved) return { ctx: resolved, claimed: null };
|
|
107175
|
+
const claim = await claimPendingLogin();
|
|
107176
|
+
if (claim.status === "none") return { ctx: null, claimed: null };
|
|
107177
|
+
if (claim.status !== "signed_in") return { ctx: null, claimed: null, refusal: claim.message };
|
|
107178
|
+
const { account } = claim;
|
|
107179
|
+
note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
107180
|
+
return { ctx: resolveContext(flags, appWorkspaceId), claimed: account };
|
|
107181
|
+
}
|
|
107182
|
+
async function requireClient(flags, appWorkspaceId) {
|
|
107183
|
+
const { ctx, refusal } = await resolveContextOrClaim(flags, { appWorkspaceId });
|
|
107184
|
+
if (refusal !== void 0) {
|
|
107185
|
+
console.error(refusal);
|
|
107186
|
+
process.exit(1);
|
|
107187
|
+
}
|
|
107017
107188
|
if (!ctx) {
|
|
107018
107189
|
console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
|
|
107019
107190
|
process.exit(1);
|
|
@@ -107157,9 +107328,10 @@ async function main() {
|
|
|
107157
107328
|
if (command === "setup") {
|
|
107158
107329
|
const target = subcommand;
|
|
107159
107330
|
if (!target) {
|
|
107160
|
-
console.error("Usage: lotics setup <
|
|
107161
|
-
console.error(" Creates an account if this machine has none \u2014 or
|
|
107162
|
-
console.error("
|
|
107331
|
+
console.error("Usage: lotics setup <apg_id | model.json> [--email <you@co.com>] [--json]");
|
|
107332
|
+
console.error(" Creates an account if this machine has none \u2014 or, when that email");
|
|
107333
|
+
console.error(" already has one, prints a sign-in page and stops with nothing created;");
|
|
107334
|
+
console.error(" press Confirm and run the same command again. Then fills its workspace.");
|
|
107163
107335
|
console.error(" A STARTER id copies a published setup whole: schema, templates,");
|
|
107164
107336
|
console.error(" knowledge docs, sample records and its apps, deployed.");
|
|
107165
107337
|
console.error(" A model \u2014 any *.json file \u2014 creates the tables, fields, views, roles");
|
|
@@ -107180,7 +107352,11 @@ async function main() {
|
|
|
107180
107352
|
if (toolArgs !== void 0) {
|
|
107181
107353
|
warn(`A path is no longer needed \u2014 setup writes nothing to disk. Ignoring "${toolArgs}".`);
|
|
107182
107354
|
}
|
|
107183
|
-
const existing =
|
|
107355
|
+
const { ctx: existing, claimed, refusal } = await resolveContextOrClaim(flags);
|
|
107356
|
+
if (refusal !== void 0) {
|
|
107357
|
+
console.error(refusal);
|
|
107358
|
+
process.exit(1);
|
|
107359
|
+
}
|
|
107184
107360
|
if (existing === null) {
|
|
107185
107361
|
const email3 = flags.email ?? (process.stdin.isTTY ? await prompt("Email: ") : "");
|
|
107186
107362
|
if (!email3) {
|
|
@@ -107197,10 +107373,13 @@ async function main() {
|
|
|
107197
107373
|
...flags.local === true ? { local: true } : {}
|
|
107198
107374
|
});
|
|
107199
107375
|
if (outcome.kind === "email_taken") {
|
|
107200
|
-
|
|
107201
|
-
email3
|
|
107202
|
-
|
|
107203
|
-
|
|
107376
|
+
if (!waitsForConfirm(flags)) {
|
|
107377
|
+
const pending = await startPendingLogin(email3);
|
|
107378
|
+
console.error(signInInstructions(pending, { waiting: false }));
|
|
107379
|
+
console.error("Then run this same command again.");
|
|
107380
|
+
process.exit(1);
|
|
107381
|
+
}
|
|
107382
|
+
const account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
|
|
107204
107383
|
note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
107205
107384
|
} else {
|
|
107206
107385
|
const { account } = outcome;
|
|
@@ -107210,7 +107389,7 @@ async function main() {
|
|
|
107210
107389
|
console.error(error52 instanceof Error ? error52.message : String(error52));
|
|
107211
107390
|
process.exit(1);
|
|
107212
107391
|
}
|
|
107213
|
-
} else if (flags.email !== void 0) {
|
|
107392
|
+
} else if (flags.email !== void 0 && claimed?.email.toLowerCase() !== flags.email.toLowerCase()) {
|
|
107214
107393
|
console.error(
|
|
107215
107394
|
`Already authenticated (${SOURCE_LABELS[existing.source]}), so --email would create a SECOND account and set that one up instead.
|
|
107216
107395
|
Use the account you have: lotics setup ${target}
|
|
@@ -107218,7 +107397,7 @@ async function main() {
|
|
|
107218
107397
|
);
|
|
107219
107398
|
process.exit(1);
|
|
107220
107399
|
}
|
|
107221
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107400
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107222
107401
|
await resolveWorkspace(client2, ctx2);
|
|
107223
107402
|
if (model !== null) {
|
|
107224
107403
|
const run = await scaffoldApply(client2, model.model, { file: target, adopt: false });
|
|
@@ -107250,6 +107429,12 @@ async function main() {
|
|
|
107250
107429
|
printModelReference();
|
|
107251
107430
|
return;
|
|
107252
107431
|
}
|
|
107432
|
+
if (subcommand === "export") {
|
|
107433
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107434
|
+
await resolveWorkspace(client2, ctx2);
|
|
107435
|
+
await scaffoldExport(client2, flags.tables.length > 0 ? { tables: flags.tables } : {});
|
|
107436
|
+
return;
|
|
107437
|
+
}
|
|
107253
107438
|
if (subcommand === "check" || subcommand === "apply") {
|
|
107254
107439
|
if (!toolArgs) {
|
|
107255
107440
|
console.error(`Usage: lotics scaffold ${subcommand} <model.json>`);
|
|
@@ -107271,7 +107456,7 @@ async function main() {
|
|
|
107271
107456
|
else console.log(summarizeModel(checked.model));
|
|
107272
107457
|
return;
|
|
107273
107458
|
}
|
|
107274
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107459
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107275
107460
|
await resolveWorkspace(client2, ctx2);
|
|
107276
107461
|
const run = await scaffoldApply(client2, checked.model, { file: toolArgs, adopt: true });
|
|
107277
107462
|
if (flags.json) {
|
|
@@ -107285,12 +107470,16 @@ async function main() {
|
|
|
107285
107470
|
}
|
|
107286
107471
|
return;
|
|
107287
107472
|
}
|
|
107288
|
-
console.error(
|
|
107473
|
+
console.error(
|
|
107474
|
+
"Usage: lotics scaffold docs | check <model.json> | apply <model.json> | export [--tables <ids>]"
|
|
107475
|
+
);
|
|
107289
107476
|
console.error(" docs \u2014 how to write a model, with a worked example (offline)");
|
|
107290
107477
|
console.error(" check \u2014 prove one, offline, before anyone has an account");
|
|
107291
107478
|
console.error(" apply \u2014 create it here; a table of the same name is ADOPTED and");
|
|
107292
107479
|
console.error(" added to, never modified, and rows land only where every");
|
|
107293
107480
|
console.error(" bound table is empty");
|
|
107481
|
+
console.error(" export \u2014 print this workspace's tables as a model file, to edit into");
|
|
107482
|
+
console.error(" a preset or the starting model for another business");
|
|
107294
107483
|
process.exit(1);
|
|
107295
107484
|
}
|
|
107296
107485
|
if (command === "library") {
|
|
@@ -107299,7 +107488,7 @@ async function main() {
|
|
|
107299
107488
|
await libraryListPublic();
|
|
107300
107489
|
return;
|
|
107301
107490
|
}
|
|
107302
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107491
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107303
107492
|
await resolveWorkspace(client2, ctx2);
|
|
107304
107493
|
await libraryList(client2);
|
|
107305
107494
|
return;
|
|
@@ -107336,7 +107525,7 @@ async function main() {
|
|
|
107336
107525
|
emitJson(published.package);
|
|
107337
107526
|
return;
|
|
107338
107527
|
}
|
|
107339
|
-
const { client: client3 } = requireClient(flags);
|
|
107528
|
+
const { client: client3 } = await requireClient(flags);
|
|
107340
107529
|
const pkg = await client3.getStarter(toolArgs);
|
|
107341
107530
|
if (pkg.latest_version === 0) {
|
|
107342
107531
|
console.error(`${toolArgs} has released no version yet, so it declares no contract.`);
|
|
@@ -107356,7 +107545,7 @@ async function main() {
|
|
|
107356
107545
|
await libraryShowPublic(toolArgs);
|
|
107357
107546
|
return;
|
|
107358
107547
|
}
|
|
107359
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107548
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107360
107549
|
await resolveWorkspace(client2, ctx2);
|
|
107361
107550
|
await libraryShow(client2, toolArgs);
|
|
107362
107551
|
return;
|
|
@@ -107382,7 +107571,7 @@ async function main() {
|
|
|
107382
107571
|
console.error(parsedBind.error);
|
|
107383
107572
|
process.exit(1);
|
|
107384
107573
|
}
|
|
107385
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107574
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107386
107575
|
await resolveWorkspace(client2, ctx2);
|
|
107387
107576
|
if (restArgs[0] !== void 0) warn(`A path is no longer needed \u2014 a copy writes nothing to disk. Ignoring "${restArgs[0]}".`);
|
|
107388
107577
|
const result = await libraryInit(client2, {
|
|
@@ -107412,7 +107601,7 @@ async function main() {
|
|
|
107412
107601
|
process.exit(1);
|
|
107413
107602
|
}
|
|
107414
107603
|
}
|
|
107415
|
-
const { client: client2, ctx: ctx2 } = requireClient(flags);
|
|
107604
|
+
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
107416
107605
|
await resolveWorkspace(client2, ctx2);
|
|
107417
107606
|
await libraryFixturesCapture(client2, {
|
|
107418
107607
|
entities: flags.entity,
|
|
@@ -107459,7 +107648,12 @@ async function main() {
|
|
|
107459
107648
|
console.error(REPORT_USAGE);
|
|
107460
107649
|
process.exit(1);
|
|
107461
107650
|
}
|
|
107462
|
-
const reportCtx =
|
|
107651
|
+
const { ctx: reportCtx, refusal } = await resolveContextOrClaim(flags);
|
|
107652
|
+
if (refusal !== void 0) {
|
|
107653
|
+
console.error(refusal);
|
|
107654
|
+
echoReport(parsed2.report);
|
|
107655
|
+
process.exit(1);
|
|
107656
|
+
}
|
|
107463
107657
|
if (!reportCtx) {
|
|
107464
107658
|
console.error(
|
|
107465
107659
|
'Not authenticated, so this could not be sent. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.'
|
|
@@ -107492,7 +107686,11 @@ async function main() {
|
|
|
107492
107686
|
return;
|
|
107493
107687
|
}
|
|
107494
107688
|
if (subcommand === "whoami") {
|
|
107495
|
-
const ctx2 =
|
|
107689
|
+
const { ctx: ctx2, refusal } = await resolveContextOrClaim(flags);
|
|
107690
|
+
if (refusal !== void 0) {
|
|
107691
|
+
console.error(refusal);
|
|
107692
|
+
process.exit(1);
|
|
107693
|
+
}
|
|
107496
107694
|
if (!ctx2) {
|
|
107497
107695
|
console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
|
|
107498
107696
|
process.exit(1);
|
|
@@ -107548,11 +107746,12 @@ async function main() {
|
|
|
107548
107746
|
}
|
|
107549
107747
|
const [orgId, profile] = resolved;
|
|
107550
107748
|
removeProfile(orgId);
|
|
107749
|
+
clearPendingLogin();
|
|
107551
107750
|
console.error(`Removed the "${profile.org_name}" credential (${orgId}).`);
|
|
107552
107751
|
return;
|
|
107553
107752
|
}
|
|
107554
107753
|
if (subcommand === "web") {
|
|
107555
|
-
const { client: client2 } = requireClient(flags);
|
|
107754
|
+
const { client: client2 } = await requireClient(flags);
|
|
107556
107755
|
const { email: email3 } = await client2.login();
|
|
107557
107756
|
console.error(`Magic link sent to ${email3}. Check your email to access the Lotics web app.`);
|
|
107558
107757
|
return;
|
|
@@ -107574,7 +107773,7 @@ async function main() {
|
|
|
107574
107773
|
}
|
|
107575
107774
|
if (command === "preview") {
|
|
107576
107775
|
if (subcommand && isStoredFileId(subcommand)) {
|
|
107577
|
-
const { client: client2 } = requireClient(flags);
|
|
107776
|
+
const { client: client2 } = await requireClient(flags);
|
|
107578
107777
|
const tmpDir = fs15.mkdtempSync(path12.join(os4.tmpdir(), "lotics-preview-"));
|
|
107579
107778
|
try {
|
|
107580
107779
|
const { path: localPath, filename } = await client2.downloadFileById(subcommand, tmpDir);
|
|
@@ -107739,7 +107938,7 @@ async function main() {
|
|
|
107739
107938
|
process.exit(1);
|
|
107740
107939
|
}
|
|
107741
107940
|
const appPathArg = subcommand === "dev" ? toolArgs : void 0;
|
|
107742
|
-
const { client, ctx } = requireClient(
|
|
107941
|
+
const { client, ctx } = await requireClient(
|
|
107743
107942
|
flags,
|
|
107744
107943
|
appManifestWorkspaceId(command, subcommand, appPathArg, flags)
|
|
107745
107944
|
);
|
package/dist/src/client.d.ts
CHANGED
|
@@ -306,6 +306,31 @@ export interface ScaffoldWorkspaceResult {
|
|
|
306
306
|
/** Rows were sent and none were written, because the run adopted a table. */
|
|
307
307
|
rows_skipped: boolean;
|
|
308
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* A workspace read BACK as a model — the inverse of the scaffold above.
|
|
311
|
+
*
|
|
312
|
+
* `entities` and `roles` are the model file's own two keys, whose authoritative
|
|
313
|
+
* shapes are `contractEntitySchema` / `contractRoleSchema` in `@lotics/shared` —
|
|
314
|
+
* specifiers a published `.d.ts` cannot resolve, so they are typed here as what
|
|
315
|
+
* this client does with them, which is hand them on whole. The same trade
|
|
316
|
+
* `ScaffoldWorkspaceRequest` makes in the other direction.
|
|
317
|
+
*
|
|
318
|
+
* `findings` are about the export rather than part of it: a workspace holds
|
|
319
|
+
* things a model file cannot express, and a file that dropped them silently
|
|
320
|
+
* would be read as the whole workspace.
|
|
321
|
+
*/
|
|
322
|
+
export interface WorkspaceModelExport {
|
|
323
|
+
contract: {
|
|
324
|
+
entities: Array<Record<string, unknown>>;
|
|
325
|
+
roles: Array<Record<string, unknown>>;
|
|
326
|
+
templates: Array<Record<string, unknown>>;
|
|
327
|
+
};
|
|
328
|
+
findings: Array<{
|
|
329
|
+
severity: "error" | "warning" | "info";
|
|
330
|
+
area: string;
|
|
331
|
+
message: string;
|
|
332
|
+
}>;
|
|
333
|
+
}
|
|
309
334
|
/**
|
|
310
335
|
* A request the API refused. The message carries the status and the server's
|
|
311
336
|
* sentence, which is what reaches a person; `status` and `body` are for the
|
|
@@ -573,6 +598,16 @@ export declare class LoticsClient {
|
|
|
573
598
|
* rows land only where every bound table is empty. Admin-only.
|
|
574
599
|
*/
|
|
575
600
|
scaffoldWorkspace(body: ScaffoldWorkspaceRequest): Promise<ScaffoldWorkspaceResult>;
|
|
601
|
+
/**
|
|
602
|
+
* Read this workspace's schema back as a model — the tables it has (or only
|
|
603
|
+
* the ones named), their fields, options and views, plus its roles.
|
|
604
|
+
*
|
|
605
|
+
* A pure read, and admin-only for the same reason the scaffold is: the whole
|
|
606
|
+
* schema is what comes back.
|
|
607
|
+
*/
|
|
608
|
+
exportWorkspaceModel(opts?: {
|
|
609
|
+
tables?: string[];
|
|
610
|
+
}): Promise<WorkspaceModelExport>;
|
|
576
611
|
/** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
|
|
577
612
|
* target from the request's workspace, never a path id. */
|
|
578
613
|
updateWorkspace(body: {
|
package/dist/src/client.js
CHANGED
|
@@ -356,6 +356,21 @@ var LoticsClient = class {
|
|
|
356
356
|
async scaffoldWorkspace(body) {
|
|
357
357
|
return this.request("POST", "/v1/workspaces/scaffold", body);
|
|
358
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* Read this workspace's schema back as a model — the tables it has (or only
|
|
361
|
+
* the ones named), their fields, options and views, plus its roles.
|
|
362
|
+
*
|
|
363
|
+
* A pure read, and admin-only for the same reason the scaffold is: the whole
|
|
364
|
+
* schema is what comes back.
|
|
365
|
+
*/
|
|
366
|
+
async exportWorkspaceModel(opts = {}) {
|
|
367
|
+
const params = new URLSearchParams();
|
|
368
|
+
if (opts.tables !== void 0 && opts.tables.length > 0) {
|
|
369
|
+
params.set("tables", opts.tables.join(","));
|
|
370
|
+
}
|
|
371
|
+
const qs = params.toString();
|
|
372
|
+
return this.request("GET", `/v1/workspaces/model${qs ? `?${qs}` : ""}`);
|
|
373
|
+
}
|
|
359
374
|
/** Renames (and re-settings) the CURRENT workspace — the endpoint reads the
|
|
360
375
|
* target from the request's workspace, never a path id. */
|
|
361
376
|
async updateWorkspace(body) {
|
package/docs/cli_reference.md
CHANGED
|
@@ -6,7 +6,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| `lotics` / `lotics --help` | Show full help with capabilities, tool categories, workflow |
|
|
8
8
|
| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |
|
|
9
|
-
| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key.
|
|
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). `--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
12
|
| `lotics auth whoami` | Print active account name, email, org, resolved workspace, and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id` + `source`. |
|
|
@@ -43,10 +43,11 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
43
43
|
| `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. Admin-only server-side (mirrors deploy + source download). Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. Title → stderr, table → stdout (pipeable). |
|
|
44
44
|
| `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`. |
|
|
45
45
|
| `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_…"`) keyed by display-name aliases, for the tables the app's queries reference (+ optional `package.json#lotics.codegen.tables` allowlist). **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`. |
|
|
46
|
-
| `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
|
|
46
|
+
| `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 …`. |
|
|
47
47
|
| `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, 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`. |
|
|
48
48
|
| `lotics scaffold check <model.json> [--json]` | **Prove a model before anyone sees it — no network, no credential**, unless the file names a preset. ONE parse of the whole file against the model schema (strict, so `tabels` or `row` is an error rather than a silently dropped key, and a model cannot express what only a starter bundle carries: `apps`, `fixtures`, `knowledge`, `knowledge_expects`, a file-backed `excel`/`word`/`pdf-form` template), then `validateWorkspaceModel` — the caps, every cross-reference, and the first rows themselves (a field the entity does not declare, an unknown option alias, a link naming no row in the file, a duplicate `ref`, a date that is not one, a value on a files or computed field). **Reports EVERY problem in one run**, each as `<path>: <message>` in the file's own keys (`entities.0.fields.1.type`, `rows.order.so_1.customer`), so fixing a model is not a round trip per mistake. Exits 1 when there is one; exits 0 with a one-line summary (`N tables, N fields, N links, N views, N roles, N rows`) on stdout. `--json` replaces both with one object and nothing else: `{ok: true, tables, fields, links, views, roles, rows}` or `{ok: false, findings: [{path, message}]}`. **A `preset` is checked as N models, not one** — every variant merged onto the base (its added entities, and its added fields keyed by entity) and put through the same rules, each finding addressed `preset.variants.<slug>.<path>`, so a preset ships with every branch proven: the branch nobody took is the one that fails in the workspace of whoever takes it, who is the one reader who cannot fix it. A variant's `fields` key naming no declared entity is a finding too — the merge keys on the entity, so a typo'd alias adds those fields to nothing. Same verdict the server reaches, because it runs the server's own functions out of `@lotics/shared` rather than a second implementation of them. **A file written as `{"from": "<preset-slug>", …}` is resolved first** — one GET of that preset's file on the website — and that read is the one step on this path that needs the network; it says so when it cannot make it, and a slug nothing serves is answered with the slugs there ARE, read from the listing, rather than with a 404 the author cannot spell their way out of. Resolution is pure (`resolveModelFrom` in `@lotics/shared`): the named variants merged onto the preset's base in order, then `rename` through the same `applyBinding` a `--bind` goes through, then the file's own `entities` appended. What comes out is the full form and goes through everything above unchanged, so a `from` file cannot reach a workspace by a route the full form does not. A variant slug the preset does not declare, an alias `rename` names that it does not declare, and a renamed label that is already another table's are each a finding rather than a silent drop — a branch quietly ignored scaffolds the base and looks like it worked. |
|
|
49
|
-
| `lotics scaffold apply <model.json> [--json]` | **Create the model in this workspace**: its tables, fields, select options, links, views, roles and first rows, through `POST /v1/workspaces/scaffold`. Runs `check` first, so a bad file never reaches the network, then resolves and ANNOUNCES its workspace (`lotics → <org> / <workspace>` on stderr) before writing — it is a destructive path. **Additive and re-runnable**: it is the verb that sends `adopt`, so an entity whose `label` already names a table here BINDS to that table and gains the fields, options and views it is missing, while `setup` refuses that same label. Nothing is ever modified or deleted, so applying the same model twice creates nothing the second time. **A renamed label therefore asks for a NEW table** — rename through `lotics run update_table` instead; after the first run the workspace is the source of truth and the file is an authoring input. **Rows land only where every bound table is empty**: one bound table already holding records and none are written anywhere, because sample rows landing among a customer's real ones cannot be told apart from them — it says so and reports `rows_skipped`. Prints `created`/`adopted` per entity with its table id, each role's group id, and rows written per entity. **Then it copies in every package the file's `apply` list names, in order** — each one a `library init` with that entry's `bind` and `no_sample_data`, and each sending `adopt`, because by then the workspace holds exactly the tables this same run just created. Order is load-bearing: a later entry may bind onto a table an earlier one made. **A refused entry stops the run and the entries before it stay** — they are separate copies, committed as they land — so the refusal carries the server's own message plus what already landed and the one-package command to retry with. `--json` prints one object and nothing else (`entities`, `roles`, `record_ids`, `rows_skipped`, `applied: [{
|
|
49
|
+
| `lotics scaffold apply <model.json> [--json]` | **Create the model in this workspace**: its tables, fields, select options, links, views, roles and first rows, through `POST /v1/workspaces/scaffold`. Runs `check` first, so a bad file never reaches the network, then resolves and ANNOUNCES its workspace (`lotics → <org> / <workspace>` on stderr) before writing — it is a destructive path. **Additive and re-runnable**: it is the verb that sends `adopt`, so an entity whose `label` already names a table here BINDS to that table and gains the fields, options and views it is missing, while `setup` refuses that same label. Nothing is ever modified or deleted, so applying the same model twice creates nothing the second time. **A renamed label therefore asks for a NEW table** — rename through `lotics run update_table` instead; after the first run the workspace is the source of truth and the file is an authoring input. **Rows land only where every bound table is empty**: one bound table already holding records and none are written anywhere, because sample rows landing among a customer's real ones cannot be told apart from them — it says so and reports `rows_skipped`. Prints `created`/`adopted` per entity with its table id, each role's group id, and rows written per entity. **Then it copies in every package the file's `apply` list names, in order** — each one a `library init` with that entry's `bind` and `no_sample_data`, and each sending `adopt`, because by then the workspace holds exactly the tables this same run just created. Order is load-bearing: a later entry may bind onto a table an earlier one made. **A refused entry stops the run and the entries before it stay** — they are separate copies, committed as they land — so the refusal carries the server's own message plus what already landed and the one-package command to retry with. `--json` prints one object and nothing else (`entities`, `roles`, `record_ids`, `rows_skipped`, `applied: [{package, apps}]` — always present, empty included, so a reader cannot mistake "applied nothing" for "too old to say" — plus `organization_id`/`workspace_id` and a `warnings` array). Admin-only. A model declares no apps of its own — build one in the workspace and publish it as a package, or name a published package in `apply`. |
|
|
50
|
+
| `lotics scaffold export [--tables <tbl_id,…>]` | **This workspace, read back as a model file** — `GET /v1/workspaces/model`. Prints the tables it has (or only the ids `--tables` names) with their fields, options and views, plus its roles and its html/email templates when it has any (a file-backed template is named on stderr and left out), as pretty JSON on **stdout**: exactly the file `lotics scaffold check` reads, so `lotics scaffold export > model.json && lotics scaffold check model.json` is the round trip. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr), like every other verb that reads one. **Findings go to stderr, each led by its severity** (`• <severity> <area>: <message>`, and one line counting the errors underneath) — a workspace holds things a model cannot express, and a file that dropped them silently would read as the whole workspace; the model is printed either way, and the exit is 1 when any finding is an `error`, because a file with a hole in it is still worth having on disk. **What comes out is a STARTING POINT, never a source of truth**: it carries one business's labels and stops describing that workspace the moment either changes. Edit the labels into the trade's words, add the `preset` block with its questions and variants (`lotics scaffold docs`), and prove every branch with `lotics scaffold check` before it is published. Admin-only. |
|
|
50
51
|
| `lotics library list` | **Works with no account**, and that is the point: whether to start from a preset, copy a package or build from scratch is decided before one exists, so requiring a key would mean signing up to learn the answer was no. **Two shelves, printed under their own headings and never merged**, because they are different kinds of thing and end in different commands. **Presets** are a trade's MODEL, served as static files on the website (`GET <site>/presets/index.json`, no credential, no server that knows what a preset is): each row is `slug · name`, the sentence, how many tables the base carries, and every branch as `slug · when`. The `when` rides the listing rather than waiting for a `show`, because it is what an answer is matched against — two trades whose names sound alike are told apart by which one has a branch describing the business in front of the reader. A preset is READ and turned into a `model.json`; nothing is copied. **Packages** are apps plus the tables they stand on, COPIED in whole. Unauthenticated it lists what Lotics publishes (`GET /v1/starters/official`, public); authenticated it lists the org shelf — the packages this organization can copy, Lotics-reviewed ones plus its own, each with at least one released version, deliberately NOT a catalogue of everything published: the server returns exactly what a copy would be allowed to take, so the list can never offer something that then refuses (admin-only). Both render through one function, and each row names WHAT IS INSIDE it — its apps and how many tables — because that is the fact the choice turns on: a name and a sentence leave a chooser guessing, and an agent matching what someone said they manage has nothing else to match against. Nothing fitting on either shelf is a real answer: `lotics scaffold docs` is where that goes. |
|
|
51
52
|
| `lotics library show <slug\|apg_id> [--json]` | **The argument says which shelf**, and both forms are allowlists rather than a fallback: an `apg_` id is a package, anything else is a preset slug (`^[a-z0-9_]+$`, refused before any request — a slug reaches a URL). **A SLUG** reads the preset's own file off the website with no credential and no account, which is the whole timing argument for serving it as a file: it prints the preset's name and sentence, the questions it may ask (at most two), every table as `alias · label` with each field as `alias:type`, and every branch as `slug · when` followed by the tables and fields taking it ADDS — a slug picked off its `when` alone cannot say whether the branch brings the column the person was asked about. It closes with the `{"from": …}` file to write. The file is proven as a MODEL on the way through (the same `readPresetModel` this repo's own test runs over these files), so a preset that would fail in the workspace of whoever takes a branch is refused here, named in the preset's own keys — ours to fix, not the reader's. **An `apg_` id** prints the package: name, description, current version, shelf tile and trust standing (`official` — reviewed by Lotics; `your organization's own`; otherwise `not copyable from this organization`), plus the date it was unpublished once it has been, then the same COMPACT table listing — each table as `alias · label`, each field as `alias:type` — which is exactly what a `--bind` is typed from. Read it before copying a package you did not publish. **Works with no account for anything Lotics publishes**, falling to `GET /v1/starters/official/{id}` the way `list` falls to the public shelf; signed in, the prose form is admin-only and readable by id from any org, but an unpublished package 404s for every org except the one that published it. `--json` prints the preset FILE for a slug, and the published contract read whole — views, labels and all — for a package: ONE shape whichever credentials the caller holds, because the reader of that object is a program writing a model from it. |
|
|
52
53
|
| `lotics library init <apg_id> [--bind <entity>=<Label> ...]` | **Copy a package into this workspace.** Server-side it scaffolds the tables and fields, creates the document templates and knowledge docs, inserts the sample records, creates every app the package carries and materializes each one's queries, workflows and agents onto it — then deploys each app from the dist the package was published with, rewriting the publisher's sentinel field keys to this workspace's. No build runs anywhere, nothing is written to this machine, and nothing here needs node: the apps are live when the command returns. **What you get is yours outright**: ordinary apps plus ordinary tables, with no link back to what it came from and nothing pinned. It does STAMP what delivered it (`apps.origin`), which nothing resolves through and only `lotics app upgrade <app_id>` reads. Edit any of it — `lotics app pull <app_id>` is how an app's code is edited afterwards. **The publisher's code runs in your workspace as you** — its apps, workflows and agents — which is why provenance is the gate: **copyable only if the package is Lotics-reviewed or your own organization published it**, enforced server-side; the disclosure is printed (and carried in `--json`'s `warnings`) whenever the package is not your own. **Refuses a workspace that already has tables** unless `--adopt`: scaffold matches an entity by DISPLAY NAME, so a package declaring `Contacts` would bind to yours. An app whose deploy failed is reported by name with its reason and the exit is non-zero, but the copy is complete around it — the tables, the records and the app row exist — so it must not be run again; the publisher fixes the package and it is copied into a fresh workspace. The sign-in link lands on the app when there is one, else on the workspace's app list. `--json` prints one object on stdout instead of progress (the shape is under `lotics setup`). `--no-sample-data` skips the sample records, and a copy that ADOPTS an existing table writes none either — that table already holds real rows, and the fixture set links to itself, so it is all-or-nothing; with them, how many landed is reported. They are ordinary records, delete them whenever. **`--bind <entity>=<Label>` says which of YOUR tables the package's entities are, and `--bind <entity>.<field>=<Label>` which of your fields** — repeatable, and split on the FIRST `=` so a label may contain one. Scaffold adopts by DISPLAY LABEL, so a bind renames the contract to what you already call things and the copy lands on your tables instead of creating a second set beside them: this is how a package of apps lands on a workspace that already has its tables. Only naming moves — a bound field must be the TYPE the package declares, or the copy is refused (409). A bound entity needs no `--adopt`. The same target named twice is refused rather than overwritten, because the caller then believes one of the two took. `lotics library show <apg_id>` lists the aliases to bind. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr). Admin-only. Authoring the registry (`opctl library publish/unpublish`) stays operator-only. |
|