@lotics/cli 0.123.0 → 0.128.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 CHANGED
@@ -45587,8 +45587,8 @@ import { randomUUID } from "node:crypto";
45587
45587
  import { tmpdir } from "node:os";
45588
45588
 
45589
45589
  // src/starter_template.ts
45590
- var STARTER_FALLBACK_UI_VERSION = "27.11.0";
45591
- var STARTER_FALLBACK_SDK_VERSION = "0.52.0";
45590
+ var STARTER_FALLBACK_UI_VERSION = "28.1.0";
45591
+ var STARTER_FALLBACK_SDK_VERSION = "0.73.0";
45592
45592
  var STARTER_REACT_NATIVE_VERSION = "0.85.3";
45593
45593
  var VITEST_SETUP_FILENAME = "vitest.setup.ts";
45594
45594
  var VITEST_SETUP_CONTENT = `import { vi } from "vitest";
@@ -45793,7 +45793,6 @@ export default defineConfig({
45793
45793
  // const base = loticsResolve();
45794
45794
  // resolve: { ...base, alias: [...base.alias, { find: "x", replacement: "y" }] }
45795
45795
  resolve: loticsResolve(),
45796
- },
45797
45796
  optimizeDeps: {
45798
45797
  // The dev dep-optimizer must pre-bundle @lotics/ui's RN-ecosystem + markdown
45799
45798
  // CJS-interop deps or \`lotics app dev\` blanks the iframe ("does not provide an
@@ -47501,6 +47500,9 @@ function calledAppAliases(sourceText) {
47501
47500
  }
47502
47501
  return out;
47503
47502
  }
47503
+ function orphanedAliases(bound, called) {
47504
+ return unboundAliases(bound, called);
47505
+ }
47504
47506
  function unboundAliases(called, bound) {
47505
47507
  const missing = (from, against) => {
47506
47508
  const have = new Set(against ?? []);
@@ -64786,6 +64788,7 @@ var querySourceSchema = zod_default.lazy(
64786
64788
  zod_default.object({ expression: zod_default.string() })
64787
64789
  ])
64788
64790
  );
64791
+ var FILES_PROJECTION_MAX_LIMIT = 10;
64789
64792
  var queryProjectionColumnObjectSchema = zod_default.object({
64790
64793
  output: zod_default.string().optional().describe(
64791
64794
  "Output column name. Omit for a passthrough \u2014 it defaults to the source field key. Required only to rename, or for a computed source."
@@ -64801,6 +64804,9 @@ var queryProjectionColumnObjectSchema = zod_default.object({
64801
64804
  kind: zod_default.literal("source_field").describe("Currently the only writable target kind")
64802
64805
  }).optional().describe(
64803
64806
  "Marks this column as writable through to the originating source field. Valid only when `source` is a bare-string field reference and upstream addressing (source_table_id, source_field_key) is unambiguous. The compiler validates this and rejects otherwise."
64807
+ ),
64808
+ limit: zod_default.number().int().min(1).max(FILES_PROJECTION_MAX_LIMIT).optional().describe(
64809
+ "FILES columns only: return at most this many entries per cell, in stored order. For a list that renders ONE thumbnail per row \u2014 without it the column yields the whole array and the response pays to resolve every entry it will never show."
64804
64810
  )
64805
64811
  });
64806
64812
  var queryProjectionColumnSchema = zod_default.union([
@@ -70433,56 +70439,53 @@ function sameSchema(a, b) {
70433
70439
  };
70434
70440
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
70435
70441
  }
70436
- function agentTypeDivergences(manifestAgents, liveAgents) {
70437
- const out = [];
70438
- for (const [alias, declared] of Object.entries(manifestAgents ?? {})) {
70439
- const live = (liveAgents ?? {})[alias];
70440
- if (!live) {
70441
- out.push(
70442
- `${alias} \u2014 typed locally but NOT bound on the app, so useAgentRun("${alias}") 400s at runtime`
70443
- );
70444
- continue;
70445
- }
70446
- for (const key of AGENT_TYPED_KEYS) {
70447
- if (!sameSchema(declared[key], live[key])) {
70448
- out.push(`${alias}.${key} \u2014 local types disagree with the live agent`);
70449
- }
70450
- }
70451
- }
70452
- return out;
70453
- }
70454
70442
  function workflowTypeDivergences(manifestWorkflows, liveWorkflows) {
70455
70443
  const out = [];
70456
70444
  for (const [alias, declared] of Object.entries(manifestWorkflows ?? {})) {
70457
70445
  const live = (liveWorkflows ?? {})[alias];
70458
70446
  if (!live) {
70459
- out.push(`${alias} \u2014 declared locally but NOT bound on the app`);
70447
+ out.push({ alias });
70460
70448
  continue;
70461
70449
  }
70462
70450
  for (const key of ["inputs", "outputs"]) {
70463
- if (!sameSchema(declared[key], live[key])) {
70464
- out.push(`${alias}.${key} \u2014 the manifest declares a schema the live binding does not have`);
70465
- }
70451
+ if (!sameSchema(declared[key], live[key])) out.push({ alias, key });
70466
70452
  }
70467
70453
  }
70468
70454
  return out;
70469
70455
  }
70456
+ function syncedEntry(entry) {
70457
+ if (entry === void 0) return void 0;
70458
+ return typeof entry === "string" ? { content: entry } : entry;
70459
+ }
70470
70460
  function readSynced(projectDir) {
70471
70461
  try {
70472
70462
  const pkg2 = JSON.parse(
70473
70463
  fs6.readFileSync(path7.join(projectDir, "package.json"), "utf-8")
70474
70464
  );
70475
- return pkg2.lotics?.synced ?? {};
70465
+ const raw = pkg2.lotics?.synced ?? {};
70466
+ return {
70467
+ workflows: normalizeSyncedMap(raw.workflows),
70468
+ agents: normalizeSyncedMap(raw.agents),
70469
+ queries: normalizeSyncedMap(raw.queries)
70470
+ };
70476
70471
  } catch {
70477
- return {};
70472
+ return { workflows: {}, agents: {}, queries: {} };
70478
70473
  }
70479
70474
  }
70480
- function writeSynced(projectDir, kind, alias, sha) {
70475
+ function normalizeSyncedMap(map3) {
70476
+ return Object.fromEntries(
70477
+ Object.entries(map3 ?? {}).map(([alias, entry]) => [
70478
+ alias,
70479
+ syncedEntry(entry)
70480
+ ])
70481
+ );
70482
+ }
70483
+ function writeSynced(projectDir, kind, alias, entry) {
70481
70484
  const pkgPath2 = path7.join(projectDir, "package.json");
70482
70485
  const pkg2 = JSON.parse(fs6.readFileSync(pkgPath2, "utf-8"));
70483
70486
  if (!pkg2.lotics) return;
70484
70487
  pkg2.lotics.synced ??= {};
70485
- pkg2.lotics.synced[kind] = { ...pkg2.lotics.synced[kind], [alias]: sha };
70488
+ pkg2.lotics.synced[kind] = { ...pkg2.lotics.synced[kind], [alias]: entry };
70486
70489
  fs6.writeFileSync(pkgPath2, JSON.stringify(pkg2, null, 2) + "\n");
70487
70490
  }
70488
70491
  var WORKFLOWS_DIR = path7.join("src", "workflows");
@@ -70544,11 +70547,14 @@ function writeWorkflowGlobals(projectDir, alias, dts) {
70544
70547
  `);
70545
70548
  return file2;
70546
70549
  }
70550
+ function normalizeWorkflowBody(source) {
70551
+ return source.replace(/\s+$/, "");
70552
+ }
70547
70553
  function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: WORKFLOW_ENVELOPE_PREFIX, suffix: WORKFLOW_ENVELOPE_SUFFIX }) {
70548
70554
  const dir = path7.join(projectDir, WORKFLOWS_DIR);
70549
70555
  fs6.mkdirSync(dir, { recursive: true });
70550
70556
  const file2 = workflowFilePath(projectDir, alias);
70551
- const body = source.replace(/\s+$/, "");
70557
+ const body = normalizeWorkflowBody(source);
70552
70558
  fs6.writeFileSync(file2, `${workflowFileHeader(alias)}
70553
70559
  ${envelope.prefix}${body}${envelope.suffix}
70554
70560
  `);
@@ -70570,7 +70576,7 @@ function writeAgentFile(projectDir, alias, instructions) {
70570
70576
  );
70571
70577
  return file2;
70572
70578
  }
