@lotics/cli 0.83.0 → 0.86.1

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.
@@ -138,7 +138,8 @@ export async function packageDoctor(client, args) {
138
138
  }
139
139
  if (health.modified.length === 0) {
140
140
  console.error(health.is_origin
141
- ? " Package artifacts: no changes since the last release."
141
+ ? " Package artifacts: no changes since the last release.\n" +
142
+ ` (schema additions aren't fingerprinted — preview them with: lotics app release ${app_id})`
142
143
  : " Package artifacts: pristine — no local edits an upgrade would revert.");
143
144
  }
144
145
  else if (health.is_origin) {
@@ -375,6 +376,79 @@ function formatTemplateResolveHint(entry) {
375
376
  : " (a local edit — revert overwrites it with the package's version; keep retains it)";
376
377
  return ` --resolve template.${entry.alias}=revert|keep${note}`;
377
378
  }
379
+ /**
380
+ * Resolve a STANDALONE content package's installation in the CURRENT workspace
381
+ * from its PACKAGE id. `UNIQUE (workspace_id, package_id)` means the package id
382
+ * fully determines the anchor row, so consumers address content by package id and
383
+ * the `pci_` resource id never surfaces (the server keeps it — resource identity —
384
+ * and the CLI resolves it here through the list endpoint; zero backend change).
385
+ * Throws a clear "not installed" error (pointing at `lotics install`) when the
386
+ * package has no content installation in this workspace.
387
+ */
388
+ async function resolveWorkspaceContentInstallation(client, package_id) {
389
+ const workspaceId = client.getWorkspaceId();
390
+ if (!workspaceId) {
391
+ throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to operate on its content installation.");
392
+ }
393
+ const installations = await client.listContentInstallations(workspaceId);
394
+ const match = installations.find((inst) => inst.package_id === package_id);
395
+ if (!match) {
396
+ throw new Error(`Package ${package_id} is not installed in this workspace — install it first: lotics install ${package_id}`);
397
+ }
398
+ return match;
399
+ }
400
+ /**
401
+ * CLEAN BREAK for an explicit `pci_` argument on `upgrade` / `uninstall`: the
402
+ * `pci_` resource id is retired from human sight — content installs are addressed
403
+ * by their PACKAGE id. Prints a loud redirect, best-effort resolving the `pci_`
404
+ * back to its package id (via list-content) so the exact command is spelled out.
405
+ * The header prints synchronously first, so the redirect is observable even when
406
+ * the best-effort resolution can't reach the registry.
407
+ */
408
+ export async function redirectContentPciForm(client, pci_id, verb) {
409
+ console.error(`Content installations are addressed by their PACKAGE id now — ${pci_id} is a server-internal resource id.`);
410
+ const workspaceId = client.getWorkspaceId();
411
+ const match = workspaceId
412
+ ? (await client.listContentInstallations(workspaceId)).find((inst) => inst.id === pci_id)
413
+ : undefined;
414
+ if (match) {
415
+ console.error(` Run: lotics ${verb} ${match.package_id}`);
416
+ }
417
+ else {
418
+ console.error(" Find the package id: lotics package list-content");
419
+ console.error(` Then run: lotics ${verb} <package_id>`);
420
+ }
421
+ process.exit(1);
422
+ }
423
+ /**
424
+ * `lotics upgrade <apg_>` — the package-id upgrade path, kind-branched. `kind` is a
425
+ * DERIVED display hint (`contractHasAppSurface`): `'content'` means no app surface.
426
+ * - a CONTENT package upgrades THIS workspace's standalone content installation
427
+ * (resolved from the package id — the anchor is unique per workspace, so the
428
+ * `pci_` never surfaces), through the same content review gate.
429
+ * - an APP-surface package FLEET-upgrades every installation across the org
430
+ * (unchanged) — the resolve/bind/apply-all flags don't apply to a fleet run.
431
+ */
432
+ export async function packageUpgradeByPackageId(client, args) {
433
+ const pkg = await client.getPackage(args.package_id);
434
+ if (pkg.kind === "content") {
435
+ const installation = await resolveWorkspaceContentInstallation(client, args.package_id);
436
+ await packageUpgradeKnowledge(client, {
437
+ installation_id: installation.id,
438
+ package_id: pkg.id,
439
+ package_name: pkg.name,
440
+ version: args.version,
441
+ resolve: args.resolve,
442
+ bind_to: parseBindToFlags(args.bindTo),
443
+ applyAll: args.applyAll,
444
+ });
445
+ return;
446
+ }
447
+ await packageFleetUpgrade(client, {
448
+ package_id: args.package_id,
449
+ ...(args.version !== undefined ? { version: args.version } : {}),
450
+ });
451
+ }
378
452
  /**
379
453
  * Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
380
454
  * AND document templates behind one review gate. Prints the per-alias plan;
@@ -388,7 +462,7 @@ function formatTemplateResolveHint(entry) {
388
462
  * resolve entries individually — the ONE namespaced grammar, passed to the
389
463
  * server verbatim.
390
464
  */
391
- export async function packageUpgradeKnowledge(client, args) {
465
+ async function packageUpgradeKnowledge(client, args) {
392
466
  const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
393
467
  ...(args.version !== undefined ? { version: args.version } : {}),
394
468
  });
@@ -399,7 +473,7 @@ export async function packageUpgradeKnowledge(client, args) {
399
473
  ...(args.version !== undefined ? { version: args.version } : {}),
400
474
  resolutions,
401
475
  });
