@lotics/cli 0.76.1 → 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,356 +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 { 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";
5
+ import { parseResolveFlags, parseBindToFlags, parseRenameFlags, readLocalAppManifest, formatExtractReport, } from "./package_commands.js";
8
6
  import { parseArgs } from "./args.js";
9
- describe("starterContract", () => {
10
- it("scaffolds a structurally + referentially valid contract", () => {
11
- const raw = starterContract("Acme CRM");
12
- // Structurally valid against the canonical contract schema.
13
- const contract = parsePackageContract(raw);
14
- // Cross-references (query from_entity, etc.) resolve — zero violations, so the
15
- // scaffold publishes without the server's contract validator rejecting it.
16
- expect(validatePackageContract(contract)).toEqual([]);
17
- });
18
- it("uses the package name as the entity label", () => {
19
- const contract = parsePackageContract(starterContract("Widgets"));
20
- expect(contract.entities[0].label).toBe("Widgets");
21
- expect(contract.config[0].default).toBe("Widgets");
22
- });
23
- });
24
- describe("package manifest round-trip", () => {
25
- function makeProject() {
26
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
27
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
28
- name: "acme-crm",
29
- version: "0.0.1",
30
- lotics: {
31
- package: { id: null, name: "Acme CRM", description: null, version: null, dev: {} },
32
- },
33
- }, null, 2) + "\n");
34
- return dir;
35
- }
36
- it("reads the package manifest off package.json#lotics.package", () => {
37
- const dir = makeProject();
38
- try {
39
- const { manifest } = readPackageProject(dir);
40
- expect(manifest).toEqual({
41
- id: null,
42
- name: "Acme CRM",
43
- description: null,
44
- kind: "app",
45
- version: null,
46
- knowledge: {},
47
- templates: {},
48
- knowledge_expects: [],
49
- dev: {},
50
- });
51
- }
52
- finally {
53
- fs.rmSync(dir, { recursive: true, force: true });
54
- }
55
- });
56
- it("persists publish + dev-installation pins and reads them back", () => {
57
- const dir = makeProject();
58
- try {
59
- const project = readPackageProject(dir);
60
- project.manifest.id = "apg_test";
61
- project.manifest.version = 3;
62
- project.manifest.dev["wsp_dev"] = { app_id: "app_dev", version: 3 };
63
- writePackageManifest(dir, project);
64
- const reread = readPackageProject(dir);
65
- expect(reread.manifest.id).toBe("apg_test");
66
- expect(reread.manifest.version).toBe(3);
67
- expect(reread.manifest.dev).toEqual({ wsp_dev: { app_id: "app_dev", version: 3 } });
68
- // Non-lotics package.json fields survive the manifest write.
69
- expect(reread.pkgJson.name).toBe("acme-crm");
70
- expect(reread.pkgJson.version).toBe("0.0.1");
71
- }
72
- finally {
73
- fs.rmSync(dir, { recursive: true, force: true });
74
- }
75
- });
76
- it("writes the manifest atomically, leaving no temp file behind", () => {
77
- const dir = makeProject();
78
- try {
79
- const project = readPackageProject(dir);
80
- project.manifest.id = "apg_atomic";
81
- writePackageManifest(dir, project);
82
- // The atomic write renames a temp file into place; nothing stray remains.
83
- const leftovers = fs.readdirSync(dir).filter((f) => f.startsWith("package.json."));
84
- expect(leftovers).toEqual([]);
85
- expect(readPackageProject(dir).manifest.id).toBe("apg_atomic");
86
- }
87
- finally {
88
- fs.rmSync(dir, { recursive: true, force: true });
89
- }
90
- });
91
- });
92
- describe("sanitizePackageJsonForSource", () => {
93
- it("strips the author-local lotics.package.dev map", () => {
94
- const sanitized = sanitizePackageJsonForSource({
95
- name: "acme-crm",
96
- version: "0.0.1",
97
- lotics: {
98
- package: {
99
- id: "apg_x",
100
- name: "Acme CRM",
101
- description: "d",
102
- version: 4,
103
- dev: { wsp_dev: { app_id: "app_dev", version: 4 } },
104
- },
105
- },
106
- });
107
- const lotics = sanitized.lotics;
108
- // The private dev bookkeeping is gone…
109
- expect("dev" in lotics.package).toBe(false);
110
- // …while the package identity + other fields survive untouched.
111
- expect(lotics.package).toEqual({
112
- id: "apg_x",
113
- name: "Acme CRM",
114
- description: "d",
115
- version: 4,
116
- });
117
- expect(sanitized.name).toBe("acme-crm");
118
- expect(sanitized.version).toBe("0.0.1");
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([]);
119
15
  });
120
- it("does not mutate the input package.json", () => {
121
- const input = {
122
- name: "acme-crm",
123
- lotics: { package: { id: "apg_x", name: "Acme CRM", dev: { wsp_dev: { app_id: "a", version: 1 } } } },
124
- };
125
- sanitizePackageJsonForSource(input);
126
- expect(input.lotics.package.dev).toEqual({ wsp_dev: { app_id: "a", version: 1 } });
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/);
127
20
  });
128
21
  });
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({}, [], {});
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-"));
204
25
  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 },
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" }],
223
33
  });
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
34
  }