70573
- function writeAgentFiles(projectDir, agents) {
70579
+ function writeAgentFiles(projectDir, agents, opts) {
70574
70580
  const written = [];
70575
70581
  for (const [alias, declaration] of Object.entries(agents)) {
70576
70582
  const instructions = declaration.instructions;
@@ -70578,15 +70584,33 @@ function writeAgentFiles(projectDir, agents) {
70578
70584
  console.error(`\u26A0 Skipped src/agents/${alias}.md \u2014 the live declaration carries no instructions.`);
70579
70585
  continue;
70580
70586
  }
70587
+ if (!opts.overwrite && localPromptUnpushed(projectDir, alias)) {
70588
+ opts.kept.push(`src/agents/${alias}.md`);
70589
+ continue;
70590
+ }
70581
70591
  writeAgentFile(projectDir, alias, instructions);
70582
70592
  written.push(alias);
70583
70593
  }
70584
70594
  return written;
70585
70595
  }
70596
+ function localPromptUnpushed(projectDir, alias) {
70597
+ const file2 = agentFilePath2(projectDir, alias);
70598
+ if (!fs6.existsSync(file2)) return false;
70599
+ const baseline = readSynced(projectDir).agents[alias]?.content;
70600
+ if (baseline === void 0) return true;
70601
+ return contentSha(stripAgentHeader(fs6.readFileSync(file2, "utf-8"))) !== baseline;
70602
+ }
70586
70603
  function generatedWorkflowDescription(appName, alias) {
70587
70604
  return `Workflow invoked by app "${appName}" via useWorkflow("${alias}").`;
70588
70605
  }
70589
- async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows) {
70606
+ function localBodyUnpushed(projectDir, alias) {
70607
+ const file2 = workflowFilePath(projectDir, alias);
70608
+ if (!fs6.existsSync(file2)) return false;
70609
+ const baseline = readSynced(projectDir).workflows[alias]?.content;
70610
+ if (baseline === void 0) return true;
70611
+ return contentSha(stripWorkflowHeader(fs6.readFileSync(file2, "utf-8"))) !== baseline;
70612
+ }
70613
+ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows, opts) {
70590
70614
  const written = [];
70591
70615
  for (const [alias, declaration] of Object.entries(workflows)) {
70592
70616
  const res = await client.getAppWorkflow(app_id, alias);
@@ -70595,7 +70619,9 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
70595
70619
  `Could not read workflow "${alias}" of ${app_id}: ${res.error}. Reading app workflow source requires an admin member \u2014 check the role behind this API key.`
70596
70620
  );
70597
70621
  }
