@lotics/cli 0.123.0 → 0.127.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,7 +45587,7 @@ 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";
45590
+ var STARTER_FALLBACK_UI_VERSION = "28.1.0";
45591
45591
  var STARTER_FALLBACK_SDK_VERSION = "0.52.0";
45592
45592
  var STARTER_REACT_NATIVE_VERSION = "0.85.3";
45593
45593
  var VITEST_SETUP_FILENAME = "vitest.setup.ts";
@@ -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 ?? []);
@@ -70433,56 +70435,53 @@ function sameSchema(a, b) {
70433
70435
  };
70434
70436
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
70435
70437
  }
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
70438
  function workflowTypeDivergences(manifestWorkflows, liveWorkflows) {
70455
70439
  const out = [];
70456
70440
  for (const [alias, declared] of Object.entries(manifestWorkflows ?? {})) {
70457
70441
  const live = (liveWorkflows ?? {})[alias];
70458
70442
  if (!live) {
70459
- out.push(`${alias} \u2014 declared locally but NOT bound on the app`);
70443
+ out.push({ alias });
70460
70444
  continue;
70461
70445
  }
70462
70446
  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
- }
70447
+ if (!sameSchema(declared[key], live[key])) out.push({ alias, key });
70466
70448
  }
70467
70449
  }
70468
70450
  return out;
70469
70451
  }
70452
+ function syncedEntry(entry) {
70453
+ if (entry === void 0) return void 0;
70454
+ return typeof entry === "string" ? { content: entry } : entry;
70455
+ }
70470
70456
  function readSynced(projectDir) {
70471
70457
  try {
70472
70458
  const pkg2 = JSON.parse(
70473
70459
  fs6.readFileSync(path7.join(projectDir, "package.json"), "utf-8")
70474
70460
  );
70475
- return pkg2.lotics?.synced ?? {};
70461
+ const raw = pkg2.lotics?.synced ?? {};
70462
+ return {
70463
+ workflows: normalizeSyncedMap(raw.workflows),
70464
+ agents: normalizeSyncedMap(raw.agents),
70465
+ queries: normalizeSyncedMap(raw.queries)
70466
+ };
70476
70467
  } catch {
70477
- return {};
70468
+ return { workflows: {}, agents: {}, queries: {} };
70478
70469
  }
70479
70470
  }
70480
- function writeSynced(projectDir, kind, alias, sha) {
70471
+ function normalizeSyncedMap(map3) {
70472
+ return Object.fromEntries(
70473
+ Object.entries(map3 ?? {}).map(([alias, entry]) => [
70474
+ alias,
70475
+ syncedEntry(entry)
70476
+ ])
70477
+ );
70478
+ }
70479
+ function writeSynced(projectDir, kind, alias, entry) {
70481
70480
  const pkgPath2 = path7.join(projectDir, "package.json");
70482
70481
  const pkg2 = JSON.parse(fs6.readFileSync(pkgPath2, "utf-8"));
70483
70482
  if (!pkg2.lotics) return;
70484
70483
  pkg2.lotics.synced ??= {};
70485
- pkg2.lotics.synced[kind] = { ...pkg2.lotics.synced[kind], [alias]: sha };
70484
+ pkg2.lotics.synced[kind] = { ...pkg2.lotics.synced[kind], [alias]: entry };
70486
70485
  fs6.writeFileSync(pkgPath2, JSON.stringify(pkg2, null, 2) + "\n");
70487
70486
  }
70488
70487
  var WORKFLOWS_DIR = path7.join("src", "workflows");
@@ -70544,11 +70543,14 @@ function writeWorkflowGlobals(projectDir, alias, dts) {
70544
70543
  `);
70545
70544
  return file2;
70546
70545
  }
70546
+ function normalizeWorkflowBody(source) {
70547
+ return source.replace(/\s+$/, "");
70548
+ }
70547
70549
  function writeWorkflowFile(projectDir, alias, source, envelope = { prefix: WORKFLOW_ENVELOPE_PREFIX, suffix: WORKFLOW_ENVELOPE_SUFFIX }) {
70548
70550
  const dir = path7.join(projectDir, WORKFLOWS_DIR);
70549
70551
  fs6.mkdirSync(dir, { recursive: true });
70550
70552
  const file2 = workflowFilePath(projectDir, alias);
70551
- const body = source.replace(/\s+$/, "");
70553
+ const body = normalizeWorkflowBody(source);
70552
70554
  fs6.writeFileSync(file2, `${workflowFileHeader(alias)}
70553
70555
  ${envelope.prefix}${body}${envelope.suffix}
70554
70556
  `);
@@ -70570,7 +70572,7 @@ function writeAgentFile(projectDir, alias, instructions) {
70570
70572
  );
70571
70573
  return file2;
70572
70574
  }
70573
- function writeAgentFiles(projectDir, agents) {
70575
+ function writeAgentFiles(projectDir, agents, opts) {
70574
70576
  const written = [];
70575
70577
  for (const [alias, declaration] of Object.entries(agents)) {
70576
70578
  const instructions = declaration.instructions;
@@ -70578,15 +70580,33 @@ function writeAgentFiles(projectDir, agents) {
70578
70580
  console.error(`\u26A0 Skipped src/agents/${alias}.md \u2014 the live declaration carries no instructions.`);
70579
70581
  continue;
70580
70582
  }
70583
+ if (!opts.overwrite && localPromptUnpushed(projectDir, alias)) {
70584
+ opts.kept.push(`src/agents/${alias}.md`);
70585
+ continue;
70586
+ }
70581
70587
  writeAgentFile(projectDir, alias, instructions);
70582
70588
  written.push(alias);
70583
70589
  }
70584
70590
  return written;
70585
70591
  }
70592
+ function localPromptUnpushed(projectDir, alias) {
70593
+ const file2 = agentFilePath2(projectDir, alias);
70594
+ if (!fs6.existsSync(file2)) return false;
70595
+ const baseline = readSynced(projectDir).agents[alias]?.content;
70596
+ if (baseline === void 0) return true;
70597
+ return contentSha(stripAgentHeader(fs6.readFileSync(file2, "utf-8"))) !== baseline;
70598
+ }
70586
70599
  function generatedWorkflowDescription(appName, alias) {
70587
70600
  return `Workflow invoked by app "${appName}" via useWorkflow("${alias}").`;
70588
70601
  }
70589
- async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows) {
70602
+ function localBodyUnpushed(projectDir, alias) {
70603
+ const file2 = workflowFilePath(projectDir, alias);
70604
+ if (!fs6.existsSync(file2)) return false;
70605
+ const baseline = readSynced(projectDir).workflows[alias]?.content;
70606
+ if (baseline === void 0) return true;
70607
+ return contentSha(stripWorkflowHeader(fs6.readFileSync(file2, "utf-8"))) !== baseline;
70608
+ }
70609
+ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows, opts) {
70590
70610
  const written = [];
70591
70611
  for (const [alias, declaration] of Object.entries(workflows)) {
70592
70612
  const res = await client.getAppWorkflow(app_id, alias);
@@ -70595,7 +70615,9 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
70595
70615
  `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
70616
  );
70597
70617
  }