254
35
  finally {
255
36
  fs.rmSync(dir, { recursive: true, force: true });
256
37
  }
257
38
  });
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-"));
39
+ it("returns null when the dir has no package.json", () => {
40
+ const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
260
41
  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
- });
340
- describe("assertDevWorkspace", () => {
341
- it("passes a dev workspace", () => {
342
- expect(() => assertDevWorkspace({ id: "wsp_d", name: "Dev", is_dev: true })).not.toThrow();
343
- });
344
- it("fails loud on a non-dev workspace", () => {
345
- expect(() => assertDevWorkspace({ id: "wsp_p", name: "Prod", is_dev: false })).toThrow(/not a dev workspace/);
346
- });
347
- it("fails closed when is_dev is absent (server doesn't serialize it)", () => {
348
- expect(() => assertDevWorkspace({ id: "wsp_u", name: "Unknown" })).toThrow(/not a dev workspace/);
349
- });
350
- it("rejects a directory that is not a package project", () => {
351
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
352
- try {
353
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "plain-app" }));
354
- expect(() => readPackageProject(dir)).toThrow(/not a package project/);
42
+ expect(readLocalAppManifest(dir)).toBeNull();
355
43
  }
356
44
  finally {
357
45
  fs.rmSync(dir, { recursive: true, force: true });
@@ -377,31 +65,33 @@ describe("parseResolveFlags", () => {
377
65
  expect(() => parseResolveFlags(["fields.deal.stage="])).toThrow(/Invalid --resolve/);
378
66
  });
379
67
  });
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" },
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",
387
82
  });
388
83
  });
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/);
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
+ });
400
89
  });
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/);
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
+ });
405
95
  });
406
96
  });
407
97
  describe("parseBindToFlags", () => {
@@ -417,31 +107,25 @@ describe("parseBindToFlags", () => {
417
107
  expect(() => parseBindToFlags(["playbook="])).toThrow(/Invalid --bind-to/);
418
108
  });
419
109
  });
