@lotics/cli 0.76.0 → 0.83.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.
@@ -2,139 +2,44 @@ import { describe, it, expect } 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 { parseAppPackageContract, validateAppPackageContract, } from "@lotics/shared/schemas/app_packages";
6
- import { starterContract, readPackageProject, writePackageManifest, sanitizePackageJsonForSource, stagePackageSource, parseResolveFlags, assertDevWorkspace, draftPackageProjectFromApp, parseAdoptBindingFile, formatExtractReport, } from "./package_commands.js";
7
- describe("starterContract", () => {
8
- it("scaffolds a structurally + referentially valid contract", () => {
9
- const raw = starterContract("Acme CRM");
10
- // Structurally valid against the canonical contract schema.
11
- const contract = parseAppPackageContract(raw);
12
- // Cross-references (query from_entity, etc.) resolve — zero violations, so the
13
- // scaffold publishes without the server's contract validator rejecting it.
14
- expect(validateAppPackageContract(contract)).toEqual([]);
5
+ import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, } 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
15
  });
16
- it("uses the package name as the entity label", () => {
17
- const contract = parseAppPackageContract(starterContract("Widgets"));
18
- expect(contract.entities[0].label).toBe("Widgets");
19
- expect(contract.config[0].default).toBe("Widgets");
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
20
  });
21
21
  });
22
- describe("package manifest round-trip", () => {
23
- function makeProject() {
24
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
25
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
26
- name: "acme-crm",
27
- version: "0.0.1",
28
- lotics: {
29
- package: { id: null, name: "Acme CRM", description: null, version: null, dev: {} },
30
- },
31
- }, null, 2) + "\n");
32
- return dir;
33
- }
34
- it("reads the package manifest off package.json#lotics.package", () => {
35
- const dir = makeProject();
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-"));
36
25
  try {
37
- const { manifest } = readPackageProject(dir);
38
- expect(manifest).toEqual({
39
- id: null,
40
- name: "Acme CRM",
41
- description: null,
42
- version: null,
43
- dev: {},
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" }],
44
33
  });
45
34
  }
46
35
  finally {
47
36
  fs.rmSync(dir, { recursive: true, force: true });
48
37
  }
49
38
  });