70598
- const source = res.result === null || typeof res.result !== "object" ? null : res.result.source;
70618
+ const result = res.result === null || typeof res.result !== "object" ? null : res.result;
70619
+ const source = result?.source;
70620
+ const body_sha = typeof result?.body_sha === "string" ? result.body_sha : void 0;
70599
70621
  if (typeof source !== "string" || source.trim() === "") {
70600
70622
  console.error(
70601
70623
  `\u26A0 Skipped src/workflows/${alias}.ts \u2014 legacy workflow with no rendered body.`
@@ -70609,8 +70631,15 @@ async function writeWorkflowFiles(client, projectDir, app_id, appName, workflows
70609
70631
  alias,
70610
70632
  toWorkflowDtsDeclaration(declaration)
70611
70633
  );
70634
+ if (!opts.overwrite && localBodyUnpushed(projectDir, alias)) {
70635
+ opts.kept.push(`src/workflows/${alias}.ts`);
70636
+ continue;
70637
+ }
70612
70638
  writeWorkflowFile(projectDir, alias, source, envelope);
70613
- writeSynced(projectDir, "workflows", alias, contentSha(source));
70639
+ writeSynced(projectDir, "workflows", alias, {
70640
+ content: contentSha(normalizeWorkflowBody(source)),
70641
+ ...body_sha ? { live: body_sha } : {}
70642
+ });
70614
70643
  const rowDescription = res.result.description;
70615
70644
  if (typeof rowDescription === "string" && rowDescription !== "" && // Skip the server's GENERATED default. It is what an undescribed alias
70616
70645
  // carries, so writing it back would commit a line of noise per workflow
@@ -70725,9 +70754,12 @@ function writeAppMeta(projectDir, meta3) {
70725
70754
  const pkgPath2 = path7.join(projectDir, "package.json");
70726
70755
  const pkg2 = JSON.parse(fs6.readFileSync(pkgPath2, "utf-8"));
70727
70756
  const existing = pkg2.lotics && typeof pkg2.lotics === "object" ? pkg2.lotics : {};
70757
+ const supplied = new Set(
70758
+ Object.entries(meta3).filter(([, value2]) => value2 !== void 0).map(([key]) => key)
70759
+ );
70728
70760
  const preserved = {};
70729
70761
  for (const [key, value2] of Object.entries(existing)) {
70730
- if (!APP_META_KEYS.includes(key)) preserved[key] = value2;
70762
+ if (!supplied.has(key)) preserved[key] = value2;
70731
70763
  }
70732
70764
  pkg2.lotics = { ...meta3, ...preserved };
70733
70765
  fs6.writeFileSync(pkgPath2, JSON.stringify(pkg2, null, 2) + "\n");
@@ -70747,7 +70779,7 @@ function writeWorkflowOutputs(projectDir, alias, outputs) {
70747
70779
  function workflowBodyDrift(projectDir, workflows, synced = {}) {
70748
70780
  const drifted = [];
70749
70781
  for (const alias of Object.keys(workflows ?? {})) {
70750
- const baseline = synced[alias];
70782
+ const baseline = syncedEntry(synced[alias])?.content;
70751
70783
  if (baseline === void 0) continue;
70752
70784
  const file2 = workflowFilePath(projectDir, alias);
70753
70785
  if (!fs6.existsSync(file2)) continue;
@@ -70757,17 +70789,6 @@ function workflowBodyDrift(projectDir, workflows, synced = {}) {
70757
70789
  }
70758
70790
  return drifted;
70759
70791
  }
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
70792
  function ensureAppTsconfig(projectDir) {
70772
70793
  const tsconfigPath = path7.join(projectDir, "tsconfig.json");
70773
70794
  if (!fs6.existsSync(tsconfigPath)) {
@@ -70930,6 +70951,15 @@ async function writeGeneratedAppFields(client, projectDir, app, queries) {
70930
70951
  } else {
70931
70952
  const tableIds = resolveCodegenTableIds(projectDir, queries);
70932
70953
  const tables = await client.getWorkspaceSchema(tableIds);
70954
+ const missing = tableIds.filter((id) => !tables.some((t) => t.id === id));
70955
+ if (missing.length > 0) {
70956
+ console.error(
70957
+ `\u26A0 Skipped .lotics/app_fields.ts \u2014 ${missing.length} table(s) the app queries could not be read: ${missing.join(", ")}.
70958
+ Writing anyway would bake a narrowed F/OPT map that compiles and then throws at runtime. Check this key's access to those tables.
70959
+ 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.`
70960
+ );
70961
+ return;
70962
+ }
70933
70963
  const fieldsPath = writeAppFields(projectDir, tables);
70934
70964
  console.error(
70935
70965
  `Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"})`
@@ -71216,11 +71246,15 @@ async function appPull(client, args) {
71216
71246
  const tmpFile = path7.join(tmpdir(), `lotics-app-${app.id}-${Date.now()}.tar.gz`);
71217
71247
  console.error(`Downloading source archive...`);
71218
71248
  await downloadToFile(sourceUrl, tmpFile);
71249
+ const keptLocal = [];
71250
+ const stagingDir = fs6.mkdtempSync(path7.join(tmpdir(), `lotics-pull-${app.id}-`));
71219
71251
  try {
71252
+ await runTar(["-xzf", tmpFile, "-C", stagingDir], stagingDir);
71220
71253
  console.error(`Extracting to ${targetPath}...`);
71221
- await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
71254
+ keptLocal.push(...copyTree(stagingDir, targetPath, { overwrite: args.force === true }));
71222
71255
  } finally {
71223
71256
  if (fs6.existsSync(tmpFile)) fs6.unlinkSync(tmpFile);
71257
+ fs6.rmSync(stagingDir, { recursive: true, force: true });
71224
71258
  }
71225
71259
  stampPulledManifest(targetPath, {
71226
71260
  app_id: app.id,
@@ -71232,7 +71266,8 @@ async function appPull(client, args) {
71232
71266
  agents: app.agents ?? {}
71233
71267
  });
71234
71268
  for (const [alias, declaration] of Object.entries(app.queries ?? {})) {
71235
- writeSynced(targetPath, "queries", alias, declarationSha(declaration));
71269
+ const sha = declarationSha(declaration);
71270
+ writeSynced(targetPath, "queries", alias, { content: sha, live: sha });
71236
71271
  }
71237
71272
  await writeGeneratedAppFields(
71238
71273
  client,
@@ -71242,7 +71277,10 @@ async function appPull(client, args) {
71242
71277
  );
71243
71278
  const workflows = app.workflows ?? {};
71244
71279
  if (Object.keys(workflows).length > 0) {
71245
- const written = await writeWorkflowFiles(client, targetPath, app.id, app.name, workflows);
71280
+ const written = await writeWorkflowFiles(client, targetPath, app.id, app.name, workflows, {
71281
+ overwrite: args.force === true,
71282
+ kept: keptLocal
71283
+ });
71246
71284
  if (written.length > 0) {
71247
71285
  console.error(
71248
71286
  `Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`
@@ -71251,10 +71289,15 @@ async function appPull(client, args) {
71251
71289
  }
71252
71290
  const agents = app.agents ?? {};
71253
71291
  if (Object.keys(agents).length > 0) {
71254
- const written = writeAgentFiles(targetPath, agents);
71292
+ const written = writeAgentFiles(targetPath, agents, {
71293
+ overwrite: args.force === true,
71294
+ kept: keptLocal
71295
+ });
71255
71296
  for (const alias of written) {
71256
71297
  const instructions = agents[alias]?.instructions;
71257
- if (instructions !== void 0) writeSynced(targetPath, "agents", alias, contentSha(instructions));
71298
+ if (instructions === void 0) continue;
71299
+ const sha = contentSha(instructions);
71300
+ writeSynced(targetPath, "agents", alias, { content: sha, live: sha });
71258
71301
  }
71259
71302
  if (written.length > 0) {
71260
71303
  console.error(
@@ -71264,6 +71307,7 @@ async function appPull(client, args) {
71264
71307
  }
71265
71308
  console.error(`Installing npm dependencies...`);
71266
71309
  await runNpm(["install"], targetPath);
71310
+ reportKeptLocalFiles(keptLocal, app.id);
71267
71311
  console.error(`
71268
71312
  Ready. Next steps:`);
71269
71313
  console.error(` cd ${path7.relative(process.cwd(), targetPath) || "."}`);
@@ -71289,35 +71333,33 @@ async function appDeploy(client, args) {
71289
71333
  const meta3 = readAppMeta(projectDir);
71290
71334
  warnAboutDevLink(projectDir, "deploy");
71291
71335
  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);
71336
+ const pending = pendingBindings({
71337
+ projectDir,
71338
+ meta: meta3,
71339
+ synced: readSynced(projectDir).workflows,
71340
+ live: liveApp
71341
+ });
71342
+ if (!nothingPending(pending)) {
71343
+ await pushPendingBindings(client, projectDir, pending);
71306
71344
  }
71307
- const declarationDrift = reportWorkflowDeclarationDrift(meta3.workflows, liveApp.workflows);
71308
71345
  const sourceText = readAppSourceText(projectDir);
71309
71346
  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
71347
  warnIfDynamicAliases(called);
71319
71348
  warnIfUndeclaredCapabilities(sourceText, meta3.capabilities);
71320
71349
  writeAppDts(projectDir, { workflows: meta3.workflows, queries: meta3.queries, agents: meta3.agents });
71350
+ try {
71351
+ await writeGeneratedAppFields(
71352
+ client,
71353
+ projectDir,
71354
+ { app_id: meta3.app_id, package_id: liveApp.package_id },
71355
+ meta3.queries ?? {}
71356
+ );
71357
+ } catch (err2) {
71358
+ console.error(
71359
+ `\u26A0 Could not regenerate .lotics/app_fields.ts (${err2.message}).
71360
+ The build will use whatever is on disk \u2014 run 'lotics app codegen' if this app is published.`
71361
+ );
71362
+ }
71321
71363
  console.error("Building...");
71322
71364
  await runNpm(["run", "build"], projectDir);
71323
71365
  const distDir = path7.join(projectDir, "dist");
@@ -71354,7 +71396,7 @@ Refusing to deploy \u2014 package.json#lotics.agents disagrees with the live app
71354
71396
  source_archive: fs6.readFileSync(tmpSource),
71355
71397
  dist_archive: fs6.readFileSync(tmpDist),
71356
71398
  prev_version_id: meta3.current_version_id,
71357
- message: args.message,
71399
+ message: args.message ?? derivedDeployMessage(pending),
71358
71400
  // Capabilities ARE manifest-authoritative: always send,
71359
71401
  // defaulting to `{}` when the manifest declares none — so deleting the
71360
71402
  // `capabilities` block turns every capability OFF on the next deploy
@@ -71379,13 +71421,24 @@ Refusing to deploy \u2014 package.json#lotics.agents disagrees with the live app
71379
71421
  });
71380
71422
  console.error(`Deployed v${result.version_number} (${result.version_id})`);
71381
71423
  console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
71424
+ let liveAfter;
71382
71425
  try {
71383
- const app = await client.getApp(meta3.app_id);
71384
- warnIfUnbranded(app);
71385
- warnIfUnboundAliases(app, called);
71426
+ liveAfter = await client.getApp(meta3.app_id);
71386
71427
  } catch (err2) {
71387
71428
  console.error(`(skipped post-deploy checks: ${err2.message})`);
71388
71429
  }
71430
+ if (liveAfter) {
71431
+ warnIfUnbranded(liveAfter);
71432
+ warnIfUnboundAliases(liveAfter, called);
71433
+ }
71434
+ if (liveAfter) {
71435
+ await pruneOrphanedBindings(
71436
+ client,
71437
+ meta3.app_id,
71438
+ orphanedBindings(called, liveAfter),
71439
+ called.dynamic
71440
+ );
71441
+ }
71389
71442
  } catch (err2) {
71390
71443
  const e = err2;
71391
71444
  if (e.code === "VERSION_CONFLICT") {
@@ -71425,78 +71478,233 @@ async function readLiveQueries(client, appId) {
71425
71478
  process.exit(1);
71426
71479
  }
71427
71480
  }
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
71481
  async function appCheck(client, args = {}) {
71441
71482
  const projectDir = path7.resolve(args.projectDir ?? process.cwd());
71442
71483
  const meta3 = readAppMeta(projectDir);
71443
71484
  const app = await client.getApp(meta3.app_id);
71444
71485
  const sourceText = readAppSourceText(projectDir);
71445
71486
  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
71487
  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 ?? {});
71488
+ warnAboutOrphanedBindings(called, app);
71489
+ const pending = pendingBindings({
71490
+ projectDir,
71491
+ meta: meta3,
71492
+ synced: readSynced(projectDir).workflows,
71493
+ live: app
71494
+ });
71495
+ reportPending(pending);
71461
71496
  warnIfDynamicAliases(called);
71462
71497
  warnIfUndeclaredCapabilities(sourceText, meta3.capabilities);
71463
71498
  warnIfUnboundAliases(app, called);
71464
71499
  warnIfUnbranded(app);
71465
- if (divergences.length > 0 || bodyDrift || declarationDrift || proseDrift || queryDrift) {
71500
+ if (!nothingPending(pending)) {
71466
71501
  process.exit(1);
71467
71502
  }
71468
71503
  console.error("Checked the app's bindings, capabilities and agent schemas \u2014 nothing blocking.");
71469
71504
  }
71470
- function reportAgentProseDrift(projectDir, liveAgents) {
71471
- let drifted = false;
71505
+ async function pushPendingBindings(client, projectDir, pending) {
71506
+ const plan = [
71507
+ ...pending.queries.map((alias) => `query ${alias}`),
71508
+ ...pending.workflows.map((alias) => `workflow ${alias}`),
71509
+ ...pending.agents.map((alias) => `agent ${alias}`)
71510
+ ];
71511
+ console.error(
71512
+ `
71513
+ Pushing ${plan.length} binding${plan.length === 1 ? "" : "s"} ahead of the app: ${plan.join(", ")}`
71514
+ );
71515
+ for (const alias of pending.queries) {
71516
+ await appQuerySet(client, { alias, projectDir });
71517
+ }
71518
+ for (const alias of pending.workflows) {
71519
+ await appWorkflowSet(client, { alias, projectDir });
71520
+ }
71521
+ for (const alias of pending.agents) {
71522
+ await appAgentSet(client, { alias, projectDir });
71523
+ }
71524
+ }
71525
+ function pendingBindings(args) {
71526
+ const { projectDir, meta: meta3, synced, live } = args;
71527
+ const workflows = /* @__PURE__ */ new Set([
71528
+ ...workflowBodyDrift(projectDir, meta3.workflows, synced),
71529
+ ...divergedWorkflowAliases(meta3.workflows, live.workflows)
71530
+ ]);
71531
+ return {
71532
+ workflows: [...workflows].sort(),
71533
+ agents: [
71534
+ .../* @__PURE__ */ new Set([
71535
+ ...driftedAgentAliases(projectDir, live.agents),
71536
+ // A manifest declaration that disagrees with the live row is a push now,
71537
+ // the same as a workflow's. `agentTypeDivergences` renders one line per
71538
+ // (alias, key) for a human, and the alias is its first token — but an
71539
+ // alias that diverged on both `inputs` and `outputs` still needs exactly
71540
+ // one push.
71541
+ ...divergedAgentAliases(meta3.agents, live.agents)
71542
+ ])
71543
+ ].sort(),
71544
+ // Only aliases the manifest DECLARES. `driftedQueryAliases` is symmetric —
71545
+ // it unions both key sets — so an alias deleted locally but still bound live
71546
+ // reads as drift, and queueing it sends the author to a push that refuses
71547
+ // ("declare it first") for a declaration they deliberately removed. A
71548
+ // deletion is not a push, and this detector only decides what to push;
71549
+ // unbinding is `remove_app_query`, which no deploy runs for any artifact.
71550
+ queries: [...driftedQueryAliases(meta3.queries ?? {}, live.queries)].filter((alias) => (meta3.queries ?? {})[alias] !== void 0).sort()
71551
+ };
71552
+ }
71553
+ function reportPending(pending) {
71554
+ const lines = [
71555
+ ...pending.workflows.map((a) => ` \u2022 workflow "${a}" \u2014 lotics app workflow set ${a}`),
71556
+ ...pending.agents.map((a) => ` \u2022 agent "${a}" \u2014 lotics app agent set ${a}`),
71557
+ ...pending.queries.map((a) => ` \u2022 query "${a}" \u2014 lotics app query set ${a}`)
71558
+ ];
71559
+ if (lines.length === 0) return;
71560
+ console.error(
71561
+ `
71562
+ \u2717 ${lines.length} binding${lines.length === 1 ? " is" : "s are"} ahead of the app:
71563
+ ` + lines.join("\n") + `
71564
+
71565
+ A version shipped now would not reproduce what runs. 'lotics app deploy' pushes
71566
+ these for you; revert the file instead to drop the edit.`
71567
+ );
71568
+ }
71569
+ function nothingPending(pending) {
71570
+ return pending.workflows.length === 0 && pending.agents.length === 0 && pending.queries.length === 0;
71571
+ }
71572
+ function divergedAgentAliases(manifestAgents, liveAgents) {
71573
+ const out = [];
71574
+ for (const [alias, declared] of Object.entries(manifestAgents ?? {})) {
71575
+ const live = (liveAgents ?? {})[alias];
71576
+ if (!live) continue;
71577
+ if (AGENT_TYPED_KEYS.some((key) => !sameSchema(declared[key], live[key]))) out.push(alias);
71578
+ }
71579
+ return out;
71580
+ }
71581
+ function driftedAgentAliases(projectDir, liveAgents) {
71582
+ const drifted = [];
71472
71583
  for (const [alias, live] of Object.entries(liveAgents ?? {})) {
71473
71584
  const file2 = agentFilePath2(projectDir, alias);
71474
71585
  if (!fs6.existsSync(file2)) continue;
71475
71586
  const local = stripAgentHeader(fs6.readFileSync(file2, "utf-8"));
71476
71587
  if (local === "" || local === (live.instructions ?? "")) continue;
71477
- drifted = true;
71588
+ drifted.push(alias);
71589
+ }
71590
+ return drifted.sort();
71591
+ }
71592
+ function divergedWorkflowAliases(manifestWorkflows, liveWorkflows) {
71593
+ return [
71594
+ ...new Set(workflowTypeDivergences(manifestWorkflows, liveWorkflows).map((d) => d.alias))
71595
+ ].sort();
71596
+ }
71597
+ function orphanedBindings(called, live) {
71598
+ const agentDeclarations = Object.values(live.agents ?? {});
71599
+ const referenced = {
71600
+ queries: [...called.queries, ...agentDeclarations.flatMap((a) => a.query_aliases ?? [])],
71601
+ workflows: [...called.workflows, ...agentDeclarations.flatMap((a) => a.workflow_aliases ?? [])],
71602
+ // Nothing declares an agent but the client bundle — an agent cannot run another.
71603
+ agents: called.agents
71604
+ };
71605
+ return orphanedAliases(
71606
+ {
71607
+ queries: Object.keys(live.queries ?? {}),
71608
+ workflows: Object.keys(live.workflows ?? {}),
71609
+ agents: Object.keys(live.agents ?? {})
71610
+ },
71611
+ referenced
71612
+ );
71613
+ }
71614
+ function warnAboutOrphanedBindings(called, live) {
71615
+ const orphans = orphanedBindings(called, live);
71616
+ const lines = [
71617
+ ...orphans.queries.map((a) => ` \u2022 query ${a}`),
71618
+ ...orphans.workflows.map((a) => ` \u2022 workflow ${a}`),
71619
+ ...orphans.agents.map((a) => ` \u2022 agent ${a}`)
71620
+ ];
71621
+ if (lines.length === 0) return 0;
71622
+ console.error(
71623
+ `
71624
+ \u26A0 ${lines.length} binding(s) are live but named nowhere in this bundle:
71625
+ ` + lines.join("\n") + (called.dynamic.length > 0 ? `
71626
+
71627
+ This app computes an alias at runtime, so one of these may be called after
71628
+ all \u2014 a deploy therefore leaves them ALL in place. Make those call sites
71629
+ literal and a deploy will clean them up.` : `
71630
+
71631
+ A deploy unbinds these after it ships.`)
71632
+ );
71633
+ return lines.length;
71634
+ }
71635
+ async function pruneOrphanedBindings(client, appId, orphans, dynamic) {
71636
+ const targets = [
71637
+ ...orphans.queries.map((alias) => ({ tool: "remove_app_query", alias, label: `query ${alias}` })),
71638
+ ...orphans.workflows.map((alias) => ({
71639
+ tool: "remove_app_workflow",
71640
+ alias,
71641
+ label: `workflow ${alias}`
71642
+ })),
71643
+ ...orphans.agents.map((alias) => ({ tool: "remove_app_agent", alias, label: `agent ${alias}` }))
71644
+ ];
71645
+ if (targets.length === 0) return 0;
71646
+ if (dynamic.length > 0) {
71478
71647
  console.error(
71479
71648
  `
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.`
71649
+ \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:
71650
+ ` + targets.map((t) => ` \u2022 ${t.label}`).join("\n") + `
71651
+ Make those call sites literal and the next deploy will clean them up, or remove the ones you know are dead by hand.`
71483
71652
  );
71653
+ return targets.length;
71484
71654
  }
71485
- return drifted;
71655
+ let failed = 0;
71656
+ for (const target of targets) {
71657
+ const res = await client.execute(target.tool, { app_id: appId, alias: target.alias });
71658
+ if (res.error) {
71659
+ failed += 1;
71660
+ console.error(` \u2717 could not unbind ${target.label} \u2014 ${res.error}`);
71661
+ continue;
71662
+ }
71663
+ console.error(` \u2713 unbound ${target.label}`);
71664
+ }
71665
+ return failed;
71666
+ }
71667
+ function derivedDeployMessage(pending) {
71668
+ const parts = [
71669
+ ...pending.queries.map((a) => `query ${a}`),
71670
+ ...pending.workflows.map((a) => `workflow ${a}`),
71671
+ ...pending.agents.map((a) => `agent ${a}`)
71672
+ ];
71673
+ return parts.length === 0 ? "Deployed app code" : `Deployed app code; pushed ${parts.join(", ")}`;
71674
+ }
71675
+ function copyTree(from, to, opts, relative = "") {
71676
+ const kept = [];
71677
+ for (const entry of fs6.readdirSync(from, { withFileTypes: true })) {
71678
+ const src = path7.join(from, entry.name);
71679
+ const dest = path7.join(to, entry.name);
71680
+ const rel = path7.join(relative, entry.name);
71681
+ if (entry.isDirectory()) {
71682
+ fs6.mkdirSync(dest, { recursive: true });
71683
+ kept.push(...copyTree(src, dest, opts, rel));
71684
+ continue;
71685
+ }
71686
+ if (!opts.overwrite && fs6.existsSync(dest)) {
71687
+ if (fs6.readFileSync(src).equals(fs6.readFileSync(dest))) continue;
71688
+ kept.push(rel);
71689
+ continue;
71690
+ }
71691
+ fs6.copyFileSync(src, dest);
71692
+ }
71693
+ return kept;
71486
71694
  }
71487
- function reportWorkflowDeclarationDrift(manifestWorkflows, liveWorkflows) {
71488
- const divergences = workflowTypeDivergences(manifestWorkflows, liveWorkflows);
71489
- if (divergences.length === 0) return false;
71695
+ function reportKeptLocalFiles(kept, appId) {
71696
+ if (kept.length === 0) return;
71490
71697
  console.error(
71491
71698
  `
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)`
71699
+ ${kept.length} local file(s) differ from the app and were KEPT, not overwritten:
71700
+ ` + kept.slice(0, 20).map((f) => ` \u2022 ${f}`).join("\n") + (kept.length > 20 ? `
71701
+ \u2026 and ${kept.length - 20} more` : ``) + `
71702
+
71703
+ Everything else was refreshed from the app. Your edits are intact.
71704
+ Ship them: lotics app deploy (pushes bodies, prompts and declarations)
71705
+ See the difference: lotics app check
71706
+ Take the app's copy: lotics app pull ${appId} --force (DISCARDS the files above)`
71498
71707
  );
71499
- return true;
71500
71708
  }
71501
71709
  function warnIfDynamicAliases(called) {
71502
71710
  if (called.dynamic.length === 0) return;
@@ -71760,7 +71968,7 @@ Agent "${args.alias}" run ${run.id} \u2192 ${run.status}${run.error_message ? `:
71760
71968
  if (run.status !== "completed") process.exit(1);
71761
71969
  }
71762
71970
  async function appAgentSet(client, args) {
71763
- const projectDir = process.cwd();
71971
+ const projectDir = args.projectDir ?? process.cwd();
71764
71972
  const meta3 = readAppMeta(projectDir);
71765
71973
  noteScope({ alias: args.alias });
71766
71974
  const app = await client.getApp(meta3.app_id);
@@ -71771,37 +71979,51 @@ async function appAgentSet(client, args) {
71771
71979
  );
71772
71980
  process.exit(1);
71773
71981
  }
71982
+ const declared = meta3.agents?.[args.alias];
71983
+ const hasDeclaration = declared?.inputs !== void 0 || declared?.outputs !== void 0;
71774
71984
  const file2 = agentFilePath2(projectDir, args.alias);
71775
- if (!fs6.existsSync(file2)) {
71985
+ if (!fs6.existsSync(file2) && !hasDeclaration) {
71776
71986
  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.`
71987
+ `No instructions at ${path7.relative(projectDir, file2)}, and nothing declared in package.json#lotics.agents.${args.alias} to push instead.
71988
+ Run 'lotics app pull ${meta3.app_id}' to write ${AGENTS_DIR}/${args.alias}.md, then edit it.`
71778
71989
  );
71779
71990
  process.exit(1);
71780
71991
  }
71781
- const instructions = stripAgentHeader(fs6.readFileSync(file2, "utf-8"));
71992
+ const instructions = fs6.existsSync(file2) ? stripAgentHeader(fs6.readFileSync(file2, "utf-8")) : void 0;
71782
71993
  if (instructions === "") {
71783
71994
  console.error(
71784
71995
  `${path7.relative(projectDir, file2)} is empty after stripping the header \u2014 refusing to push an empty prompt.`
71785
71996
  );
71786
71997
  process.exit(1);
71787
71998
  }
71999
+ const proseBaseline = readSynced(projectDir).agents[args.alias]?.live;
71788
72000
  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] } : {}
72001
+ // Prose and its precondition ride together. Gating a declaration-only push on
72002
+ // the prose baseline would refuse it because someone edited a PROMPT — a
72003
+ // precondition must guard what this call actually writes.
72004
+ ...instructions !== void 0 ? { instructions, ...proseBaseline ? { expected_instructions_sha: proseBaseline } : {} } : {},
72005
+ ...declared?.inputs !== void 0 ? { inputs: declared.inputs } : {},
72006
+ ...declared?.outputs !== void 0 ? { outputs: declared.outputs } : {}
71793
72007
  });
71794
72008
  if (res.error) {
71795
72009
  console.error(`Failed to set agent "${args.alias}": ${res.error}`);
71796
72010
  process.exit(1);
71797
72011
  }
71798
- writeSynced(projectDir, "agents", args.alias, contentSha(instructions));
72012
+ if (instructions !== void 0) {
72013
+ const proseSha = contentSha(instructions);
72014
+ writeSynced(projectDir, "agents", args.alias, { content: proseSha, live: proseSha });
72015
+ }
72016
+ const pushed = [
72017
+ ...instructions !== void 0 ? [`${instructions.length} chars of instructions`] : [],
72018
+ ...declared?.inputs !== void 0 ? ["inputs"] : [],
72019
+ ...declared?.outputs !== void 0 ? ["outputs"] : []
72020
+ ];
71799
72021
  console.error(
71800
- `Set agent "${args.alias}" (${instructions.length} chars of instructions` + (live.model_tier ? `, ${live.model_tier}` : "") + `). Typed fields left as they are.`
72022
+ `Set agent "${args.alias}" (${pushed.join(", ")}` + (live.model_tier ? `, ${live.model_tier}` : "") + `). Everything else left as it is.`
71801
72023
  );
71802
72024
  }
71803
72025
  async function appWorkflowSet(client, args) {
71804
- const projectDir = process.cwd();
72026
+ const projectDir = args.projectDir ?? process.cwd();
71805
72027
  const meta3 = readAppMeta(projectDir);
71806
72028
  const declaration = meta3.workflows?.[args.alias];
71807
72029
  if (!declaration) {
@@ -71813,7 +72035,9 @@ async function appWorkflowSet(client, args) {
71813
72035
  const file2 = workflowFilePath(projectDir, args.alias);
71814
72036
  if (!fs6.existsSync(file2)) {
71815
72037
  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.`
72038
+ `No workflow body at ${path7.relative(projectDir, file2)}.
72039
+ If "${args.alias}" is already bound: lotics app pull ${meta3.app_id} (writes the body)
72040
+ If it is a NEW alias you declared: create that file yourself, then re-run this.`
71817
72041
  );
71818
72042
  process.exit(1);
71819
72043
  }
@@ -71838,13 +72062,17 @@ async function appWorkflowSet(client, args) {
71838
72062
  // The body this edit was built on. Turns the push into a conditional write:
71839
72063
  // if the live body moved since this project last saw it, the server refuses
71840
72064
  // rather than letting this copy overwrite a change nobody here has seen.
71841
- expected_body_sha: readSynced(projectDir).workflows?.[args.alias]
72065
+ expected_body_sha: readSynced(projectDir).workflows[args.alias]?.live
71842
72066
  });
71843
72067
  if (res.error) {
71844
72068
  console.error(`Failed to set workflow "${args.alias}": ${res.error}`);
71845
72069
  process.exit(1);
71846
72070
  }
71847
- writeSynced(projectDir, "workflows", args.alias, contentSha(source));
72071
+ const pushedBodySha = res.result !== null && typeof res.result === "object" ? res.result.body_sha : void 0;
72072
+ writeSynced(projectDir, "workflows", args.alias, {
72073
+ content: contentSha(source),
72074
+ ...typeof pushedBodySha === "string" ? { live: pushedBodySha } : {}
72075
+ });
71848
72076
  const result = res.result ?? {};
71849
72077
  const workflowId = typeof result.workflow_id === "string" ? result.workflow_id : "(unknown)";
71850
72078
  console.error(`Set workflow "${args.alias}" \u2192 ${workflowId}`);
@@ -71864,7 +72092,7 @@ async function appWorkflowSet(client, args) {
71864
72092
  }
71865
72093
  }
71866
72094
  async function appQuerySet(client, args) {
71867
- const projectDir = process.cwd();
72095
+ const projectDir = args.projectDir ?? process.cwd();
71868
72096
  const meta3 = readAppMeta(projectDir);
71869
72097
  const declared = meta3.queries ?? {};
71870
72098
  const aliases = "all" in args ? Object.keys(declared).sort() : [args.alias];
@@ -71886,21 +72114,22 @@ async function appQuerySet(client, args) {
71886
72114
  meta3.app_id,
71887
72115
  alias,
71888
72116
  declaration,
71889
- readSynced(projectDir).queries?.[alias]
72117
+ readSynced(projectDir).queries[alias]?.live
71890
72118
  );
71891
72119
  if (res.error) {
71892
72120
  if (pushed.length > 0) console.error(`Pushed before the failure: ${pushed.join(", ")}.`);
71893
72121
  console.error(`Failed to set query "${alias}": ${res.error}`);
71894
72122
  process.exit(1);
71895
72123
  }
71896
- writeSynced(projectDir, "queries", alias, declarationSha(declaration));
72124
+ const querySha = declarationSha(declaration);
72125
+ writeSynced(projectDir, "queries", alias, { content: querySha, live: querySha });
71897
72126
  pushed.push(alias);
71898
72127
  }
71899
72128
  console.error(
71900
72129
  `Set ${pushed.length} quer${pushed.length === 1 ? "y" : "ies"} on ${meta3.app_id}: ${pushed.join(", ")}.`
71901
72130
  );
71902
72131
  }
71903
- async function appWorkflowPull(client) {
72132
+ async function appWorkflowPull(client, args = {}) {
71904
72133
  const projectDir = process.cwd();
71905
72134
  const meta3 = readAppMeta(projectDir);
71906
72135
  const app = await client.getApp(meta3.app_id);
@@ -71909,10 +72138,15 @@ async function appWorkflowPull(client) {
71909
72138
  console.error(`App ${meta3.app_id} has no bound workflows.`);
71910
72139
  return;
71911
72140
  }
71912
- const written = await writeWorkflowFiles(client, projectDir, meta3.app_id, app.name, workflows);
72141
+ const kept = [];
72142
+ const written = await writeWorkflowFiles(client, projectDir, meta3.app_id, app.name, workflows, {
72143
+ overwrite: args.force === true,
72144
+ kept
72145
+ });
71913
72146
  console.error(
71914
72147
  `Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/` + (written.length > 0 ? ` (${written.join(", ")})` : "")
71915
72148
  );
72149
+ reportKeptLocalFiles(kept, meta3.app_id);
71916
72150
  ensureAppTsconfig(projectDir);
71917
72151
  }
71918
72152
  async function appWorkflowCheck(args) {
@@ -71995,6 +72229,7 @@ ${totalErrors} error${totalErrors === 1 ? "" : "s"} across ${results.length - pa
71995
72229
  function parseArgs(argv) {
71996
72230
  const flags = {
71997
72231
  json: false,
72232
+ force: false,
71998
72233
  timeout: void 0,
71999
72234
  output: void 0,
72000
72235
  as: void 0,
@@ -72027,6 +72262,9 @@ function parseArgs(argv) {
72027
72262
  case "--json":
72028
72263
  flags.json = true;
72029
72264
  break;
72265
+ case "--force":
72266
+ flags.force = true;
72267
+ break;
72030
72268
  case "--timeout":
72031
72269
  flags.timeout = parseInt(argv[++i2], 10);
72032
72270
  break;
@@ -100725,17 +100963,11 @@ Available workspaces:`);
100725
100963
  process.exit(1);
100726
100964
  }
100727
100965
  const targetPath = restArgs[0];
100728
- await appPull(client, { app_id: appId, targetPath });
100966
+ await appPull(client, { app_id: appId, targetPath, force: flags.force === true });
100729
100967
  return;
100730
100968
  }
100731
100969
  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 });
100970
+ await appDeploy(client, { message: (flags.message ?? toolArgs)?.trim() || void 0 });
100739
100971
  return;
100740
100972
  }
100741
100973
  if (subcommand === "subdomain") {
@@ -100816,7 +101048,7 @@ Available workspaces:`);
100816
101048
  return;
100817
101049
  }
100818
101050
  if (action === "pull") {
100819
- await appWorkflowPull(client);
101051
+ await appWorkflowPull(client, { force: flags.force === true });
100820
101052
  return;
100821
101053
  }
100822
101054
  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.127.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {