@lotics/cli 0.86.1 → 0.87.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.
@@ -1 +0,0 @@
1
- export {};
@@ -1,309 +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, } 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
- it("reads app_id + knowledge from a pulled app project", () => {
24
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-app-test-"));
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
- });
34
- }
35
- finally {
36
- fs.rmSync(dir, { recursive: true, force: true });
37
- }
38
- });
39
- it("returns null when the dir has no package.json", () => {
40
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
41
- try {
42
- expect(readLocalAppManifest(dir)).toBeNull();
43
- }
44
- finally {
45
- fs.rmSync(dir, { recursive: true, force: true });
46
- }
47
- });
48
- });
49
- describe("parseResolveFlags", () => {
50
- it("maps 'recreate' and ids to their resolution shapes", () => {
51
- expect(parseResolveFlags(["fields.deal.stage=recreate", "templates.quote=dtl_abc"])).toEqual({
52
- "fields.deal.stage": "recreate",
53
- "templates.quote": { bind_to: "dtl_abc" },
54
- });
55
- });
56
- it("maps modified-core consents 'revert' and 'keep' as literals", () => {
57
- expect(parseResolveFlags(["queries.tasks=revert", "workflows.notify=keep"])).toEqual({
58
- "queries.tasks": "revert",
59
- "workflows.notify": "keep",
60
- });
61
- });
62
- it("rejects entries without a key=value shape", () => {
63
- expect(() => parseResolveFlags(["fields.deal.stage"])).toThrow(/Invalid --resolve/);
64
- expect(() => parseResolveFlags(["=recreate"])).toThrow(/Invalid --resolve/);
65
- expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
66
- });
67
- });
68
- describe("parseResolveFlags — the one namespaced grammar", () => {
69
- it("passes knowledge verbs through under knowledge.<alias> keys", () => {
70
- expect(parseResolveFlags([
71
- "knowledge.pricing=apply",
72
- "knowledge.faq=keep",
73
- "knowledge.old=archive",
74
- "knowledge.gone=recreate",
75
- "knowledge.legacy=unbind",
76
- ])).toEqual({
77
- "knowledge.pricing": "apply",
78
- "knowledge.faq": "keep",
79
- "knowledge.old": "archive",
80
- "knowledge.gone": "recreate",
81
- "knowledge.legacy": "unbind",
82
- });
83
- });
84
- it("passes template consents through under template.<alias> keys", () => {
85
- expect(parseResolveFlags(["template.quote=revert", "template.invoice=keep"])).toEqual({
86
- "template.quote": "revert",
87
- "template.invoice": "keep",
88
- });
89
- });
90
- it("maps a non-verb value to a bind_to id on any key (roles rebind, knowledge adopt)", () => {
91
- expect(parseResolveFlags(["roles.approver=grp_new", "knowledge.faq=kdc_existing"])).toEqual({
92
- "roles.approver": { bind_to: "grp_new" },
93
- "knowledge.faq": { bind_to: "kdc_existing" },
94
- });
95
- });
96
- });
97
- describe("parseBindToFlags", () => {
98
- it("maps each alias to its consent doc id", () => {
99
- expect(parseBindToFlags(["playbook=kdc_1", "faq=kdc_2"])).toEqual({
100
- playbook: "kdc_1",
101
- faq: "kdc_2",
102
- });
103
- });
104
- it("rejects entries without an alias=id shape", () => {
105
- expect(() => parseBindToFlags(["playbook"])).toThrow(/Invalid --bind-to/);
106
- expect(() => parseBindToFlags(["=kdc_1"])).toThrow(/Invalid --bind-to/);
107
- expect(() => parseBindToFlags(["playbook="])).toThrow(/Invalid --bind-to/);
108
- });
109
- });
110
- describe("parseArgs — app publish --rename", () => {
111
- it("captures repeated --rename flags with the positional app id", () => {
112
- const { command, subcommand, toolArgs, flags } = parseArgs([
113
- "app",
114
- "publish",
115
- "app_x",
116
- "--rename",
117
- "item=deal",
118
- "--rename",
119
- "e.f=name",
120
- ]);
121
- expect(command).toBe("app");
122
- expect(subcommand).toBe("publish");
123
- expect(toolArgs).toBe("app_x");
124
- expect(flags.rename).toEqual(["item=deal", "e.f=name"]);
125
- });
126
- it("throws when --rename has no value", () => {
127
- expect(() => parseArgs(["app", "publish", "--rename"])).toThrow(/--rename requires a value/);
128
- expect(() => parseArgs(["app", "publish", "--rename", "--yes"])).toThrow(/--rename requires a value/);
129
- });
130
- });
131
- describe("parseArgs — package list-content", () => {
132
- it("parses the list-content subcommand with no positional", () => {
133
- const { command, subcommand, toolArgs } = parseArgs(["package", "list-content"]);
134
- expect(command).toBe("package");
135
- expect(subcommand).toBe("list-content");
136
- expect(toolArgs).toBeUndefined();
137
- });
138
- it("captures --workspace for a scoped list-content", () => {
139
- const { subcommand, flags } = parseArgs([
140
- "package",
141
- "list-content",
142
- "--workspace",
143
- "wsp_dev",
144
- ]);
145
- expect(subcommand).toBe("list-content");
146
- expect(flags.workspace).toBe("wsp_dev");
147
- });
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
- });
282
- describe("formatExtractReport", () => {
283
- it("groups findings errors → warnings → info and formats each line", () => {
284
- const { lines, hasError } = formatExtractReport([
285
- { severity: "info", area: "queries.items", message: "inverted cleanly" },
286
- { severity: "error", area: "fields.deal.stage", message: "button field is not portable" },
287
- { severity: "warning", area: "workflows.notify", message: "legacy key retained" },
288
- { severity: "error", area: "queries.deals", message: "round-trip mismatch" },
289
- ]);
290
- expect(lines).toEqual([
291
- " [error] fields.deal.stage: button field is not portable",
292
- " [error] queries.deals: round-trip mismatch",
293
- " [warning] workflows.notify: legacy key retained",
294
- " [info] queries.items: inverted cleanly",
295
- ]);
296
- expect(hasError).toBe(true);
297
- });
298
- it("reports no error when only warnings/info are present", () => {
299
- const { lines, hasError } = formatExtractReport([
300
- { severity: "warning", area: "a", message: "w" },
301
- { severity: "info", area: "b", message: "i" },
302
- ]);
303
- expect(lines).toEqual([" [warning] a: w", " [info] b: i"]);
304
- expect(hasError).toBe(false);
305
- });
306
- it("handles an empty report", () => {
307
- expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
308
- });
309
- });