402
- console.error(`Upgraded ${args.installation_id} → v${updated.package_version}.`);
476
+ console.error(`Upgraded ${args.package_name} (${args.package_id}) → v${updated.package_version}.`);
403
477
  };
404
478
  if (preview.entries.length === 0 && preview.templates.length === 0) {
405
479
  if (preview.to_version === preview.from_version) {
@@ -472,8 +546,10 @@ export async function packageInstall(client, args) {
472
546
  // Surface the trust badge at the consent point: installing materializes the
473
547
  // package's workflows/agents (or its doc corpus) under YOUR authority.
474
548
  const pkg = await client.getPackage(args.package_id);
475
- const kindLabel = pkg.kind === "content" ? "content package" : "package";
476
- console.error(`Installing ${pkg.name} ${trustBadge(pkg)} (${kindLabel})...`);
549
+ // The kind hint only earns a mention when it adds information (content —
550
+ // no app materializes); "(package)" after "third-party package" is noise.
551
+ const kindSuffix = pkg.kind === "content" ? " (content — docs/templates, no app)" : "";
552
+ console.error(`Installing ${pkg.name} — ${trustBadge(pkg)}${kindSuffix}...`);
477
553
  const result = await client.installPackage(args.package_id, {
478
554
  ...(args.version !== undefined ? { version: args.version } : {}),
479
555
  ...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
@@ -483,8 +559,7 @@ export async function packageInstall(client, args) {
483
559
  const { installation, warnings } = result;
484
560
  const docs = installation.binding.knowledge;
485
561
  const templates = installation.binding.templates;
486
- console.error(`Installed ${pkg.name} v${installation.package_version} content installation ${installation.id} ` +
487
- `(workspace ${installation.workspace_id}).`);
562
+ console.error(`Installed ${pkg.name} v${installation.package_version} (workspace ${installation.workspace_id}).`);
488
563
  const docAliases = Object.keys(docs);
489
564
  if (docAliases.length > 0) {
490
565
  console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
@@ -494,8 +569,8 @@ export async function packageInstall(client, args) {
494
569
  console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
495
570
  }
496
571
  warnMissingExpectedDocs(warnings.missing_expected_docs);
497
- console.error(` Upgrade later: lotics upgrade ${installation.id}`);
498
- console.error(` Uninstall: lotics uninstall ${installation.id} [--keep-content]`);
572
+ console.error(` Upgrade later: lotics upgrade ${args.package_id}`);
573
+ console.error(` Uninstall: lotics uninstall ${args.package_id} [--keep-content]`);
499
574
  return;
500
575
  }
501
576
  const { app, knowledge_warnings } = result;
@@ -511,9 +586,11 @@ export async function packageInstall(client, args) {
511
586
  console.error(` Health / uninstall: lotics package doctor ${app.id} · lotics uninstall ${app.id} [--archive-tables]`);
512
587
  }
513
588
  /**
514
- * `lotics uninstall <app_id|pci_id>` — ONE top-level command over both installation
515
- * kinds, dispatched by the id form (mirrors `lotics upgrade`):
516
- * - a `pci_` id a STANDALONE CONTENT installation: deletes the row and (unless
589
+ * `lotics uninstall <app_id|package_id>` — ONE top-level command over both
590
+ * installation kinds, dispatched by the id form (mirrors `lotics upgrade`):
591
+ * - a package id (`apg_`)THIS workspace's STANDALONE CONTENT installation
592
+ * (content installs are addressed by package id, UNIQUE per workspace, so the
593
+ * `pci_` resource id never surfaces): deletes the row and (unless
517
594
  * `--keep-content`) archives its package-bound docs AND templates, listing each
518
595
  * archived id.
519
596
  * - anything else (an `app_id`) → an APP installation: archives its workflow
@@ -522,19 +599,25 @@ export async function packageInstall(client, args) {
522
599
  * A flag used on the wrong path is a loud error, never silently ignored.
523
600
  */
524
601
  export async function packageUninstall(client, args) {
525
- if (args.id.startsWith("pci_")) {
602
+ if (args.id.startsWith("apg_")) {
526
603
  if (args.archive_tables) {
527
- throw new Error("--archive-tables applies only to an app installation (<app_id>). A content installation " +
528
- "(pci_) has no scaffolded tables — use --keep-content to retain its docs/templates.");
604
+ throw new Error("--archive-tables applies only to an app installation (<app_id>). A content package " +
605
+ "has no scaffolded tables — use --keep-content to retain its docs/templates.");
606
+ }
607
+ const pkg = await client.getPackage(args.id);
608
+ // kind is a DERIVED display hint (contractHasAppSurface): 'content' = no app.
609
+ if (pkg.kind !== "content") {
610
+ throw new Error(`Package ${args.id} is an app package — uninstall an app installation by its app id: lotics uninstall <app_id>.`);
529
611
  }
530
- const result = await client.uninstallContentPackage(args.id, {
612
+ const installation = await resolveWorkspaceContentInstallation(client, args.id);
613
+ const result = await client.uninstallContentPackage(installation.id, {
531
614
  keep_content: args.keep_content,
532
615
  });
533
616
  if (args.keep_content) {
534
- console.error(`Uninstalled content installation ${result.installation_id} — its docs and templates were kept as ordinary workspace content.`);
617
+ console.error(`Uninstalled ${pkg.name} (${pkg.id}) — its docs and templates were kept as ordinary workspace content.`);
535
618
  }
536
619
  else {
537
- console.error(`Uninstalled content installation ${result.installation_id} — archived ` +
620
+ console.error(`Uninstalled ${pkg.name} (${pkg.id}) — archived ` +
538
621
  `${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
539
622
  for (const docId of result.archived_doc_ids)
540
623
  console.error(` ${docId}`);
@@ -544,7 +627,7 @@ export async function packageUninstall(client, args) {
544
627
  return;
545
628
  }
546
629
  if (args.keep_content) {
547
- throw new Error("--keep-content applies only to a standalone content installation (pci_). An app installation " +
630
+ throw new Error("--keep-content applies only to a content package (apg_). An app installation " +
548
631
  "(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
549
632
  }
550
633
  const app = await client.getApp(args.id);
@@ -571,8 +654,9 @@ export async function packageUninstall(client, args) {
571
654
  * `lotics package list-content` — list the selected workspace's STANDALONE
572
655
  * content installations (an app-bundled corpus rides its app's
573
656
  * `binding.knowledge` and shows on the Apps surface instead), each with its
574
- * registry status. The read surface that surfaces a `pci_` id for
575
- * `upgrade` / `uninstall` (install prints it once; nothing else did before).
657
+ * registry status. The what-is-installed listing: each row leads with the PACKAGE
658
+ * id the address for `lotics upgrade <package_id>` / `lotics uninstall
659
+ * <package_id>` (the `pci_` resource id stays hidden).
576
660
  */
577
661
  export async function packageListContent(client) {
578
662
  const workspaceId = client.getWorkspaceId();
@@ -592,7 +676,7 @@ export async function packageListContent(client) {
592
676
  const versionLabel = latest !== undefined && latest !== inst.package_version
593
677
  ? `v${inst.package_version} → latest v${latest}`
594
678
  : `v${inst.package_version}`;
595
- console.error(` ${inst.id} ${name} ${versionLabel}` +
679
+ console.error(` ${inst.package_id} ${name} ${versionLabel}` +
596
680
  (updateAvailable ? " → update available" : ""));
597
681
  }
598
682
  }
@@ -793,34 +877,35 @@ export async function appPublish(client, args) {
793
877
  console.error(` lotics app release ${appId} -m "<what changed>"`);
794
878
  console.error(` Install it elsewhere: lotics install ${result.package_id}`);
795
879
  }
796
- /**
797
- * Resolve the release/publish target app id: an explicit `<app_id>` wins; `.` (or
798
- * no positional) resolves the local app project manifest's `lotics.app_id` — the
799
- * project `lotics app pull` writes — so a release from the pulled app dir needs
800
- * no id.
801
- */
802
- function resolveOriginAppId(projectDir, explicit) {
803
- if (explicit !== undefined && explicit !== ".")
804
- return explicit;
805
- const local = readLocalAppManifest(projectDir);
806
- if (local?.app_id)
807
- return local.app_id;
808
- throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
809
- }
810
880
  /**
811
881
  * `lotics app release [app_id|.] -m <changelog> [--yes]` — snapshot an
812
882
  * adopted/installed origin app into its next registry version (docs/packages.md
813
883
  * § Promotion). The origin is the permanent working copy; a release binding-aware-
814
884
  * extracts it (stable aliases), repackages its DEPLOYED source + dist as the
815
885
  * bundle, publishes the next version, and re-pins the origin. Prints the preview
816
- * first (next version, new + changed aliases, findings); applies only with
817
- * `--yes`, else exits 1 so a review step can't be skipped. An `error` finding
818
- * blocks the apply.
886
+ * first (next version, new + changed aliases, the bundled-knowledge delta,
887
+ * findings); applies only with `--yes`, else exits 1 so a review step can't be
888
+ * skipped. An `error` finding blocks the apply.
889
+ *
890
+ * Run from the pulled app project, the manifest's `lotics.knowledge` (alias →
891
+ * doc_id) is the bundle DECLARATION — it re-declares which docs the package owns
892
+ * (add/drop/re-snapshot). Forwarded only when non-empty; empty (or a bare id from
893
+ * elsewhere) sends nothing, so the current corpus is reconstructed from the pin
894
+ * (never silently dropped).
819
895
  */
820
896
  export async function appRelease(client, args) {
821
897
  const projectDir = path.resolve(args.projectDir ?? process.cwd());
822
- const appId = resolveOriginAppId(projectDir, args.app_id);
823
- const preview = await client.previewPackageRelease(appId);
898
+ const local = readLocalAppManifest(projectDir);
899
+ const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
900
+ const appId = explicit ?? local?.app_id ?? null;
901
+ if (appId === null) {
902
+ 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
+ }
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".
907
+ const knowledge = local && local.app_id === appId && local.knowledge.length > 0 ? local.knowledge : undefined;
908
+ const preview = await client.previewPackageRelease(appId, { knowledge });
824
909
  const { lines, hasError } = formatExtractReport(preview.findings);
825
910
  console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
826
911
  if (preview.added_aliases.length > 0) {
@@ -829,7 +914,21 @@ export async function appRelease(client, args) {
829
914
  if (preview.changed_artifacts.length > 0) {
830
915
  console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
831
916
  }
832
- if (preview.added_aliases.length === 0 && preview.changed_artifacts.length === 0) {
917
+ // Deploy-skew boundary: a pre-knowledge-declaration server omits the field.
918
+ const k = preview.knowledge ?? { added: [], removed: [], changed: [] };
919
+ if (k.added.length > 0 || k.removed.length > 0 || k.changed.length > 0) {
920
+ const parts = [
921
+ k.added.length > 0 ? `+${k.added.join(", ")}` : null,
922
+ k.removed.length > 0 ? `dropped ${k.removed.join(", ")}` : null,
923
+ k.changed.length > 0 ? `changed ${k.changed.join(", ")}` : null,
924
+ ].filter((p) => p !== null);
925
+ console.error(` Knowledge: ${parts.join("; ")}`);
926
+ }
927
+ if (preview.added_aliases.length === 0 &&
928
+ preview.changed_artifacts.length === 0 &&
929
+ k.added.length === 0 &&
930
+ k.removed.length === 0 &&
931
+ k.changed.length === 0) {
833
932
  console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
834
933
  }
835
934
  if (lines.length > 0) {
@@ -848,7 +947,7 @@ export async function appRelease(client, args) {
848
947
  process.exitCode = 1;
849
948
  return;
850
949
  }
851
- const result = await client.releasePackage(appId, { changelog: args.changelog });
950
+ const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge });
852
951
  console.error(`Released ${result.package_id} v${result.version}.`);
853
952
  console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
854
953
  }
@@ -876,7 +975,7 @@ export async function packageYank(client, args) {
876
975
  * installations are reported per line and the process exits 1 so a release
877
976
  * script can gate on "fleet fully current".
878
977
  */
879
- export async function packageFleetUpgrade(client, args) {
978
+ async function packageFleetUpgrade(client, args) {
880
979
  const result = await client.fleetUpgradePackage(args.package_id, {
881
980
  ...(args.version !== undefined ? { version: args.version } : {}),
882
981
  });
@@ -1,8 +1,8 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, } from "./package_commands.js";
5
+ import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, appRelease, packageUpgradeByPackageId, } from "./package_commands.js";
6
6
  import { parseArgs } from "./args.js";
7
7
  describe("parseRenameFlags", () => {
8
8
  it("parses old=new pairs", () => {
@@ -146,6 +146,139 @@ describe("parseArgs — package list-content", () => {
146
146
  expect(flags.workspace).toBe("wsp_dev");
147
147
  });
148
148
  });
149
+ describe("appRelease — knowledge declaration forwarding", () => {
150
+ const previewResult = {
151
+ package_id: "apg_1",
152
+ version: 2,
153
+ added_aliases: [],
154
+ changed_artifacts: [],
155
+ knowledge: { added: ["guide"], removed: [], changed: [] },
156
+ findings: [],
157
+ };
158
+ /** A mock client capturing the knowledge forwarded to preview + release. */
159
+ function mockClient() {
160
+ const preview = [];
161
+ const release = [];
162
+ const client = {
163
+ previewPackageRelease: async (app_id, opts = {}) => {
164
+ preview.push({ app_id, opts });
165
+ return previewResult;
166
+ },
167
+ releasePackage: async (app_id, body) => {
168
+ release.push({ app_id, body });
169
+ return { ...previewResult, findings: undefined };
170
+ },
171
+ };
172
+ return { client, preview, release };
173
+ }
174
+ function withManifestDir(lotics, fn) {
175
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-rel-"));
176
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "app", lotics }));
177
+ return fn(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true }));
178
+ }
179
+ it("forwards the manifest's knowledge declaration from the app's own project", async () => {
180
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
181
+ const { client, preview, release } = mockClient();
182
+ await withManifestDir({ app_id: "app_x", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
183
+ await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
184
+ });
185
+ expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: [{ alias: "guide", doc_id: "kdc_1" }] } });
186
+ expect(release[0].body).toEqual({ changelog: "ship", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] });
187
+ vi.restoreAllMocks();
188
+ });
189
+ it("omits knowledge (reconstruct from the pin) when the manifest declares none", async () => {
190
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
191
+ const { client, preview, release } = mockClient();
192
+ await withManifestDir({ app_id: "app_x" }, async (dir) => {
193
+ await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
194
+ });
195
+ expect(preview[0].opts).toEqual({ knowledge: undefined });
196
+ expect(release[0].body).toEqual({ changelog: "ship", knowledge: undefined });
197
+ vi.restoreAllMocks();
198
+ });
199
+ it("omits knowledge for a bare app id whose manifest is a DIFFERENT app", async () => {
200
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
201
+ const { client, preview } = mockClient();
202
+ await withManifestDir({ app_id: "app_other", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
203
+ await appRelease(client, { app_id: "app_x", changelog: "ship", yes: true, projectDir: dir });
204
+ });
205
+ expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: undefined } });
206
+ vi.restoreAllMocks();
207
+ });
208
+ });
209
+ describe("packageUpgradeByPackageId — kind-branched by the package read", () => {
210
+ /**
211
+ * A mock client recording which upgrade path a `lotics upgrade <apg_>` takes.
212
+ * `kind` drives the branch: a content package resolves THIS workspace's content
213
+ * installation from the package id (the `pci_` never surfaces) and runs the
214
+ * content upgrade; an app package fleet-upgrades the org.
215
+ */
216
+ function mockClient(opts) {
217
+ const calls = { previewContent: [], fleet: [], listedWorkspaces: [] };
218
+ const client = {
219
+ getPackage: async (package_id) => ({
220
+ id: package_id,
221
+ name: "Ops corpus",
222
+ kind: opts.kind,
223
+ latest_version: 3,
224
+ is_official: false,
225
+ retired_at: null,
226
+ }),
227
+ getWorkspaceId: () => "wsp_1",
228
+ listContentInstallations: async (workspace_id) => {
229
+ calls.listedWorkspaces.push(workspace_id);
230
+ return (opts.installations ?? []).map((i) => ({
231
+ ...i,
232
+ workspace_id,
233
+ package_version: 2,
234
+ app_id: null,
235
+ binding: { knowledge: {}, templates: {} },
236
+ installed_by: null,
237
+ created_at: "",
238
+ updated_at: "",
239
+ package_registry: null,
240
+ }));
241
+ },
242
+ previewContentInstallationUpgrade: async (installation_id) => {
243
+ calls.previewContent.push(installation_id);
244
+ return { from_version: 2, to_version: 2, entries: [], templates: [] };
245
+ },
246
+ fleetUpgradePackage: async (package_id) => {
247
+ calls.fleet.push(package_id);
248
+ return { package_id, target_version: 3, installations: [] };
249
+ },
250
+ };
251
+ return { client, calls };
252
+ }
253
+ const args = { version: undefined, resolve: [], bindTo: [], applyAll: false };
254
+ it("routes a CONTENT package to this workspace's content installation (resolved from the package id)", async () => {
255
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
256
+ const { client, calls } = mockClient({
257
+ kind: "content",
258
+ installations: [{ id: "pci_1", package_id: "apg_content" }],
259
+ });
260
+ await packageUpgradeByPackageId(client, { package_id: "apg_content", ...args });
261
+ expect(calls.listedWorkspaces).toEqual(["wsp_1"]);
262
+ expect(calls.previewContent).toEqual(["pci_1"]); // resolved apg_ → pci_ client-side
263
+ expect(calls.fleet).toEqual([]); // never a fleet run for content
264
+ vi.restoreAllMocks();
265
+ });
266
+ it("routes an APP package to the org fleet upgrade", async () => {
267
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
268
+ const { client, calls } = mockClient({ kind: "app" });
269
+ await packageUpgradeByPackageId(client, { package_id: "apg_app", ...args });
270
+ expect(calls.fleet).toEqual(["apg_app"]);
271
+ expect(calls.previewContent).toEqual([]);
272
+ expect(calls.listedWorkspaces).toEqual([]); // no content resolution for an app package
273
+ vi.restoreAllMocks();
274
+ });
275
+ it("errors clearly when a content package is not installed in this workspace", async () => {
276
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
277
+ const { client } = mockClient({ kind: "content", installations: [] });
278
+ await expect(packageUpgradeByPackageId(client, { package_id: "apg_content", ...args })).rejects.toThrow(/not installed in this workspace[\s\S]*lotics install apg_content/);
279
+ vi.restoreAllMocks();
280
+ });
281
+ });
149
282
  describe("formatExtractReport", () => {
150
283
  it("groups findings errors → warnings → info and formats each line", () => {
151
284
  const { lines, hasError } = formatExtractReport([