@lotics/cli 0.76.0 → 0.76.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.
@@ -2,19 +2,21 @@ 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";
5
+ import { createHash } from "node:crypto";
6
+ import { parsePackageContract, validatePackageContract, } from "@lotics/shared/schemas/packages";
7
+ import { starterContract, readPackageProject, writePackageManifest, sanitizePackageJsonForSource, stagePackageSource, foldKnowledgeIntoContract, foldTemplateShasIntoContract, foldTemplatesIntoContract, parseResolveFlags, routeContentResolveFlags, parseBindToFlags, routeAppUpgradeResolve, assertDevWorkspace, draftPackageProjectFromApp, parseAdoptBindingFile, formatExtractReport, } from "./package_commands.js";
8
+ import { parseArgs } from "./args.js";
7
9
  describe("starterContract", () => {
8
10
  it("scaffolds a structurally + referentially valid contract", () => {
9
11
  const raw = starterContract("Acme CRM");
10
12
  // Structurally valid against the canonical contract schema.
11
- const contract = parseAppPackageContract(raw);
13
+ const contract = parsePackageContract(raw);
12
14
  // Cross-references (query from_entity, etc.) resolve — zero violations, so the
13
15
  // scaffold publishes without the server's contract validator rejecting it.
14
- expect(validateAppPackageContract(contract)).toEqual([]);
16
+ expect(validatePackageContract(contract)).toEqual([]);
15
17
  });
16
18
  it("uses the package name as the entity label", () => {
17
- const contract = parseAppPackageContract(starterContract("Widgets"));
19
+ const contract = parsePackageContract(starterContract("Widgets"));
18
20
  expect(contract.entities[0].label).toBe("Widgets");
19
21
  expect(contract.config[0].default).toBe("Widgets");
20
22
  });
@@ -39,7 +41,11 @@ describe("package manifest round-trip", () => {
39
41
  id: null,
40
42
  name: "Acme CRM",
41
43
  description: null,
44
+ kind: "app",
42
45
  version: null,
46
+ knowledge: {},
47
+ templates: {},
48
+ knowledge_expects: [],
43
49
  dev: {},
44
50
  });
45
51
  }
@@ -120,6 +126,217 @@ describe("sanitizePackageJsonForSource", () => {
120
126
  expect(input.lotics.package.dev).toEqual({ wsp_dev: { app_id: "a", version: 1 } });
121
127
  });
122
128
  });