70598
- const source = res.result === null || typeof res.result !== "object" ? null : res.result.source;
70622
+ const result = res.result === null || typeof res.result !== "object" ? null : res.result;
70623
+ const source = result?.source;
70624
+ const body_sha = typeof result?.body_sha === "string" ? result.body_sha : void 0;
70599
70625
  if (typeof source !== "string" || source.trim() === "") {
70600
70626
  console.error(
70601
70627
  `\u26A0 Skipped src/workflows/${alias}.ts \u2014 legacy workflow with no rendered body.`
@@ -70609,8 +70635,15 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
70609
70635
  alias,
70610
70636
  toWorkflowDtsDeclaration(declaration)
70611
70637
  );
70638
+ if (!opts.overwrite && localBodyUnpushed(projectDir, alias)) {
70639
+ opts.kept.push(`src/workflows/${alias}.ts`);
70640
+ continue;
70641
+ }
70612
70642
  writeWorkflowFile(projectDir, alias, source, envelope);
70613
- writeSynced(projectDir, "workflows", alias, contentSha(source));
70643
+ writeSynced(projectDir, "workflows", alias, {
70644
+ content: contentSha(normalizeWorkflowBody(source)),
70645
+ ...body_sha ? { live: body_sha } : {}
70646
+ });
70614
70647
  const rowDescription = res.result.description;
70615
70648
  if (typeof rowDescription === "string" && rowDescription !== "" && // Skip the server's GENERATED default. It is what an undescribed alias
70616
70649
  // carries, so writing it back would commit a line of noise per workflow
@@ -70725,9 +70758,12 @@ function writeAppMeta(projectDir, meta3) {
70725
70758
  const pkgPath2 = path7.join(projectDir, "package.json");
70726
70759
  const pkg2 = JSON.parse(fs6.readFileSync(pkgPath2, "utf-8"));
70727
70760
  const existing = pkg2.lotics && typeof pkg2.lotics === "object" ? pkg2.lotics : {};
70761
+ const supplied = new Set(
70762
+ Object.entries(meta3).filter(([, value2]) => value2 !== void 0).map(([key]) => key)
70763
+ );
70728
70764
  const preserved = {};
70729
70765
  for (const [key, value2] of Object.entries(existing)) {
70730
- if (!APP_META_KEYS.includes(key)) preserved[key] = value2;
70766
+ if (!supplied.has(key)) preserved[key] = value2;
70731
70767
  }
70732
70768
  pkg2.lotics = { ...meta3, ...preserved };
70733
70769
  fs6.writeFileSync(pkgPath2, JSON.stringify(pkg2, null, 2) + "\n");
@@ -70747,7 +70783,7 @@ function writeWorkflowOutputs(projectDir, alias, outputs) {
70747
70783
  function workflowBodyDrift(projectDir, workflows, synced = {}) {
70748
70784
  const drifted = [];
70749
70785
  for (const alias of Object.keys(workflows ?? {})) {
70750
- const baseline = synced[alias];
70786
+ const baseline = syncedEntry(synced[alias])?.content;
70751
70787
  if (baseline === void 0) continue;
70752
70788
  const file2 = workflowFilePath(projectDir, alias);
70753
70789
  if (!fs6.existsSync(file2)) continue;
@@ -70757,17 +70793,6 @@ function workflowBodyDrift(projectDir, workflows, synced = {}) {
70757
70793
  }
70758
70794
  return drifted;
70759
70795
  }
70760
- function reportWorkflowBodyDrift(projectDir, workflows, synced = {}) {
70761
- const drifted = workflowBodyDrift(projectDir, workflows, synced);
70762
- if (drifted.length === 0) return false;
70763
- console.error(
70764
- `
70765
- \u2717 ${drifted.length === 1 ? "This workflow body differs" : "These workflow bodies differ"} from what is live: ${drifted.join(", ")}
70766
- A deploy does not push bodies, so this version would not reproduce what runs.
70767
- Send each with 'lotics app workflow set <alias>' \u2014 or revert the file to drop the edit.`
70768
- );
70769
- return true;
70770
- }
70771
70796
  function ensureAppTsconfig(projectDir) {
70772
70797
  const tsconfigPath = path7.join(projectDir, "tsconfig.json");
70773
70798
  if (!fs6.existsSync(tsconfigPath)) {
@@ -70882,6 +70907,7 @@ function writeAppDts(projectDir, manifest) {
70882
70907
  writeDevLinkTsconfig(projectDir);
70883
70908
  writeKitTypeAugmentation(projectDir);
70884
70909
  ensureAppTsconfig(projectDir);
70910
+ ensureAppVitestSetup(projectDir);
70885
70911
  return written.map(([file2]) => file2);
70886
70912
  }
70887
70913
  function readCodegenTablesAllowlist(projectDir) {
@@ -70909,7 +70935,6 @@ function writeBindingAppFields(projectDir, binding) {
70909
70935
  fs6.mkdirSync(dotLotics, { recursive: true });
70910
70936
  const file2 = path7.join(dotLotics, "app_fields.ts");
70911
70937
  fs6.writeFileSync(file2, generatePackageAppFields(binding));
70912
- ensureAppVitestSetup(projectDir);
70913
70938
  return file2;
70914
70939
  }
70915
70940
  async function writeGeneratedAppFields(client, projectDir, app, queries) {
@@ -70930,6 +70955,15 @@ async function writeGeneratedAppFields(client, projectDir, app, queries) {
70930
70955
  } else {
70931
70956
  const tableIds = resolveCodegenTableIds(projectDir, queries);
70932
70957
  const tables = await client.getWorkspaceSchema(tableIds);
70958
+ const missing = tableIds.filter((id) => !tables.some((t) => t.id === id));
70959
+ if (missing.length > 0) {
70960
+ console.error(
70961
+ `\u26A0 Skipped .lotics/app_fields.ts \u2014 ${missing.length} table(s) the app queries could not be read: ${missing.join(", ")}.
70962
+ Writing anyway would bake a narrowed F/OPT map that compiles and then throws at runtime. Check this key's access to those tables.
70963
+ An existing app_fields.ts is kept and still works; a project that has none yet will fail to BUILD until this resolves \u2014 which is the loud failure, not a new problem.`
70964
+ );
70965
+ return;
70966
+ }
70933
70967
  const fieldsPath = writeAppFields(projectDir, tables);
70934
70968
  console.error(
70935
70969
  `Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"})`
@@ -71216,11 +71250,15 @@ async function appPull(client, args) {
71216
71250
  const tmpFile = path7.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
71217
71251
  console.error(`Downloading source archive...`);
71218
71252
  await downloadToFile(sourceUrl, tmpFile);
71253
+ const keptLocal = [];
71254
+ const stagingDir = fs6.mkdtempSync(path7.join(tmpdir(), `lotics-pull-${app.id}-`));
71219
71255
  try {
71256
+ await runTar(["-xzf", tmpFile, "-C", stagingDir], stagingDir);
71220
71257
  console.error(`Extracting to ${targetPath}...`);
71221
- await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
71258
+ keptLocal.push(...copyTree(stagingDir, targetPath, { overwrite: args.force === true }));
71222
71259
  } finally {
71223
71260
  if (fs6.existsSync(tmpFile)) fs6.unlinkSync(tmpFile);
71261
+ fs6.rmSync(stagingDir, { recursive: true, force: true });
71224
71262
  }
71225
71263
  stampPulledManifest(targetPath, {
71226
71264
  app_id: app.id,
@@ -71232,7 +71270,8 @@ async function appPull(client, args) {
71232
71270
  agents: app.agents ?? {}
71233
71271
  });
71234
71272
  for (const [alias, declaration] of Object.entries(app.queries ?? {})) {
71235
- writeSynced(targetPath, "queries", alias, declarationSha(declaration));
71273
+ const sha = declarationSha(declaration);
71274
+ writeSynced(targetPath, "queries", alias, { content: sha, live: sha });
71236
71275
  }
71237
71276
  await writeGeneratedAppFields(
71238
71277
  client,
@@ -71242,7 +71281,10 @@ async function appPull(client, args) {
71242
71281
  );
71243
71282
  const workflows = app.workflows ?? {};
71244
71283
  if (Object.keys(workflows).length > 0) {
71245
- const written = await writeWorkflowFiles(client, targetPath, app.id, app.name, workflows);
71284
+ const written = await writeWorkflowFiles(client, targetPath, app.id, app.name, workflows, {
71285
+ overwrite: args.force === true,
71286
+ kept: keptLocal
71287
+ });
71246
71288
  if (written.length > 0) {
71247
71289
  console.error(
71248
71290
  `Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`
@@ -71251,10 +71293,15 @@ async function appPull(client, args) {
71251
71293
  }
71252
71294
  const agents = app.agents ?? {};
71253
71295
  if (Object.keys(agents).length > 0) {
71254
- const written = writeAgentFiles(targetPath, agents);
71296
+ const written = writeAgentFiles(targetPath, agents, {
71297
+ overwrite: args.force === true,
71298
+ kept: keptLocal
71299
+ });
71255
71300
  for (const alias of written) {
71256
71301
  const instructions = agents[alias]?.instructions;
71257
- if (instructions !== void 0) writeSynced(targetPath, "agents", alias, contentSha(instructions));
71302
+ if (instructions === void 0) continue;
71303
+ const sha = contentSha(instructions);
71304
+ writeSynced(targetPath, "agents", alias, { content: sha, live: sha });
71258
71305
  }
71259
71306
  if (written.length > 0) {
71260
71307
  console.error(
@@ -71264,6 +71311,7 @@ async function appPull(client, args) {
71264
71311
  }
71265
71312
  console.error(`Installing npm dependencies...`);
71266
71313
  await runNpm(["install"], targetPath);
71314
+ reportKeptLocalFiles(keptLocal, app.id);
71267
71315
  console.error(`
71268
71316
  Ready. Next steps:`);
71269
71317
  console.error(` cd ${path7.relative(process.cwd(), targetPath) || "."}`);
@@ -71289,35 +71337,33 @@ async function appDeploy(client, args) {
71289
71337
  const meta3 = readAppMeta(projectDir);
71290
71338
  warnAboutDevLink(projectDir, "deploy");
71291
71339
  const liveApp = await client.getApp(meta3.app_id);
71292
- const divergences = agentTypeDivergences(meta3.agents, liveApp.agents);
71293
- if (divergences.length > 0) {
71294
- console.error(
71295
- `
71296
- Refusing to deploy \u2014 package.json#lotics.agents disagrees with the live app:
71297
- ` + divergences.map((d) => ` \u2022 ${d}`).join("\n") + `
71298
-
71299
- lotics.agents is a REFLECTION of the app, not an authoring surface, but it is what
71300
- types useAgentRun \u2014 so this build was checked against an agent that does not exist.
71301
- To take the app's shape: lotics app codegen (refreshes the block + the types)
71302
- To CHANGE the agent instead: lotics run set_app_agent '{"app_id":"${meta3.app_id}","alias":"\u2026","outputs":{\u2026}}'
71303
- (send only what changes \u2014 the server merges), then codegen.`
71304
- );
71305
- process.exit(1);
71340
+ const pending = pendingBindings({
71341
+ projectDir,
71342
+ meta: meta3,
71343
+ synced: readSynced(projectDir).workflows,
71344
+ live: liveApp
71345
+ });
71346
+ if (!nothingPending(pending)) {
71347
+ await pushPendingBindings(client, projectDir, pending);
71306
71348
  }
71307
- const declarationDrift = reportWorkflowDeclarationDrift(meta3.workflows, liveApp.workflows);
71308
71349
  const sourceText = readAppSourceText(projectDir);
71309
71350
  const called = calledAppAliases(sourceText);
71310
- const proseDrift = reportAgentProseDrift(projectDir, liveApp.agents);
71311
- const bodyDrift = reportWorkflowBodyDrift(projectDir, meta3.workflows, readSynced(projectDir).workflows);
71312
- const queryDrift = reportQueryDrift(liveApp, meta3.queries ?? {});
71313
- if (bodyDrift || declarationDrift || proseDrift || queryDrift) {
71314
- console.error(`
71315
- Nothing was deployed.`);
71316
- process.exit(1);
71317
- }
71318
71351
  warnIfDynamicAliases(called);
71319
71352
  warnIfUndeclaredCapabilities(sourceText, meta3.capabilities);
71320
71353
  writeAppDts(projectDir, { workflows: meta3.workflows, queries: meta3.queries, agents: meta3.agents });
71354
+ try {
71355
+ await writeGeneratedAppFields(
71356
+ client,
71357
+ projectDir,
71358
+ { app_id: meta3.app_id, package_id: liveApp.package_id },
71359
+ meta3.queries ?? {}
71360
+ );
71361
+ } catch (err2) {
71362
+ console.error(
71363
+ `\u26A0 Could not regenerate .lotics/app_fields.ts (${err2.message}).
71364
+ The build will use whatever is on disk \u2014 run 'lotics app codegen' if this app is published.`
71365
+ );
71366
+ }
71321
71367
  console.error("Building...");
71322
71368
  await runNpm(["run", "build"], projectDir);
71323
71369
  const distDir = path7.join(projectDir, "dist");
@@ -71354,7 +71400,7 @@ Refusing to deploy \u2014 package.json#lotics.agents disagrees with the live app
71354
71400
  source_archive: fs6.readFileSync(tmpSource),
71355
71401
  dist_archive: fs6.readFileSync(tmpDist),
71356
71402
  prev_version_id: meta3.current_version_id,
71357
- message: args.message,
71403
+ message: args.message ?? derivedDeployMessage(pending),
71358
71404
  // Capabilities ARE manifest-authoritative: always send,
71359
71405
  // defaulting to `{}` when the manifest declares none — so deleting the
71360
71406
  // `capabilities` block turns every capability OFF on the next deploy
@@ -71379,13 +71425,24 @@ Refusing to deploy \u2014 package.json#lotics.agents disagrees with the live app
71379
71425
  });
71380
71426
  console.error(`Deployed v${result.version_number} (${result.version_id})`);
71381
71427
  console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
71428
+ let liveAfter;
71382
71429
  try {
71383
- const app = await client.getApp(meta3.app_id);
71384
- warnIfUnbranded(app);
71385
- warnIfUnboundAliases(app, called);
71430
+ liveAfter = await client.getApp(meta3.app_id);
71386
71431
  } catch (err2) {
71387
71432
  console.error(`(skipped post-deploy checks: ${err2.message})`);
71388
71433
  }
71434
+ if (liveAfter) {
71435
+ warnIfUnbranded(liveAfter);
71436
+ warnIfUnboundAliases(liveAfter, called);
71437
+ }
71438
+ if (liveAfter) {
71439
+ await pruneOrphanedBindings(
71440
+ client,
71441
+ meta3.app_id,
71442
+ orphanedBindings(called, liveAfter),
71443
+ called.dynamic
71444
+ );
71445
+ }
71389
71446
  } catch (err2) {
71390
71447
  const e = err2;
71391
71448
  if (e.code === "VERSION_CONFLICT") {
@@ -71425,78 +71482,233 @@ async function readLiveQueries(client, appId) {
71425
71482
  process.exit(1);
71426
71483
  }
71427
71484
  }
71428
- function reportQueryDrift(app, manifest) {
71429
- const drifted = driftedQueryAliases(manifest, app.queries);
71430
- if (drifted.length === 0) return false;
71431
- console.error(
71432
- `
71433
- \u2717 ${drifted.length} quer${drifted.length === 1 ? "y" : "ies"} in package.json differ from the app: ${drifted.join(", ")}
71434
- A deploy does not write them, so this version would not reproduce what runs.
71435
- Push yours with 'lotics app query set --all' (or one alias at a time),
71436
- or adopt the app's with 'lotics app pull'.`
71437
- );
71438
- return drifted.length > 0;
71439
- }
71440
71485
  async function appCheck(client, args = {}) {
71441
71486
  const projectDir = path7.resolve(args.projectDir ?? process.cwd());
71442
71487
  const meta3 = readAppMeta(projectDir);
71443
71488
  const app = await client.getApp(meta3.app_id);
71444
71489
  const sourceText = readAppSourceText(projectDir);
71445
71490
  const called = calledAppAliases(sourceText);
71446
- const divergences = agentTypeDivergences(meta3.agents, app.agents);
71447
- for (const d of divergences) {
71448
- console.error(`\u2717 package.json#lotics.agents.${d}`);
71449
- }
71450
- if (divergences.length > 0) {
71451
- console.error(
71452
- ` A deploy REFUSES this: the bundle would be typed against an agent that does not exist.
71453
- Take the app's shape with 'lotics app pull ${meta3.app_id}', or change the agent with set_app_agent.`
71454
- );
71455
- }
71456
71491
  warnAboutDevLink(projectDir, "deploy");
71457
- const proseDrift = reportAgentProseDrift(projectDir, app.agents);
71458
- const bodyDrift = reportWorkflowBodyDrift(projectDir, meta3.workflows, readSynced(projectDir).workflows);
71459
- const declarationDrift = reportWorkflowDeclarationDrift(meta3.workflows, app.workflows);
71460
- const queryDrift = reportQueryDrift(app, meta3.queries ?? {});
71492
+ warnAboutOrphanedBindings(called, app);
71493
+ const pending = pendingBindings({
71494
+ projectDir,
71495
+ meta: meta3,
71496
+ synced: readSynced(projectDir).workflows,
71497
+ live: app
71498
+ });
71499
+ reportPending(pending);
71461
71500
  warnIfDynamicAliases(called);
71462
71501
  warnIfUndeclaredCapabilities(sourceText, meta3.capabilities);
71463
71502
  warnIfUnboundAliases(app, called);
71464
71503
  warnIfUnbranded(app);
71465
- if (divergences.length > 0 || bodyDrift || declarationDrift || proseDrift || queryDrift) {
71504
+ if (!nothingPending(pending)) {
71466
71505
  process.exit(1);
71467
71506
  }
71468
71507
  console.error("Checked the app's bindings, capabilities and agent schemas \u2014 nothing blocking.");
71469
71508
  }
71470
- function reportAgentProseDrift(projectDir, liveAgents) {
71471
- let drifted = false;
71509
+ async function pushPendingBindings(client, projectDir, pending) {
71510
+ const plan = [
71511
+ ...pending.queries.map((alias) => `query ${alias}`),
71512
+ ...pending.workflows.map((alias) => `workflow ${alias}`),
71513
+ ...pending.agents.map((alias) => `agent ${alias}`)
71514
+ ];
71515
+ console.error(
71516
+ `
71517
+ Pushing ${plan.length} binding${plan.length === 1 ? "" : "s"} ahead of the app: ${plan.join(", ")}`
71518
+ );
71519
+ for (const alias of pending.queries) {
71520
+ await appQuerySet(client, { alias, projectDir });
71521
+ }
71522
+ for (const alias of pending.workflows) {
71523
+ await appWorkflowSet(client, { alias, projectDir });
71524
+ }
71525
+ for (const alias of pending.agents) {
71526
+ await appAgentSet(client, { alias, projectDir });
71527
+ }
71528
+ }
71529
+ function pendingBindings(args) {
71530
+ const { projectDir, meta: meta3, synced, live } = args;
71531
+ const workflows = /* @__PURE__ */ new Set([
71532
+ ...workflowBodyDrift(projectDir, meta3.workflows, synced),
71533
+ ...divergedWorkflowAliases(meta3.workflows, live.workflows)
71534
+ ]);
71535
+ return {
71536
+ workflows: [...workflows].sort(),
71537
+ agents: [
71538
+ .../* @__PURE__ */ new Set([
71539
+ ...driftedAgentAliases(projectDir, live.agents),
71540
+ // A manifest declaration that disagrees with the live row is a push now,
71541
+ // the same as a workflow's. `agentTypeDivergences` renders one line per
71542
+ // (alias, key) for a human, and the alias is its first token — but an
71543
+ // alias that diverged on both `inputs` and `outputs` still needs exactly
71544
+ // one push.
71545
+ ...divergedAgentAliases(meta3.agents, live.agents)
71546
+ ])
71547
+ ].sort(),
71548
+ // Only aliases the manifest DECLARES. `driftedQueryAliases` is symmetric —
71549
+ // it unions both key sets — so an alias deleted locally but still bound live
71550
+ // reads as drift, and queueing it sends the author to a push that refuses
71551
+ // ("declare it first") for a declaration they deliberately removed. A
71552
+ // deletion is not a push, and this detector only decides what to push;
71553
+ // unbinding is `remove_app_query`, which no deploy runs for any artifact.
71554
+ queries: [...driftedQueryAliases(meta3.queries ?? {}, live.queries)].filter((alias) => (meta3.queries ?? {})[alias] !== void 0).sort()
71555
+ };
71556
+ }
71557
+ function reportPending(pending) {
71558
+ const lines = [
71559
+ ...pending.workflows.map((a) => ` \u2022 workflow "${a}" \u2014 lotics app workflow set ${a}`),
71560
+ ...pending.agents.map((a) => ` \u2022 agent "${a}" \u2014 lotics app agent set ${a}`),
71561
+ ...pending.queries.map((a) => ` \u2022 query "${a}" \u2014 lotics app query set ${a}`)
71562
+ ];
71563
+ if (lines.length === 0) return;
71564
+ console.error(
71565
+ `
71566
+ \u2717 ${lines.length} binding${lines.length === 1 ? " is" : "s are"} ahead of the app:
71567
+ ` + lines.join("\n") + `
71568
+
71569
+ A version shipped now would not reproduce what runs. 'lotics app deploy' pushes
71570
+ these for you; revert the file instead to drop the edit.`
71571
+ );
71572
+ }
71573
+ function nothingPending(pending) {
71574
+ return pending.workflows.length === 0 && pending.agents.length === 0 && pending.queries.length === 0;
71575
+ }
71576
+ function divergedAgentAliases(manifestAgents, liveAgents) {
71577
+ const out = [];
71578
+ for (const [alias, declared] of Object.entries(manifestAgents ?? {})) {
71579
+ const live = (liveAgents ?? {})[alias];
71580
+ if (!live) continue;
71581
+ if (AGENT_TYPED_KEYS.some((key) => !sameSchema(declared[key], live[key]))) out.push(alias);
71582
+ }
71583
+ return out;
71584
+ }
71585
+ function driftedAgentAliases(projectDir, liveAgents) {
71586
+ const drifted = [];
71472
71587
  for (const [alias, live] of Object.entries(liveAgents ?? {})) {
71473
71588
  const file2 = agentFilePath2(projectDir, alias);
71474
71589
  if (!fs6.existsSync(file2)) continue;
71475
71590
  const local = stripAgentHeader(fs6.readFileSync(file2, "utf-8"));
71476
71591
  if (local === "" || local === (live.instructions ?? "")) continue;
71477
- drifted = true;
71592
+ drifted.push(alias);
71593
+ }
71594
+ return drifted.sort();
71595
+ }
71596
+ function divergedWorkflowAliases(manifestWorkflows, liveWorkflows) {
71597
+ return [
71598
+ ...new Set(workflowTypeDivergences(manifestWorkflows, liveWorkflows).map((d) => d.alias))
71599
+ ].sort();
71600
+ }
71601
+ function orphanedBindings(called, live) {
71602
+ const agentDeclarations = Object.values(live.agents ?? {});
71603
+ const referenced = {
71604
+ queries: [...called.queries, ...agentDeclarations.flatMap((a) => a.query_aliases ?? [])],
71605
+ workflows: [...called.workflows, ...agentDeclarations.flatMap((a) => a.workflow_aliases ?? [])],
71606
+ // Nothing declares an agent but the client bundle — an agent cannot run another.
71607
+ agents: called.agents
71608
+ };
71609
+ return orphanedAliases(
71610
+ {
71611
+ queries: Object.keys(live.queries ?? {}),
71612
+ workflows: Object.keys(live.workflows ?? {}),
71613
+ agents: Object.keys(live.agents ?? {})
71614
+ },
71615
+ referenced
71616
+ );
71617
+ }
71618
+ function warnAboutOrphanedBindings(called, live) {
71619
+ const orphans = orphanedBindings(called, live);
71620
+ const lines = [
71621
+ ...orphans.queries.map((a) => ` \u2022 query ${a}`),
71622
+ ...orphans.workflows.map((a) => ` \u2022 workflow ${a}`),
71623
+ ...orphans.agents.map((a) => ` \u2022 agent ${a}`)
71624
+ ];
71625
+ if (lines.length === 0) return 0;
71626
+ console.error(
71627
+ `
71628
+ \u26A0 ${lines.length} binding(s) are live but named nowhere in this bundle:
71629
+ ` + lines.join("\n") + (called.dynamic.length > 0 ? `
71630
+
71631
+ This app computes an alias at runtime, so one of these may be called after
71632
+ all \u2014 a deploy therefore leaves them ALL in place. Make those call sites
71633
+ literal and a deploy will clean them up.` : `
71634
+
71635
+ A deploy unbinds these after it ships.`)
71636
+ );
71637
+ return lines.length;
71638
+ }
71639
+ async function pruneOrphanedBindings(client, appId, orphans, dynamic) {
71640
+ const targets = [
71641
+ ...orphans.queries.map((alias) => ({ tool: "remove_app_query", alias, label: `query ${alias}` })),
71642
+ ...orphans.workflows.map((alias) => ({
71643
+ tool: "remove_app_workflow",
71644
+ alias,
71645
+ label: `workflow ${alias}`
71646
+ })),
71647
+ ...orphans.agents.map((alias) => ({ tool: "remove_app_agent", alias, label: `agent ${alias}` }))
71648
+ ];
71649
+ if (targets.length === 0) return 0;
71650
+ if (dynamic.length > 0) {
71478
71651
  console.error(
71479
71652
  `
71480
- \u2717 ${path7.relative(projectDir, file2)} differs from the live agent's instructions.
71481
- A deploy does not push prose, so this version would not reproduce what runs.
71482
- Send it with 'lotics app agent set ${alias}' \u2014 or revert the file to drop the edit.`
71653
+ \u26A0 Left ${targets.length} unused binding(s) in place: this app calls ${dynamic.join(" / ")} with an alias it computes at runtime, and a static scan cannot tell which binding that reaches \u2014 so any of these may be live:
71654
+ ` + targets.map((t) => ` \u2022 ${t.label}`).join("\n") + `
71655
+ Make those call sites literal and the next deploy will clean them up, or remove the ones you know are dead by hand.`
71483
71656
  );
71657
+ return targets.length;
71484
71658
  }
71485
- return drifted;
71659
+ let failed = 0;
71660
+ for (const target of targets) {
71661
+ const res = await client.execute(target.tool, { app_id: appId, alias: target.alias });
71662
+ if (res.error) {
71663
+ failed += 1;
71664
+ console.error(` \u2717 could not unbind ${target.label} \u2014 ${res.error}`);
71665
+ continue;
71666
+ }
71667
+ console.error(` \u2713 unbound ${target.label}`);
71668
+ }
71669
+ return failed;
71670
+ }
71671
+ function derivedDeployMessage(pending) {
71672
+ const parts = [
71673
+ ...pending.queries.map((a) => `query ${a}`),
71674
+ ...pending.workflows.map((a) => `workflow ${a}`),
71675
+ ...pending.agents.map((a) => `agent ${a}`)
71676
+ ];
71677
+ return parts.length === 0 ? "Deployed app code" : `Deployed app code; pushed ${parts.join(", ")}`;
71678
+ }
71679
+ function copyTree(from, to, opts, relative = "") {
71680
+ const kept = [];
71681
+ for (const entry of fs6.readdirSync(from, { withFileTypes: true })) {
71682
+ const src = path7.join(from, entry.name);
71683
+ const dest = path7.join(to, entry.name);
71684
+ const rel = path7.join(relative, entry.name);
71685
+ if (entry.isDirectory()) {
71686
+ fs6.mkdirSync(dest, { recursive: true });
71687
+ kept.push(...copyTree(src, dest, opts, rel));
71688
+ continue;
71689
+ }
71690
+ if (!opts.overwrite && fs6.existsSync(dest)) {
71691
+ if (fs6.readFileSync(src).equals(fs6.readFileSync(dest))) continue;
71692
+ kept.push(rel);
71693
+ continue;
71694
+ }
71695
+ fs6.copyFileSync(src, dest);
71696
+ }
71697
+ return kept;
71486
71698
  }
71487
- function reportWorkflowDeclarationDrift(manifestWorkflows, liveWorkflows) {
71488
- const divergences = workflowTypeDivergences(manifestWorkflows, liveWorkflows);
71489
- if (divergences.length === 0) return false;
71699
+ function reportKeptLocalFiles(kept, appId) {
71700
+ if (kept.length === 0) return;
71490
71701
  console.error(
71491
71702
  `
71492
- \u2717 package.json#lotics.workflows disagrees with the live app:
71493
- ` + divergences.map((d) => ` \u2022 ${d}`).join("\n") + `
71494
-
71495
- This build's types came from the manifest, so the mismatch is invisible to tsc \u2014
71496
- the bundle would ship typed against a schema the live binding rejects.
71497
- Push the declarations: lotics app workflow set <alias> (a deploy never binds one)`
71703
+ ${kept.length} local file(s) differ from the app and were KEPT, not overwritten:
71704
+ ` + kept.slice(0, 20).map((f) => ` \u2022 ${f}`).join("\n") + (kept.length > 20 ? `
71705
+ \u2026 and ${kept.length - 20} more` : ``) + `
71706
+
71707
+ Everything else was refreshed from the app. Your edits are intact.
71708
+ Ship them: lotics app deploy (pushes bodies, prompts and declarations)
71709
+ See the difference: lotics app check
71710
+ Take the app's copy: lotics app pull ${appId} --force (DISCARDS the files above)`
71498
71711
  );
71499
- return true;
71500
71712
  }
71501
71713
  function warnIfDynamicAliases(called) {
71502
71714
  if (called.dynamic.length === 0) return;
@@ -71760,7 +71972,7 @@ Agent "${args.alias}" run ${run.id} \u2192 ${run.status}${run.error_message ? `:
71760
71972
  if (run.status !== "completed") process.exit(1);
71761
71973
  }
71762
71974
  async function appAgentSet(client, args) {
71763
- const projectDir = process.cwd();
71975
+ const projectDir = args.projectDir ?? process.cwd();
71764
71976
  const meta3 = readAppMeta(projectDir);
71765
71977
  noteScope({ alias: args.alias });
71766
71978
  const app = await client.getApp(meta3.app_id);
@@ -71771,37 +71983,51 @@ async function appAgentSet(client, args) {
71771
71983
  );
71772
71984
  process.exit(1);
71773
71985
  }
71986
+ const declared = meta3.agents?.[args.alias];
71987
+ const hasDeclaration = declared?.inputs !== void 0 || declared?.outputs !== void 0;
71774
71988
  const file2 = agentFilePath2(projectDir, args.alias);
71775
- if (!fs6.existsSync(file2)) {
71989
+ if (!fs6.existsSync(file2) && !hasDeclaration) {
71776
71990
  console.error(
71777
- `No instructions at ${path7.relative(projectDir, file2)}. Run 'lotics app pull ${meta3.app_id}' to write ${AGENTS_DIR}/${args.alias}.md, then edit it.`
71991
+ `No instructions at ${path7.relative(projectDir, file2)}, and nothing declared in package.json#lotics.agents.${args.alias} to push instead.
71992
+ Run 'lotics app pull ${meta3.app_id}' to write ${AGENTS_DIR}/${args.alias}.md, then edit it.`
71778
71993
  );
71779
71994
  process.exit(1);
71780
71995
  }
71781
- const instructions = stripAgentHeader(fs6.readFileSync(file2, "utf-8"));
71996
+ const instructions = fs6.existsSync(file2) ? stripAgentHeader(fs6.readFileSync(file2, "utf-8")) : void 0;
71782
71997
  if (instructions === "") {
71783
71998
  console.error(
71784
71999
  `${path7.relative(projectDir, file2)} is empty after stripping the header \u2014 refusing to push an empty prompt.`
71785
72000
  );
71786
72001
  process.exit(1);
71787
72002
  }
72003
+ const proseBaseline = readSynced(projectDir).agents[args.alias]?.live;
71788
72004
  const res = await client.setAppAgent(meta3.app_id, args.alias, {
71789
- instructions,
71790
- // Conditional on the prose this edit was built from, so a push from a stale
71791
- // checkout is refused instead of overwriting an edit made elsewhere.
71792
- ...readSynced(projectDir).agents?.[args.alias] ? { expected_instructions_sha: readSynced(projectDir).agents?.[args.alias] } : {}
72005
+ // Prose and its precondition ride together. Gating a declaration-only push on
72006
+ // the prose baseline would refuse it because someone edited a PROMPT — a
72007
+ // precondition must guard what this call actually writes.
72008
+ ...instructions !== void 0 ? { instructions, ...proseBaseline ? { expected_instructions_sha: proseBaseline } : {} } : {},
72009
+ ...declared?.inputs !== void 0 ? { inputs: declared.inputs } : {},
72010
+ ...declared?.outputs !== void 0 ? { outputs: declared.outputs } : {}
71793
72011
  });
71794
72012
  if (res.error) {
71795
72013
  console.error(`Failed to set agent "${args.alias}": ${res.error}`);
71796
72014
  process.exit(1);
71797
72015
  }
71798
- writeSynced(projectDir, "agents", args.alias, contentSha(instructions));
72016
+ if (instructions !== void 0) {
72017
+ const proseSha = contentSha(instructions);
72018
+ writeSynced(projectDir, "agents", args.alias, { content: proseSha, live: proseSha });
72019
+ }
72020
+ const pushed = [
72021
+ ...instructions !== void 0 ? [`${instructions.length} chars of instructions`] : [],
72022
+ ...declared?.inputs !== void 0 ? ["inputs"] : [],
72023
+ ...declared?.outputs !== void 0 ? ["outputs"] : []
72024
+ ];
71799
72025
  console.error(
71800
- `Set agent "${args.alias}" (${instructions.length} chars of instructions` + (live.model_tier ? `, ${live.model_tier}` : "") + `). Typed fields left as they are.`
72026
+ `Set agent "${args.alias}" (${pushed.join(", ")}` + (live.model_tier ? `, ${live.model_tier}` : "") + `). Everything else left as it is.`
71801
72027
  );
71802
72028
  }
71803
72029
  async function appWorkflowSet(client, args) {
71804
- const projectDir = process.cwd();
72030
+ const projectDir = args.projectDir ?? process.cwd();
71805
72031
  const meta3 = readAppMeta(projectDir);
71806
72032
  const declaration = meta3.workflows?.[args.alias];
71807
72033
  if (!declaration) {
@@ -71813,7 +72039,9 @@ async function appWorkflowSet(client, args) {
71813
72039
  const file2 = workflowFilePath(projectDir, args.alias);
71814
72040
  if (!fs6.existsSync(file2)) {
71815
72041
  console.error(
71816
- `No workflow body at ${path7.relative(projectDir, file2)}. Run 'lotics app pull ${meta3.app_id}' to write src/workflows/${args.alias}.ts, then edit it.`
72042
+ `No workflow body at ${path7.relative(projectDir, file2)}.
72043
+ If "${args.alias}" is already bound: lotics app pull ${meta3.app_id} (writes the body)
72044
+ If it is a NEW alias you declared: create that file yourself, then re-run this.`
71817
72045
  );
71818
72046
  process.exit(1);
71819
72047
  }
@@ -71838,13 +72066,17 @@ async function appWorkflowSet(client, args) {
71838
72066
  // The body this edit was built on. Turns the push into a conditional write:
71839
72067
  // if the live body moved since this project last saw it, the server refuses
71840
72068
  // rather than letting this copy overwrite a change nobody here has seen.
71841
- expected_body_sha: readSynced(projectDir).workflows?.[args.alias]
72069
+ expected_body_sha: readSynced(projectDir).workflows[args.alias]?.live
71842
72070
  });
71843
72071
  if (res.error) {
71844
72072
  console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
71845
72073
  process.exit(1);
71846
72074
  }
71847
- writeSynced(projectDir, "workflows", args.alias, contentSha(source));
72075
+ const pushedBodySha = res.result !== null && typeof res.result === "object" ? res.result.body_sha : void 0;
72076
+ writeSynced(projectDir, "workflows", args.alias, {
72077
+ content: contentSha(source),
72078
+ ...typeof pushedBodySha === "string" ? { live: pushedBodySha } : {}
72079
+ });
71848
72080
  const result = res.result ?? {};
71849
72081
  const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
71850
72082
  console.error(`Set workflow "${args.alias}" \u2192 ${workflowId}`);
@@ -71864,7 +72096,7 @@ async function appWorkflowSet(client, args) {
71864
72096
  }
71865
72097
  }
71866
72098
  async function appQuerySet(client, args) {
71867
- const projectDir = process.cwd();
72099
+ const projectDir = args.projectDir ?? process.cwd();
71868
72100
  const meta3 = readAppMeta(projectDir);
71869
72101
  const declared = meta3.queries ?? {};
71870
72102
  const aliases = "all" in args ? Object.keys(declared).sort() : [args.alias];
@@ -71886,21 +72118,22 @@ async function appQuerySet(client, args) {
71886
72118
  meta3.app_id,
71887
72119
  alias,
71888
72120
  declaration,
71889
- readSynced(projectDir).queries?.[alias]
72121
+ readSynced(projectDir).queries[alias]?.live
71890
72122
  );
71891
72123
  if (res.error) {
71892
72124
  if (pushed.length > 0) console.error(`Pushed before the failure: ${pushed.join(", ")}.`);
71893
72125
  console.error(`Failed to set query "${alias}": ${res.error}`);
71894
72126
  process.exit(1);
71895
72127
  }
71896
- writeSynced(projectDir, "queries", alias, declarationSha(declaration));
72128
+ const querySha = declarationSha(declaration);
72129
+ writeSynced(projectDir, "queries", alias, { content: querySha, live: querySha });
71897
72130
  pushed.push(alias);
71898
72131
  }
71899
72132
  console.error(
71900
72133
  `Set ${pushed.length} quer${pushed.length === 1 ? "y" : "ies"} on ${meta3.app_id}: ${pushed.join(", ")}.`
71901
72134
  );
71902
72135
  }
71903
- async function appWorkflowPull(client) {
72136
+ async function appWorkflowPull(client, args = {}) {
71904
72137
  const projectDir = process.cwd();
71905
72138
  const meta3 = readAppMeta(projectDir);
71906
72139
  const app = await client.getApp(meta3.app_id);
@@ -71909,10 +72142,15 @@ async function appWorkflowPull(client) {
71909
72142
  console.error(`App ${meta3.app_id} has no bound workflows.`);
71910
72143
  return;
71911
72144
  }
71912
- const written = await writeWorkflowFiles(client, projectDir, meta3.app_id, app.name, workflows);
72145
+ const kept = [];
72146
+ const written = await writeWorkflowFiles(client, projectDir, meta3.app_id, app.name, workflows, {
72147
+ overwrite: args.force === true,
72148
+ kept
72149
+ });
71913
72150
  console.error(
71914
72151
  `Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` + (written.length > 0 ? ` (${written.join(", ")})` : "")
71915
72152
  );
72153
+ reportKeptLocalFiles(kept, meta3.app_id);
71916
72154
  ensureAppTsconfig(projectDir);
71917
72155
  }
71918
72156
  async function appWorkflowCheck(args) {
@@ -71995,6 +72233,7 @@ ${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - pa
71995
72233
  function parseArgs(argv) {
71996
72234
  const flags = {
71997
72235
  json: false,
72236
+ force: false,
71998
72237
  timeout: void 0,
71999
72238
  output: void 0,
72000
72239
  as: void 0,
@@ -72027,6 +72266,9 @@ function parseArgs(argv) {
72027
72266
  case "--json":
72028
72267
  flags.json = true;
72029
72268
  break;
72269
+ case "--force":
72270
+ flags.force = true;
72271
+ break;
72030
72272
  case "--timeout":
72031
72273
  flags.timeout = parseInt(argv[++i2], 10);
72032
72274
  break;
@@ -100725,17 +100967,11 @@ Available workspaces:`);
100725
100967
  process.exit(1);
100726
100968
  }
100727
100969
  const targetPath = restArgs[0];
100728
- await appPull(client, { app_id: appId, targetPath });
100970
+ await appPull(client, { app_id: appId, targetPath, force: flags.force === true });
100729
100971
  return;
100730
100972
  }
100731
100973
  if (subcommand === "deploy") {
100732
- const message2 = (flags.message ?? toolArgs)?.trim();
100733
- if (!message2) {
100734
- console.error('Usage: lotics app deploy -m "<what changed + why>"');
100735
- console.error("A deploy message is required \u2014 it's the version's audit trail (see `lotics app versions`).");
100736
- process.exit(1);
100737
- }
100738
- await appDeploy(client, { message: message2 });
100974
+ await appDeploy(client, { message: (flags.message ?? toolArgs)?.trim() || void 0 });
100739
100975
  return;
100740
100976
  }
100741
100977
  if (subcommand === "subdomain") {
@@ -100816,7 +101052,7 @@ Available workspaces:`);
100816
101052
  return;
100817
101053
  }
100818
101054
  if (action === "pull") {
100819
- await appWorkflowPull(client);
101055
+ await appWorkflowPull(client, { force: flags.force === true });
100820
101056
  return;
100821
101057
  }
100822
101058
  workflowUsage();
@@ -244,6 +244,14 @@ export declare class LoticsClient {
244
244
  model_tier?: string;
245
245
  inputs?: Record<string, unknown>;
246
246
  outputs?: Record<string, unknown>;
247
+ /**
248
+ * The app's own queries/workflows this agent may call through
249
+ * `run_app_query` / `run_app_workflow`. These are REFERENCES that never
250
+ * appear in the client bundle, so anything asking "is this alias still
251
+ * used" must read them or it will answer no for an agent-driven app.
252
+ */
253
+ query_aliases?: string[];
254
+ workflow_aliases?: string[];
247
255
  }> | null;
248
256
  /**
249
257
  * Installation-level customization config values (the `useConfig()`
@@ -29,11 +29,11 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
29
29
  | `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>]` | Call `update_knowledge` with **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). At least one field required; --from and --content are mutually exclusive. |
30
30
  | `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. |
31
31
  | `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1 |
32
- | `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, npm install, 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; this avoids the stray nested `./<name>/` subdir a pull-from-inside-the-app used to drop. — `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 the runtime `.lotics/app_fields.ts` (the same linked-vs-bespoke branch `app codegen` runs, off the app row already fetched — see that row for the two forms). 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. The write NAMES the form and the reason, because an in-place pull can FLIP a project between them (`opctl app publish` links an origin, `package eject` unlinks it) and that changes what the module does at load. 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 binding/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, and pull always overwrites it from live, leaving no second copy to drift. 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 |
33
- | `lotics app deploy -m <message>` | **`-m` is REQUIRED** (CLI errors without a non-empty message) — each deploy is a version row read back by `lotics app versions`, so a blank message loses the audit trail. npm run build; tar source + dist; POST /v1/apps/{id}/versions multipart. Carries code + capabilities only — **neither queries nor workflow/agent bindings are a deploy concern** (`set_app_workflow` / `remove_app_workflow` own `apps.workflows`; the manifest's `workflows` map is a pulled reflection, read by `useWorkflow` codegen and by `app workflow set`, never written by a deploy). Deploy DOES send the manifest's `lotics.workflows` alias KEYS (not the bindings) as `workflow_aliases`, recorded on the version row so `remove_app_workflow` can refuse to unbind an alias the served version still declares. It also reports any `lotics.queries` alias whose declaration DIFFERS from the app's, naming both recoveries (`app query set --all` to push yours, `app pull` to adopt the app's) — a deploy no longer writes them, so the two are allowed to drift. After a successful deploy it **warns loudly about any alias the source CALLS that is NOT bound on the server** (a `getApp` diff via `warnIfUnboundAliases`) — since deploy never binds them, that would otherwise throw only at the app's first `useWorkflow` / `useAgentRun` call; the warning points to `lotics app workflow set` / `set_app_agent`. Advisory only (never fails the deploy). |
32
+ | `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, npm install, 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; this avoids the stray nested `./<name>/` subdir a pull-from-inside-the-app used to drop. **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. An in-place refresh used to replace uncommitted work silently, since `tar -xzf` overwrites unconditionally and the command still exits 0. The same rule covers the two kinds the archive does NOT carry — `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, both rewritten from the live row on every pull — so an unpushed body or prompt survives too. 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. — `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 the runtime `.lotics/app_fields.ts` (the same linked-vs-bespoke branch `app codegen` runs, off the app row already fetched — see that row for the two forms). 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. The write NAMES the form and the reason, because an in-place pull can FLIP a project between them (`opctl app publish` links an origin, `package eject` unlinks it) and that changes what the module does at load. 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 binding/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, and pull always overwrites it from live, leaving no second copy to drift. 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 |
33
+ | `lotics app deploy -m <message>` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it actually pushed. Requiring it was the most common real failure in CLI telemetry, and a hard stop yields a retry plus filler rather than an audit trail; pass `-m` when you have a reason worth recording. npm run build; tar source + dist; POST /v1/apps/{id}/versions multipart. **One command ships everything**: before the bundle moves, a deploy 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. It never AUTHORS a binding itself — those verbs stay the single writers — 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. `package.json` means the same thing for both artifacts: editing `lotics.agents.<alias>.inputs`/`outputs` is pushed exactly like the workflow equivalent (only those two fields — `set_app_agent` merges, so everything the manifest does not model is left untouched). It also regenerates `.lotics/app_fields.ts` before building, since the build INLINES it and which form is correct follows from whether the app is a package installation — a deploy that skipped it could ship an origin's baked ids into every other install. `lotics app check` reports the same set without pushing; neither has a `--strict`. Deploy also sends the manifest's `lotics.workflows` alias KEYS as `workflow_aliases`, recorded on the version row so `remove_app_workflow` can refuse to unbind an alias the served version still declares. After a successful deploy it warns about any alias the source CALLS that is NOT bound, and **unbinds the inverse** — bindings the app still serves that this bundle names nowhere. No flag: an orphan is a live, callable read path under the deployer's authority, and a deploy that adds bindings automatically but requires a decision to remove one just accumulates them. Unbinding runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares — so doing it first is refused by the guard that makes it safe. It is skipped ENTIRELY (with a warning, never a failure) when the source computes an alias at run time, since the scan cannot tell which binding that reaches; that is the only case where the reference set is incomplete, because a binding is reachable from the bundle and from an agent's `query_aliases`/`workflow_aliases` and from nothing else — an app workflow carries no `on({...})` trigger, so no table event or schedule reaches one. A binding that will not unbind is reported and does NOT fail the release: the version is live and correct, and the leftover is the state every deploy left behind before this existed. |
34
34
  | `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it. Admin-only server-side (mirrors deploy + source download). Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. The deploy pipeline already persisted all of this in `app_versions`; this is the read surface. Title → stderr, table → stdout (pipeable). |
35
35
  | `lotics app codegen [path]` | Regenerate `.lotics/*` from the manifest + workspace schema **without a deploy**. The three `.d.ts` companions (`app_{workflows,queries,agents}.d.ts`) are always rewritten (synchronous, no network). When credentials resolve, also rewrites the **runtime** `.lotics/app_fields.ts` — **branched on whether the app is a package installation** (`getApp().package_id` set, from `generate_package_fields.ts`): a **linked/published** app emits the BINDING form (`F`/`OPT`/`ROLE` resolved from the installation's LIVE binding — via `appBinding` / the `binding` RPC — at module load through `getAppBinding()` + top-level await, so the source stays portable across every install); a **bespoke** app emits the BAKED form (`generate_app_fields.ts`) — a real `.ts` exporting `F` (table→field→`"fld_…"`) + `OPT` (table→select-field→option→`"opt_…"`) keyed by display-name aliases, for the tables the app's queries reference (+ optional `package.json#lotics.codegen.tables` allowlist). Both forms share the `F`/`OPT` shape (contract aliases derive from the same slugified display names), so a published origin's deployed source compiles unchanged. Writing the BINDING form also heals the project's vitest setup (`ensureAppVitestSetup`, folded into the same write boundary): the binding form awaits `getAppBinding()` (a network call) at module load, so without a stub `npm test` fails to collect any test that imports the app graph — the heal writes `vitest.setup.ts` (mocks only `getAppBinding`, returning an echo binding: any alias → a self-identifying `fld:test:…`/`opt:test:…`/`grp:test:…` id) if absent, and warns the one-liner to add to `vite.config.ts`'s `test.setupFiles` if the wiring is missing (TS source isn't safely munged, mirroring `ensureAppTsconfig`'s JSONC-tsconfig warn). New scaffolds ship both. Also refreshes each bound workflow's `.lotics/workflows/<alias>.globals.d.ts` + re-wraps its EXISTING `src/workflows/<alias>.ts` body in the current envelope (strips + re-wraps; never re-fetches the body, so local edits survive). **`.lotics/` is reconciled to the manifest, not merely added to** — a `<alias>.globals.d.ts` whose alias the manifest no longer declares is DELETED (that directory is read as the app's alias inventory, so a companion for a binding nobody can reach misreports what the app has). Only that exact filename shape is removed; anything else in the directory is left alone. The reconcile runs before the credential branch, so it happens offline too. The authored counterpart is never deleted — a `src/workflows/<alias>.ts` the manifest does not declare is NAMED instead (`check` and `set` both take their alias set from the manifest, so editing an undeclared body is a silent no-op). A getApp / binding / schema / dts-fetch failure is non-fatal (warns, keeps the last-generated files). **Re-silvers `package.json#lotics.agents`** from the live app row whenever its `inputs`/`outputs` disagree, then rewrites the agent `.d.ts` from the refreshed block: that block is a mirror AND the offline seed for `useAgentRun` typings, so a stale copy types the app against an agent that does not exist. Refreshing here makes the divergence self-healing on a command already in the loop and keeps the remedy off `app pull` (which rewrites `src/workflows/*.ts` and would eat uncommitted body edits). The write is surgical and order-preserving (`orderedLike`), so it changes only the fields that actually differ. A hand edit to that block is therefore reverted — it never changed the agent anyway; to change one, `set_app_agent`. |
36
- | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping: the manifest's agent schemas against the live app row, aliases the source calls that nothing bound (queries, workflows AND agents), capability-gated SDK calls the manifest doesn't declare, `lotics.queries` drift, a missing icon/theme, and a notice for any alias the source computes at runtime (invisible to every check here and to the deploy's unbind guard). Adds no rule of its own — each finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **Exits 1 only on what `deploy` REFUSES** (an agent schema that disagrees with the live app), so CI can gate on it while advisories stay advisory. It also names any `src/agents/<alias>.md` that differs from the live agent's instructions — a WARNING, not a gate, because `deploy` never pushes prose and shipping unrelated UI while a prompt is mid-edit is normal; a stale prompt does not make the bundle lie about its own types the way a stale schema does. The point is the question being ASKABLE: these checks used to cost a build, a tar, an upload and a version row in the audit trail, which is expensive enough that the honest move was to skip them and find out in production. |
36
+ | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings the app serves that the source names nowhere, capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, and a notice for any alias the source computes at runtime (invisible to every check here and to the deploy's unbind guard). Adds no rule of its own — each finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **Exits 1 on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, and any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query). Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. The point is the question being ASKABLE: these checks used to cost a build, a tar, an upload and a version row in the audit trail, which is expensive enough that the honest move was to skip them and find out in production. |
37
37
  | `lotics app workflow run <alias> '<json>'` | Execute a bound app workflow end-to-end via `appWorkflow`. `app_id` comes from the local manifest; the alias must be bound (`set_app_workflow`). Inputs ingest exactly like `lotics run` (inline JSON / `@file` / stdin — bulk inputs bypass `ARG_MAX`). Prints the full `{status,message,data,files,side_effects}` JSON to stdout + a one-line summary to stderr; exits non-zero on `status:"error"` (assertable). `--print-created` (alias `--report-effects`) renders the honest post-run harvest: created records grouped by table, a paste-ready `lotics run delete_records …` per table, then the **mandatory caveat** naming what cannot be auto-undone (external integrations + notifications) and that sub-workflows may have run. `--cleanup` (DEFAULT OFF, implies the report) additionally runs the deletes for harvested records ONLY — never files / external / notifications. Neither is a rollback — a rollback is structurally impossible here. |
38
38
  | `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. Deploy still never authors workflows — this is a CLI convenience over the existing tool. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. |
39
39
  | `lotics app agent set <alias>` | Push the edited `src/agents/<alias>.md` instructions back through `set_app_agent` — the agent mirror of `app workflow set`, and the deploy-free authoring path for an agent's PROSE. It sends the instructions and nothing else: the server merges against the stored declaration, so every typed field keeps exactly what is bound. This is deliberate and it is the opposite of what the symmetry with `app workflow set` suggests — **the manifest is a snapshot from the last `app pull`, so replaying its typed half would silently revert whatever was bound since** (the chat authoring agent adding `knowledge_doc_ids`, another operator granting `query_aliases`), and the CLI would print success while the agent quietly lost its knowledge and its read surface. To change a typed field, call `set_app_agent` with just that field (`lotics run set_app_agent '{"app_id":…,"alias":…,"outputs":{…}}'` — it merges), then `app pull` to bring the manifest back in step. Editing `package.json#lotics.agents` by hand pushes nothing, and since that block is what types `useAgentRun`, `codegen` warns and `deploy` REFUSES while it disagrees with the live app. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a file that is empty once the header is stripped (refusing to push an empty prompt). `app pull` writes the file; edit, then `set`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.123.0",
3
+ "version": "0.128.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {