@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.
- package/README.md +31 -1
- package/dist/args.d.ts +15 -2
- package/dist/args.js +21 -0
- package/dist/cli.js +82 -31
- package/dist/client.d.ts +171 -30
- package/dist/client.js +77 -32
- package/dist/package_commands.d.ts +194 -32
- package/dist/package_commands.js +846 -93
- package/dist/package_commands.test.js +354 -5
- package/dist/src/cli.js +18856 -1538
- package/package.json +1 -1
|
@@ -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 {
|
|
6
|
-
import {
|
|
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 =
|
|
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(
|
|
16
|
+
expect(validatePackageContract(contract)).toEqual([]);
|
|
15
17
|
});
|
|
16
18
|
it("uses the package name as the entity label", () => {
|
|
17
|
-
const contract =
|
|
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/);
|