@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.
@@ -2,7 +2,7 @@ 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, appRelease, packageUpgradeByPackageId, } from "./package_commands.js";
5
+ import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, appRelease, packageUpgradeByPackageId, packageDoctor, } from "./package_commands.js";
6
6
  import { parseArgs } from "./args.js";
7
7
  describe("parseRenameFlags", () => {
8
8
  it("parses old=new pairs", () => {
@@ -20,21 +20,42 @@ describe("parseRenameFlags", () => {
20
20
  });
21
21
  });
22
22
  describe("readLocalAppManifest", () => {
23
- it("reads app_id + knowledge from a pulled app project", () => {
23
+ function withManifest(lotics, fn) {
24
24
  const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-app-test-"));
25
25
  try {
26
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
27
- name: "crm",
28
- lotics: { app_id: "app_x", knowledge: [{ alias: "sop", doc_id: "kdc_1" }] },
29
- }));
30
- expect(readLocalAppManifest(dir)).toEqual({
31
- app_id: "app_x",
32
- knowledge: [{ alias: "sop", doc_id: "kdc_1" }],
33
- });
26
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "crm", lotics }));
27
+ return fn(dir);
34
28
  }
35
29
  finally {
36
30
  fs.rmSync(dir, { recursive: true, force: true });
37
31
  }
32
+ }
33
+ it("reads app_id + knowledge + config from a pulled app project", () => {
34
+ withManifest({
35
+ app_id: "app_x",
36
+ knowledge: [{ alias: "sop", doc_id: "kdc_1" }],
37
+ config: [{ alias: "page_size", label: "Page size", type: "number", default: 25 }],
38
+ }, (dir) => {
39
+ expect(readLocalAppManifest(dir)).toEqual({
40
+ app_id: "app_x",
41
+ knowledge: [{ alias: "sop", doc_id: "kdc_1" }],
42
+ config: [{ alias: "page_size", label: "Page size", type: "number", default: 25 }],
43
+ });
44
+ });
45
+ });
46
+ it("defaults knowledge + config to empty arrays when the manifest omits them", () => {
47
+ withManifest({ app_id: "app_x" }, (dir) => {
48
+ expect(readLocalAppManifest(dir)).toEqual({ app_id: "app_x", knowledge: [], config: [] });
49
+ });
50
+ });
51
+ it("validates each config knob against the shared schema, throwing NAMING the bad alias", () => {
52
+ withManifest({
53
+ app_id: "app_x",
54
+ // A select knob with no `options` — the shared schema rejects it.
55
+ config: [{ alias: "theme", label: "Theme", type: "select", default: "light" }],
56
+ }, (dir) => {
57
+ expect(() => readLocalAppManifest(dir)).toThrow(/lotics\.config knob "theme"/);
58
+ });
38
59
  });
39
60
  it("returns null when the dir has no package.json", () => {
40
61
  const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
@@ -146,27 +167,30 @@ describe("parseArgs — package list-content", () => {
146
167
  expect(flags.workspace).toBe("wsp_dev");
147
168
  });
148
169
  });