50
- it("persists publish + dev-installation pins and reads them back", () => {
51
- const dir = makeProject();
52
- try {
53
- const project = readPackageProject(dir);
54
- project.manifest.id = "apg_test";
55
- project.manifest.version = 3;
56
- project.manifest.dev["wsp_dev"] = { app_id: "app_dev", version: 3 };
57
- writePackageManifest(dir, project);
58
- const reread = readPackageProject(dir);
59
- expect(reread.manifest.id).toBe("apg_test");
60
- expect(reread.manifest.version).toBe(3);
61
- expect(reread.manifest.dev).toEqual({ wsp_dev: { app_id: "app_dev", version: 3 } });
62
- // Non-lotics package.json fields survive the manifest write.
63
- expect(reread.pkgJson.name).toBe("acme-crm");
64
- expect(reread.pkgJson.version).toBe("0.0.1");
65
- }
66
- finally {
67
- fs.rmSync(dir, { recursive: true, force: true });
68
- }
69
- });
70
- it("writes the manifest atomically, leaving no temp file behind", () => {
71
- const dir = makeProject();
72
- try {
73
- const project = readPackageProject(dir);
74
- project.manifest.id = "apg_atomic";
75
- writePackageManifest(dir, project);
76
- // The atomic write renames a temp file into place; nothing stray remains.
77
- const leftovers = fs.readdirSync(dir).filter((f) => f.startsWith("package.json."));
78
- expect(leftovers).toEqual([]);
79
- expect(readPackageProject(dir).manifest.id).toBe("apg_atomic");
80
- }
81
- finally {
82
- fs.rmSync(dir, { recursive: true, force: true });
83
- }
84
- });
85
- });
86
- describe("sanitizePackageJsonForSource", () => {
87
- it("strips the author-local lotics.package.dev map", () => {
88
- const sanitized = sanitizePackageJsonForSource({
89
- name: "acme-crm",
90
- version: "0.0.1",
91
- lotics: {
92
- package: {
93
- id: "apg_x",
94
- name: "Acme CRM",
95
- description: "d",
96
- version: 4,
97
- dev: { wsp_dev: { app_id: "app_dev", version: 4 } },
98
- },
99
- },
100
- });
101
- const lotics = sanitized.lotics;
102
- // The private dev bookkeeping is gone…
103
- expect("dev" in lotics.package).toBe(false);
104
- // …while the package identity + other fields survive untouched.
105
- expect(lotics.package).toEqual({
106
- id: "apg_x",
107
- name: "Acme CRM",
108
- description: "d",
109
- version: 4,
110
- });
111
- expect(sanitized.name).toBe("acme-crm");
112
- expect(sanitized.version).toBe("0.0.1");
113
- });
114
- it("does not mutate the input package.json", () => {
115
- const input = {
116
- name: "acme-crm",
117
- lotics: { package: { id: "apg_x", name: "Acme CRM", dev: { wsp_dev: { app_id: "a", version: 1 } } } },
118
- };
119
- sanitizePackageJsonForSource(input);
120
- expect(input.lotics.package.dev).toEqual({ wsp_dev: { app_id: "a", version: 1 } });
121
- });
122
- });
123
- describe("assertDevWorkspace", () => {
124
- it("passes a dev workspace", () => {
125
- expect(() => assertDevWorkspace({ id: "wsp_d", name: "Dev", is_dev: true })).not.toThrow();
126
- });
127
- it("fails loud on a non-dev workspace", () => {
128
- expect(() => assertDevWorkspace({ id: "wsp_p", name: "Prod", is_dev: false })).toThrow(/not a dev workspace/);
129
- });
130
- it("fails closed when is_dev is absent (server doesn't serialize it)", () => {
131
- expect(() => assertDevWorkspace({ id: "wsp_u", name: "Unknown" })).toThrow(/not a dev workspace/);
132
- });
133
- it("rejects a directory that is not a package project", () => {
134
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
39
+ it("returns null when the dir has no package.json", () => {
40
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
135
41
  try {
136
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "plain-app" }));
137
- expect(() => readPackageProject(dir)).toThrow(/not a package project/);
42
+ expect(readLocalAppManifest(dir)).toBeNull();
138
43
  }
139
44
  finally {
140
45
  fs.rmSync(dir, { recursive: true, force: true });
@@ -160,81 +65,85 @@ describe("parseResolveFlags", () => {
160
65
  expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
161
66
  });
162
67
  });
163
- describe("draftPackageProjectFromApp", () => {
164
- it("strips the app manifest and grafts an unpublished package manifest", () => {
165
- const project = draftPackageProjectFromApp({
166
- name: "acme-crm",
167
- version: "0.0.1",
168
- dependencies: { "@lotics/app-sdk": "^1.0.0" },
169
- lotics: {
170
- app_id: "app_x",
171
- workspace_id: "wsp_y",
172
- current_version_id: "apv_z",
173
- workflows: { notify: { workflow_id: "wfl_1" } },
174
- },
175
- }, { name: "Acme CRM", description: null });
176
- // The app manifest (app_id/workspace_id/workflows) is gone entirely…
177
- expect("lotics" in project.pkgJson).toBe(false);
178
- // …the package manifest is fresh + unpublished…
179
- expect(project.manifest).toEqual({
180
- id: null,
181
- name: "Acme CRM",
182
- description: null,
183
- version: null,
184
- dev: {},
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",
185
82
  });
186
- // …and non-lotics fields survive verbatim.
187
- expect(project.pkgJson.name).toBe("acme-crm");
188
- expect(project.pkgJson.version).toBe("0.0.1");
189
- expect(project.pkgJson.dependencies).toEqual({ "@lotics/app-sdk": "^1.0.0" });
190
83
  });
191
- it("round-trips through writePackageManifest to a valid package project on disk", () => {
192
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
193
- try {
194
- writePackageManifest(dir, draftPackageProjectFromApp({ name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } }, { name: "Acme CRM", description: "A CRM" }));
195
- // readPackageProject only accepts a real package project (lotics.package).
196
- const { manifest, pkgJson } = readPackageProject(dir);
197
- expect(manifest).toEqual({
198
- id: null,
199
- name: "Acme CRM",
200
- description: "A CRM",
201
- version: null,
202
- dev: {},
203
- });
204
- // The old app manifest keys never made it to disk.
205
- const lotics = pkgJson.lotics;
206
- expect("app_id" in lotics).toBe(false);
207
- expect("workspace_id" in lotics).toBe(false);
208
- }
209
- finally {
210
- fs.rmSync(dir, { recursive: true, force: true });
211
- }
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
+ });
212
89
  });
213
- it("does not mutate the input package.json", () => {
214
- const input = { name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } };
215
- draftPackageProjectFromApp(input, { name: "Acme CRM", description: null });
216
- expect(input.lotics).toEqual({ app_id: "app_x", workspace_id: "wsp_y" });
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
+ });
217
95
  });
