@lotics/cli 0.76.1 → 0.86.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.
- package/README.md +57 -52
- package/dist/app_commands.d.ts +9 -4
- package/dist/app_commands.js +44 -10
- package/dist/app_commands.test.js +69 -0
- package/dist/args.d.ts +6 -5
- package/dist/args.js +18 -17
- package/dist/args.test.js +25 -17
- package/dist/cli.js +214 -255
- package/dist/cli_dispatch.test.js +90 -25
- package/dist/client.d.ts +134 -176
- package/dist/client.js +72 -95
- package/dist/generate_app_workflows_dts.js +11 -2
- package/dist/generate_app_workflows_dts.test.js +3 -3
- package/dist/generate_package_fields.d.ts +39 -38
- package/dist/generate_package_fields.js +113 -60
- package/dist/generate_package_fields.test.js +30 -22
- package/dist/package_commands.d.ts +116 -332
- package/dist/package_commands.js +420 -1316
- package/dist/package_commands.test.js +194 -537
- package/dist/src/cli.js +721 -1846
- package/dist/starter_template.d.ts +0 -19
- package/dist/starter_template.js +0 -389
- package/dist/starter_template.test.js +1 -69
- package/package.json +1 -1
|
@@ -1,357 +1,45 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } 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 { 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, appRelease, packageUpgradeByPackageId, } from "./package_commands.js";
|
|
8
6
|
import { parseArgs } from "./args.js";
|
|
9
|
-
describe("
|
|
10
|
-
it("
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
expect(
|
|
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("
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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("
|
|
130
|
-
|
|
131
|
-
const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-
|
|
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" });
|
|
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-"));
|
|
215
25
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
expect(
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
}
|
|
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
34
|
}
|
|
323
35
|
finally {
|
|
324
36
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
325
37
|
}
|
|
326
38
|
});
|
|
327
|
-
it("
|
|
328
|
-
const dir =
|
|
39
|
+
it("returns null when the dir has no package.json", () => {
|
|
40
|
+
const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-empty-"));
|
|
329
41
|
try {
|
|
330
|
-
|
|
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("
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
knowledge
|
|
386
|
-
|
|
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("
|
|
390
|
-
expect((
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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("
|
|
402
|
-
expect((
|
|
403
|
-
|
|
404
|
-
|
|
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 —
|
|
421
|
-
it("captures
|
|
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
|
-
"
|
|
424
|
-
"
|
|
425
|
-
"
|
|
426
|
-
"--
|
|
427
|
-
"
|
|
428
|
-
"--
|
|
429
|
-
"
|
|
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("
|
|
436
|
-
expect(subcommand).toBe("
|
|
437
|
-
expect(toolArgs).toBe("
|
|
438
|
-
expect(flags.
|
|
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 --
|
|
444
|
-
expect(() => parseArgs(["
|
|
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,128 +146,137 @@ describe("parseArgs — package list-content", () => {
|
|
|
462
146
|
expect(flags.workspace).toBe("wsp_dev");
|
|
463
147
|
});
|
|
464
148
|
});
|
|
465
|
-
describe("
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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" } },
|
|
149
|
+
describe("appRelease — knowledge declaration forwarding", () => {
|
|
150
|
+
const previewResult = {
|
|
151
|
+
package_id: "apg_1",
|
|
152
|
+
version: 2,
|
|
153
|
+
added_aliases: [],
|
|
154
|
+
changed_artifacts: [],
|
|
155
|
+
knowledge: { added: ["guide"], removed: [], changed: [] },
|
|
156
|
+
findings: [],
|
|
157
|
+
};
|
|
158
|
+
/** A mock client capturing the knowledge forwarded to preview + release. */
|
|
159
|
+
function mockClient() {
|
|
160
|
+
const preview = [];
|
|
161
|
+
const release = [];
|
|
162
|
+
const client = {
|
|
163
|
+
previewPackageRelease: async (app_id, opts = {}) => {
|
|
164
|
+
preview.push({ app_id, opts });
|
|
165
|
+
return previewResult;
|
|
166
|
+
},
|
|
167
|
+
releasePackage: async (app_id, body) => {
|
|
168
|
+
release.push({ app_id, body });
|
|
169
|
+
return { ...previewResult, findings: undefined };
|
|
509
170
|
},
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
dev: {},
|
|
171
|
+
};
|
|
172
|
+
return { client, preview, release };
|
|
173
|
+
}
|
|
174
|
+
function withManifestDir(lotics, fn) {
|
|
175
|
+
const dir = fs.mkdtempSync(path.join(tmpdir(), "lotics-rel-"));
|
|
176
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "app", lotics }));
|
|
177
|
+
return fn(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
178
|
+
}
|
|
179
|
+
it("forwards the manifest's knowledge declaration from the app's own project", async () => {
|
|
180
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
181
|
+
const { client, preview, release } = mockClient();
|
|
182
|
+
await withManifestDir({ app_id: "app_x", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
|
|
183
|
+
await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
|
|
524
184
|
});
|
|
525
|
-
|
|
526
|
-
expect(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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" });
|
|
185
|
+
expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: [{ alias: "guide", doc_id: "kdc_1" }] } });
|
|
186
|
+
expect(release[0].body).toEqual({ changelog: "ship", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] });
|
|
187
|
+
vi.restoreAllMocks();
|
|
188
|
+
});
|
|
189
|
+
it("omits knowledge (reconstruct from the pin) when the manifest declares none", async () => {
|
|
190
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
191
|
+
const { client, preview, release } = mockClient();
|
|
192
|
+
await withManifestDir({ app_id: "app_x" }, async (dir) => {
|
|
193
|
+
await appRelease(client, { app_id: ".", changelog: "ship", yes: true, projectDir: dir });
|
|
194
|
+
});
|
|
195
|
+
expect(preview[0].opts).toEqual({ knowledge: undefined });
|
|
196
|
+
expect(release[0].body).toEqual({ changelog: "ship", knowledge: undefined });
|
|
197
|
+
vi.restoreAllMocks();
|
|
198
|
+
});
|
|
199
|
+
it("omits knowledge for a bare app id whose manifest is a DIFFERENT app", async () => {
|
|
200
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
201
|
+
const { client, preview } = mockClient();
|
|
202
|
+
await withManifestDir({ app_id: "app_other", knowledge: [{ alias: "guide", doc_id: "kdc_1" }] }, async (dir) => {
|
|
203
|
+
await appRelease(client, { app_id: "app_x", changelog: "ship", yes: true, projectDir: dir });
|
|
204
|
+
});
|
|
205
|
+
expect(preview[0]).toEqual({ app_id: "app_x", opts: { knowledge: undefined } });
|
|
206
|
+
vi.restoreAllMocks();
|
|
560
207
|
});
|
|
561
208
|
});
|
|
562
|
-
describe("
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
209
|
+
describe("packageUpgradeByPackageId — kind-branched by the package read", () => {
|
|
210
|
+
/**
|
|
211
|
+
* A mock client recording which upgrade path a `lotics upgrade <apg_>` takes.
|
|
212
|
+
* `kind` drives the branch: a content package resolves THIS workspace's content
|
|
213
|
+
* installation from the package id (the `pci_` never surfaces) and runs the
|
|
214
|
+
* content upgrade; an app package fleet-upgrades the org.
|
|
215
|
+
*/
|
|
216
|
+
function mockClient(opts) {
|
|
217
|
+
const calls = { previewContent: [], fleet: [], listedWorkspaces: [] };
|
|
218
|
+
const client = {
|
|
219
|
+
getPackage: async (package_id) => ({
|
|
220
|
+
id: package_id,
|
|
221
|
+
name: "Ops corpus",
|
|
222
|
+
kind: opts.kind,
|
|
223
|
+
latest_version: 3,
|
|
224
|
+
is_official: false,
|
|
225
|
+
retired_at: null,
|
|
226
|
+
}),
|
|
227
|
+
getWorkspaceId: () => "wsp_1",
|
|
228
|
+
listContentInstallations: async (workspace_id) => {
|
|
229
|
+
calls.listedWorkspaces.push(workspace_id);
|
|
230
|
+
return (opts.installations ?? []).map((i) => ({
|
|
231
|
+
...i,
|
|
232
|
+
workspace_id,
|
|
233
|
+
package_version: 2,
|
|
234
|
+
app_id: null,
|
|
235
|
+
binding: { knowledge: {}, templates: {} },
|
|
236
|
+
installed_by: null,
|
|
237
|
+
created_at: "",
|
|
238
|
+
updated_at: "",
|
|
239
|
+
package_registry: null,
|
|
240
|
+
}));
|
|
241
|
+
},
|
|
242
|
+
previewContentInstallationUpgrade: async (installation_id) => {
|
|
243
|
+
calls.previewContent.push(installation_id);
|
|
244
|
+
return { from_version: 2, to_version: 2, entries: [], templates: [] };
|
|
245
|
+
},
|
|
246
|
+
fleetUpgradePackage: async (package_id) => {
|
|
247
|
+
calls.fleet.push(package_id);
|
|
248
|
+
return { package_id, target_version: 3, installations: [] };
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
return { client, calls };
|
|
252
|
+
}
|
|
253
|
+
const args = { version: undefined, resolve: [], bindTo: [], applyAll: false };
|
|
254
|
+
it("routes a CONTENT package to this workspace's content installation (resolved from the package id)", async () => {
|
|
255
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
256
|
+
const { client, calls } = mockClient({
|
|
257
|
+
kind: "content",
|
|
258
|
+
installations: [{ id: "pci_1", package_id: "apg_content" }],
|
|
259
|
+
});
|
|
260
|
+
await packageUpgradeByPackageId(client, { package_id: "apg_content", ...args });
|
|
261
|
+
expect(calls.listedWorkspaces).toEqual(["wsp_1"]);
|
|
262
|
+
expect(calls.previewContent).toEqual(["pci_1"]); // resolved apg_ → pci_ client-side
|
|
263
|
+
expect(calls.fleet).toEqual([]); // never a fleet run for content
|
|
264
|
+
vi.restoreAllMocks();
|
|
265
|
+
});
|
|
266
|
+
it("routes an APP package to the org fleet upgrade", async () => {
|
|
267
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
268
|
+
const { client, calls } = mockClient({ kind: "app" });
|
|
269
|
+
await packageUpgradeByPackageId(client, { package_id: "apg_app", ...args });
|
|
270
|
+
expect(calls.fleet).toEqual(["apg_app"]);
|
|
271
|
+
expect(calls.previewContent).toEqual([]);
|
|
272
|
+
expect(calls.listedWorkspaces).toEqual([]); // no content resolution for an app package
|
|
273
|
+
vi.restoreAllMocks();
|
|
274
|
+
});
|
|
275
|
+
it("errors clearly when a content package is not installed in this workspace", async () => {
|
|
276
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
277
|
+
const { client } = mockClient({ kind: "content", installations: [] });
|
|
278
|
+
await expect(packageUpgradeByPackageId(client, { package_id: "apg_content", ...args })).rejects.toThrow(/not installed in this workspace[\s\S]*lotics install apg_content/);
|
|
279
|
+
vi.restoreAllMocks();
|
|
587
280
|
});
|
|
588
281
|
});
|
|
589
282
|
describe("formatExtractReport", () => {
|
|
@@ -614,39 +307,3 @@ describe("formatExtractReport", () => {
|
|
|
614
307
|
expect(formatExtractReport([])).toEqual({ lines: [], hasError: false });
|
|
615
308
|
});
|
|
616
309
|
});
|
|
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
|
-
});
|