129
+ describe("foldKnowledgeIntoContract", () => {
130
+ function makeKnowledgeProject(knowledge, knowledge_expects, files) {
131
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-know-test-"));
132
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
133
+ name: "acme-kb",
134
+ lotics: {
135
+ // Everything package-related lives under `lotics.package` — including
136
+ // knowledge_expects (reconciled from the earlier lotics.knowledge_expects).
137
+ package: {
138
+ id: null,
139
+ name: "Acme KB",
140
+ description: null,
141
+ version: null,
142
+ knowledge,
143
+ knowledge_expects,
144
+ dev: {},
145
+ },
146
+ },
147
+ }, null, 2) + "\n");
148
+ for (const [rel, content] of Object.entries(files)) {
149
+ const full = path.join(dir, rel);
150
+ fs.mkdirSync(path.dirname(full), { recursive: true });
151
+ fs.writeFileSync(full, content);
152
+ }
153
+ return dir;
154
+ }
155
+ it("emits a contract knowledge namespace with the file's sha256 + canonical content_ref", () => {
156
+ const content = "# Pricing\n\nHow we price things.\n";
157
+ const dir = makeKnowledgeProject({ pricing: { name: "Pricing Guide", description: "How we price", active_by_default: false } }, ["Employee Handbook"], { "knowledge/pricing.md": content });
158
+ try {
159
+ const project = readPackageProject(dir);
160
+ const folded = foldKnowledgeIntoContract(dir, project, { entities: [] });
161
+ const expectedSha = createHash("sha256").update(Buffer.from(content)).digest("hex");
162
+ expect(folded.knowledge).toEqual({
163
+ pricing: {
164
+ name: "Pricing Guide",
165
+ description: "How we price",
166
+ content_ref: "knowledge/pricing.md",
167
+ content_sha256: expectedSha,
168
+ active_by_default: false,
169
+ },
170
+ });
171
+ expect(folded.knowledge_expects).toEqual(["Employee Handbook"]);
172
+ // The folded contract is valid end-to-end (parse + referential-integrity).
173
+ const parsed = parsePackageContract(folded);
174
+ expect(validatePackageContract(parsed)).toEqual([]);
175
+ }
176
+ finally {
177
+ fs.rmSync(dir, { recursive: true, force: true });
178
+ }
179
+ });
180
+ it("defaults active_by_default to true and description to empty when omitted", () => {
181
+ const dir = makeKnowledgeProject({ policies: { name: "Policies" } }, [], { "knowledge/policies.md": "body" });
182
+ try {
183
+ const project = readPackageProject(dir);
184
+ const folded = foldKnowledgeIntoContract(dir, project, { entities: [] });
185
+ expect(folded.knowledge.policies.active_by_default).toBe(true);
186
+ expect(folded.knowledge.policies.description).toBe("");
187
+ }
188
+ finally {
189
+ fs.rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+ it("throws when a declared knowledge doc has no content file", () => {
193
+ const dir = makeKnowledgeProject({ ghost: { name: "Ghost" } }, [], {});
194
+ try {
195
+ const project = readPackageProject(dir);
196
+ expect(() => foldKnowledgeIntoContract(dir, project, { entities: [] })).toThrow(/knowledge\/ghost\.md/);
197
+ }
198
+ finally {
199
+ fs.rmSync(dir, { recursive: true, force: true });
200
+ }
201
+ });
202
+ it("leaves a knowledge-free contract untouched", () => {
203
+ const dir = makeKnowledgeProject({}, [], {});
204
+ try {
205
+ const project = readPackageProject(dir);
206
+ const contract = { entities: [] };
207
+ expect(foldKnowledgeIntoContract(dir, project, contract)).toBe(contract);
208
+ }
209
+ finally {
210
+ fs.rmSync(dir, { recursive: true, force: true });
211
+ }
212
+ });
213
+ it("round-trips lotics.package.knowledge through a manifest write (id stamp never drops it)", () => {
214
+ const dir = makeKnowledgeProject({ pricing: { name: "Pricing Guide", description: "d", active_by_default: true } }, ["Handbook"], { "knowledge/pricing.md": "body" });
215
+ try {
216
+ const project = readPackageProject(dir);
217
+ project.manifest.id = "apg_stamped";
218
+ writePackageManifest(dir, project);
219
+ const reread = readPackageProject(dir);
220
+ expect(reread.manifest.id).toBe("apg_stamped");
221
+ expect(reread.manifest.knowledge).toEqual({
222
+ pricing: { name: "Pricing Guide", description: "d", active_by_default: true },
223
+ });
224
+ // knowledge_expects now lives under lotics.package and survives the write.
225
+ expect(reread.manifest.knowledge_expects).toEqual(["Handbook"]);
226
+ const lotics = reread.pkgJson.lotics;
227
+ expect(lotics.package.knowledge_expects).toEqual(["Handbook"]);
228
+ }
229
+ finally {
230
+ fs.rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ });
233
+ });
234
+ describe("foldTemplateShasIntoContract", () => {
235
+ it("computes content_sha256 for inline (content) and file-backed (bytes_ref) templates", () => {
236
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tmpl-test-"));
237
+ const inlineContent = "<p>{{deal.title}}</p>";
238
+ const fileBytes = Buffer.from("PK fake xlsx bytes");
239
+ fs.mkdirSync(path.join(dir, "templates"), { recursive: true });
240
+ fs.writeFileSync(path.join(dir, "templates", "invoice.xlsx"), fileBytes);
241
+ try {
242
+ const contract = {
243
+ templates: [
244
+ { alias: "quote", label: "Quote", type: "html", content: inlineContent },
245
+ { alias: "invoice", label: "Invoice", type: "excel", bytes_ref: "templates/invoice.xlsx" },
246
+ ],
247
+ };
248
+ const folded = foldTemplateShasIntoContract(dir, contract);
249
+ const inlineSha = createHash("sha256").update(Buffer.from(inlineContent, "utf-8")).digest("hex");
250
+ const fileSha = createHash("sha256").update(fileBytes).digest("hex");
251
+ expect(folded.templates.find((t) => t.alias === "quote")?.content_sha256).toBe(inlineSha);
252
+ expect(folded.templates.find((t) => t.alias === "invoice")?.content_sha256).toBe(fileSha);
253
+ }
254
+ finally {
255
+ fs.rmSync(dir, { recursive: true, force: true });
256
+ }
257
+ });
258
+ it("throws when a file-backed template's bytes_ref is missing from the project", () => {
259
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tmpl-test-"));
260
+ try {
261
+ expect(() => foldTemplateShasIntoContract(dir, {
262
+ templates: [{ alias: "invoice", label: "Invoice", type: "excel", bytes_ref: "templates/missing.xlsx" }],
263
+ })).toThrow(/templates\/missing\.xlsx/);
264
+ }
265
+ finally {
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+ });
269
+ it("leaves a template-free contract untouched", () => {
270
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-tmpl-test-"));
271
+ try {
272
+ const contract = { entities: [] };
273
+ expect(foldTemplateShasIntoContract(dir, contract)).toBe(contract);
274
+ }
275
+ finally {
276
+ fs.rmSync(dir, { recursive: true, force: true });
277
+ }
278
+ });
279
+ });
280
+ describe("foldTemplatesIntoContract (content-package manifest templates)", () => {
281
+ function makeTemplateProject(templates, files) {
282
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-ctmpl-test-"));
283
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
284
+ name: "acme-templates",
285
+ lotics: {
286
+ package: { id: null, name: "Acme Templates", kind: "content", version: null, templates },
287
+ },
288
+ }, null, 2) + "\n");
289
+ for (const [rel, content] of Object.entries(files)) {
290
+ const full = path.join(dir, rel);
291
+ fs.mkdirSync(path.dirname(full), { recursive: true });
292
+ fs.writeFileSync(full, content);
293
+ }
294
+ return dir;
295
+ }
296
+ it("folds inline content + file-backed bytes_ref, and the sha fold makes them publish-valid", () => {
297
+ const inline = "<p>Hello {{customer.name}}</p>";
298
+ const dir = makeTemplateProject({
299
+ welcome: { name: "Welcome Email", type: "html", file: "welcome.html" },
300
+ invoice: { name: "Invoice", type: "excel", file: "invoice.xlsx" },
301
+ }, { "templates/welcome.html": inline, "templates/invoice.xlsx": "PK fake xlsx" });
302
+ try {
303
+ const project = readPackageProject(dir);
304
+ const folded = foldTemplateShasIntoContract(dir, foldTemplatesIntoContract(dir, project, { entities: [], templates: [] }));
305
+ const welcome = folded.templates.find((t) => t.alias === "welcome");
306
+ const invoice = folded.templates.find((t) => t.alias === "invoice");
307
+ expect(welcome).toMatchObject({ label: "Welcome Email", type: "html", content: inline });
308
+ expect(invoice).toMatchObject({ label: "Invoice", type: "excel", bytes_ref: "templates/invoice.xlsx" });
309
+ // The sha fold ran over the manifest-folded templates → both carry a sha.
310
+ expect(typeof welcome?.content_sha256).toBe("string");
311
+ expect(typeof invoice?.content_sha256).toBe("string");
312
+ }
313
+ finally {
314
+ fs.rmSync(dir, { recursive: true, force: true });
315
+ }
316
+ });
317
+ it("throws when a declared template file is missing from templates/", () => {
318
+ const dir = makeTemplateProject({ welcome: { name: "Welcome", type: "html", file: "welcome.html" } }, {});
319
+ try {
320
+ const project = readPackageProject(dir);
321
+ expect(() => foldTemplatesIntoContract(dir, project, { templates: [] })).toThrow(/templates\/welcome\.html/);
322
+ }
323
+ finally {
324
+ fs.rmSync(dir, { recursive: true, force: true });
325
+ }
326
+ });
327
+ it("rejects a manifest alias that collides with a contract-authored template alias", () => {
328
+ const dir = makeTemplateProject({ quote: { name: "Quote", type: "html", file: "quote.html" } }, { "templates/quote.html": "<p>q</p>" });
329
+ try {
330
+ const project = readPackageProject(dir);
331
+ expect(() => foldTemplatesIntoContract(dir, project, {
332
+ templates: [{ alias: "quote", label: "Quote", type: "html", content: "x" }],
333
+ })).toThrow(/declared in both/);
334
+ }
335
+ finally {
336
+ fs.rmSync(dir, { recursive: true, force: true });
337
+ }
338
+ });
339
+ });
123
340
  describe("assertDevWorkspace", () => {
124
341
  it("passes a dev workspace", () => {
125
342
  expect(() => assertDevWorkspace({ id: "wsp_d", name: "Dev", is_dev: true })).not.toThrow();
@@ -160,6 +377,124 @@ describe("parseResolveFlags", () => {
160
377
  expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
161
378
  });
162
379
  });
380
+ describe("routeContentResolveFlags", () => {
381
+ const knowledge = new Set(["pricing", "faq", "old", "gone"]);
382
+ const templates = new Set(["quote", "invoice"]);
383
+ it("routes each alias to its namespace, validating the value", () => {
384
+ expect(routeContentResolveFlags(["pricing=apply", "faq=keep", "old=archive", "gone=recreate", "quote=revert", "invoice=keep"], knowledge, templates)).toEqual({
385
+ knowledge: { pricing: "apply", faq: "keep", old: "archive", gone: "recreate" },
386
+ templates: { quote: "revert", invoice: "keep" },
387
+ });
388
+ });
389
+ it("rejects a knowledge value outside the knowledge set", () => {
390
+ expect(() => routeContentResolveFlags(["pricing=revert"], knowledge, templates)).toThrow(/Invalid --resolve value/);
391
+ });
392
+ it("rejects a template value outside revert|keep", () => {
393
+ expect(() => routeContentResolveFlags(["quote=apply"], knowledge, templates)).toThrow(/Invalid --resolve value/);
394
+ });
395
+ it("errors loudly on an alias present in BOTH namespaces (no guess)", () => {
396
+ expect(() => routeContentResolveFlags(["overview=keep"], new Set(["overview"]), new Set(["overview"]))).toThrow(/ambiguous/);
397
+ });
398
+ it("errors on an alias in neither namespace", () => {
399
+ expect(() => routeContentResolveFlags(["nope=keep"], knowledge, templates)).toThrow(/does not name a knowledge doc or template/);
400
+ });
401
+ it("rejects entries without a key=value shape", () => {
402
+ expect(() => routeContentResolveFlags(["pricing"], knowledge, templates)).toThrow(/Invalid --resolve/);
403
+ expect(() => routeContentResolveFlags(["=apply"], knowledge, templates)).toThrow(/Invalid --resolve/);
404
+ expect(() => routeContentResolveFlags(["pricing="], knowledge, templates)).toThrow(/Invalid --resolve/);
405
+ });
406
+ });
407
+ describe("parseBindToFlags", () => {
408
+ it("maps each alias to its consent doc id", () => {
409
+ expect(parseBindToFlags(["playbook=kdc_1", "faq=kdc_2"])).toEqual({
410
+ playbook: "kdc_1",
411
+ faq: "kdc_2",
412
+ });
413
+ });
414
+ it("rejects entries without an alias=id shape", () => {
415
+ expect(() => parseBindToFlags(["playbook"])).toThrow(/Invalid --bind-to/);
416
+ expect(() => parseBindToFlags(["=kdc_1"])).toThrow(/Invalid --bind-to/);
417
+ expect(() => parseBindToFlags(["playbook="])).toThrow(/Invalid --bind-to/);
418
+ });
419
+ });
420
+ describe("parseArgs — content package flags", () => {
421
+ it("captures --kind, --bind-to (repeatable), --keep-content, --apply-all", () => {
422
+ const { command, subcommand, toolArgs, flags } = parseArgs([
423
+ "package",
424
+ "install",
425
+ "apg_1",
426
+ "--bind-to",
427
+ "playbook=kdc_1",
428
+ "--bind-to",
429
+ "faq=kdc_2",
430
+ "--keep-content",
431
+ "--apply-all",
432
+ "--kind",
433
+ "content",
434
+ ]);
435
+ expect(command).toBe("package");
436
+ expect(subcommand).toBe("install");
437
+ expect(toolArgs).toBe("apg_1");
438
+ expect(flags.bindTo).toEqual(["playbook=kdc_1", "faq=kdc_2"]);
439
+ expect(flags.keepContent).toBe(true);
440
+ expect(flags.applyAll).toBe(true);
441
+ expect(flags.kind).toBe("content");
442
+ });
443
+ it("throws when --bind-to has no value", () => {
444
+ expect(() => parseArgs(["package", "install", "apg_1", "--bind-to"])).toThrow(/--bind-to/);
445
+ });
446
+ });
447
+ describe("parseArgs — package list-content", () => {
448
+ it("parses the list-content subcommand with no positional", () => {
449
+ const { command, subcommand, toolArgs } = parseArgs(["package", "list-content"]);
450
+ expect(command).toBe("package");
451
+ expect(subcommand).toBe("list-content");
452
+ expect(toolArgs).toBeUndefined();
453
+ });
454
+ it("captures --workspace for a scoped list-content", () => {
455
+ const { subcommand, flags } = parseArgs([
456
+ "package",
457
+ "list-content",
458
+ "--workspace",
459
+ "wsp_dev",
460
+ ]);
461
+ expect(subcommand).toBe("list-content");
462
+ expect(flags.workspace).toBe("wsp_dev");
463
+ });
464
+ });
465
+ describe("routeAppUpgradeResolve", () => {
466
+ const kEntry = (alias, change, modified = false) => ({
467
+ alias,
468
+ name: alias,
469
+ change,
470
+ modified,
471
+ doc_id: change === "added" ? null : "kdc_x",
472
+ });
473
+ it("routes a knowledge-alias --resolve to the knowledge bucket, everything else to core", () => {
474
+ const { coreResolve, knowledgeResolutions } = routeAppUpgradeResolve(["policy=apply", "fields.deal.stage=recreate", "queries.tasks=keep"], [kEntry("policy", "changed", true)], new Set(["fields.deal.stage", "queries.tasks"]));
475
+ expect(knowledgeResolutions).toEqual({ policy: "apply" });
476
+ // Core entries keep their raw <key>=<value> form for parseResolveFlags.
477
+ expect(coreResolve).toEqual(["fields.deal.stage=recreate", "queries.tasks=keep"]);
478
+ });
479
+ it("errors when a --resolve key names BOTH a knowledge doc and a core artifact", () => {
480
+ expect(() => routeAppUpgradeResolve(["policy=apply"], [kEntry("policy", "changed", true)], new Set(["policy"]))).toThrow(/ambiguous/);
481
+ });
482
+ it("rejects a knowledge --resolve value invalid for the doc's change class", () => {
483
+ // `changed` accepts apply|keep — `revert` is a core value, not a knowledge one.
484
+ expect(() => routeAppUpgradeResolve(["policy=revert"], [kEntry("policy", "changed", true)], new Set())).toThrow(/Invalid --resolve value "revert" for knowledge doc "policy"/);
485
+ // `drifted` accepts recreate|unbind, not apply.
486
+ expect(() => routeAppUpgradeResolve(["gone=apply"], [kEntry("gone", "drifted")], new Set())).toThrow(/expected recreate\|unbind/);
487
+ });
488
+ it("rejects a malformed --resolve entry", () => {
489
+ expect(() => routeAppUpgradeResolve(["policy"], [], new Set())).toThrow(/Invalid --resolve/);
490
+ expect(() => routeAppUpgradeResolve(["=apply"], [], new Set())).toThrow(/Invalid --resolve/);
491
+ });
492
+ it("leaves an unmatched key as a core resolution (the server is the validator)", () => {
493
+ const { coreResolve, knowledgeResolutions } = routeAppUpgradeResolve(["templates.quote=dtl_abc"], [], new Set());
494
+ expect(knowledgeResolutions).toEqual({});
495
+ expect(coreResolve).toEqual(["templates.quote=dtl_abc"]);
496
+ });
497
+ });
163
498
  describe("draftPackageProjectFromApp", () => {
164
499
  it("strips the app manifest and grafts an unpublished package manifest", () => {
165
500
  const project = draftPackageProjectFromApp({
@@ -180,7 +515,11 @@ describe("draftPackageProjectFromApp", () => {
180
515
  id: null,
181
516
  name: "Acme CRM",
182
517
  description: null,
518
+ kind: "app",
183
519
  version: null,
520
+ knowledge: {},
521
+ templates: {},
522
+ knowledge_expects: [],
184
523
  dev: {},
185
524
  });
186
525
  // …and non-lotics fields survive verbatim.
@@ -198,7 +537,11 @@ describe("draftPackageProjectFromApp", () => {
198
537
  id: null,
199
538
  name: "Acme CRM",
200
539
  description: "A CRM",
540
+ kind: "app",
201
541
  version: null,
542
+ knowledge: {},
543
+ templates: {},
544
+ knowledge_expects: [],
202
545
  dev: {},
203
546
  });
204
547
  // The old app manifest keys never made it to disk.
@@ -227,6 +570,12 @@ describe("parseAdoptBindingFile", () => {
227
570
  expect(pin.app_id).toBe("app_x");
228
571
  expect(pin.workspace_id).toBe("wsp_y");
229
572
  expect(pin.binding.entities).toEqual({ item: "tbl_1" });
573
+ // knowledge_binding is optional → defaults to {} when absent.
574
+ expect(pin.knowledge_binding).toEqual({});
575
+ });
576
+ it("carries a knowledge_binding when present", () => {
577
+ const pin = parseAdoptBindingFile({ ...validPin, knowledge_binding: { playbook: "kdc_1", faq: "kdc_2" } }, "app_x");
578
+ expect(pin.knowledge_binding).toEqual({ playbook: "kdc_1", faq: "kdc_2" });
230
579
  });
231
580
  it("REFUSES a pin recorded for a different app", () => {
232
581
  expect(() => parseAdoptBindingFile(validPin, "app_OTHER")).toThrow(/records app app_x, but you are adopting app_OTHER/);