218
96
  });
219
- describe("parseAdoptBindingFile", () => {
220
- const validPin = {
221
- app_id: "app_x",
222
- workspace_id: "wsp_y",
223
- binding: { entities: { item: "tbl_1" }, fields: {}, options: {}, templates: {}, roles: {}, workflows: {} },
224
- };
225
- it("accepts a pin whose app_id matches the app being adopted", () => {
226
- const pin = parseAdoptBindingFile(validPin, "app_x");
227
- expect(pin.app_id).toBe("app_x");
228
- expect(pin.workspace_id).toBe("wsp_y");
229
- expect(pin.binding.entities).toEqual({ item: "tbl_1" });
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
+ });
230
103
  });
231
- it("REFUSES a pin recorded for a different app", () => {
232
- expect(() => parseAdoptBindingFile(validPin, "app_OTHER")).toThrow(/records app app_x, but you are adopting app_OTHER/);
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"]);
233
125
  });
234
- it("rejects a malformed pin", () => {
235
- expect(() => parseAdoptBindingFile({ app_id: "app_x" }, "app_x")).toThrow(/Malformed/);
236
- expect(() => parseAdoptBindingFile(null, "app_x")).toThrow(/Malformed/);
237
- expect(() => parseAdoptBindingFile({ app_id: "app_x", workspace_id: "wsp_y" }, "app_x")).toThrow(/Malformed/);
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");
238
147
  });
239
148
  });
240
149
  describe("formatExtractReport", () => {
@@ -265,39 +174,3 @@ describe("formatExtractReport", () => {
265
174
  expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
266
175
  });
267
176
  });
268
- describe("stagePackageSource", () => {
269
- it("keeps nested dist-named dirs, drops top-level excludes, sanitizes package.json", () => {
270
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-test-"));
271
- const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-out-"));
272
- try {
273
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
274
- name: "pkg",
275
- lotics: { package: { id: "apg_x", name: "pkg", version: 3, dev: { wsp_a: "app_1" } } },
276
- }));
277
- fs.mkdirSync(path.join(dir, "src"));
278
- fs.writeFileSync(path.join(dir, "src", "main.tsx"), "export {}");
279
- // A nested dir NAMED dist must ship — only the top-level dist is a build output.
280
- fs.mkdirSync(path.join(dir, "templates", "dist"), { recursive: true });
281
- fs.writeFileSync(path.join(dir, "templates", "dist", "quote.xlsx"), "bytes");
282
- fs.mkdirSync(path.join(dir, "dist"));
283
- fs.writeFileSync(path.join(dir, "dist", "index.js"), "built");
284
- fs.mkdirSync(path.join(dir, "node_modules", "x"), { recursive: true });
285
- fs.writeFileSync(path.join(dir, "node_modules", "x", "i.js"), "dep");
286
- fs.writeFileSync(path.join(dir, "tsconfig.tsbuildinfo"), "{}");
287
- stagePackageSource(dir, stage);
288
- expect(fs.existsSync(path.join(stage, "src", "main.tsx"))).toBe(true);
289
- expect(fs.existsSync(path.join(stage, "templates", "dist", "quote.xlsx"))).toBe(true);
290
- expect(fs.existsSync(path.join(stage, "dist"))).toBe(false);
291
- expect(fs.existsSync(path.join(stage, "node_modules"))).toBe(false);
292
- expect(fs.existsSync(path.join(stage, "tsconfig.tsbuildinfo"))).toBe(false);
293
- // The staged package.json is the sanitized one — dev pins stripped.
294
- const staged = JSON.parse(fs.readFileSync(path.join(stage, "package.json"), "utf-8"));
295
- expect(staged.lotics.package.dev).toBeUndefined();
296
- expect(staged.lotics.package.id).toBe("apg_x");
297
- }
298
- finally {
299
- fs.rmSync(dir, { recursive: true, force: true });
300
- fs.rmSync(stage, { recursive: true, force: true });
301
- }
302
- });
303
- });