149
- describe("appRelease — knowledge declaration forwarding", () => {
170
+ describe("appRelease — knowledge + config declaration forwarding", () => {
171
+ const pageSizeKnob = { alias: "page_size", label: "Page size", type: "number", default: 25 };
150
172
  const previewResult = {
151
173
  package_id: "apg_1",
152
174
  version: 2,
153
175
  added_aliases: [],
154
176
  changed_artifacts: [],
155
177
  knowledge: { added: ["guide"], removed: [], changed: [] },
178
+ config: { added: ["page_size"], removed: [], changed: [] },
156
179
  findings: [],
157
180
  };
158
- /** A mock client capturing the knowledge forwarded to preview + release. */
159
- function mockClient() {
181
+ /** A mock client capturing the knowledge + config forwarded to preview + release. */
182
+ function mockClient(overridePreview) {
160
183
  const preview = [];
161
184
  const release = [];
185
+ const result = { ...previewResult, ...overridePreview };
162
186
  const client = {
163
187
  previewPackageRelease: async (app_id, opts = {}) => {
164
188
  preview.push({ app_id, opts });
165
- return previewResult;
189
+ return result;
166
190
  },
167
191
  releasePackage: async (app_id, body) => {
168
192
  release.push({ app_id, body });
169
- return { ...previewResult, findings: undefined };
193
+ return { ...result, findings: undefined };
170
194
  },
171
195
  };
172
196
  return { client, preview, release };
@@ -176,33 +200,53 @@ describe("appRelease — knowledge declaration forwarding", () => {
176
200
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "app", lotics }));
177
201
  return fn(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true }));
178
202
  }
179
- it("forwards the manifest's knowledge declaration from the app's own project", async () => {
203
+ it("forwards the manifest's knowledge + config declarations from the app's own project", async () => {
180
204
  vi.spyOn(console, "error").mockImplementation(() => undefined);
181
205
  const { client, preview, release } = mockClient();
182
- await withManifestDir({ app_id: "app_x", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
206
+ await withManifestDir({ app_id: "app_x", knowledge: [{ alias: "guide", doc_id: "kdc_1" }], config: [pageSizeKnob] }, async (dir) => {
183
207
  await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
184
208
  });
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" }] });
209
+ expect(preview[0]).toEqual({
210
+ app_id: "app_x",
211
+ opts: { knowledge: [{ alias: "guide", doc_id: "kdc_1" }], config: [pageSizeKnob] },
212
+ });
213
+ expect(release[0].body).toEqual({
214
+ changelog: "ship",
215
+ knowledge: [{ alias: "guide", doc_id: "kdc_1" }],
216
+ config: [pageSizeKnob],
217
+ });
187
218
  vi.restoreAllMocks();
188
219
  });
189
- it("omits knowledge (reconstruct from the pin) when the manifest declares none", async () => {
220
+ it("omits knowledge + config (reconstruct/carry-forward) when the manifest declares neither", async () => {
190
221
  vi.spyOn(console, "error").mockImplementation(() => undefined);
191
222
  const { client, preview, release } = mockClient();
192
223
  await withManifestDir({ app_id: "app_x" }, async (dir) => {
193
224
  await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
194
225
  });
195
- expect(preview[0].opts).toEqual({ knowledge: undefined });
196
- expect(release[0].body).toEqual({ changelog: "ship", knowledge: undefined });
226
+ expect(preview[0].opts).toEqual({ knowledge: undefined, config: undefined });
227
+ expect(release[0].body).toEqual({ changelog: "ship", knowledge: undefined, config: undefined });
197
228
  vi.restoreAllMocks();
198
229
  });
199
- it("omits knowledge for a bare app id whose manifest is a DIFFERENT app", async () => {
230
+ it("omits knowledge + config for a bare app id whose manifest is a DIFFERENT app", async () => {
200
231
  vi.spyOn(console, "error").mockImplementation(() => undefined);
201
232
  const { client, preview } = mockClient();
202
- await withManifestDir({ app_id: "app_other", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
233
+ await withManifestDir({ app_id: "app_other", knowledge: [{ alias: "guide", doc_id: "kdc_1" }], config: [pageSizeKnob] }, async (dir) => {
203
234
  await appRelease(client, { app_id: "app_x", changelog: "ship", yes: true, projectDir: dir });
204
235
  });
205
- expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: undefined } });
236
+ expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: undefined, config: undefined } });
237
+ vi.restoreAllMocks();
238
+ });
239
+ it("warns LOUDLY when a SENT config declaration is ignored by a pre-declaration server (deploy skew)", async () => {
240
+ const errs = [];
241
+ vi.spyOn(console, "error").mockImplementation((msg) => {
242
+ errs.push(String(msg));
243
+ });
244
+ // A pre-declaration server omits `config` from the preview echo.
245
+ const { client } = mockClient({ config: undefined });
246
+ await withManifestDir({ app_id: "app_x", config: [pageSizeKnob] }, async (dir) => {
247
+ await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
248
+ });
249
+ expect(errs.some((e) => /server ignored the config declaration/.test(e))).toBe(true);
206
250
  vi.restoreAllMocks();
207
251
  });
208
252
  });
@@ -307,3 +351,65 @@ describe("formatExtractReport", () => {
307
351
  expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
308
352
  });
309
353
  });
354
+ describe("packageDoctor — content package-id form", () => {
355
+ function mockClient(preview) {
356
+ return {
357
+ getWorkspaceId: () => "wsp_1",
358
+ listContentInstallations: async () => [
359
+ {
360
+ id: "pci_1",
361
+ package_id: "apg_corpus",
362
+ workspace_id: "wsp_1",
363
+ package_version: preview.from_version,
364
+ app_id: null,
365
+ binding: { knowledge: {}, templates: {} },
366
+ installed_by: null,
367
+ created_at: "",
368
+ updated_at: "",
369
+ package_registry: {
370
+ name: "Ops corpus",
371
+ kind: "content",
372
+ is_official: false,
373
+ latest_version: preview.to_version,
374
+ update_available: preview.to_version > preview.from_version,
375
+ },
376
+ },
377
+ ],
378
+ previewContentInstallationUpgrade: async () => preview,
379
+ };
380
+ }
381
+ it("reports a healthy, pristine install without failing the check", async () => {
382
+ const lines = [];
383
+ vi.spyOn(console, "error").mockImplementation((msg) => void lines.push(msg));
384
+ const before = process.exitCode;
385
+ await packageDoctor(mockClient({ from_version: 3, to_version: 3, entries: [], templates: [] }), { app_id: "apg_corpus" });
386
+ expect(lines.join("\n")).toContain("Ops corpus — content installation of apg_corpus");
387
+ expect(lines.join("\n")).toContain("Installed: v3 Latest: v3");
388
+ expect(lines.join("\n")).toContain("Binding: healthy");
389
+ expect(lines.join("\n")).toContain("Content: pristine");
390
+ expect(process.exitCode).toBe(before);
391
+ vi.restoreAllMocks();
392
+ });
393
+ it("reports drift (non-zero exit) and local edits with their consent framing", async () => {
394
+ const lines = [];
395
+ vi.spyOn(console, "error").mockImplementation((msg) => void lines.push(msg));
396
+ await packageDoctor(mockClient({
397
+ from_version: 2,
398
+ to_version: 3,
399
+ entries: [
400
+ { alias: "sop", name: "SOP", change: "drifted", modified: false, doc_id: null },
401
+ { alias: "pricing", name: "Pricing", change: "changed", modified: true, doc_id: "kdc_2" },
402
+ ],
403
+ templates: [{ kind: "template", alias: "bien_ban" }],
404
+ }), { app_id: "apg_corpus" });
405
+ const out = lines.join("\n");
406
+ expect(out).toContain("→ update available");
407
+ expect(out).toContain('knowledge.sop "SOP" (bound doc is gone)');
408
+ expect(out).toContain("--resolve knowledge.<alias>=recreate|unbind");
409
+ expect(out).toContain('knowledge.pricing "Pricing"');
410
+ expect(out).toContain("template.bien_ban");
411
+ expect(process.exitCode).toBe(1);
412
+ process.exitCode = 0;
413
+ vi.restoreAllMocks();
414
+ });
415
+ });