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