420
- describe("parseArgs — content package flags", () => {
421
- it("captures --kind, --bind-to (repeatable), --keep-content, --apply-all", () => {
110
+ describe("parseArgs — app publish --rename", () => {
111
+ it("captures repeated --rename flags with the positional app id", () => {
422
112
  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",
113
+ "app",
114
+ "publish",
115
+ "app_x",
116
+ "--rename",
117
+ "item=deal",
118
+ "--rename",
119
+ "e.f=name",
434
120
  ]);
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");
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"]);
442
125
  });
443
- it("throws when --bind-to has no value", () => {
444
- expect(() => parseArgs(["package", "install", "apg_1", "--bind-to"])).toThrow(/--bind-to/);
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/);
445
129
  });
446
130
  });
447
131
  describe("parseArgs — package list-content", () => {
@@ -462,130 +146,6 @@ describe("parseArgs — package list-content", () => {
462
146
  expect(flags.workspace).toBe("wsp_dev");
463
147
  });
464
148
  });
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
- });
498
- describe("draftPackageProjectFromApp", () => {
499
- it("strips the app manifest and grafts an unpublished package manifest", () => {
500
- const project = draftPackageProjectFromApp({
501
- name: "acme-crm",
502
- version: "0.0.1",
503
- dependencies: { "@lotics/app-sdk": "^1.0.0" },
504
- lotics: {
505
- app_id: "app_x",
506
- workspace_id: "wsp_y",
507
- current_version_id: "apv_z",
508
- workflows: { notify: { workflow_id: "wfl_1" } },
509
- },
510
- }, { name: "Acme CRM", description: null });
511
- // The app manifest (app_id/workspace_id/workflows) is gone entirely…
512
- expect("lotics" in project.pkgJson).toBe(false);
513
- // …the package manifest is fresh + unpublished…
514
- expect(project.manifest).toEqual({
515
- id: null,
516
- name: "Acme CRM",
517
- description: null,
518
- kind: "app",
519
- version: null,
520
- knowledge: {},
521
- templates: {},
522
- knowledge_expects: [],
523
- dev: {},
524
- });
525
- // …and non-lotics fields survive verbatim.
526
- expect(project.pkgJson.name).toBe("acme-crm");
527
- expect(project.pkgJson.version).toBe("0.0.1");
528
- expect(project.pkgJson.dependencies).toEqual({ "@lotics/app-sdk": "^1.0.0" });
529
- });
530
- it("round-trips through writePackageManifest to a valid package project on disk", () => {
531
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-test-"));
532
- try {
533
- writePackageManifest(dir, draftPackageProjectFromApp({ name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } }, { name: "Acme CRM", description: "A CRM" }));
534
- // readPackageProject only accepts a real package project (lotics.package).
535
- const { manifest, pkgJson } = readPackageProject(dir);
536
- expect(manifest).toEqual({
537
- id: null,
538
- name: "Acme CRM",
539
- description: "A CRM",
540
- kind: "app",
541
- version: null,
542
- knowledge: {},
543
- templates: {},
544
- knowledge_expects: [],
545
- dev: {},
546
- });
547
- // The old app manifest keys never made it to disk.
548
- const lotics = pkgJson.lotics;
549
- expect("app_id" in lotics).toBe(false);
550
- expect("workspace_id" in lotics).toBe(false);
551
- }
552
- finally {
553
- fs.rmSync(dir, { recursive: true, force: true });
554
- }
555
- });
556
- it("does not mutate the input package.json", () => {
557
- const input = { name: "acme-crm", lotics: { app_id: "app_x", workspace_id: "wsp_y" } };
558
- draftPackageProjectFromApp(input, { name: "Acme CRM", description: null });
559
- expect(input.lotics).toEqual({ app_id: "app_x", workspace_id: "wsp_y" });
560
- });
561
- });
562
- describe("parseAdoptBindingFile", () => {
563
- const validPin = {
564
- app_id: "app_x",
565
- workspace_id: "wsp_y",
566
- binding: { entities: { item: "tbl_1" }, fields: {}, options: {}, templates: {}, roles: {}, workflows: {} },
567
- };
568
- it("accepts a pin whose app_id matches the app being adopted", () => {
569
- const pin = parseAdoptBindingFile(validPin, "app_x");
570
- expect(pin.app_id).toBe("app_x");
571
- expect(pin.workspace_id).toBe("wsp_y");
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" });
579
- });
580
- it("REFUSES a pin recorded for a different app", () => {
581
- expect(() => parseAdoptBindingFile(validPin, "app_OTHER")).toThrow(/records app app_x, but you are adopting app_OTHER/);
582
- });
583
- it("rejects a malformed pin", () => {
584
- expect(() => parseAdoptBindingFile({ app_id: "app_x" }, "app_x")).toThrow(/Malformed/);
585
- expect(() => parseAdoptBindingFile(null, "app_x")).toThrow(/Malformed/);
586
- expect(() => parseAdoptBindingFile({ app_id: "app_x", workspace_id: "wsp_y" }, "app_x")).toThrow(/Malformed/);
587
- });
588
- });
589
149
  describe("formatExtractReport", () => {
590
150
  it("groups findings errors → warnings → info and formats each line", () => {
591
151
  const { lines, hasError } = formatExtractReport([
@@ -614,39 +174,3 @@ describe("formatExtractReport", () => {
614
174
  expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
615
175
  });
616
176
  });
617
- describe("stagePackageSource", () => {
618
- it("keeps nested dist-named dirs, drops top-level excludes, sanitizes package.json", () => {
619
- const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-test-"));
620
- const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-stage-out-"));
621
- try {
622
- fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({
623
- name: "pkg",
624
- lotics: { package: { id: "apg_x", name: "pkg", version: 3, dev: { wsp_a: "app_1" } } },
625
- }));
626
- fs.mkdirSync(path.join(dir, "src"));
627
- fs.writeFileSync(path.join(dir, "src", "main.tsx"), "export {}");
628
- // A nested dir NAMED dist must ship — only the top-level dist is a build output.
629
- fs.mkdirSync(path.join(dir, "templates", "dist"), { recursive: true });
630
- fs.writeFileSync(path.join(dir, "templates", "dist", "quote.xlsx"), "bytes");
631
- fs.mkdirSync(path.join(dir, "dist"));
632
- fs.writeFileSync(path.join(dir, "dist", "index.js"), "built");
633
- fs.mkdirSync(path.join(dir, "node_modules", "x"), { recursive: true });
634
- fs.writeFileSync(path.join(dir, "node_modules", "x", "i.js"), "dep");
635
- fs.writeFileSync(path.join(dir, "tsconfig.tsbuildinfo"), "{}");
636
- stagePackageSource(dir, stage);
637
- expect(fs.existsSync(path.join(stage, "src", "main.tsx"))).toBe(true);
638
- expect(fs.existsSync(path.join(stage, "templates", "dist", "quote.xlsx"))).toBe(true);
639
- expect(fs.existsSync(path.join(stage, "dist"))).toBe(false);
640
- expect(fs.existsSync(path.join(stage, "node_modules"))).toBe(false);
641
- expect(fs.existsSync(path.join(stage, "tsconfig.tsbuildinfo"))).toBe(false);
642
- // The staged package.json is the sanitized one — dev pins stripped.
643
- const staged = JSON.parse(fs.readFileSync(path.join(stage, "package.json"), "utf-8"));
644
- expect(staged.lotics.package.dev).toBeUndefined();
645
- expect(staged.lotics.package.id).toBe("apg_x");
646
- }
647
- finally {
648
- fs.rmSync(dir, { recursive: true, force: true });
649
- fs.rmSync(stage, { recursive: true, force: true });
650
- }
651
- });
652
- });