@lotics/cli 0.86.0 → 0.86.2

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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
3
+ *
4
+ * Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
5
+ * holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
6
+ * A browser PUT straight to the presigned URL is blocked before it leaves the page,
7
+ * so without a relay no file-touching app can be exercised locally at all.
8
+ *
9
+ * The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
10
+ * preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
11
+ *
12
+ * The security property, and the reason the page never names its destination: a
13
+ * relay that forwarded to a client-supplied URL would be an open proxy. So the page
14
+ * sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
15
+ * itself for that id, moments earlier, via its own authenticated API call. There is
16
+ * no client-controlled target — hence nothing to allowlist, and no SSRF surface.
17
+ *
18
+ * Production is untouched: it PUTs direct-to-storage, keeping every byte off the
19
+ * API server.
20
+ */
21
+ /**
22
+ * How long a mint is worth keeping. This is a pruning window, not a correctness gate —
23
+ * storage is the authority on whether a presign is still valid (an expired one is refused
24
+ * there, and the wrapper surfaces that). It only needs to outlive the presign (~10 min) so
25
+ * the relay never 404s a PUT that storage would still have accepted.
26
+ */
27
+ const PRESIGN_TTL_MS = 15 * 60 * 1000;
28
+ export function createUploadRelay(now = Date.now) {
29
+ const minted = new Map();
30
+ const prune = (at) => {
31
+ for (const [id, entry] of minted) {
32
+ if (at - entry.mintedAt > PRESIGN_TTL_MS)
33
+ minted.delete(id);
34
+ }
35
+ };
36
+ return {
37
+ rewriteMint(result) {
38
+ const mint = result;
39
+ if (typeof mint?.file_id !== "string" || typeof mint?.upload_url !== "string")
40
+ return result;
41
+ const at = now();
42
+ prune(at);
43
+ minted.set(mint.file_id, { url: mint.upload_url, mintedAt: at });
44
+ return { ...mint, upload_url: `/_upload/${encodeURIComponent(mint.file_id)}` };
45
+ },
46
+ destinationFor(fileId) {
47
+ const entry = minted.get(fileId);
48
+ if (!entry)
49
+ return null;
50
+ if (now() - entry.mintedAt > PRESIGN_TTL_MS) {
51
+ minted.delete(fileId);
52
+ return null;
53
+ }
54
+ return entry.url;
55
+ },
56
+ settle(fileId) {
57
+ minted.delete(fileId);
58
+ },
59
+ size: () => minted.size,
60
+ };
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createUploadRelay } from "./upload_relay.js";
3
+ const MINT = {
4
+ file_id: "fil_abc",
5
+ file_storage_key: "org/files/fil_abc/photo.jpg",
6
+ upload_url: "https://production.r2.cloudflarestorage.com/org/files/fil_abc/photo.jpg?X-Amz-Signature=deadbeef",
7
+ };
8
+ describe("upload relay — the page PUTs same-origin", () => {
9
+ it("hands the page a same-origin URL and keeps everything else it needs to finalize", () => {
10
+ const relay = createUploadRelay();
11
+ const seen = relay.rewriteMint(MINT);
12
+ expect(seen.upload_url).toBe("/_upload/fil_abc");
13
+ // file_storage_key + file_id still reach the page — `upload_complete` needs both.
14
+ expect(seen.file_id).toBe("fil_abc");
15
+ expect(seen.file_storage_key).toBe(MINT.file_storage_key);
16
+ });
17
+ it("does not mutate the mint it was handed", () => {
18
+ const relay = createUploadRelay();
19
+ relay.rewriteMint(MINT);
20
+ expect(MINT.upload_url).toContain("r2.cloudflarestorage.com");
21
+ });
22
+ });
23
+ describe("upload relay — it writes ONLY where it was told to by itself", () => {
24
+ it("relays to the presigned URL it minted for that id", () => {
25
+ const relay = createUploadRelay();
26
+ relay.rewriteMint(MINT);
27
+ expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
28
+ });
29
+ it("refuses an id it never minted — there is no client-supplied destination to honour", () => {
30
+ const relay = createUploadRelay();
31
+ expect(relay.destinationFor("fil_never_seen")).toBeNull();
32
+ });
33
+ it("refuses a mint that has aged past its presign", () => {
34
+ let clock = 1_000_000;
35
+ const relay = createUploadRelay(() => clock);
36
+ relay.rewriteMint(MINT);
37
+ clock += 16 * 60 * 1000; // presigns die at ~10 min; the relay holds 15
38
+ expect(relay.destinationFor("fil_abc")).toBeNull();
39
+ });
40
+ });
41
+ describe("upload relay — lifecycle", () => {
42
+ it("forgets a mint once its bytes have landed", () => {
43
+ const relay = createUploadRelay();
44
+ relay.rewriteMint(MINT);
45
+ relay.settle("fil_abc");
46
+ expect(relay.destinationFor("fil_abc")).toBeNull();
47
+ expect(relay.size()).toBe(0);
48
+ });
49
+ it("keeps the mint on a failed PUT so the page's retry can reuse it", () => {
50
+ const relay = createUploadRelay();
51
+ relay.rewriteMint(MINT);
52
+ // No settle() — the storage PUT 5xx'd and the wrapper page retries.
53
+ expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
54
+ });
55
+ it("prunes dead mints instead of growing forever across a long dev session", () => {
56
+ let clock = 0;
57
+ const relay = createUploadRelay(() => clock);
58
+ relay.rewriteMint({ ...MINT, file_id: "fil_old" });
59
+ clock += 16 * 60 * 1000;
60
+ relay.rewriteMint({ ...MINT, file_id: "fil_new" });
61
+ expect(relay.size()).toBe(1); // the stale one was swept on the next mint
62
+ expect(relay.destinationFor("fil_new")).toBe(MINT.upload_url);
63
+ });
64
+ });
65
+ describe("upload relay — a response that carries no mint", () => {
66
+ it("passes through untouched rather than inventing a relay for it", () => {
67
+ const relay = createUploadRelay();
68
+ const odd = { message: "upstream changed shape" };
69
+ expect(relay.rewriteMint(odd)).toBe(odd);
70
+ expect(relay.size()).toBe(0);
71
+ });
72
+ it("tolerates null without throwing", () => {
73
+ const relay = createUploadRelay();
74
+ expect(relay.rewriteMint(null)).toBeNull();
75
+ });
76
+ });
@@ -120,7 +120,13 @@ export function buildWrapperPage(args) {
120
120
 
121
121
  // The iframe SDK sends one "upload" op carrying a File. A File can't
122
122
  // cross the JSON /_rpc hop, so the upload runs here in the browser:
123
- // mint a presigned URL, PUT the bytes to storage, then finalize.
123
+ // mint an upload URL, PUT the bytes, then finalize.
124
+ //
125
+ // In dev the minted URL is same-origin (/_upload/<file_id>) — the dev
126
+ // server relays the bytes to storage on our behalf, because the prod
127
+ // bucket's CORS does not admit a localhost origin. Production PUTs the
128
+ // presigned storage URL directly. Same three lines either way: the page
129
+ // PUTs wherever the mint points.
124
130
  //
125
131
  // Retry policy parity with the production SDK
126
132
  // (packages/app-sdk/src/upload/transport.ts): 3 PUT attempts with
@@ -1,13 +1,17 @@
1
1
  import { LoticsClient, type ExtractFinding } from "./client.js";
2
- import { type UpgradeResolutions } from "@lotics/shared/schemas/packages";
2
+ import { type ContractConfigEntry, type UpgradeResolutions } from "@lotics/shared/schemas/packages";
3
3
  /**
4
4
  * Read the local APP project's manifest (`package.json#lotics.app_id` +
5
- * `lotics.knowledge`) — what `lotics app pull` writes. `lotics app publish` /
6
- * `release` run from a pulled app project resolve the app id from it, and
7
- * `publish` forwards the package-managed knowledge declaration to the server
8
- * (agents reference docs by free text, so the author declares which the package
9
- * owns). Returns null when the dir has no package.json. `app_id` is null when the
10
- * manifest is a package/non-app project.
5
+ * `lotics.knowledge` + `lotics.config`) — what `lotics app pull` writes.
6
+ * `lotics app publish` / `release` run from a pulled app project resolve the app
7
+ * id from it and forward the package-managed knowledge declaration AND the config
8
+ * knob declarations to the server both are declarations extract cannot invert
9
+ * from artifacts (agents reference docs by free text; a config knob has no concrete
10
+ * artifact), so the author declares them. Each `lotics.config` entry is validated
11
+ * against the shared `contractConfigEntrySchema` so a malformed knob fails LOUDLY
12
+ * client-side naming the alias, never silently dropped. Returns null when the dir
13
+ * has no package.json. `app_id` is null when the manifest is a package/non-app
14
+ * project.
11
15
  */
12
16
  export declare function readLocalAppManifest(projectDir: string): {
13
17
  app_id: string | null;
@@ -15,6 +19,7 @@ export declare function readLocalAppManifest(projectDir: string): {
15
19
  alias: string;
16
20
  doc_id: string;
17
21
  }>;
22
+ config: ContractConfigEntry[];
18
23
  } | null;
19
24
  /** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
20
25
  export declare function parseRenameFlags(renames: string[]): Array<{
@@ -43,7 +48,9 @@ export declare function formatExtractReport(report: ExtractFinding[]): {
43
48
  export declare function parseResolveFlags(resolve: string[]): UpgradeResolutions;
44
49
  /**
45
50
  * Health check: version pin vs. registry latest + binding drift. Exits
46
- * non-zero when drift is found so scripts can gate on it.
51
+ * non-zero when drift is found so scripts can gate on it. An `apg_` PACKAGE id
52
+ * resolves THIS workspace's standalone content installation — the same
53
+ * package-id addressing `upgrade`/`uninstall` speak.
47
54
  */
48
55
  export declare function packageDoctor(client: LoticsClient, args: {
49
56
  app_id?: string;
@@ -21,7 +21,7 @@
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import "./client.js";
24
- import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
24
+ import { contractConfigEntrySchema, knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
25
25
  function packageJsonPath(projectDir) {
26
26
  return path.join(projectDir, "package.json");
27
27
  }
@@ -30,12 +30,16 @@ function isPlainObject(value) {
30
30
  }
31
31
  /**
32
32
  * Read the local APP project's manifest (`package.json#lotics.app_id` +
33
- * `lotics.knowledge`) — what `lotics app pull` writes. `lotics app publish` /
34
- * `release` run from a pulled app project resolve the app id from it, and
35
- * `publish` forwards the package-managed knowledge declaration to the server
36
- * (agents reference docs by free text, so the author declares which the package
37
- * owns). Returns null when the dir has no package.json. `app_id` is null when the
38
- * manifest is a package/non-app project.
33
+ * `lotics.knowledge` + `lotics.config`) — what `lotics app pull` writes.
34
+ * `lotics app publish` / `release` run from a pulled app project resolve the app
35
+ * id from it and forward the package-managed knowledge declaration AND the config
36
+ * knob declarations to the server both are declarations extract cannot invert
37
+ * from artifacts (agents reference docs by free text; a config knob has no concrete
38
+ * artifact), so the author declares them. Each `lotics.config` entry is validated
39
+ * against the shared `contractConfigEntrySchema` so a malformed knob fails LOUDLY
40
+ * client-side naming the alias, never silently dropped. Returns null when the dir
41
+ * has no package.json. `app_id` is null when the manifest is a package/non-app
42
+ * project.
39
43
  */
40
44
  export function readLocalAppManifest(projectDir) {
41
45
  const pkgPath = packageJsonPath(projectDir);
@@ -54,7 +58,19 @@ export function readLocalAppManifest(projectDir) {
54
58
  }
55
59
  }
56
60
  }
57
- return { app_id, knowledge };
61
+ const config = [];
62
+ if (Array.isArray(lotics.config)) {
63
+ for (const entry of lotics.config) {
64
+ const result = contractConfigEntrySchema.safeParse(entry);
65
+ if (!result.success) {
66
+ const alias = isPlainObject(entry) && typeof entry.alias === "string" ? entry.alias : "?";
67
+ throw new Error(`Invalid lotics.config knob "${alias}" in package.json: ` +
68
+ result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
69
+ }
70
+ config.push(result.data);
71
+ }
72
+ }
73
+ return { app_id, knowledge, config };
58
74
  }
59
75
  /** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
60
76
  export function parseRenameFlags(renames) {
@@ -115,9 +131,15 @@ export function parseResolveFlags(resolve) {
115
131
  }
116
132
  /**
117
133
  * Health check: version pin vs. registry latest + binding drift. Exits
118
- * non-zero when drift is found so scripts can gate on it.
134
+ * non-zero when drift is found so scripts can gate on it. An `apg_` PACKAGE id
135
+ * resolves THIS workspace's standalone content installation — the same
136
+ * package-id addressing `upgrade`/`uninstall` speak.
119
137
  */
120
138
  export async function packageDoctor(client, args) {
139
+ if (args.app_id !== undefined && args.app_id.startsWith("apg_")) {
140
+ await contentDoctor(client, args.app_id);
141
+ return;
142
+ }
121
143
  const app_id = resolveInstallationAppId(args.app_id);
122
144
  const health = await client.getPackageHealth(app_id);
123
145
  console.error(`${health.package_name} — installation ${health.app_id}` +
@@ -376,6 +398,47 @@ function formatTemplateResolveHint(entry) {
376
398
  : " (a local edit — revert overwrites it with the package's version; keep retains it)";
377
399
  return ` --resolve template.${entry.alias}=revert|keep${note}`;
378
400
  }
401
+ /**
402
+ * Health report for a STANDALONE content installation, addressed by PACKAGE id.
403
+ * Composed client-side from the surfaces the consumer verbs already speak: the
404
+ * list row (pin vs registry latest) and the upgrade preview against latest
405
+ * (which carries per-alias drift + local-edit detection even when the pin is
406
+ * current — the repair view). Exits non-zero on drift, mirroring the app
407
+ * doctor's gate semantics.
408
+ */
409
+ async function contentDoctor(client, package_id) {
410
+ const installation = await resolveWorkspaceContentInstallation(client, package_id);
411
+ const preview = await client.previewContentInstallationUpgrade(installation.id, {});
412
+ const name = installation.package_registry?.name ?? package_id;
413
+ console.error(`${name} — content installation of ${package_id} (this workspace)`);
414
+ console.error(` Installed: v${preview.from_version} Latest: v${preview.to_version}` +
415
+ (preview.to_version > preview.from_version ? " → update available" : ""));
416
+ const drifted = preview.entries.filter((e) => e.change === "drifted");
417
+ if (drifted.length === 0) {
418
+ console.error(" Binding: healthy — every bound doc resolves.");
419
+ }
420
+ else {
421
+ console.error(` Binding drift (${drifted.length}):`);
422
+ for (const entry of drifted) {
423
+ console.error(` - knowledge.${entry.alias} "${entry.name}" (bound doc is gone)`);
424
+ }
425
+ console.error(` Resolve while upgrading:\n lotics upgrade ${package_id}` +
426
+ ` --resolve knowledge.<alias>=recreate|unbind (or --bind-to <alias>=<kdc_id> to re-point)`);
427
+ process.exitCode = 1;
428
+ }
429
+ const editedDocs = preview.entries.filter((e) => e.modified);
430
+ const editedTemplates = preview.templates;
431
+ if (editedDocs.length === 0 && editedTemplates.length === 0) {
432
+ console.error(" Content: pristine — no local edits an upgrade would ask about.");
433
+ }
434
+ else {
435
+ console.error(` Local edits (${editedDocs.length + editedTemplates.length}) — each takes a keep/overwrite consent at upgrade:`);
436
+ for (const entry of editedDocs)
437
+ console.error(` - knowledge.${entry.alias} "${entry.name}"`);
438
+ for (const entry of editedTemplates)
439
+ console.error(` - template.${entry.alias}`);
440
+ }
441
+ }
379
442
  /**
380
443
  * Resolve a STANDALONE content package's installation in the CURRENT workspace
381
444
  * from its PACKAGE id. `UNIQUE (workspace_id, package_id)` means the package id
@@ -816,16 +879,17 @@ export async function appPublish(client, args) {
816
879
  throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
817
880
  "or pass one: lotics app publish <app_id>.");
818
881
  }
819
- // Forward the app's package-managed knowledge declaration ONLY when the local
820
- // manifest is this app's own project (agents reference docs by free text, so the
821
- // author declares which the package owns). A bare id published from elsewhere
822
- // ships without a bundled corpus — publish from the app dir to include it.
882
+ // Forward the app's package-managed knowledge + config declarations ONLY when the
883
+ // local manifest is this app's own project (both are declarations extract can't
884
+ // invert, so the author declares them). A bare id published from elsewhere ships
885
+ // without them — publish from the app dir to include them.
823
886
  const knowledge = local && local.app_id === appId ? local.knowledge : [];
887
+ const config = local && local.app_id === appId ? local.config : [];
824
888
  const renames = parseRenameFlags(args.renames);
825
889
  // Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
826
890
  // so the aliases v1 freezes forever are never a blind publish and `--rename`
827
891
  // targets are inspectable before committing.
828
- const preview = await client.previewPublishAppPackage(appId, { renames, knowledge });
892
+ const preview = await client.previewPublishAppPackage(appId, { renames, knowledge, config });
829
893
  const { lines, hasError } = formatExtractReport(preview.findings);
830
894
  console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
831
895
  const groups = [
@@ -847,6 +911,9 @@ export async function appPublish(client, args) {
847
911
  else {
848
912
  console.error(" No renamable aliases.");
849
913
  }
914
+ if (config.length > 0) {
915
+ console.error(` Config knobs (${config.length}): ${config.map((c) => `${c.alias} (${c.type})`).join(", ")}`);
916
+ }
850
917
  if (lines.length > 0) {
851
918
  console.error(` Findings (${preview.findings.length}):`);
852
919
  for (const line of lines)
@@ -870,6 +937,7 @@ export async function appPublish(client, args) {
870
937
  renames,
871
938
  changelog: args.changelog ?? null,
872
939
  knowledge,
940
+ config,
873
941
  });
874
942
  console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
875
943
  console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
@@ -901,11 +969,12 @@ export async function appRelease(client, args) {
901
969
  if (appId === null) {
902
970
  throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
903
971
  }
904
- // Forward the manifest's knowledge DECLARATION only from this app's own project
905
- // AND only when it lists docs — an empty/absent declaration is "no change"
906
- // (reconstruct from the pin), never "drop the bundle".
972
+ // Forward the manifest's knowledge + config DECLARATIONS only from this app's own
973
+ // project AND only when non-empty — an empty/absent declaration is "no change"
974
+ // (reconstruct knowledge from the pin / carry config forward), never "drop them".
907
975
  const knowledge = local && local.app_id === appId && local.knowledge.length > 0 ? local.knowledge : undefined;
908
- const preview = await client.previewPackageRelease(appId, { knowledge });
976
+ const config = local && local.app_id === appId && local.config.length > 0 ? local.config : undefined;
977
+ const preview = await client.previewPackageRelease(appId, { knowledge, config });
909
978
  const { lines, hasError } = formatExtractReport(preview.findings);
910
979
  console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
911
980
  if (preview.added_aliases.length > 0) {
@@ -914,7 +983,15 @@ export async function appRelease(client, args) {
914
983
  if (preview.changed_artifacts.length > 0) {
915
984
  console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
916
985
  }
917
- // Deploy-skew boundary: a pre-knowledge-declaration server omits the field.
986
+ // Deploy-skew boundary: a pre-declaration server omits the echo field. Silent
987
+ // skew is dangerous exactly when a declaration was SENT — the server ignored it
988
+ // and the preview would misread as "no changes"; say so loudly, per surface.
989
+ if (preview.knowledge === undefined && knowledge !== undefined) {
990
+ console.error(" WARNING: the server ignored the knowledge declaration (it predates bundled-knowledge releases) — the bundle will NOT change. Retry after the platform deploy completes.");
991
+ }
992
+ if (preview.config === undefined && config !== undefined) {
993
+ console.error(" WARNING: the server ignored the config declaration (it predates manifest config declarations) — the knobs will NOT change. Retry after the platform deploy completes.");
994
+ }
918
995
  const k = preview.knowledge ?? { added: [], removed: [], changed: [] };
919
996
  if (k.added.length > 0 || k.removed.length > 0 || k.changed.length > 0) {
920
997
  const parts = [
@@ -924,11 +1001,23 @@ export async function appRelease(client, args) {
924
1001
  ].filter((p) => p !== null);
925
1002
  console.error(` Knowledge: ${parts.join("; ")}`);
926
1003
  }
1004
+ const cfg = preview.config ?? { added: [], removed: [], changed: [] };
1005
+ if (cfg.added.length > 0 || cfg.removed.length > 0 || cfg.changed.length > 0) {
1006
+ const parts = [
1007
+ cfg.added.length > 0 ? `+${cfg.added.join(", ")}` : null,
1008
+ cfg.removed.length > 0 ? `dropped ${cfg.removed.join(", ")}` : null,
1009
+ cfg.changed.length > 0 ? `changed ${cfg.changed.join(", ")}` : null,
1010
+ ].filter((p) => p !== null);
1011
+ console.error(` Config: ${parts.join("; ")}`);
1012
+ }
927
1013
  if (preview.added_aliases.length === 0 &&
928
1014
  preview.changed_artifacts.length === 0 &&
929
1015
  k.added.length === 0 &&
930
1016
  k.removed.length === 0 &&
931
- k.changed.length === 0) {
1017
+ k.changed.length === 0 &&
1018
+ cfg.added.length === 0 &&
1019
+ cfg.removed.length === 0 &&
1020
+ cfg.changed.length === 0) {
932
1021
  console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
933
1022
  }
934
1023
  if (lines.length > 0) {
@@ -947,7 +1036,7 @@ export async function appRelease(client, args) {
947
1036
  process.exitCode = 1;
948
1037
  return;
949
1038
  }
950
- const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge });
1039
+ const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge, config });
951
1040
  console.error(`Released ${result.package_id} v${result.version}.`);
952
1041
  console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
953
1042
  }
@@ -990,7 +1079,7 @@ async function packageFleetUpgrade(client, args) {
990
1079
  const from = inst.from_version === null ? "?" : `v${inst.from_version}`;
991
1080
  const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
992
1081
  if (inst.outcome === "skipped" && inst.blockers) {
993
- console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
1082
+ console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified} knowledge=${inst.blockers.knowledge}`);
994
1083
  console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
995
1084
  }
996
1085
  else if (inst.message) {