@lotics/cli 0.86.2 → 0.87.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.
@@ -1 +0,0 @@
1
- export {};
@@ -1,415 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, appRelease, packageUpgradeByPackageId, packageDoctor, } from "./package_commands.js";
6
- import { parseArgs } from "./args.js";
7
- describe("parseRenameFlags", () => {
8
- it("parses old=new pairs", () => {
9
- expect(parseRenameFlags(["item=deal", "don_hang=orders"])).toEqual([
10
- { from: "item", to: "deal" },
11
- { from: "don_hang", to: "orders" },
12
- ]);
13
- expect(parseRenameFlags(["e.f=name"])).toEqual([{ from: "e.f", to: "name" }]);
14
- expect(parseRenameFlags([])).toEqual([]);
15
- });
16
- it("rejects a malformed rename", () => {
17
- expect(() => parseRenameFlags(["nope"])).toThrow(/expected old=new/);
18
- expect(() => parseRenameFlags(["=x"])).toThrow(/expected old=new/);
19
- expect(() => parseRenameFlags(["x="])).toThrow(/expected old=new/);
20
- });
21
- });
22
- describe("readLocalAppManifest", () => {
23
- function withManifest(lotics, fn) {
24
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-app-test-"));
25
- try {
26
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "crm", lotics }));
27
- return fn(dir);
28
- }
29
- finally {
30
- fs.rmSync(dir, { recursive: true, force: true });
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
- });
59
- });
60
- it("returns null when the dir has no package.json", () => {
61
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
62
- try {
63
- expect(readLocalAppManifest(dir)).toBeNull();
64
- }
65
- finally {
66
- fs.rmSync(dir, { recursive: true, force: true });
67
- }
68
- });
69
- });
70
- describe("parseResolveFlags", () => {
71
- it("maps 'recreate' and ids to their resolution shapes", () => {
72
- expect(parseResolveFlags(["fields.deal.stage=recreate", "templates.quote=dtl_abc"])).toEqual({
73
- "fields.deal.stage": "recreate",
74
- "templates.quote": { bind_to: "dtl_abc" },
75
- });
76
- });
77
- it("maps modified-core consents 'revert' and 'keep' as literals", () => {
78
- expect(parseResolveFlags(["queries.tasks=revert", "workflows.notify=keep"])).toEqual({
79
- "queries.tasks": "revert",
80
- "workflows.notify": "keep",
81
- });
82
- });
83
- it("rejects entries without a key=value shape", () => {
84
- expect(() => parseResolveFlags(["fields.deal.stage"])).toThrow(/Invalid --resolve/);
85
- expect(() => parseResolveFlags(["=recreate"])).toThrow(/Invalid --resolve/);
86
- expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
87
- });
88
- });
89
- describe("parseResolveFlags — the one namespaced grammar", () => {
90
- it("passes knowledge verbs through under knowledge.<alias> keys", () => {
91
- expect(parseResolveFlags([
92
- "knowledge.pricing=apply",
93
- "knowledge.faq=keep",
94
- "knowledge.old=archive",
95
- "knowledge.gone=recreate",
96
- "knowledge.legacy=unbind",
97
- ])).toEqual({
98
- "knowledge.pricing": "apply",
99
- "knowledge.faq": "keep",
100
- "knowledge.old": "archive",
101
- "knowledge.gone": "recreate",
102
- "knowledge.legacy": "unbind",
103
- });
104
- });
105
- it("passes template consents through under template.<alias> keys", () => {
106
- expect(parseResolveFlags(["template.quote=revert", "template.invoice=keep"])).toEqual({
107
- "template.quote": "revert",
108
- "template.invoice": "keep",
109
- });
110
- });
111
- it("maps a non-verb value to a bind_to id on any key (roles rebind, knowledge adopt)", () => {
112
- expect(parseResolveFlags(["roles.approver=grp_new", "knowledge.faq=kdc_existing"])).toEqual({
113
- "roles.approver": { bind_to: "grp_new" },
114
- "knowledge.faq": { bind_to: "kdc_existing" },
115
- });
116
- });
117
- });
118
- describe("parseBindToFlags", () => {
119
- it("maps each alias to its consent doc id", () => {
120
- expect(parseBindToFlags(["playbook=kdc_1", "faq=kdc_2"])).toEqual({
121
- playbook: "kdc_1",
122
- faq: "kdc_2",
123
- });
124
- });
125
- it("rejects entries without an alias=id shape", () => {
126
- expect(() => parseBindToFlags(["playbook"])).toThrow(/Invalid --bind-to/);
127
- expect(() => parseBindToFlags(["=kdc_1"])).toThrow(/Invalid --bind-to/);
128
- expect(() => parseBindToFlags(["playbook="])).toThrow(/Invalid --bind-to/);
129
- });
130
- });
131
- describe("parseArgs — app publish --rename", () => {
132
- it("captures repeated --rename flags with the positional app id", () => {
133
- const { command, subcommand, toolArgs, flags } = parseArgs([
134
- "app",
135
- "publish",
136
- "app_x",
137
- "--rename",
138
- "item=deal",
139
- "--rename",
140
- "e.f=name",
141
- ]);
142
- expect(command).toBe("app");
143
- expect(subcommand).toBe("publish");
144
- expect(toolArgs).toBe("app_x");
145
- expect(flags.rename).toEqual(["item=deal", "e.f=name"]);
146
- });
147
- it("throws when --rename has no value", () => {
148
- expect(() => parseArgs(["app", "publish", "--rename"])).toThrow(/--rename requires a value/);
149
- expect(() => parseArgs(["app", "publish", "--rename", "--yes"])).toThrow(/--rename requires a value/);
150
- });
151
- });
152
- describe("parseArgs — package list-content", () => {
153
- it("parses the list-content subcommand with no positional", () => {
154
- const { command, subcommand, toolArgs } = parseArgs(["package", "list-content"]);
155
- expect(command).toBe("package");
156
- expect(subcommand).toBe("list-content");
157
- expect(toolArgs).toBeUndefined();
158
- });
159
- it("captures --workspace for a scoped list-content", () => {
160
- const { subcommand, flags } = parseArgs([
161
- "package",
162
- "list-content",
163
- "--workspace",
164
- "wsp_dev",
165
- ]);
166
- expect(subcommand).toBe("list-content");
167
- expect(flags.workspace).toBe("wsp_dev");
168
- });
169
- });
170
- describe("appRelease — knowledge + config declaration forwarding", () => {
171
- const pageSizeKnob = { alias: "page_size", label: "Page size", type: "number", default: 25 };
172
- const previewResult = {
173
- package_id: "apg_1",
174
- version: 2,
175
- added_aliases: [],
176
- changed_artifacts: [],
177
- knowledge: { added: ["guide"], removed: [], changed: [] },
178
- config: { added: ["page_size"], removed: [], changed: [] },
179
- findings: [],
180
- };
181
- /** A mock client capturing the knowledge + config forwarded to preview + release. */
182
- function mockClient(overridePreview) {
183
- const preview = [];
184
- const release = [];
185
- const result = { ...previewResult, ...overridePreview };
186
- const client = {
187
- previewPackageRelease: async (app_id, opts = {}) => {
188
- preview.push({ app_id, opts });
189
- return result;
190
- },
191
- releasePackage: async (app_id, body) => {
192
- release.push({ app_id, body });
193
- return { ...result, findings: undefined };
194
- },
195
- };
196
- return { client, preview, release };
197
- }
198
- function withManifestDir(lotics, fn) {
199
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-rel-"));
200
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "app", lotics }));
201
- return fn(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true }));
202
- }
203
- it("forwards the manifest's knowledge + config declarations from the app's own project", async () => {
204
- vi.spyOn(console, "error").mockImplementation(() => undefined);
205
- const { client, preview, release } = mockClient();
206
- await withManifestDir({ app_id: "app_x", knowledge: [{ alias: "guide", doc_id: "kdc_1" }], config: [pageSizeKnob] }, async (dir) => {
207
- await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
208
- });
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
- });
218
- vi.restoreAllMocks();
219
- });
220
- it("omits knowledge + config (reconstruct/carry-forward) when the manifest declares neither", async () => {
221
- vi.spyOn(console, "error").mockImplementation(() => undefined);
222
- const { client, preview, release } = mockClient();
223
- await withManifestDir({ app_id: "app_x" }, async (dir) => {
224
- await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
225
- });
226
- expect(preview[0].opts).toEqual({ knowledge: undefined, config: undefined });
227
- expect(release[0].body).toEqual({ changelog: "ship", knowledge: undefined, config: undefined });
228
- vi.restoreAllMocks();
229
- });
230
- it("omits knowledge + config for a bare app id whose manifest is a DIFFERENT app", async () => {
231
- vi.spyOn(console, "error").mockImplementation(() => undefined);
232
- const { client, preview } = mockClient();
233
- await withManifestDir({ app_id: "app_other", knowledge: [{ alias: "guide", doc_id: "kdc_1" }], config: [pageSizeKnob] }, async (dir) => {
234
- await appRelease(client, { app_id: "app_x", changelog: "ship", yes: true, projectDir: dir });
235
- });
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);
250
- vi.restoreAllMocks();
251
- });
252
- });
253
- describe("packageUpgradeByPackageId — kind-branched by the package read", () => {
254
- /**
255
- * A mock client recording which upgrade path a `lotics upgrade <apg_>` takes.
256
- * `kind` drives the branch: a content package resolves THIS workspace's content
257
- * installation from the package id (the `pci_` never surfaces) and runs the
258
- * content upgrade; an app package fleet-upgrades the org.
259
- */
260
- function mockClient(opts) {
261
- const calls = { previewContent: [], fleet: [], listedWorkspaces: [] };
262
- const client = {
263
- getPackage: async (package_id) => ({
264
- id: package_id,
265
- name: "Ops corpus",
266
- kind: opts.kind,
267
- latest_version: 3,
268
- is_official: false,
269
- retired_at: null,
270
- }),
271
- getWorkspaceId: () => "wsp_1",
272
- listContentInstallations: async (workspace_id) => {
273
- calls.listedWorkspaces.push(workspace_id);
274
- return (opts.installations ?? []).map((i) => ({
275
- ...i,
276
- workspace_id,
277
- package_version: 2,
278
- app_id: null,
279
- binding: { knowledge: {}, templates: {} },
280
- installed_by: null,
281
- created_at: "",
282
- updated_at: "",
283
- package_registry: null,
284
- }));
285
- },
286
- previewContentInstallationUpgrade: async (installation_id) => {
287
- calls.previewContent.push(installation_id);
288
- return { from_version: 2, to_version: 2, entries: [], templates: [] };
289
- },
290
- fleetUpgradePackage: async (package_id) => {
291
- calls.fleet.push(package_id);
292
- return { package_id, target_version: 3, installations: [] };
293
- },
294
- };
295
- return { client, calls };
296
- }
297
- const args = { version: undefined, resolve: [], bindTo: [], applyAll: false };
298
- it("routes a CONTENT package to this workspace's content installation (resolved from the package id)", async () => {
299
- vi.spyOn(console, "error").mockImplementation(() => undefined);
300
- const { client, calls } = mockClient({
301
- kind: "content",
302
- installations: [{ id: "pci_1", package_id: "apg_content" }],
303
- });
304
- await packageUpgradeByPackageId(client, { package_id: "apg_content", ...args });
305
- expect(calls.listedWorkspaces).toEqual(["wsp_1"]);
306
- expect(calls.previewContent).toEqual(["pci_1"]); // resolved apg_ → pci_ client-side
307
- expect(calls.fleet).toEqual([]); // never a fleet run for content
308
- vi.restoreAllMocks();
309
- });
310
- it("routes an APP package to the org fleet upgrade", async () => {
311
- vi.spyOn(console, "error").mockImplementation(() => undefined);
312
- const { client, calls } = mockClient({ kind: "app" });
313
- await packageUpgradeByPackageId(client, { package_id: "apg_app", ...args });
314
- expect(calls.fleet).toEqual(["apg_app"]);
315
- expect(calls.previewContent).toEqual([]);
316
- expect(calls.listedWorkspaces).toEqual([]); // no content resolution for an app package
317
- vi.restoreAllMocks();
318
- });
319
- it("errors clearly when a content package is not installed in this workspace", async () => {
320
- vi.spyOn(console, "error").mockImplementation(() => undefined);
321
- const { client } = mockClient({ kind: "content", installations: [] });
322
- await expect(packageUpgradeByPackageId(client, { package_id: "apg_content", ...args })).rejects.toThrow(/not installed in this workspace[\s\S]*lotics install apg_content/);
323
- vi.restoreAllMocks();
324
- });
325
- });
326
- describe("formatExtractReport", () => {
327
- it("groups findings errors → warnings → info and formats each line", () => {
328
- const { lines, hasError } = formatExtractReport([
329
- { severity: "info", area: "queries.items", message: "inverted cleanly" },
330
- { severity: "error", area: "fields.deal.stage", message: "button field is not portable" },
331
- { severity: "warning", area: "workflows.notify", message: "legacy key retained" },
332
- { severity: "error", area: "queries.deals", message: "round-trip mismatch" },
333
- ]);
334
- expect(lines).toEqual([
335
- " [error] fields.deal.stage: button field is not portable",
336
- " [error] queries.deals: round-trip mismatch",
337
- " [warning] workflows.notify: legacy key retained",
338
- " [info] queries.items: inverted cleanly",
339
- ]);
340
- expect(hasError).toBe(true);
341
- });
342
- it("reports no error when only warnings/info are present", () => {
343
- const { lines, hasError } = formatExtractReport([
344
- { severity: "warning", area: "a", message: "w" },
345
- { severity: "info", area: "b", message: "i" },
346
- ]);
347
- expect(lines).toEqual([" [warning] a: w", " [info] b: i"]);
348
- expect(hasError).toBe(false);
349
- });
350
- it("handles an empty report", () => {
351
- expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
352
- });
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
- });