@lotics/cli 0.225.0 → 0.226.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/dist/src/cli.js +179 -46
- package/docs/building_an_app.md +2 -1
- package/docs/cli_reference.md +4 -4
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -52,7 +52,7 @@ var __toESM = (mod2, isNodeMode, target) => (target = mod2 != null ? __create(__
|
|
|
52
52
|
var define_LOTICS_KIT_VERSIONS_default;
|
|
53
53
|
var init_define_LOTICS_KIT_VERSIONS = __esm({
|
|
54
54
|
"<define:__LOTICS_KIT_VERSIONS__>"() {
|
|
55
|
-
define_LOTICS_KIT_VERSIONS_default = { ui: "
|
|
55
|
+
define_LOTICS_KIT_VERSIONS_default = { ui: "56.0.0", sdk: "0.98.3", runtime: "0.18.0" };
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -53876,7 +53876,7 @@ function resultSideEffects(result) {
|
|
|
53876
53876
|
|
|
53877
53877
|
// src/version.ts
|
|
53878
53878
|
init_define_LOTICS_KIT_VERSIONS();
|
|
53879
|
-
var VERSION = "0.
|
|
53879
|
+
var VERSION = "0.226.0";
|
|
53880
53880
|
|
|
53881
53881
|
// src/timezone.ts
|
|
53882
53882
|
init_define_LOTICS_KIT_VERSIONS();
|
|
@@ -64438,8 +64438,8 @@ var WORDS = {
|
|
|
64438
64438
|
},
|
|
64439
64439
|
verbs: {
|
|
64440
64440
|
open: (entity) => `New ${entity}`,
|
|
64441
|
-
add: (entity) => `Add
|
|
64442
|
-
failed: (entity) => `Could not add
|
|
64441
|
+
add: (entity) => `Add ${entity}`,
|
|
64442
|
+
failed: (entity) => `Could not add ${entity}.`,
|
|
64443
64443
|
noMatch: "Nothing matches."
|
|
64444
64444
|
},
|
|
64445
64445
|
figures: { net: "Net" },
|
|
@@ -64479,8 +64479,8 @@ var WORDS = {
|
|
|
64479
64479
|
},
|
|
64480
64480
|
verbs: {
|
|
64481
64481
|
open: (entity) => `${entity} m\u1EDBi`,
|
|
64482
|
-
add: (entity) => `Th\xEAm
|
|
64483
|
-
failed: (entity) => `Kh\xF4ng th\xEAm \u0111\u01B0\u1EE3c
|
|
64482
|
+
add: (entity) => `Th\xEAm ${entity}`,
|
|
64483
|
+
failed: (entity) => `Kh\xF4ng th\xEAm \u0111\u01B0\u1EE3c ${entity}.`,
|
|
64484
64484
|
noMatch: "Kh\xF4ng c\xF3 m\u1EE5c n\xE0o kh\u1EDBp."
|
|
64485
64485
|
},
|
|
64486
64486
|
figures: { net: "R\xF2ng" },
|
|
@@ -65823,6 +65823,9 @@ function captionField(entry, roles) {
|
|
|
65823
65823
|
|
|
65824
65824
|
// src/plan_creates.ts
|
|
65825
65825
|
init_define_LOTICS_KIT_VERSIONS();
|
|
65826
|
+
function memberStamp(column, field) {
|
|
65827
|
+
return { column, multi: field.type === "select_member" && field.multi === true };
|
|
65828
|
+
}
|
|
65826
65829
|
function planCreates(entry, roles) {
|
|
65827
65830
|
const plan = planWrites(entry) === void 0 ? void 0 : planCreate(entry, roles);
|
|
65828
65831
|
const rows = writesChildren(entry.screen.screen.writes) ? [...childCreates(entry, roles), ...[...entry.parties.values()].flatMap((party) => childCreates(party.surface, roles))] : [];
|
|
@@ -65838,9 +65841,11 @@ function childCreates(entry, roles) {
|
|
|
65838
65841
|
const typed = section.kind === "thread" ? [section.body] : composedEntry(section);
|
|
65839
65842
|
const inputs = typed.flatMap((field) => {
|
|
65840
65843
|
const bound = child.fields.get(field.alias) ?? child.operands.get(field.alias);
|
|
65841
|
-
return bound === void 0 ? [] : [{ name: bound.alias, field, bound, label: bound.label, required: field.alias === typed[0].alias, into: "row" }];
|
|
65844
|
+
return bound === void 0 ? [] : [{ name: bound.alias, field, bound, label: bound.label, required: field.alias === typed[0].alias || field.required === true, into: "row" }];
|
|
65842
65845
|
});
|
|
65843
65846
|
if (inputs.length === 0) return [];
|
|
65847
|
+
const author = section.kind === "journal" ? section.author : void 0;
|
|
65848
|
+
const keeper = author === void 0 ? void 0 : child.fields.get(author.alias) ?? child.operands.get(author.alias);
|
|
65844
65849
|
return [
|
|
65845
65850
|
{
|
|
65846
65851
|
entry,
|
|
@@ -65849,6 +65854,7 @@ function childCreates(entry, roles) {
|
|
|
65849
65854
|
options: child.alias,
|
|
65850
65855
|
parent: { bound: child.link, screen: entry, param: recordParam(entry.screen.entity.alias) },
|
|
65851
65856
|
...section.kind === "journal" ? { day: child.fields.get(section.when.alias)?.alias ?? child.operands.get(section.when.alias)?.alias } : {},
|
|
65857
|
+
...author === void 0 || keeper === void 0 ? {} : { keeper: memberStamp(keeper, author) },
|
|
65852
65858
|
reads: [],
|
|
65853
65859
|
unique: [],
|
|
65854
65860
|
inputs,
|
|
@@ -66003,6 +66009,7 @@ function createWrites(plan) {
|
|
|
66003
66009
|
const own = [
|
|
66004
66010
|
...plan.parent === void 0 ? [] : [plan.parent.bound.alias],
|
|
66005
66011
|
...plan.opening === void 0 ? [] : [plan.opening.bound.alias],
|
|
66012
|
+
...plan.keeper === void 0 ? [] : [plan.keeper.column.alias],
|
|
66006
66013
|
...plan.party === void 0 ? [] : [plan.party.bound.alias],
|
|
66007
66014
|
...plan.reads.flatMap((read2) => read2.copies.map((copy) => copy.into.alias)),
|
|
66008
66015
|
...plan.inputs.flatMap((input) => input.into === "row" ? [input.bound.alias] : [])
|
|
@@ -66019,7 +66026,7 @@ function createWrites(plan) {
|
|
|
66019
66026
|
];
|
|
66020
66027
|
}
|
|
66021
66028
|
function createNoun(plan) {
|
|
66022
|
-
return plan.
|
|
66029
|
+
return plan.entity.singular ?? plan.entity.label;
|
|
66023
66030
|
}
|
|
66024
66031
|
function createVerb(plan) {
|
|
66025
66032
|
const words = plan.entry.words.verbs;
|
|
@@ -68412,7 +68419,7 @@ function historyAppend(entry) {
|
|
|
68412
68419
|
if (mapped.length === 0) continue;
|
|
68413
68420
|
const moved = section.history.by;
|
|
68414
68421
|
const column = moved === void 0 ? void 0 : child.operands.get(moved.alias);
|
|
68415
|
-
const by = moved === void 0 || column === void 0 ? void 0 :
|
|
68422
|
+
const by = moved === void 0 || column === void 0 ? void 0 : memberStamp(column, moved);
|
|
68416
68423
|
return {
|
|
68417
68424
|
table: child.table.id,
|
|
68418
68425
|
tableAlias: child.tableAlias,
|
|
@@ -68429,6 +68436,10 @@ function historyAppend(entry) {
|
|
|
68429
68436
|
function mappedStage(append, value) {
|
|
68430
68437
|
return `${append.mapped.map(([mine, theirs]) => `${value} == ${str(mine)} ? ${str(theirs)} : `).join("")}null`;
|
|
68431
68438
|
}
|
|
68439
|
+
function stampCell(stamp) {
|
|
68440
|
+
const caller = "runtime.triggered_by_member_id";
|
|
68441
|
+
return `${str(stamp.column.id)}: ${stamp.multi ? `isNull(${caller}) ? null : [${caller}]` : caller},`;
|
|
68442
|
+
}
|
|
68432
68443
|
function historyRow(append, record2, stage, pad) {
|
|
68433
68444
|
const cells = [
|
|
68434
68445
|
`${str(append.link.id)}: [${record2}],`,
|
|
@@ -68436,16 +68447,7 @@ function historyRow(append, record2, stage, pad) {
|
|
|
68436
68447
|
// THE MOMENT IS THE SERVER'S CLOCK, taken as the write lands — a caller's
|
|
68437
68448
|
// idea of when this happened is a value any caller can write.
|
|
68438
68449
|
`${str(append.at.id)}: now(),`,
|
|
68439
|
-
|
|
68440
|
-
// as an input is a value any caller can write, so the stamp would say
|
|
68441
|
-
// whoever the presser claimed to be.
|
|
68442
|
-
//
|
|
68443
|
-
// A COLUMN THAT HOLDS ONE MEMBER TAKES THE BARE ID. Wrapped in a list it is
|
|
68444
|
-
// the multi column's write type, which the single one refuses — and the
|
|
68445
|
-
// list arm cannot carry the `null`, which is why only it branches.
|
|
68446
|
-
...append.by === void 0 ? [] : [
|
|
68447
|
-
`${str(append.by.column.id)}: ${append.by.multi ? "isNull(runtime.triggered_by_member_id) ? null : [runtime.triggered_by_member_id]" : "runtime.triggered_by_member_id"},`
|
|
68448
|
-
]
|
|
68450
|
+
...append.by === void 0 ? [] : [stampCell(append.by)]
|
|
68449
68451
|
];
|
|
68450
68452
|
return `${pad}await create_records({
|
|
68451
68453
|
${pad} table_id: ${str(append.table)},
|
|
@@ -68594,6 +68596,7 @@ function createSource(plan) {
|
|
|
68594
68596
|
const cells = [];
|
|
68595
68597
|
if (plan.parent !== void 0) cells.push(` ${str(plan.parent.bound.id)}: [i.${plan.parent.param}],`);
|
|
68596
68598
|
if (plan.opening !== void 0) cells.push(` ${str(plan.opening.bound.id)}: ${str(plan.opening.option.id)},`);
|
|
68599
|
+
if (plan.keeper !== void 0) cells.push(` ${stampCell(plan.keeper)}`);
|
|
68597
68600
|
if (party !== void 0) cells.push(` ${str(party.bound.id)}: [${party.local}],`);
|
|
68598
68601
|
for (const read2 of plan.reads) {
|
|
68599
68602
|
for (const copy of read2.copies) cells.push(` ${str(copy.into.id)}: ${read2.local}.data[${str(copy.from.id)}],`);
|
|
@@ -71503,7 +71506,24 @@ async function requireRuntimeKitRanges(instead) {
|
|
|
71503
71506
|
`@lotics/app-runtime@${manifest.version} declares no ${ui === void 0 ? "@lotics/ui" : "@lotics/app-sdk"} peer dependency, so there is no kit it is known to render with. That publish is ours to fix.`
|
|
71504
71507
|
);
|
|
71505
71508
|
}
|
|
71506
|
-
|
|
71509
|
+
const [uiLatest, sdkLatest] = await Promise.all([
|
|
71510
|
+
requireLatestNpmVersion("@lotics/ui", instead === void 0 ? {} : { instead }),
|
|
71511
|
+
requireLatestNpmVersion("@lotics/app-sdk", instead === void 0 ? {} : { instead })
|
|
71512
|
+
]);
|
|
71513
|
+
return { ui: flooredAtLatest(ui, uiLatest), sdk: flooredAtLatest(sdk, sdkLatest), runtime: `^${manifest.version}` };
|
|
71514
|
+
}
|
|
71515
|
+
function caretRange(range2) {
|
|
71516
|
+
const match2 = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range2);
|
|
71517
|
+
if (match2 === null) return null;
|
|
71518
|
+
const floor2 = [Number(match2[1]), Number(match2[2]), Number(match2[3])];
|
|
71519
|
+
return { line: floor2[0] === 0 ? `0.${floor2[1]}` : String(floor2[0]), floor: floor2 };
|
|
71520
|
+
}
|
|
71521
|
+
function flooredAtLatest(peer, latest) {
|
|
71522
|
+
const stated3 = caretRange(peer);
|
|
71523
|
+
const published = caretRange(`^${latest}`);
|
|
71524
|
+
if (stated3 === null || published === null || stated3.line !== published.line) return peer;
|
|
71525
|
+
const differs = published.floor.findIndex((part, at2) => part !== stated3.floor[at2]);
|
|
71526
|
+
return differs !== -1 && published.floor[differs] > stated3.floor[differs] ? `^${latest}` : peer;
|
|
71507
71527
|
}
|
|
71508
71528
|
async function requireLatestNpmVersion(packageName, { timeoutMs = 1e4, instead } = {}) {
|
|
71509
71529
|
const version2 = await fetchLatestNpmVersion(packageName, { timeoutMs });
|
|
@@ -71790,12 +71810,6 @@ function siblingPackage(source, name) {
|
|
|
71790
71810
|
function tarballDigest(packed) {
|
|
71791
71811
|
return sha256(packed.path);
|
|
71792
71812
|
}
|
|
71793
|
-
function caretRange(range2) {
|
|
71794
|
-
const match2 = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range2);
|
|
71795
|
-
if (match2 === null) return null;
|
|
71796
|
-
const floor2 = [Number(match2[1]), Number(match2[2]), Number(match2[3])];
|
|
71797
|
-
return { line: floor2[0] === 0 ? `0.${floor2[1]}` : String(floor2[0]), floor: floor2 };
|
|
71798
|
-
}
|
|
71799
71813
|
function accepts(held, wanted2) {
|
|
71800
71814
|
if (held === null) return false;
|
|
71801
71815
|
if (held === wanted2) return true;
|
|
@@ -71921,20 +71935,41 @@ function readGeneratedAliases(projectDir) {
|
|
|
71921
71935
|
const manifestPath = path12.join(projectDir, BASE_MANIFEST);
|
|
71922
71936
|
if (!fs10.existsSync(manifestPath)) return null;
|
|
71923
71937
|
const read2 = JSON.parse(fs10.readFileSync(manifestPath, "utf-8"));
|
|
71924
|
-
const
|
|
71925
|
-
|
|
71938
|
+
const at2 = (from, key) => typeof from === "object" && from !== null ? Reflect.get(from, key) : void 0;
|
|
71939
|
+
const list3 = (from, key) => {
|
|
71940
|
+
const value = at2(from, key);
|
|
71926
71941
|
return Array.isArray(value) ? value.filter((one) => typeof one === "string") : [];
|
|
71927
71942
|
};
|
|
71928
|
-
|
|
71943
|
+
const retired = at2(read2, "retired");
|
|
71944
|
+
return {
|
|
71945
|
+
queries: list3(read2, "queries"),
|
|
71946
|
+
workflows: list3(read2, "workflows"),
|
|
71947
|
+
retired: { queries: list3(retired, "queries"), workflows: list3(retired, "workflows") }
|
|
71948
|
+
};
|
|
71929
71949
|
}
|
|
71930
71950
|
function writeGeneratedAliases(projectDir, aliases) {
|
|
71931
71951
|
fs10.mkdirSync(path12.join(projectDir, BASE_DIR), { recursive: true });
|
|
71932
71952
|
fs10.writeFileSync(
|
|
71933
71953
|
path12.join(projectDir, BASE_MANIFEST),
|
|
71934
|
-
`${JSON.stringify(
|
|
71954
|
+
`${JSON.stringify(
|
|
71955
|
+
{
|
|
71956
|
+
queries: [...aliases.queries].sort(),
|
|
71957
|
+
workflows: [...aliases.workflows].sort(),
|
|
71958
|
+
retired: { queries: [...aliases.retired.queries].sort(), workflows: [...aliases.retired.workflows].sort() }
|
|
71959
|
+
},
|
|
71960
|
+
null,
|
|
71961
|
+
2
|
|
71962
|
+
)}
|
|
71935
71963
|
`
|
|
71936
71964
|
);
|
|
71937
71965
|
}
|
|
71966
|
+
function retiredAfter(previous, next) {
|
|
71967
|
+
const owed = (was, still, now) => [.../* @__PURE__ */ new Set([...was, ...still])].filter((alias) => !now.includes(alias)).sort();
|
|
71968
|
+
return {
|
|
71969
|
+
queries: owed(previous?.queries ?? [], previous?.retired.queries ?? [], next.queries),
|
|
71970
|
+
workflows: owed(previous?.workflows ?? [], previous?.retired.workflows ?? [], next.workflows)
|
|
71971
|
+
};
|
|
71972
|
+
}
|
|
71938
71973
|
function droppedPatch(entries2) {
|
|
71939
71974
|
return entries2.map(
|
|
71940
71975
|
({ path: relative2, before, after }) => [
|
|
@@ -72067,6 +72102,7 @@ var issueSchema = external_exports.object({
|
|
|
72067
72102
|
// src/app_commands.ts
|
|
72068
72103
|
var BREAKING_API_CHANGE_CODE = "breaking_api_change";
|
|
72069
72104
|
var ACKNOWLEDGMENT_ARG = "acknowledge_breaking_api_change";
|
|
72105
|
+
var STALE_BASELINE_CODE = "stale_baseline";
|
|
72070
72106
|
function wholeQueryDeclaration(declaration) {
|
|
72071
72107
|
return {
|
|
72072
72108
|
ast: declaration.ast,
|
|
@@ -73408,7 +73444,8 @@ async function appCreate(client, args) {
|
|
|
73408
73444
|
if (plan !== null && scaffolded !== null) {
|
|
73409
73445
|
writeGeneratedAliases(targetPath, {
|
|
73410
73446
|
queries: Object.keys(plan.queries),
|
|
73411
|
-
workflows: Object.keys(plan.workflows ?? {})
|
|
73447
|
+
workflows: Object.keys(plan.workflows ?? {}),
|
|
73448
|
+
retired: { queries: [], workflows: [] }
|
|
73412
73449
|
});
|
|
73413
73450
|
writeAppDts(targetPath, { queries: plan.queries, workflows: plan.workflows });
|
|
73414
73451
|
await writeGeneratedAppFields(client, targetPath, { app_id: app.id }, { queries: plan.queries });
|
|
@@ -74199,12 +74236,43 @@ async function appDeploy(client, args) {
|
|
|
74199
74236
|
warnIfInertAgentTools(liveAfter);
|
|
74200
74237
|
}
|
|
74201
74238
|
if (liveAfter) {
|
|
74202
|
-
const
|
|
74239
|
+
const undeclaredAll = undeclaredBindings({
|
|
74203
74240
|
meta: meta3,
|
|
74204
74241
|
synced: readSynced(projectDir),
|
|
74205
74242
|
live: liveAfter
|
|
74206
74243
|
});
|
|
74207
|
-
const
|
|
74244
|
+
const generated = readGeneratedAliases(projectDir);
|
|
74245
|
+
const retired = generated === null ? { queries: [], workflows: [], agents: [] } : retiredBindings(generated.retired, undeclaredAll);
|
|
74246
|
+
if (generated !== null && generated.retired.queries.length + generated.retired.workflows.length > 0) {
|
|
74247
|
+
let refused = retired;
|
|
74248
|
+
if (!nothingOrphaned(retired)) {
|
|
74249
|
+
console.error(
|
|
74250
|
+
`
|
|
74251
|
+
Unbinding ${retired.queries.length + retired.workflows.length} binding(s) the plan retired, so chat and MCP stop reaching them:`
|
|
74252
|
+
);
|
|
74253
|
+
const synced = readSynced(projectDir);
|
|
74254
|
+
const outcome = await pruneOrphanedBindings(
|
|
74255
|
+
client,
|
|
74256
|
+
projectDir,
|
|
74257
|
+
{ app_id: meta3.app_id, workflows: liveAfter.workflows },
|
|
74258
|
+
retired,
|
|
74259
|
+
retired,
|
|
74260
|
+
[],
|
|
74261
|
+
retired.workflows.filter((alias) => synced.workflows[alias]?.live !== void 0),
|
|
74262
|
+
args.acknowledgeBreakingApi
|
|
74263
|
+
);
|
|
74264
|
+
refused = outcome.bound;
|
|
74265
|
+
if (!nothingOrphaned(outcome.rewritten)) {
|
|
74266
|
+
warn(
|
|
74267
|
+
`
|
|
74268
|
+
\u26A0 No longer this plan's to unbind, and dropped from what it retired: ${[...outcome.rewritten.queries.map((alias) => `query ${alias}`), ...outcome.rewritten.workflows.map((alias) => `workflow ${alias}`)].join(", ")}. Each was rewritten elsewhere after this checkout bound it.`
|
|
74269
|
+
);
|
|
74270
|
+
}
|
|
74271
|
+
}
|
|
74272
|
+
writeGeneratedAliases(projectDir, { ...generated, retired: { queries: refused.queries, workflows: refused.workflows } });
|
|
74273
|
+
}
|
|
74274
|
+
const undeclared = withoutAliases(undeclaredAll, retired);
|
|
74275
|
+
const lostCallSite = withoutAliases(orphanedBindings(called, liveAfter, meta3.bundle_calls), retired);
|
|
74208
74276
|
const orphans = allOrphans(lostCallSite, undeclared);
|
|
74209
74277
|
let outstanding = orphans;
|
|
74210
74278
|
if (args.prune) {
|
|
@@ -74213,7 +74281,7 @@ async function appDeploy(client, args) {
|
|
|
74213
74281
|
"Nothing to prune yet: this project has no record of what the previous bundle called, so there is no way to tell a retired binding from an agent-facing one. This deploy records it; the next can compare."
|
|
74214
74282
|
);
|
|
74215
74283
|
}
|
|
74216
|
-
|
|
74284
|
+
const outcome = await pruneOrphanedBindings(
|
|
74217
74285
|
client,
|
|
74218
74286
|
projectDir,
|
|
74219
74287
|
{ app_id: meta3.app_id, workflows: liveAfter.workflows },
|
|
@@ -74223,6 +74291,7 @@ async function appDeploy(client, args) {
|
|
|
74223
74291
|
args.pruneInvoked ?? [],
|
|
74224
74292
|
args.acknowledgeBreakingApi
|
|
74225
74293
|
);
|
|
74294
|
+
outstanding = allOrphans(outcome.bound, outcome.rewritten);
|
|
74226
74295
|
} else {
|
|
74227
74296
|
warnAboutOrphanedBindings(called, liveAfter, meta3.bundle_calls, undeclared);
|
|
74228
74297
|
}
|
|
@@ -74779,6 +74848,20 @@ function undeclaredBindings(args) {
|
|
|
74779
74848
|
agents: removed("agents", meta3.agents, live.agents)
|
|
74780
74849
|
};
|
|
74781
74850
|
}
|
|
74851
|
+
function retiredBindings(retired, undeclared) {
|
|
74852
|
+
return {
|
|
74853
|
+
queries: undeclared.queries.filter((alias) => retired.queries.includes(alias)),
|
|
74854
|
+
workflows: undeclared.workflows.filter((alias) => retired.workflows.includes(alias)),
|
|
74855
|
+
agents: []
|
|
74856
|
+
};
|
|
74857
|
+
}
|
|
74858
|
+
function withoutAliases(set2, taken) {
|
|
74859
|
+
return {
|
|
74860
|
+
queries: set2.queries.filter((alias) => !taken.queries.includes(alias)),
|
|
74861
|
+
workflows: set2.workflows.filter((alias) => !taken.workflows.includes(alias)),
|
|
74862
|
+
agents: set2.agents.filter((alias) => !taken.agents.includes(alias))
|
|
74863
|
+
};
|
|
74864
|
+
}
|
|
74782
74865
|
function nothingOrphaned(orphans) {
|
|
74783
74866
|
return orphans.queries.length === 0 && orphans.workflows.length === 0 && orphans.agents.length === 0;
|
|
74784
74867
|
}
|
|
@@ -74828,10 +74911,13 @@ function warnAboutOrphanedBindings(called, live, previouslyCalled, undeclared) {
|
|
|
74828
74911
|
}
|
|
74829
74912
|
async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclared, dynamic, pruneInvoked, acknowledgeBreakingApi) {
|
|
74830
74913
|
const stillBound = { queries: [], workflows: [], agents: [] };
|
|
74914
|
+
const rewritten = { queries: [], workflows: [], agents: [] };
|
|
74915
|
+
const baselines = readSynced(projectDir);
|
|
74831
74916
|
const targets = [
|
|
74832
74917
|
...orphans.queries.map((alias) => ({
|
|
74833
74918
|
tool: "remove_app_query",
|
|
74834
74919
|
kind: "queries",
|
|
74920
|
+
expected: "expected_sha",
|
|
74835
74921
|
alias,
|
|
74836
74922
|
label: `query ${alias}`,
|
|
74837
74923
|
setCommand: `lotics app query set ${alias}`
|
|
@@ -74839,6 +74925,7 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclare
|
|
|
74839
74925
|
...orphans.workflows.map((alias) => ({
|
|
74840
74926
|
tool: "remove_app_workflow",
|
|
74841
74927
|
kind: "workflows",
|
|
74928
|
+
expected: "expected_body_sha",
|
|
74842
74929
|
alias,
|
|
74843
74930
|
label: `workflow ${alias}`,
|
|
74844
74931
|
setCommand: `lotics app workflow set ${alias}`
|
|
@@ -74846,12 +74933,13 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclare
|
|
|
74846
74933
|
...orphans.agents.map((alias) => ({
|
|
74847
74934
|
tool: "remove_app_agent",
|
|
74848
74935
|
kind: "agents",
|
|
74936
|
+
expected: "expected_instructions_sha",
|
|
74849
74937
|
alias,
|
|
74850
74938
|
label: `agent ${alias}`,
|
|
74851
74939
|
setCommand: `lotics app agent set ${alias}`
|
|
74852
74940
|
}))
|
|
74853
74941
|
];
|
|
74854
|
-
if (targets.length === 0) return stillBound;
|
|
74942
|
+
if (targets.length === 0) return { bound: stillBound, rewritten };
|
|
74855
74943
|
const deferred = dynamic.length > 0 ? targets.filter((t) => !undeclared[t.kind].includes(t.alias)) : [];
|
|
74856
74944
|
if (deferred.length > 0) {
|
|
74857
74945
|
console.error(
|
|
@@ -74863,7 +74951,7 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclare
|
|
|
74863
74951
|
for (const t of deferred) stillBound[t.kind].push(t.alias);
|
|
74864
74952
|
}
|
|
74865
74953
|
const prunable = targets.filter((t) => !deferred.includes(t));
|
|
74866
|
-
if (prunable.length === 0) return stillBound;
|
|
74954
|
+
if (prunable.length === 0) return { bound: stillBound, rewritten };
|
|
74867
74955
|
const tablesBefore = new Set(
|
|
74868
74956
|
resolveCodegenTableIds(readAppMeta(projectDir).queries ?? {}, app.workflows)
|
|
74869
74957
|
);
|
|
@@ -74878,15 +74966,24 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclare
|
|
|
74878
74966
|
let removedLocally = false;
|
|
74879
74967
|
for (const target of prunable) {
|
|
74880
74968
|
const overrideInvocationGuard = target.kind === "workflows" && pruneInvoked.includes(target.alias);
|
|
74969
|
+
const baseline = baselines[target.kind][target.alias]?.live;
|
|
74881
74970
|
const res = await client.execute(target.tool, {
|
|
74882
74971
|
app_id: app.app_id,
|
|
74883
74972
|
alias: target.alias,
|
|
74973
|
+
...baseline === void 0 ? {} : { [target.expected]: baseline },
|
|
74884
74974
|
// Sent only for the aliases the operator named, so the default stays the
|
|
74885
74975
|
// guard: a run recorded against an alias means something outside the
|
|
74886
74976
|
// bundle reaches it.
|
|
74887
74977
|
...overrideInvocationGuard ? { even_if_invoked: true } : {},
|
|
74888
74978
|
...acknowledgeBreakingApi ? { acknowledge_breaking_api_change: true } : {}
|
|
74889
74979
|
});
|
|
74980
|
+
if (res.error_code === STALE_BASELINE_CODE) {
|
|
74981
|
+
console.error(
|
|
74982
|
+
` \u2717 left ${target.label} bound \u2014 it was rewritten after this checkout last saw it, so the decision to unbind was about something else. lotics app pull takes the current one.`
|
|
74983
|
+
);
|
|
74984
|
+
rewritten[target.kind].push(target.alias);
|
|
74985
|
+
continue;
|
|
74986
|
+
}
|
|
74890
74987
|
if (res.error) {
|
|
74891
74988
|
console.error(` \u2717 could not unbind ${target.label} \u2014 ${res.error}`);
|
|
74892
74989
|
if (target.kind === "workflows" && !overrideInvocationGuard) {
|
|
@@ -74948,7 +75045,7 @@ async function pruneOrphanedBindings(client, projectDir, app, orphans, undeclare
|
|
|
74948
75045
|
);
|
|
74949
75046
|
}
|
|
74950
75047
|
}
|
|
74951
|
-
return stillBound;
|
|
75048
|
+
return { bound: stillBound, rewritten };
|
|
74952
75049
|
}
|
|
74953
75050
|
function derivedDeployMessage(pending) {
|
|
74954
75051
|
const parts = [
|
|
@@ -77033,7 +77130,7 @@ function describePlan(model, options = {}) {
|
|
|
77033
77130
|
const counting = window2 === void 0 ? "" : ` counting down, attention within ${window2} days`;
|
|
77034
77131
|
return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${under}${counting}${operated}`;
|
|
77035
77132
|
}
|
|
77036
|
-
if (entry.unbound === true) return `${entry.name} (unbound)`;
|
|
77133
|
+
if (entry.unbound === true) return `${entry.name} (unbound by the plan)`;
|
|
77037
77134
|
if (entry.ambiguous.length > 0) return `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
|
|
77038
77135
|
const takes = slotTakes(resolved.screen.shape, entry.name);
|
|
77039
77136
|
const roles = (takes.length === 0 ? [entry.role] : takes).map((role) => `"${role}"`);
|
|
@@ -77756,6 +77853,7 @@ function diffModelAgainstWorkspace(declared, live, binding) {
|
|
|
77756
77853
|
if (claimedFields.has(field)) continue;
|
|
77757
77854
|
lines.push({ side: "standing", line: ` ${entity.label}.${field.label}: in the workspace, not in the model` });
|
|
77758
77855
|
}
|
|
77856
|
+
lines.push(...uniqueDifferences(entity, here, boundFields));
|
|
77759
77857
|
}
|
|
77760
77858
|
for (const entity of live) {
|
|
77761
77859
|
if (claimed.has(entity)) continue;
|
|
@@ -77763,6 +77861,29 @@ function diffModelAgainstWorkspace(declared, live, binding) {
|
|
|
77763
77861
|
}
|
|
77764
77862
|
return lines;
|
|
77765
77863
|
}
|
|
77864
|
+
function uniqueDifferences(entity, here, fields) {
|
|
77865
|
+
const signature = (aliases) => [...aliases].sort().join("\0");
|
|
77866
|
+
const liveAlias = (alias) => {
|
|
77867
|
+
const found = fields.get(alias);
|
|
77868
|
+
return found?.how === "id" || found?.how === "label" ? found.live.alias : void 0;
|
|
77869
|
+
};
|
|
77870
|
+
const named3 = (among, tuple2) => tuple2.map((alias) => among.fields.find((field) => field.alias === alias)?.label ?? alias).join(" + ");
|
|
77871
|
+
const held = new Set((here.unique ?? []).map(signature));
|
|
77872
|
+
const stated3 = /* @__PURE__ */ new Set();
|
|
77873
|
+
const lines = [];
|
|
77874
|
+
for (const tuple2 of entity.unique ?? []) {
|
|
77875
|
+
const live = tuple2.flatMap((alias) => liveAlias(alias) ?? []);
|
|
77876
|
+
const whole = live.length === tuple2.length ? signature(live) : void 0;
|
|
77877
|
+
if (whole !== void 0) stated3.add(whole);
|
|
77878
|
+
if (whole !== void 0 && held.has(whole)) continue;
|
|
77879
|
+
lines.push({ side: "create", line: ` ${entity.label}: workspace lacks the unique set ${named3(entity, tuple2)}` });
|
|
77880
|
+
}
|
|
77881
|
+
for (const tuple2 of here.unique ?? []) {
|
|
77882
|
+
if (stated3.has(signature(tuple2))) continue;
|
|
77883
|
+
lines.push({ side: "standing", line: ` ${entity.label}: model lacks the unique set ${named3(here, tuple2)}` });
|
|
77884
|
+
}
|
|
77885
|
+
return lines;
|
|
77886
|
+
}
|
|
77766
77887
|
async function applyModelDocuments(client, model, options) {
|
|
77767
77888
|
const documents = modelRowDocuments(model.contract, model.rows).filter(
|
|
77768
77889
|
(document2) => document2.kind === "path"
|
|
@@ -82163,7 +82284,7 @@ function renderSummary(summary, dryRun) {
|
|
|
82163
82284
|
" A deploy will change what the live app runs:",
|
|
82164
82285
|
...deploy.added.length === 0 ? [] : [` add ${list2(deploy.added)}`],
|
|
82165
82286
|
...deploy.changed.length === 0 ? [] : [` change ${list2(deploy.changed)}`],
|
|
82166
|
-
...deploy.removed.length === 0 ? [] : [` remove ${list2(deploy.removed)} \u2014
|
|
82287
|
+
...deploy.removed.length === 0 ? [] : [` remove ${list2(deploy.removed)} \u2014 retired by the plan; the deploy unbinds it`]
|
|
82167
82288
|
],
|
|
82168
82289
|
...bindingLines(summary, dryRun),
|
|
82169
82290
|
...brandingLines(summary.branding),
|
|
@@ -82233,6 +82354,8 @@ async function foldRegeneration(client, args = {}) {
|
|
|
82233
82354
|
);
|
|
82234
82355
|
const synced = readSynced(projectDir).workflows;
|
|
82235
82356
|
const live = await client.getApp(String(lotics.app_id));
|
|
82357
|
+
const generatedNow = { queries: Object.keys(plan.queries), workflows: Object.keys(plan.workflows ?? {}) };
|
|
82358
|
+
const retiredNow = retiredAfter(declared, generatedNow);
|
|
82236
82359
|
const pending = withPlannedBodies(
|
|
82237
82360
|
pendingBindings({
|
|
82238
82361
|
projectDir,
|
|
@@ -82248,7 +82371,20 @@ async function foldRegeneration(client, args = {}) {
|
|
|
82248
82371
|
queries,
|
|
82249
82372
|
workflows,
|
|
82250
82373
|
droppedDeclarations: folded.dropped,
|
|
82251
|
-
deploy
|
|
82374
|
+
// What the deploy will unbind is read the way the deploy reads it, over the
|
|
82375
|
+
// manifest this run leaves.
|
|
82376
|
+
deploy: deployPreview(
|
|
82377
|
+
pending,
|
|
82378
|
+
live,
|
|
82379
|
+
retiredBindings(
|
|
82380
|
+
retiredNow,
|
|
82381
|
+
undeclaredBindings({
|
|
82382
|
+
meta: { queries: queries.entries, workflows: workflows.entries, agents: {} },
|
|
82383
|
+
synced: readSynced(projectDir),
|
|
82384
|
+
live
|
|
82385
|
+
})
|
|
82386
|
+
)
|
|
82387
|
+
),
|
|
82252
82388
|
pushed: [],
|
|
82253
82389
|
refused: [],
|
|
82254
82390
|
droppedPatch: generation.droppedPatch !== void 0,
|
|
@@ -82256,10 +82392,7 @@ async function foldRegeneration(client, args = {}) {
|
|
|
82256
82392
|
};
|
|
82257
82393
|
if (args.dryRun === true) return summary;
|
|
82258
82394
|
applyGeneration(projectDir, generation);
|
|
82259
|
-
writeGeneratedAliases(projectDir, {
|
|
82260
|
-
queries: Object.keys(plan.queries),
|
|
82261
|
-
workflows: Object.keys(plan.workflows ?? {})
|
|
82262
|
-
});
|
|
82395
|
+
writeGeneratedAliases(projectDir, { ...generatedNow, retired: retiredNow });
|
|
82263
82396
|
whole.lotics = {
|
|
82264
82397
|
...lotics,
|
|
82265
82398
|
plan: rememberedPlanReference(projectDir, reference),
|
|
@@ -82319,8 +82452,8 @@ function deployPreview(pending, live, retired) {
|
|
|
82319
82452
|
}
|
|
82320
82453
|
}
|
|
82321
82454
|
const removed = [
|
|
82322
|
-
...retired.queries.
|
|
82323
|
-
...retired.workflows.
|
|
82455
|
+
...retired.queries.map((alias) => `query ${alias}`),
|
|
82456
|
+
...retired.workflows.map((alias) => `workflow ${alias}`)
|
|
82324
82457
|
];
|
|
82325
82458
|
return { added: added.sort(), changed: changed.sort(), removed: removed.sort() };
|
|
82326
82459
|
}
|
package/docs/building_an_app.md
CHANGED
|
@@ -199,7 +199,8 @@ before you write one:
|
|
|
199
199
|
does not re-push nine workflows.
|
|
200
200
|
- **Deleting the declaration and the file does not unbind it.** The binding keeps serving, and
|
|
201
201
|
keeps being published to chat and to MCP. `lotics app deploy --prune` unbinds what the project no
|
|
202
|
-
longer names, and it is opt-in because an alias can be invoked from outside the bundle.
|
|
202
|
+
longer names, and it is opt-in because an alias can be invoked from outside the bundle. An alias
|
|
203
|
+
`lotics app regenerate` retired is the exception: the next deploy unbinds it unasked.
|
|
203
204
|
|
|
204
205
|
A workflow body is a file you open and edit. The new-alias path is typed from the first line:
|
|
205
206
|
|
package/docs/cli_reference.md
CHANGED
|
@@ -22,7 +22,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
22
22
|
| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone — `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |
|
|
23
23
|
| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** — `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too — every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |
|
|
24
24
|
| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` — every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> "<name>" (<id>) → <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |
|
|
25
|
-
| `lotics workspace build <model.json> [--dry-run] [--deploy]` | **A plan to live apps in one command**, composed out of the verbs that already own each step — it authors nothing, so every refusal a reader sees is the refusal the underlying command writes. In order: `scaffold check` (a model that does not check stops the run with its findings and nothing is written); the `scaffold diff` join against this workspace, printed; `scaffold apply` **only when that diff found something**, because the apply is additive and idempotent but costs a round trip per table and the common case is a model that has not moved; then, per app the plan declares, `app create --from <model.json>#<alias>` into `<dir-of-model>/<alias-with-dashes>` when that directory does not exist, else `app regenerate` there — which also sets the icon and colour the plan states where the live tile shows otherwise — then `app check`, then `app deploy` under `--deploy
|
|
25
|
+
| `lotics workspace build <model.json> [--dry-run] [--deploy]` | **A plan to live apps in one command**, composed out of the verbs that already own each step — it authors nothing, so every refusal a reader sees is the refusal the underlying command writes. In order: `scaffold check` (a model that does not check stops the run with its findings and nothing is written); the `scaffold diff` join against this workspace, printed; `scaffold apply` **only when that diff found something**, because the apply is additive and idempotent but costs a round trip per table and the common case is a model that has not moved; then, per app the plan declares, `app create --from <model.json>#<alias>` into `<dir-of-model>/<alias-with-dashes>` when that directory does not exist, else `app regenerate` there — which also sets the icon and colour the plan states where the live tile shows otherwise — then `app check`, then `app deploy` under `--deploy` — which unbinds every alias the regeneration retired (see `app deploy`). **One app's failure is not the run's.** A refusal or a red check is recorded and the next app still runs — the author is going to fix that one and run this again, and an app that never ran is an app whose state nobody knows — so the summary at the end carries a line per app (created/regenerated, files rewritten, the check verdict, the version deployed) and the exit code is 1 if any line is bad. **`--dry-run` writes nothing, locally or remotely** — it checks the model, prints the diff and names which apps it would create and which it would regenerate. A regenerated app's bindings ride its own deploy, so the check between the two is told as much rather than refusing the state the regeneration was asked to leave, and a run without `--deploy` ends by naming how many bindings still await one. Admin-only, like the apply it runs. |
|
|
26
26
|
| `lotics field rename <table> <field> "<new label>" [--model <file>] [--apps <dir>]` | **One field renamed everywhere it is addressed.** `<table>` and `<field>` each take a name or an id/key. ONE `update_table` call, then the places that call the old name: the label inside a `--model` file (rewritten as JSON, addressed by the entity and field the rename names, so a namesake label elsewhere is left alone), and every `--apps` project (repeatable) whose `src/**/*.{ts,tsx}` addresses `T.<field>`, `F.<TABLE>.<field>` or `OPT.<TABLE>.<field>.*` — each rewritten and then re-codegened, in that order, so no project is left holding new source against the old map. `T.` is rewritten only in a file that binds `const T = F.<TABLE>;` for THIS table: unscoped it would rename a namesake field on whatever table that file is about, which still compiles and reads the wrong column. **The old→new alias pair is read off the table's schema BEFORE and AFTER the write**, never off slugifying the new label alone: an alias is deduped against its neighbours (`ngay`, `ngay_2`), so a rename that frees a slug moves a field nobody touched — and computing it in isolation would leave that one addressed by a key the map no longer has. Everything after the one write reads its result, so a refused rename leaves the file and every project as they were. Admin-only. |
|
|
27
27
|
| `lotics tools` | List tools by category with descriptions |
|
|
28
28
|
| `lotics tools <name>` | Full description + JSON Schema for one tool |
|
|
@@ -45,10 +45,10 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
45
45
|
| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches — while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as "everything". |
|
|
46
46
|
| `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
|
|
47
47
|
| `lotics app create <name> [path] [--api]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1. **`--api`** scaffolds an app with NO screens instead: the app's declared queries and workflows are the whole of what it offers, called over HTTP by the customer's own site, server or agent (`POST /v1/apps/{app_id}/queries/{alias}` and `/workflows/{alias}/execute`). It writes `package.json` (the `lotics` block, `typescript` as its only devDependency, `typecheck` as its only script), `tsconfig.json`, the brief in `src/workflows/`, a CI workflow and the two READMEs — no `index.html`, no `src/App.tsx`, no `vite.config.ts`, no kit. Nothing is built and nothing is deployed, so `current_version_id` stays null: `lotics app query set` and `lotics app workflow set` publish each declaration on their own, and `lotics app api publish` snapshots what they promise. The npm registry is not consulted (there is no `@lotics/ui` or `@lotics/app-sdk` range to resolve), `npm install` still runs for `typescript` (which `app workflow check` loads), and, for want of a bundle, `app dev` and `app check --screens` (no `vite.config.*`) and `app deploy` (no `build` script) are refused on such a project in one sentence, before a binding is pushed; `app pull` says its project lives in version control, where `app codegen` rebuilds `.lotics/`. `--api` beside `--from` is refused before anything is written — a plan describes screens. The generated `package.json#name` is the app name FOLDED to ASCII (`Đơn hàng` → `don-hang`), never stripped of it — dropping the marks would treat each accented vowel as a separator and can slug a name away to nothing. The app's display name is unaffected; this is the npm field only. **`--from <model.json>#<app>` builds the app a PLAN describes, and it is JSON.** The file is checked as `scaffold check` checks it, the named app (or the only one) is taken, every screen's entity is found as the live table this WORKSPACE remembers the alias became — and BY LABEL only for an alias nothing has bound, which is a new entity or a workspace nothing has applied this model to — so a table or a column relabelled since the apply is still the same one, while a bound id the workspace no longer serves is refused by name rather than re-bound to its namesake; then every field the model declares on it — the record shows the ones the list leaves out — and, for each child entity a record section is over, its table and the fields that section draws, and, for each one-row link the record's facts let a reader RE-POINT, the target entity's table and the column its rows are picked by (`display_field_aliases`, else the target's `identity`) — a table the workspace lacks is refused first, naming every missing one and `scaffold apply`; then a field a table lacks, a label two tables or two fields share, or a field whose live type is not the model's — all before anything is created. **What it writes is `app.json`**: the bound plan, whole — every screen with its registry shape and the fields its slots read, its strip, lenses, summary and acts; the RECORD that shape opens, off the same roles (`recordSections`), with its header, its band, its facts in bands and its sections in the archetype's job order — a progress over the lifecycle, an expected set per required set, a children register per child entity, a files pile per files field; and the create panels, each with the inputs its workflow declares. Every id in it is live (`tbl_`, `fld_`, `opt_`). Beside it: a five-line `src/main.tsx` that mounts `@lotics/app-runtime` over the spec, `src/components/index.ts` seeded empty (the one hatch — a screen or a section the plan has no word for names a component there), one `src/workflows/<alias>.ts` per write, and the README. There is no screen source: **the runtime draws the spec**, so a kit correction reaches the app with its next `npm install` rather than with a regeneration, and `@lotics/app-runtime` is installed as a dependency for a plan-built app only. The manifest declares one `project` query per screen (every column the screen and its record read, a files cell whole, a dated book newest first) plus one per child entity, filtered to the record through whichever of those links names it and taking it as a declared `{{params.<entity>_id}}`, plus one per re-pointable link — the target's rows under their naming column alone, sorted by it, carrying the target's own `read_scope` and a `{{params.search}}` the picker narrows by server-side; the `.lotics` companions and `app_fields.ts` are written before the first build, and the deploy pushes the queries as it pushes any. **And it declares what the app WRITES** — `package.json#lotics.writes`, table alias → the field aliases each record surface's editor changes, derived from the same `update_<entity>` declarations it emits beside the bodies — **and what it DELETES**, `package.json#lotics.deletes`, the table aliases whose ROWS its bodies take (a delete names no column, so no field entry could carry it; today that is the publish desk's child, whose withdrawal is the one generated body that deletes). Seeded once: from then on the declaration is the app's, and `app check` refuses a body that writes or deletes outside it. A screen the plan marks `writes: false` contributes none. |
|
|
48
|
-
| `lotics app regenerate [--from <model.json>#<app>] [--dry-run] [--bind-new] [--screens]` | **Re-run the plan over an app that already exists, and rewrite its spec.** Run inside the app directory. The plan is whatever `package.json#lotics.plan` remembers — written there by `app create --from` and by this command — unless `--from` names another, which is then remembered in its place; no plan and no flag is a refusal, as is a directory with no `lotics.app_id`, a model that does not check (every finding printed), and a plan that names no such app. It resolves against the LIVE workspace through the same function `app create --from` resolves with, so the refusals and the output are that command's. **Files. The generator owns what it emits.** `app.json` is derived from the plan and rewritten whole — there is nothing in a spec to merge — the entry beside it never varies, and a workflow body is the generator's too: what it replaced goes into `.lotics/regenerate-dropped.patch`, file by file, rather than into a three-way merge, because a conflict marker inside a body is a file the server has to parse. A body is compared as its BODY, since `app codegen` wraps every one on disk in the header and `__workflow` envelope the server verifies against, so comparing bytes would read that wrapper as your edit on every app. **`src/components/` is the exception and the only one**: seeded where it is absent, named back where you have changed it, never rewritten — it is the hatch, so it is the app's code. A body whose alias the generator has retired is deleted. **Manifest.** `.lotics/generated/manifest.json` records the alias sets each generation declared, and that is what the reconciliation reads: queries and workflow declarations the generator emits replace their counterparts (each `workflow_id` carried over — the server minted it), aliases the last generation emitted and this one does not are removed and listed, and aliases you added by hand are kept. `lotics.writes` gains the generator's columns and `lotics.deletes` its tables, and each loses an entry on two facts only, each named in the summary: a column the live table no longer carries, and one nothing in the app WRITES any more (a delete retires on the second rule alone — it names no column for a rename to strand) (the bodies it holds after the run, plus this generation's own declaration — a column a screen only READS is not a write, and `app check` can never find one, because a declaration covering more than the bodies write refuses nothing). A body the subset refuses, or one calling a tool this CLI's registry does not know, suspends that second rule for the run: nothing is dropped on a guess. **It pushes NOTHING.** What the live app RUNS changes at `lotics app deploy` and nowhere else, because the bundle production serves was built against the bindings it has: a regeneration that replaced a live workflow body or a live picker query left a deployed create dialog posting inputs its workflow no longer declared, and a picker answering nothing. Instead the summary NAMES what a deploy will do to the live app — `add` for an alias it does not have, `change` for one this tree is ahead of, `remove` for one
|
|
48
|
+
| `lotics app regenerate [--from <model.json>#<app>] [--dry-run] [--bind-new] [--screens]` | **Re-run the plan over an app that already exists, and rewrite its spec.** Run inside the app directory. The plan is whatever `package.json#lotics.plan` remembers — written there by `app create --from` and by this command — unless `--from` names another, which is then remembered in its place; no plan and no flag is a refusal, as is a directory with no `lotics.app_id`, a model that does not check (every finding printed), and a plan that names no such app. It resolves against the LIVE workspace through the same function `app create --from` resolves with, so the refusals and the output are that command's. **Files. The generator owns what it emits.** `app.json` is derived from the plan and rewritten whole — there is nothing in a spec to merge — the entry beside it never varies, and a workflow body is the generator's too: what it replaced goes into `.lotics/regenerate-dropped.patch`, file by file, rather than into a three-way merge, because a conflict marker inside a body is a file the server has to parse. A body is compared as its BODY, since `app codegen` wraps every one on disk in the header and `__workflow` envelope the server verifies against, so comparing bytes would read that wrapper as your edit on every app. **`src/components/` is the exception and the only one**: seeded where it is absent, named back where you have changed it, never rewritten — it is the hatch, so it is the app's code. A body whose alias the generator has retired is deleted. **Manifest.** `.lotics/generated/manifest.json` records the alias sets each generation declared, and that is what the reconciliation reads: queries and workflow declarations the generator emits replace their counterparts (each `workflow_id` carried over — the server minted it), aliases the last generation emitted and this one does not are removed and listed, and aliases you added by hand are kept. `lotics.writes` gains the generator's columns and `lotics.deletes` its tables, and each loses an entry on two facts only, each named in the summary: a column the live table no longer carries, and one nothing in the app WRITES any more (a delete retires on the second rule alone — it names no column for a rename to strand) (the bodies it holds after the run, plus this generation's own declaration — a column a screen only READS is not a write, and `app check` can never find one, because a declaration covering more than the bodies write refuses nothing). A body the subset refuses, or one calling a tool this CLI's registry does not know, suspends that second rule for the run: nothing is dropped on a guess. **It pushes NOTHING.** What the live app RUNS changes at `lotics app deploy` and nowhere else, because the bundle production serves was built against the bindings it has: a regeneration that replaced a live workflow body or a live picker query left a deployed create dialog posting inputs its workflow no longer declared, and a picker answering nothing. Instead the summary NAMES what a deploy will do to the live app — `add` for an alias it does not have, `change` for one this tree is ahead of, `remove` for one the generator retired that this checkout bound and the app still serves (the next deploy unbinds it; the retirement is recorded in `.lotics/generated/manifest.json` until one does) — read through the same detector `app check` reports from and `app deploy` pushes from, so the preview cannot disagree with the deploy. **`--bind-new`** is the one live effect left: it binds the aliases the app does not have YET and refuses, naming them, to touch one that already exists, because `lotics app dev` forwards its queries to production and a new alias cannot be exercised until something binds it, while adding one the deployed bundle never calls cannot change what that bundle does. **The kit follows the runtime.** The spec is written for today's `@lotics/app-runtime`, so the directory's `@lotics/app-runtime`, `@lotics/ui` and `@lotics/app-sdk` ranges move to the runtime's latest line and the kit ranges its `peerDependencies` accept, in one install, and the summary names each move; a range already on that line is left as written, and a package installed from a checkout (`lotics app kit`) is left and named. The plan's icon and colour are compared with the live app's and a difference is named — `workspace build` sets it, since this command changes nothing live. Then `app codegen` runs, the summary prints — files written / kept / deleted, what a deploy will change, what was bound ahead, and `lotics app deploy` as the next command — and the fast `app check` runs (`--screens` passes through), with the bindings this run deliberately left ahead reported by the summary rather than failed by the check. `--dry-run` decides the whole run, prints it, and writes nothing: not a file, not a binding; it cannot be combined with `--bind-new`. |
|
|
49
49
|
| `lotics app eject <screen\|<section key>\|<act label>>` | **Hand ONE part of a JSON app's spec to the app — one-way, per part.** Run inside the app directory; it is local and offline, and touches no workspace. It writes `src/components/<Name>.tsx` whose whole body renders what `@lotics/app-runtime` was rendering for that part — `ScreenView` over the screen the spec still states, `RecordSectionView` over the node the section WAS, `ActView` over the act — points `app.json` at it (`component` on the screen, the section node replaced by `{"kind": "custom", …}`, or `component` on the act), and adds the import and the map entry to `src/components/index.ts`, which is what the app's entry reads its components from. The node a section was travels INTO the file, because the spec no longer states it — which also takes that section out of what `app check` proves, since the spec no longer names the columns it reads; the plan still declares the query behind it, so the manifest keeps the alias. The name is derived (`<Screen>Register`, `<Entity><Key>`, `<Address>Act`), so two ejects can never collide. **Targets**: `screen` (or the register's alias) for the register; a section by its `key`, or `<entity>.<key>` where two records carry the same one; an ACT by its label, its key, or its address (`screen#<key>` for the row's ⋯, the selection bar or the export, `<entity>#<key>` for a record's own header menu, `<entity>.<section>#<key>` for a verb on a section — the `#` is what keeps an act's address off a section's). **An act's eject ADDS a surface rather than taking one over**: a paper act's default is to make the document on the press, so the component is the PANEL it now opens instead — the runtime owns the dialog, the file starts at the runtime's own confirm-and-run body, and `props.run({ … })` makes the paper with whatever the panel asked for beside the rows it was pressed on. An AGENT act is refused: the panel a run is reviewed in is the kit's — started once, reviewed before it is applied, cancelled by closing — so there is nothing to hand over without handing those over too. **Refusals, all before anything is written**: a directory that is not a JSON app; an `app.json` that does not read (every finding printed); a part already ejected, naming the component it points at; a `src/components/<Name>.tsx` that already exists, because that file IS the ejected part and rewriting it would be the generator taking your screen away; a word that reaches two parts, naming every address; a name something under `src/components/` already exports; and a `src/components/index.ts` that no longer states `export const components: RuntimeComponents = { … }`, which names the two lines to add by hand rather than guessing at the author's own file. **It is one-way because the file is.** Dropping the clause by hand does not put the component back — delete the file too. `lotics app regenerate` keeps both: `app.json` is rewritten whole, and every `component` clause an eject wrote is folded back onto the fresh spec, so the parts you did NOT eject go on taking every ruling the runtime makes. A part whose key the plan later retires has no node left to point at, and its clause goes with it; the file stays. |
|
|
50
50
|
| `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, install dependencies (`npm ci --ignore-scripts` when a lockfile is present, else `npm install --ignore-scripts`), stamp package.json's `lotics` field. With no `[path]`: refresh the cwd IN PLACE when it's already this app's own project (its manifest `app_id` matches — the documented `cd <app> && lotics app pull` flow), else clone into an `<name>/` subdir. **A pull never overwrites a file that differs from what it is about to write** — it writes only what is ABSENT or already identical, keeps the rest, and reports which files it kept plus the commands that close the gap. The same rule covers `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, so an unpushed body or prompt survives too. Those two are written from the LIVE App row (`apps.workflows` / `apps.agents`), which owns them, and the archive's own copy of them is deliberately SKIPPED on extract: a deploy tars the whole source directory, so the tarball holds a deploy-time snapshot that is stale for anything authored since. The comparison is against the app's own content, not git, so it holds for a project that was never a repo. `--force` takes the app's copy and DISCARDS local edits; there is no other way to lose them. **For a KEPT workflow body or agent prompt the pull records the server's fingerprint only when the server has not moved** — that token is `set_app_workflow`/`set_app_agent`'s lost-update precondition, so recording one for text the author has not seen would clear the next push's refusal by disarming the guard, and silently overwrite whoever edited it. When the live text HAS moved, the pull writes it beside the checkout (`.lotics/agents/<alias>.live.md`, `.lotics/workflows/<alias>.live.ts`), names it, and leaves the token stale: the next deploy is refused, which is correct, and the text to merge is now on disk. A file whose prose/body already reads back AS the live text is never "kept" at all — the baselines are healed from it, so a checkout whose prose was pushed out of band (chat, `lotics run set_app_agent`) converges instead of latching. **A pull also REPORTS the files it restored** when refreshing a tree that already claimed a version: a pull mirrors the last DEPLOYED source, so a file deleted locally comes back until the deletion itself ships, and saying so is the only honest fix — nothing can read a deletion off the disk. **When a pull ACROSS versions keeps files, the manifest is left on the OLDER of the two versions** — the tree is then part one and part the other, and claiming the newer would make `deploy`'s `prev_version_id` check pass and ship a half-and-half bundle. Older, not "the one it had": `--from-version` pulls a deliberately old revision, so the version it had is the NEWER side, and holding that would match what the server serves and let the old source ship. Either way a deploy from that tree is refused until you reconcile the listed files by hand and pull again, or take the app's copy with `--force`. The report says which version each side is on, because a kept file is your unshipped work when the project was already current and merely the OLD version when it was behind — and nothing in a byte comparison can tell those apart. **`--from-version <apv_…>`** pulls an OLDER revision instead of the current one (`lotics app versions` lists the ids) — point it at a NEW path to read a previous revision without disturbing the project you are in. The manifest records the version actually written, never the live pointer, so a deploy from that checkout is refused by the version guard rather than shipping old source over newer. — `workflows` and `agents` are sourced from the live App row (NOT the archived manifest), so `set_app_workflow` / `set_app_agent` authoring survives the pull. Regenerates `.lotics/app_{workflows,queries,agents}.d.ts` so `useWorkflow` / `useQuery` / `useAgentRun` stay typed, AND — AFTER `npm install`, so `node_modules/@lotics/ui` actually exists to read — `.lotics/tsconfig.link.json`'s peer pins and, for a kit old enough to ship one, its `react-native` augmentation (a kit that ships none has the previously-written copy deleted); a pulled project's own `tsc` used to fail until `app codegen` was run by hand, because nothing had regenerated either one after install populated node_modules. AND the runtime `.lotics/app_fields.ts` (the same generation `app codegen` runs, off the app row already fetched). That one is not optional: `app deploy` tars source with `--exclude=.lotics`, so no archive can carry it, and a pulled project whose `src/` imports `F`/`OPT` would fail to build with `Could not resolve "../../.lotics/app_fields"` until `app codegen` was run by hand. Skipped under `--view-as` (the schema is read as that member and silently drops tables they cannot see — a narrowed `F` map compiles and then throws at runtime, worse than the missing module). A schema fetch failure is non-fatal and names the right recovery for what is on disk: an existing file is kept, an ABSENT one warns about the build error and points at `app codegen`. Pull GENERATES but never RECONCILES `.lotics/` — deleting a companion whose alias the manifest no longer declares is `app codegen`'s alone, since pull's authority is the server's alias set and a declared-but-not-yet-`set` alias is supported. Also writes one `src/workflows/<alias>.ts` per bound workflow (faithful body from `get_app_workflow`) and one `src/agents/<alias>.md` per bound agent (its instructions, straight off the live row) — so the prose an author actually edits lives in a file. A pull writes it from live UNLESS the local file holds unpushed work, in which case it is kept and the live text is parked beside the checkout — the same rule the rest of this row describes. A legacy workflow alias with no rendered source, or an agent with no instructions, warns and is skipped. The stamped `lotics.agents` map carries the TYPED half only (`inputs`/`outputs`/`tool_names`/`model_tier`/…) — an agent's prose lives solely in its `.md`, so there is never a second local copy to desync; a stale `instructions` left by an older CLI is inert and disappears on the next pull |
|
|
51
|
-
| `lotics app deploy [--prune] [--prune-invoked <alias>] [-m <message>] [--acknowledge-breaking-api]` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it pushed. Runs the app's `npm run typecheck` and `npm run build`, tars source + dist, POST /v1/apps/{id}/versions multipart. **One command ships everything.** Before the bundle moves it pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and FAILS the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` rides that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. Editing `lotics.agents.<alias>.inputs`/`outputs` is pushed the same way, and only those two fields (`set_app_agent` merges, so anything the manifest does not model is left untouched). The deploy never AUTHORS a binding itself, and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. It regenerates the `.lotics/*.d.ts` companions and `.lotics/app_fields.ts` before building — the build INLINES the latter — and then typechecks against them; a `package.json` with no `typecheck` script is warned about, never passed in silence. `lotics app check` reports the same set without pushing; neither has a `--strict`. The aliases the version RECORDS as called — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — are read by the SERVER out of the uploaded source archive, never reported by the client that is also what unbinds. **After the ship it reports two things and removes nothing.** Aliases the source CALLS that nothing bound. And bindings this project has RETIRED, which is two transitions, each with its own evidence: an alias the previous bundle called and this one does not (`package.json#lotics.bundle_calls`, recorded by each deploy), and an alias still bound live that this checkout holds a `lotics.synced.<kind>.<alias>` baseline for and no longer DECLARES. Neither piece of evidence present is an alias this checkout has never seen — bound by chat, by another operator, or after this tree was pulled — which is not a removal and is never a prune target. With no `bundle_calls` the first transition reports nothing; that deploy records it and the next can compare. The `bundle_calls` baseline is STICKY: it advances only once the call-site half is settled, so the `--prune` a warning names still finds the transition on a later run. **`--prune` unbinds them, and only when passed.** It runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares. When the source computes an alias at run time, the call-site half is left in place with a warning (the scan cannot tell which binding that call reaches); the declaration-removed half is unbound anyway, since deleting a declaration here states the removal outright. A removal DELETES the local declaration too — `package.json#lotics.<kind>.<alias>` and its `synced` baseline — or the next plain deploy would push it straight back; what it deleted is written to `.lotics/pruned/<kind>/<alias>.json` and the ✓ names that file plus the `set` verb that re-binds it. (These trees are never committed, so `lotics app pull --from-version <apv_…>` is the only other route back.) The generated companions are then regenerated from the narrowed manifest; a table named ONLY by a pruned query leaves `F`/`OPT`, which is reported — a workflow that still writes it keeps it, since the codegen set is the queries' tables plus every bound workflow's own `table_ids`. A binding that will not unbind is reported and never fails the release, and neither does a local write that fails: the version is already live, and the report names which aliases were unbound server-side. The server refuses to unbind a WORKFLOW this workspace has actually run — a recorded execution means a caller the source cannot name — printed as `✗ could not unbind …` with the date it last ran. **`--prune-invoked <alias>` lifts that guard for the alias you name** (repeatable, comma-separated; needs `--prune`, and is refused as a no-op without it), keeping the prune's report, undo file and manifest cleanup that a raw `lotics run remove_app_workflow` loses. Finally it refreshes `.lotics/workflows/<alias>.globals.d.ts` for any alias whose `// lotics:declaration` stamp says this deploy moved its declaration — from the manifest, re-wrapping the SAME on-disk body, so local edits survive. Non-fatal: the release has shipped, and stale types never fail it. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). It rides every write the release makes — the bindings pushed ahead of the bundle, the version itself, and a `--prune`'s unbinds — because the answer is about the RELEASE. |
|
|
51
|
+
| `lotics app deploy [--prune] [--prune-invoked <alias>] [-m <message>] [--acknowledge-breaking-api]` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it pushed. Runs the app's `npm run typecheck` and `npm run build`, tars source + dist, POST /v1/apps/{id}/versions multipart. **One command ships everything.** Before the bundle moves it pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and FAILS the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` rides that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. Editing `lotics.agents.<alias>.inputs`/`outputs` is pushed the same way, and only those two fields (`set_app_agent` merges, so anything the manifest does not model is left untouched). The deploy never AUTHORS a binding itself, and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. It regenerates the `.lotics/*.d.ts` companions and `.lotics/app_fields.ts` before building — the build INLINES the latter — and then typechecks against them; a `package.json` with no `typecheck` script is warned about, never passed in silence. `lotics app check` reports the same set without pushing; neither has a `--strict`. The aliases the version RECORDS as called — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — are read by the SERVER out of the uploaded source archive, never reported by the client that is also what unbinds. **After the ship it unbinds what the GENERATOR retired, unasked**: an alias `.lotics/generated/manifest.json` records as retired by `app regenerate` that this checkout bound and no longer declares — the same transition rule as below, so an alias another checkout bound, or one declared again by hand, is never touched. Every unbind — this one and `--prune`'s — carries the fingerprint the project last saw live, so an alias rewritten since (by chat, by another checkout) is refused and left bound, and a retired one is then dropped from the record, named. Beside that fingerprint alone the invocation guard is lifted, since the recorded runs are then the generated app's own calls to a body nobody rewrote; one that will not unbind for any other reason stays recorded and the next deploy retries it. **Beyond those it reports two things and removes nothing.** Aliases the source CALLS that nothing bound. And bindings this project has RETIRED, which is two transitions, each with its own evidence: an alias the previous bundle called and this one does not (`package.json#lotics.bundle_calls`, recorded by each deploy), and an alias still bound live that this checkout holds a `lotics.synced.<kind>.<alias>` baseline for and no longer DECLARES. Neither piece of evidence present is an alias this checkout has never seen — bound by chat, by another operator, or after this tree was pulled — which is not a removal and is never a prune target. With no `bundle_calls` the first transition reports nothing; that deploy records it and the next can compare. The `bundle_calls` baseline is STICKY: it advances only once the call-site half is settled, so the `--prune` a warning names still finds the transition on a later run. **`--prune` unbinds them, and only when passed.** It runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares. When the source computes an alias at run time, the call-site half is left in place with a warning (the scan cannot tell which binding that call reaches); the declaration-removed half is unbound anyway, since deleting a declaration here states the removal outright. A removal DELETES the local declaration too — `package.json#lotics.<kind>.<alias>` and its `synced` baseline — or the next plain deploy would push it straight back; what it deleted is written to `.lotics/pruned/<kind>/<alias>.json` and the ✓ names that file plus the `set` verb that re-binds it. (These trees are never committed, so `lotics app pull --from-version <apv_…>` is the only other route back.) The generated companions are then regenerated from the narrowed manifest; a table named ONLY by a pruned query leaves `F`/`OPT`, which is reported — a workflow that still writes it keeps it, since the codegen set is the queries' tables plus every bound workflow's own `table_ids`. A binding that will not unbind is reported and never fails the release, and neither does a local write that fails: the version is already live, and the report names which aliases were unbound server-side. The server refuses to unbind a WORKFLOW this workspace has actually run — a recorded execution means a caller the source cannot name — printed as `✗ could not unbind …` with the date it last ran. **`--prune-invoked <alias>` lifts that guard for the alias you name** (repeatable, comma-separated; needs `--prune`, and is refused as a no-op without it), keeping the prune's report, undo file and manifest cleanup that a raw `lotics run remove_app_workflow` loses. Finally it refreshes `.lotics/workflows/<alias>.globals.d.ts` for any alias whose `// lotics:declaration` stamp says this deploy moved its declaration — from the manifest, re-wrapping the SAME on-disk body, so local edits survive. Non-fatal: the release has shipped, and stale types never fail it. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). It rides every write the release makes — the bindings pushed ahead of the bundle, the version itself, and a `--prune`'s unbinds — because the answer is about the RELEASE. |
|
|
52
52
|
| `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it. Server-side it is the app's owner or an org admin, the same gate deploy and source download take — a `manager` share on somebody else's app does not reach it. Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. Title → stderr, table → stdout (pipeable). |
|
|
53
53
|
| `lotics app upgrade [app_id]` | `POST /v1/apps/{id}/upgrade` — apply the latest version of the package this app was COPIED from. A copy records its provenance (`apps.origin`: package, version, app alias and the `bind` it was made under) and this is the only thing that reads it — a hand-built app, or one copied before the column existed, has no package to offer one and answers 400. Run it once per app: a package's apps each carry their own provenance. **The schema is additive** — fields, options and views the new version declares are created under the recorded bind, so they land on the same tables the copy did; nothing is renamed, retyped or deleted, and a field the new version stopped declaring keeps its column and its data and is REPORTED. **An artifact is replaced only while it is still byte-for-byte what was delivered**: a workflow or agent you have edited here is kept as it is and named, so the offer is partial by design and every part it declined to touch is printed. Queries are replaced outright (generated from the contract, no edit to lose) and only a knowledge doc the new version ADDS is created. The app is then redeployed from the new version's prebuilt dist — **nothing local is read or sent**, so a checkout on this machine is behind afterwards and the report ends at `lotics app pull <app_id>`. app_id from the local manifest, or pass one to upgrade any app without pulling it. **Already on the latest version prints that one line and exits 0** — it is a refusal before the first write, not a failure, and re-applying the version it is on would re-stamp your edits as delivered. Every other refusal (an unpublished package, a contract that no longer validates, a bind the new version broke) is a package that cannot be applied: the app is untouched, the server's sentence is printed, and the exit is 1. Anything else — no provenance to read, not an admin, no such app — exits 1. Admin-only. Audited as `app.upgrade`. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). |
|
|
54
54
|
| `lotics app api publish [app_id]` \| `unpublish` \| `status` \| `spec [-o <file.json>]` | **The app's API — what its declared queries, workflows and agents promise to a caller OUTSIDE it** (a customer's own site or server, an integration, another system). An agent is in the contract as its alias plus whichever of `inputs` / `outputs` it declares, and the ABSENCE of either half is part of the promise: no `inputs` means a run accepts any object, no `outputs` means it answers free text. So declaring an input schema where there was none is breaking (a caller's own keys start being refused), dropping one is additive, and declaring or dropping `outputs` is breaking either way, because the result changes kind. `publish` (`POST /v1/apps/{id}/api/publish`) snapshots that promise as a numbered contract version and prints the version, when it was taken, and every warning about what the published surface exposes — a query anyone holding the public link can reach, a field an owner may not have meant to hand out. It is REFUSED (400) while a query does not name the columns it returns: those field names come from the table and would change under the consumer whenever the table does, so they are not the app's to promise — the refusal names each such alias and the `project` that fixes it, and nothing is written. **From the publish onward a manifest write is a release**: additive changes re-snapshot silently, and one that breaks what is promised is refused with every breaking change named, unless the write carries the acknowledgment (`--acknowledge-breaking-api` on `app deploy` / `app query set` / `app workflow set` / `app agent set` / `app upgrade`). There is no `app agent remove` verb, so removing a published agent is `app deploy --prune --acknowledge-breaking-api` once the bundle stops naming it. `unpublish` ends the promise; the superseded snapshot stays, so a later publish continues the numbering rather than reusing a version. `status` says whether one is published and which version its callers hold. `spec` prints the OpenAPI 3.1 document — rendered by the server from the SNAPSHOT rather than from the manifest, so it describes what the app has promised — to stdout, or to the file `-o` names; 404 while nothing is published. It carries one operation per query, per workflow and per agent, plus, whenever the contract holds an agent at all, the single `GET /v1/apps/{id}/agent-runs/{run_id}` where every run's result is read: an agent's own operation answers a `text/event-stream`, so its `output` shape is stated there and nowhere else. The app_id comes from the local manifest, or pass one to act on any app in the workspace. **`--json` answers `publish` / `unpublish` / `status` with one object on stdout and nothing else**, every warning carried in it rather than printed away; `spec` already prints a document there. **Who may run which**: starting and ending the promise is an organization ADMIN's — what an app hands outside the workspace is the same capability that declared it. `status` is the app's AUTHOR's (its owner, or an admin): whether their own app publishes anything is theirs to see. `spec` is reachable by whoever may USE the app, which on a publicly-shared app is anyone holding the link — it is the document a consumer generates their client from. |
|
|
@@ -59,7 +59,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
59
59
|
| `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: `fixtures`, `knowledge`, `knowledge_expects`, a file-backed `excel`/`word`/`pdf-form` template; its `apps` are a PLAN of screens, never built code), 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 platform-computed field, a files cell that is not a relative path beside the model or a `fil_` id, a document path with no file beside the model), then `field_roles` — every role on a field its type can answer — and the screen plan, every shape's slots bound from those roles, a required slot nothing fills refused. **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 the counts (`N tables, N fields, N links, N views, N roles, N rows`, plus `N apps, N screens` when the file plans any and `N custom` when a screen has no shape), then the plan — one line per screen with the field in each slot — then what the first rows would show, all on stdout. `--json` replaces both with one object and nothing else: `{ok: true, tables, fields, links, views, roles, rows, apps, screens, custom, plan, coverage}` 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. **It also prints WHO WRITES WHAT across the plan's apps** — one line per record surface left operable, naming the apps whose screens open it, and saying so when two desks write the same record: that is the shape behind an app changing a column its catalogue gives to another desk, and the plan is where it is visible before either app is built. A model cannot state a sanctioned split, so this is a reading rather than a refusal — the statement belongs in each built app's `package.json#lotics.writes`, where `shared_with` names the other desk and `app check` holds every body to it. On stdout with the plan and the coverage — the reference states that `check` prints it, which makes it part of the verdict rather than a diagnostic beside it — and in `--json` as `writers` (entity alias → app aliases). |
|
|
60
60
|
| `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 — and a declared PAIRING (`sync_both_ways` / `paired_field_alias`) over a link this workspace already has one-way is REFUSED before the first write, naming the alias and `update_fields sync_both_ways`, because a pairing is only ever created with the link and adoption would otherwise finish clean over a half-paired link. **After the first run the WORKSPACE remembers what each alias became**, so every later run binds entity, field, select option, template and role BY ID and a relabel on either side is a RENAME rather than a second table: the run reports each moved name with the command that reconciles it (`lotics field rename` moves the platform, the file and every bound app together; `update_table` moves a table's name), and applies anyway — which of the two names is right is the author's to decide, and the run bound the thing the model has always meant either way. A bound target the workspace no longer holds is refused by name, with `restore_table` and `lotics scaffold unbind` as the two ways out. **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 **and the delta that landed on it** (`+8 fields, +2 options, +1 view`, and nothing where the run added nothing) — `adopted` alone cannot report the columns, options and views a later version of a model puts on a table that is already the owner's, and the only other proof was a full re-export and a diff — marking `(bound by name)` the one case where a NAME decided which table the model points at; the same `created`/`adopted` per ROLE with its group id — an adopted role binds a group that already exists, which is how one silently inherits another workspace's members — and rows written per entity. **The binding is the workspace's, not the file's**: a model file is never committed, so memory kept beside it is one author's disk — absent for a teammate, on a second machine or after a delete, and each of those falls silently back to the label join that grows the twin. It holds what each entity, field, option, role and template alias became (`tbl_`/`fld_`/`opt_`/`grp_`/`tpl_`), plus one entry per first ROW under its own `<entity>:<ref>` and `documents` (path → `fil_`), and is read by `diff`, `--documents`, `app create --from`, `app regenerate --from` and `workspace build` before any of them reads a label. **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`, each with `bound_by`: `created` by this run, `id` through the binding, or `label` for an alias nothing had bound; `roles`, `record_ids`, `rows_skipped`, `drift`, `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 PLANS its apps as shapes over entities and builds none of them — `apply` creates no app; build one in the workspace and publish it as a package, or name a published package in `apply`. |
|
|
61
61
|
| `lotics scaffold apply <model.json> --documents` | **The `files` cells of `rows`, and nothing else** — no table, no field, no row. The half a re-apply cannot redo: rows land only into empty tables, so a model whose binaries were missed the first time has no other way back, and a starter that binds a `mark` or a file section ships with empty wells until this runs. Each distinct path is uploaded once, recorded against the file it became, and attached with `update_records add_to` onto the record the binding names — joined by the row's own REF (`<entity>:<ref>`), so a row added to or removed from the file since the apply changes nothing about the rest. **A ref this workspace holds no record for is a refusal**, naming it: the alternative is filing its document under whichever row happened to sit at that position. **Idempotent by construction**: an uploaded path is reused from the binding's `documents` map and `add_to` is a set union, so running it twice leaves one id in the cell. The field is bound by the id the binding recorded, and by LABEL only for a field added since — a label that has moved with nothing bound to it is refused, pointing at `lotics scaffold diff`. Admin-only. |
|
|
62
|
-
| `lotics scaffold diff <model.json>` | **Where the file and this workspace have come apart.** Runs `scaffold export` against the selected workspace and prints what the model has and the workspace lacks, the reverse, and every field the two disagree about — a type, the option labels one carries and the other does not, or a PAIRING the model declares over a link that is one-way here, which `apply` refuses and nothing else named. **Joined on the ids this workspace remembers**, entity then field then select option, and on LABEL only for an alias nothing has bound — a new entity, or a workspace this model has never been applied to, which is the join the first apply itself makes. A relabel on either side therefore prints as one rename under the alias (`order.state: label: model "Stage" · workspace "Giai đoạn"`) rather than as one thing the workspace lacks and one the model lacks, which is what made an additive apply add a second one. A bound id this workspace no longer serves is named with the command that puts it back, never silently re-bound by label. **Exits 1 on any difference**, so it is a gate: a starter published from a model that has drifted would ship the FILE's labels while the workspace uses others, and nothing else compares them. Checks the file offline first. Admin-only (the export is). |
|
|
62
|
+
| `lotics scaffold diff <model.json>` | **Where the file and this workspace have come apart.** Runs `scaffold export` against the selected workspace and prints what the model has and the workspace lacks, the reverse, and every field the two disagree about — a type, the option labels one carries and the other does not, or a PAIRING the model declares over a link that is one-way here, which `apply` refuses and nothing else named — and each `unique` set one side declares and the other lacks, compared as an unordered set of the workspace's fields. **Joined on the ids this workspace remembers**, entity then field then select option, and on LABEL only for an alias nothing has bound — a new entity, or a workspace this model has never been applied to, which is the join the first apply itself makes. A relabel on either side therefore prints as one rename under the alias (`order.state: label: model "Stage" · workspace "Giai đoạn"`) rather than as one thing the workspace lacks and one the model lacks, which is what made an additive apply add a second one. A bound id this workspace no longer serves is named with the command that puts it back, never silently re-bound by label. **Exits 1 on any difference**, so it is a gate: a starter published from a model that has drifted would ship the FILE's labels while the workspace uses others, and nothing else compares them. Checks the file offline first. Admin-only (the export is). |
|
|
63
63
|
| `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. **`--tables` names the closure it returned**, on stderr above the findings: the walk takes the transitive closure over links, so one seed in a connected workspace comes back with nearly all of it, and this file is what a preset is written from — a pull-in nobody stated is a preset nobody chose. **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. |
|
|
64
64
|
| `lotics scaffold unbind <kind> <alias>` | **Make this workspace forget what one of a model's aliases is bound to** — `DELETE /v1/workspaces/model/binding`. `kind` is `entity`, `field`, `option`, `template`, `role`, `row` or `document`; `alias` is spelled in that kind's own grammar (`order`, `order.state`, `order.state:open`, `order:first`, or a document's relative path). **The escape from a binding whose target was deleted**: every verb refuses that alias by name rather than quietly creating a second thing beside it, and this is where somebody who is sure says so. Deliberately a command of its own and never a flag on `apply` — forgetting means the next apply CREATES a new one, and nothing afterwards joins it to whatever was there. An alias is a namespace, so forgetting an entity forgets its fields, its options and its rows with it, and the count of what went is what it prints. 404 when nothing was bound. Admin-only. |
|
|
65
65
|
| `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. |
|