@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
package/dist/client.js
CHANGED
|
@@ -143,15 +143,6 @@ export class LoticsClient {
|
|
|
143
143
|
async listWorkspaces() {
|
|
144
144
|
return this.request("GET", "/v1/workspaces");
|
|
145
145
|
}
|
|
146
|
-
/**
|
|
147
|
-
* Resolve one workspace's info by id from the org's workspace list (the only
|
|
148
|
-
* API-key-accessible source carrying `is_dev`). Returns null when the
|
|
149
|
-
* workspace isn't visible to these credentials.
|
|
150
|
-
*/
|
|
151
|
-
async getWorkspaceInfo(id) {
|
|
152
|
-
const workspaces = await this.listWorkspaces();
|
|
153
|
-
return workspaces.find((w) => w.id === id) ?? null;
|
|
154
|
-
}
|
|
155
146
|
async createWorkspace(body) {
|
|
156
147
|
return this.request("POST", "/v1/workspaces", body);
|
|
157
148
|
}
|
|
@@ -218,7 +209,7 @@ export class LoticsClient {
|
|
|
218
209
|
/**
|
|
219
210
|
* Uninstall a standalone content package — delete the installation row. By
|
|
220
211
|
* default the package-bound docs are ARCHIVED; `keep_content` retains them as
|
|
221
|
-
* ordinary workspace docs. Admin-only. Backs `lotics
|
|
212
|
+
* ordinary workspace docs. Admin-only. Backs `lotics uninstall`.
|
|
222
213
|
*/
|
|
223
214
|
async uninstallContentPackage(installation_id, opts = {}) {
|
|
224
215
|
const qs = opts.keep_content ? "?keep_content=true" : "";
|
|
@@ -253,7 +244,7 @@ export class LoticsClient {
|
|
|
253
244
|
return this.request("GET", `/v1/workspaces/${encodeURIComponent(workspace_id)}/content-installations`);
|
|
254
245
|
}
|
|
255
246
|
/**
|
|
256
|
-
* Uninstall a package installation (backs `lotics
|
|
247
|
+
* Uninstall a package installation (backs `lotics uninstall`). Does
|
|
257
248
|
* everything DELETE does plus archives the installation's lifecycle
|
|
258
249
|
* artifacts; with `archive_tables` it also archives the scaffolded entity
|
|
259
250
|
* tables — refused server-side unless this installation created them
|
|
@@ -271,10 +262,11 @@ export class LoticsClient {
|
|
|
271
262
|
return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/package-config`, body);
|
|
272
263
|
}
|
|
273
264
|
/**
|
|
274
|
-
* Retire (or `undo` un-retire) a registry package (backs `lotics
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* Owner-org
|
|
265
|
+
* Retire (or `undo` un-retire) a registry package (backs `lotics app
|
|
266
|
+
* unpublish` — the endpoint/audit action keep the `retire` name to avoid API
|
|
267
|
+
* churn). Retiring refuses NEW installs and hides the package from non-owning
|
|
268
|
+
* orgs; existing installations keep working and may still upgrade. Owner-org
|
|
269
|
+
* admin-only.
|
|
278
270
|
*/
|
|
279
271
|
async retirePackage(package_id, body) {
|
|
280
272
|
return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/retire`, body);
|
|
@@ -289,42 +281,12 @@ export class LoticsClient {
|
|
|
289
281
|
async ejectPackage(app_id) {
|
|
290
282
|
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/eject`);
|
|
291
283
|
}
|
|
292
|
-
/**
|
|
293
|
-
* Extract a DRAFT app package from an existing bespoke app — the promotion
|
|
294
|
-
* read (docs/packages.md § Promotion). Pure: nothing is written. Returns
|
|
295
|
-
* the alias-keyed draft `contract` (opaque to the CLI — the server is the
|
|
296
|
-
* validating authority), the origin workspace's `binding` (which doubles as
|
|
297
|
-
* the adopt binding), a findings `report` (any `error` ⇒ not publishable
|
|
298
|
-
* as-is), and the file-backed `template_files` the CLI must stage into the
|
|
299
|
-
* project at their `bytes_ref` paths. Backs `lotics package extract`.
|
|
300
|
-
* Admin-only.
|
|
301
|
-
*/
|
|
302
|
-
async extractPackage(app_id, opts = {}) {
|
|
303
|
-
// The knowledge declaration ([{alias, doc_id}]) rides as a JSON-string query
|
|
304
|
-
// param — extract stays a GET (a pure read); the server validates it.
|
|
305
|
-
const qs = opts.knowledge && opts.knowledge.length > 0
|
|
306
|
-
? `?knowledge=${encodeURIComponent(JSON.stringify(opts.knowledge))}`
|
|
307
|
-
: "";
|
|
308
|
-
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-extract${qs}`);
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Adopt a published package onto an EXISTING (bespoke or ejected) app — the
|
|
312
|
-
* final promotion step. The workspace already holds the concrete objects, so
|
|
313
|
-
* nothing is scaffolded or rewritten: the server verifies the `binding` is
|
|
314
|
-
* complete, live, and FAITHFUL to the version's contract, then writes only the
|
|
315
|
-
* installation pin (the app becomes installation #1, upgradeable again). A
|
|
316
|
-
* ConflictError names the aliases that diverge. `version` omitted adopts the
|
|
317
|
-
* latest. Backs `lotics package adopt`. Admin-only.
|
|
318
|
-
*/
|
|
319
|
-
async adoptPackage(app_id, body) {
|
|
320
|
-
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-adopt`, body);
|
|
321
|
-
}
|
|
322
284
|
/**
|
|
323
285
|
* Fleet upgrade — bring every installation of a package across the CALLER'S
|
|
324
286
|
* org to the target version (latest when omitted) in one call. Hands-off
|
|
325
287
|
* applies only where the preview is clean; installations with breaking/
|
|
326
288
|
* drift/modified-core findings are skipped and reported for the normal
|
|
327
|
-
* per-installation consent flow. Backs `lotics
|
|
289
|
+
* per-installation consent flow. Backs `lotics upgrade <package_id>` (fleet).
|
|
328
290
|
* Admin-only; org-scoped (no workspace header needed).
|
|
329
291
|
*/
|
|
330
292
|
/**
|
|
@@ -338,16 +300,10 @@ export class LoticsClient {
|
|
|
338
300
|
async fleetUpgradePackage(package_id, body) {
|
|
339
301
|
return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/fleet-upgrade`, body);
|
|
340
302
|
}
|
|
341
|
-
// ---
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
* local manifest has no `package_id`), then publishes version 1 against the
|
|
346
|
-
* returned id. Admin-only.
|
|
347
|
-
*/
|
|
348
|
-
async createPackage(body) {
|
|
349
|
-
return this.request("POST", "/v1/packages", body);
|
|
350
|
-
}
|
|
303
|
+
// --- Packages (registry reads + installations) ---
|
|
304
|
+
// Authoring is server-side: apps via `POST /v1/apps/{id}/package-publish|release`,
|
|
305
|
+
// content packages via the `publish_content`/`release_content` tools. There is no
|
|
306
|
+
// client-side create-package / upload-bundle path.
|
|
351
307
|
/**
|
|
352
308
|
* Fetch a registry app package's metadata (incl. `kind`, `latest_version` and
|
|
353
309
|
* the Lotics-backed `is_official` trust badge). Admin-only; cross-tenant by id.
|
|
@@ -359,31 +315,6 @@ export class LoticsClient {
|
|
|
359
315
|
async listPackageVersions(package_id) {
|
|
360
316
|
return this.request("GET", `/v1/packages/${encodeURIComponent(package_id)}/versions`);
|
|
361
317
|
}
|
|
362
|
-
/**
|
|
363
|
-
* Publish a new immutable package version — multipart upload of the alias-keyed
|
|
364
|
-
* contract (JSON) + the prebuilt code bundle (a gzipped tarball carrying
|
|
365
|
-
* `source.tar.gz` + `dist.tar.gz` members). The server validates the contract +
|
|
366
|
-
* bundle, then allocates the next monotonic version. The `contract` is opaque
|
|
367
|
-
* JSON to the transport (the server is the validating authority). Admin-only.
|
|
368
|
-
*/
|
|
369
|
-
async publishPackageVersion(package_id, args) {
|
|
370
|
-
const formData = new FormData();
|
|
371
|
-
formData.append("contract", JSON.stringify(args.contract));
|
|
372
|
-
formData.append("bundle", new Blob([new Uint8Array(args.bundle)], { type: "application/gzip" }), "bundle.tar.gz");
|
|
373
|
-
if (args.changelog)
|
|
374
|
-
formData.append("changelog", args.changelog);
|
|
375
|
-
if (args.channel)
|
|
376
|
-
formData.append("channel", args.channel);
|
|
377
|
-
const url = `${this.baseUrl}/v1/packages/${encodeURIComponent(package_id)}/versions`;
|
|
378
|
-
const response = await fetch(url, {
|
|
379
|
-
method: "POST",
|
|
380
|
-
headers: this.buildHeaders(), // no Content-Type — fetch sets the multipart boundary
|
|
381
|
-
body: formData,
|
|
382
|
-
});
|
|
383
|
-
if (!response.ok)
|
|
384
|
-
await this.throwResponseError(response);
|
|
385
|
-
return response.json();
|
|
386
|
-
}
|
|
387
318
|
/**
|
|
388
319
|
* Upgrade a package installation to a newer published version — extends the
|
|
389
320
|
* binding additively, re-materializes the target version's
|
|
@@ -403,14 +334,6 @@ export class LoticsClient {
|
|
|
403
334
|
const query = opts.version !== undefined ? `?version=${opts.version}` : "";
|
|
404
335
|
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-upgrade${query}`);
|
|
405
336
|
}
|
|
406
|
-
/**
|
|
407
|
-
* Re-bind a package role to a different workspace group (the current group
|
|
408
|
-
* still exists). Re-materializes at the pinned version; refuses over
|
|
409
|
-
* modified-core findings. Admin-only.
|
|
410
|
-
*/
|
|
411
|
-
async rebindPackageRole(app_id, body) {
|
|
412
|
-
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-rebind-role`, body);
|
|
413
|
-
}
|
|
414
337
|
/**
|
|
415
338
|
* Workspace-wide dangling-reference sweep — active app/workflow artifacts
|
|
416
339
|
* whose prefixed schema ids no longer resolve. Backs
|
|
@@ -428,13 +351,67 @@ export class LoticsClient {
|
|
|
428
351
|
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-health`);
|
|
429
352
|
}
|
|
430
353
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
354
|
+
* Preview a release — the dry run behind `lotics app release`. Runs the
|
|
355
|
+
* binding-aware extract of the origin (aliases stable through the app's current
|
|
356
|
+
* binding) and reports the next version number, the new + changed aliases, the
|
|
357
|
+
* bundled-knowledge delta, and any extract findings (an `error` blocks the
|
|
358
|
+
* apply). An optional `knowledge` declaration (from the pulled app manifest)
|
|
359
|
+
* re-declares the bundle set — added/dropped/changed docs surface in the delta;
|
|
360
|
+
* omitted, the current corpus is reconstructed from the pin. No writes. Admin,
|
|
361
|
+
* owning-org only.
|
|
362
|
+
*/
|
|
363
|
+
async previewPackageRelease(app_id, opts = {}) {
|
|
364
|
+
const params = new URLSearchParams();
|
|
365
|
+
if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
|
|
366
|
+
params.set("knowledge", JSON.stringify(opts.knowledge));
|
|
367
|
+
}
|
|
368
|
+
const query = params.toString();
|
|
369
|
+
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release${query ? `?${query}` : ""}`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Release — snapshot the origin app into the next registry version. The server
|
|
373
|
+
* binding-aware-extracts it, repackages its deployed source + dist as the
|
|
374
|
+
* bundle, publishes the next `release`-channel version with the changelog, and
|
|
375
|
+
* re-pins the origin. An optional `knowledge` declaration re-declares the
|
|
376
|
+
* bundled-knowledge set (added/dropped/re-snapshotted docs; omitted preserves
|
|
377
|
+
* the current corpus). Error findings from extract surface as a 409; a
|
|
378
|
+
* missing/archived declared doc is a 400, a foreign-package doc a 409. Admin,
|
|
379
|
+
* owning-org only. Backs `lotics app release --yes`.
|
|
380
|
+
*/
|
|
381
|
+
async releasePackage(app_id, body) {
|
|
382
|
+
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-release`, body);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Dry-run preview of a first-release — the `GET` behind `lotics app publish`
|
|
386
|
+
* (no `--yes`), the publish-side analogue of `previewPackageRelease`. The
|
|
387
|
+
* server runs the same fresh-alias extract + `src/` scan the apply runs
|
|
388
|
+
* (through any `renames`) and returns the package name it would mint, the
|
|
389
|
+
* auto-minted RENAMABLE aliases (the exact `--rename` keys), and the extract
|
|
390
|
+
* findings (an `error` blocks the apply). No writes. Admin-only.
|
|
391
|
+
*/
|
|
392
|
+
async previewPublishAppPackage(app_id, opts = {}) {
|
|
393
|
+
const params = new URLSearchParams();
|
|
394
|
+
if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
|
|
395
|
+
params.set("knowledge", JSON.stringify(opts.knowledge));
|
|
396
|
+
}
|
|
397
|
+
if (opts.renames !== undefined && opts.renames.length > 0) {
|
|
398
|
+
params.set("renames", JSON.stringify(opts.renames));
|
|
399
|
+
}
|
|
400
|
+
const query = params.toString();
|
|
401
|
+
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-publish${query ? `?${query}` : ""}`);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* First-release apply — mint a package from a BESPOKE app and publish v1 in one
|
|
405
|
+
* call (the `POST` behind `lotics app publish --yes`). The server extracts an
|
|
406
|
+
* alias-keyed contract from the app (fresh aliases; `renames` fixes them before
|
|
407
|
+
* v1 freezes), creates the registry package (name/description from the app),
|
|
408
|
+
* publishes v1 from the app's deployed source + dist, and pins the origin as
|
|
409
|
+
* installation #1. Error findings from extract surface as a 409. An
|
|
410
|
+
* already-linked app must use `releasePackage` instead. Admin-only. Backs
|
|
411
|
+
* `lotics app publish <app_id>`.
|
|
435
412
|
*/
|
|
436
|
-
async
|
|
437
|
-
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-
|
|
413
|
+
async publishAppAsPackage(app_id, body) {
|
|
414
|
+
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-publish`, body);
|
|
438
415
|
}
|
|
439
416
|
/**
|
|
440
417
|
* Resolve the display name + fields (incl. select options) of the given tables
|
|
@@ -127,7 +127,12 @@ function inputDeclToTsType(decl) {
|
|
|
127
127
|
return null;
|
|
128
128
|
})
|
|
129
129
|
.filter((v) => v !== null);
|
|
130
|
-
|
|
130
|
+
// The literal union keeps autocomplete for hand-typed ids, but a package
|
|
131
|
+
// app addresses options through the runtime-resolved OPT map (typed
|
|
132
|
+
// string — consumer workspaces bind DIFFERENT concrete ids), so the
|
|
133
|
+
// input must also accept string. `(string & {})` widens without
|
|
134
|
+
// collapsing the union in intellisense.
|
|
135
|
+
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
131
136
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
132
137
|
}
|
|
133
138
|
case "date_range":
|
|
@@ -193,7 +198,11 @@ function outputDeclToTsType(decl) {
|
|
|
193
198
|
? JSON.stringify(o.value)
|
|
194
199
|
: null)
|
|
195
200
|
.filter((v) => v !== null);
|
|
196
|
-
|
|
201
|
+
// Same widening on the READ side: a package app's runtime output value is
|
|
202
|
+
// the CONSUMER workspace's concrete opt_ id, not the origin's literal —
|
|
203
|
+
// exhaustive narrowing over origin literals would be unsound. `(string & {})`
|
|
204
|
+
// keeps autocomplete without lying about the value space.
|
|
205
|
+
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
197
206
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
198
207
|
}
|
|
199
208
|
case "object": {
|
|
@@ -77,8 +77,8 @@ describe("generateAppWorkflowsDts", () => {
|
|
|
77
77
|
});
|
|
78
78
|
expect(dts).toContain("record_id: string;"); // required → NOT nullable
|
|
79
79
|
expect(dts).toContain("due?: string | null;"); // optional scalar → clearable
|
|
80
|
-
expect(dts).toContain('status?: "opt_done" | null;');
|
|
81
|
-
expect(dts).toContain('tags?: ReadonlyArray<"opt_a"> | null;'); // optional multi
|
|
80
|
+
expect(dts).toContain('status?: "opt_done" | (string & {}) | null;');
|
|
81
|
+
expect(dts).toContain('tags?: ReadonlyArray<"opt_a" | (string & {})> | null;'); // optional multi
|
|
82
82
|
// The object itself is a top-level optional → nullable; its NESTED optional
|
|
83
83
|
// field mirrors the backend's plain `.optional()` (no `| null`).
|
|
84
84
|
expect(dts).toContain("note?: string;");
|
|
@@ -94,7 +94,7 @@ describe("generateAppWorkflowsDts", () => {
|
|
|
94
94
|
},
|
|
95
95
|
},
|
|
96
96
|
});
|
|
97
|
-
expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b">');
|
|
97
|
+
expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b" | (string & {})>');
|
|
98
98
|
});
|
|
99
99
|
it("types a multi file input as ReadonlyArray<string>, single file as string", () => {
|
|
100
100
|
const dts = generateAppWorkflowsDts({
|
|
@@ -1,48 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a
|
|
3
|
-
* its
|
|
2
|
+
* Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PUBLISHED / linked app
|
|
3
|
+
* (a package installation — its own published origin, or an install of another
|
|
4
|
+
* org's package) from that installation's LIVE binding.
|
|
4
5
|
*
|
|
5
|
-
* The bespoke twin (`generate_app_fields.ts`) bakes
|
|
6
|
-
* `fld_…`/`opt_…` ids at
|
|
7
|
-
* co-live, fundamentally wrong for a
|
|
8
|
-
*
|
|
9
|
-
* `OPT` surface at MODULE LOAD from the
|
|
10
|
-
* `getAppBinding()` (the `binding` RPC op),
|
|
11
|
-
* graph waits for the binding before any
|
|
12
|
-
* `F.TASKS.title` is a plain string everywhere, including
|
|
13
|
-
* constants.
|
|
14
|
-
* import path, same shapes
|
|
15
|
-
* the same display names
|
|
6
|
+
* The bespoke twin (`generate_app_fields.ts`) bakes THIS workspace's concrete
|
|
7
|
+
* `fld_…`/`opt_…` ids at codegen time — correct when source and workspace
|
|
8
|
+
* co-live, fundamentally wrong for a linked app (the source is portable and
|
|
9
|
+
* installs into many workspaces, each with different ids). A linked app instead
|
|
10
|
+
* resolves the SAME `F` / `OPT` / `ROLE` surface at MODULE LOAD from the
|
|
11
|
+
* installation's binding via the SDK's `getAppBinding()` (the `binding` RPC op),
|
|
12
|
+
* using top-level await — the ESM graph waits for the binding before any
|
|
13
|
+
* importer evaluates, so `F.TASKS.title` is a plain string everywhere, including
|
|
14
|
+
* module-top-level constants. A published origin's source is portable by
|
|
15
|
+
* construction: same import path, same `F`/`OPT` shapes the bespoke codegen
|
|
16
|
+
* emitted (contract aliases derive from the same display names it slugifies), so
|
|
17
|
+
* the deployed source compiles unchanged, resolved per-install.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* The generator sources the alias SET from the app's LIVE binding (the `binding`
|
|
20
|
+
* RPC / `appBinding` client method), NOT a contract file: every bound field
|
|
21
|
+
* (`entity.field`), option (`entity.field:opt`), and role becomes an `F` / `OPT`
|
|
22
|
+
* / `ROLE` entry keyed by its stable alias.
|
|
23
|
+
*
|
|
24
|
+
* A missing alias fails LOUD at boot (the binding is install-verified complete,
|
|
25
|
+
* so a miss means the generated file is stale relative to the installed contract
|
|
26
|
+
* version) — never a silent undefined key in a write.
|
|
20
27
|
*
|
|
21
28
|
* Requires the app build to target es2022+ (the starter's `build.target`) —
|
|
22
29
|
* top-level await does not exist below it.
|
|
23
30
|
*/
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}>;
|
|
36
|
-
roles?: Array<{
|
|
37
|
-
alias: string;
|
|
38
|
-
}>;
|
|
31
|
+
/**
|
|
32
|
+
* A materialized app installation's binding — the exact shape the `binding` RPC
|
|
33
|
+
* / `client.appBinding()` returns (a subset of the full package binding: only
|
|
34
|
+
* the maps the client-side `F`/`OPT`/`ROLE` surface resolves through). Keys are
|
|
35
|
+
* fully-qualified contract aliases: `fields` keyed `entity.field`, `options`
|
|
36
|
+
* keyed `entity.field:opt`, `roles` keyed by role alias.
|
|
37
|
+
*/
|
|
38
|
+
export interface AppFieldsBinding {
|
|
39
|
+
fields: Record<string, string>;
|
|
40
|
+
options: Record<string, string>;
|
|
41
|
+
roles: Record<string, string>;
|
|
39
42
|
}
|
|
40
43
|
/**
|
|
41
|
-
* Generate the full
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* identifier instead of emitting broken TypeScript into a "DO NOT EDIT" file.
|
|
46
|
-
* Pure; same contract → same bytes.
|
|
44
|
+
* Generate the full binding-resolved `.lotics/app_fields.ts` source from an
|
|
45
|
+
* installation's live binding. Pure — same binding → same bytes. Idempotent.
|
|
46
|
+
* An empty binding yields valid, empty `F`/`OPT`/`ROLE` maps so the file always
|
|
47
|
+
* compiles and imports resolve.
|
|
47
48
|
*/
|
|
48
|
-
export declare function generatePackageAppFields(
|
|
49
|
+
export declare function generatePackageAppFields(binding: AppFieldsBinding): string;
|
|
@@ -1,34 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a
|
|
3
|
-
* its
|
|
2
|
+
* Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PUBLISHED / linked app
|
|
3
|
+
* (a package installation — its own published origin, or an install of another
|
|
4
|
+
* org's package) from that installation's LIVE binding.
|
|
4
5
|
*
|
|
5
|
-
* The bespoke twin (`generate_app_fields.ts`) bakes
|
|
6
|
-
* `fld_…`/`opt_…` ids at
|
|
7
|
-
* co-live, fundamentally wrong for a
|
|
8
|
-
*
|
|
9
|
-
* `OPT` surface at MODULE LOAD from the
|
|
10
|
-
* `getAppBinding()` (the `binding` RPC op),
|
|
11
|
-
* graph waits for the binding before any
|
|
12
|
-
* `F.TASKS.title` is a plain string everywhere, including
|
|
13
|
-
* constants.
|
|
14
|
-
* import path, same shapes
|
|
15
|
-
* the same display names
|
|
6
|
+
* The bespoke twin (`generate_app_fields.ts`) bakes THIS workspace's concrete
|
|
7
|
+
* `fld_…`/`opt_…` ids at codegen time — correct when source and workspace
|
|
8
|
+
* co-live, fundamentally wrong for a linked app (the source is portable and
|
|
9
|
+
* installs into many workspaces, each with different ids). A linked app instead
|
|
10
|
+
* resolves the SAME `F` / `OPT` / `ROLE` surface at MODULE LOAD from the
|
|
11
|
+
* installation's binding via the SDK's `getAppBinding()` (the `binding` RPC op),
|
|
12
|
+
* using top-level await — the ESM graph waits for the binding before any
|
|
13
|
+
* importer evaluates, so `F.TASKS.title` is a plain string everywhere, including
|
|
14
|
+
* module-top-level constants. A published origin's source is portable by
|
|
15
|
+
* construction: same import path, same `F`/`OPT` shapes the bespoke codegen
|
|
16
|
+
* emitted (contract aliases derive from the same display names it slugifies), so
|
|
17
|
+
* the deployed source compiles unchanged, resolved per-install.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* The generator sources the alias SET from the app's LIVE binding (the `binding`
|
|
20
|
+
* RPC / `appBinding` client method), NOT a contract file: every bound field
|
|
21
|
+
* (`entity.field`), option (`entity.field:opt`), and role becomes an `F` / `OPT`
|
|
22
|
+
* / `ROLE` entry keyed by its stable alias.
|
|
23
|
+
*
|
|
24
|
+
* A missing alias fails LOUD at boot (the binding is install-verified complete,
|
|
25
|
+
* so a miss means the generated file is stale relative to the installed contract
|
|
26
|
+
* version) — never a silent undefined key in a write.
|
|
20
27
|
*
|
|
21
28
|
* Requires the app build to target es2022+ (the starter's `build.target`) —
|
|
22
29
|
* top-level await does not exist below it.
|
|
23
30
|
*/
|
|
24
31
|
import { propKey } from "./generate_app_fields.js";
|
|
25
|
-
const HEADER = `// Auto-generated by 'lotics
|
|
26
|
-
// DO NOT EDIT — regenerated
|
|
32
|
+
const HEADER = `// Auto-generated by 'lotics app codegen' (linked/published app; also app pull/dev/deploy).
|
|
33
|
+
// DO NOT EDIT — regenerated from the installation's live binding.
|
|
27
34
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
35
|
+
// A package installation resolves F/OPT/ROLE at MODULE LOAD from its binding
|
|
36
|
+
// (contract alias → THIS workspace's concrete id) via the SDK's \`binding\` RPC.
|
|
37
|
+
// Top-level await: the module graph waits for the binding before any importer
|
|
38
|
+
// evaluates, so every entry is a plain string.
|
|
32
39
|
import { getAppBinding } from "@lotics/app-sdk";
|
|
33
40
|
|
|
34
41
|
const binding = await getAppBinding();
|
|
@@ -45,54 +52,100 @@ function bound(map: Record<string, string>, key: string, kind: string): string {
|
|
|
45
52
|
}
|
|
46
53
|
`;
|
|
47
54
|
/**
|
|
48
|
-
*
|
|
49
|
-
* aliases are slug-grammar (
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
55
|
+
* Reconstruct the entity → field → option nesting (plus role list) from the flat
|
|
56
|
+
* binding maps. Entity/field/option aliases are slug-grammar (`[a-z][a-z0-9_]*`,
|
|
57
|
+
* no `.` or `:`), so a field key splits on its single `.` and an option key on
|
|
58
|
+
* its single `:`. First-seen order is preserved (the binding is written in
|
|
59
|
+
* contract order), so the output is deterministic for a given binding. An option
|
|
60
|
+
* whose owning field is absent from `fields` is skipped defensively — the binding
|
|
61
|
+
* is the authority, and a select field is always present among the fields.
|
|
54
62
|
*/
|
|
55
|
-
|
|
56
|
-
const entities =
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
for (const
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
63
|
+
function parseBinding(binding) {
|
|
64
|
+
const entities = [];
|
|
65
|
+
const entityByAlias = new Map();
|
|
66
|
+
const fieldByKey = new Map();
|
|
67
|
+
for (const key of Object.keys(binding.fields)) {
|
|
68
|
+
const dot = key.indexOf(".");
|
|
69
|
+
if (dot <= 0 || dot >= key.length - 1)
|
|
70
|
+
continue;
|
|
71
|
+
const entityAlias = key.slice(0, dot);
|
|
72
|
+
const fieldAlias = key.slice(dot + 1);
|
|
73
|
+
let entity = entityByAlias.get(entityAlias);
|
|
74
|
+
if (entity === undefined) {
|
|
75
|
+
entity = { alias: entityAlias, fields: [] };
|
|
76
|
+
entityByAlias.set(entityAlias, entity);
|
|
77
|
+
entities.push(entity);
|
|
78
|
+
}
|
|
79
|
+
const field = { alias: fieldAlias, key, options: [] };
|
|
80
|
+
entity.fields.push(field);
|
|
81
|
+
fieldByKey.set(key, field);
|
|
82
|
+
}
|
|
83
|
+
for (const key of Object.keys(binding.options)) {
|
|
84
|
+
const colon = key.indexOf(":");
|
|
85
|
+
if (colon <= 0 || colon >= key.length - 1)
|
|
86
|
+
continue;
|
|
87
|
+
const fieldKey = key.slice(0, colon);
|
|
88
|
+
const optionAlias = key.slice(colon + 1);
|
|
89
|
+
const field = fieldByKey.get(fieldKey);
|
|
90
|
+
if (field === undefined)
|
|
62
91
|
continue;
|
|
63
|
-
|
|
92
|
+
field.options.push({ alias: optionAlias, key });
|
|
64
93
|
}
|
|
65
|
-
|
|
94
|
+
return { entities, roles: Object.keys(binding.roles) };
|
|
95
|
+
}
|
|
96
|
+
/** Emit the `F` map: `{ ENTITY: { field: bound(binding.fields, "entity.field", "field") } }`. */
|
|
97
|
+
function emitFieldMap(entities) {
|
|
98
|
+
if (entities.length === 0)
|
|
99
|
+
return `export const F = {} as const;`;
|
|
100
|
+
const blocks = entities.map((entity) => {
|
|
101
|
+
const lines = entity.fields.map((field) => ` ${propKey(field.alias)}: bound(binding.fields, ${JSON.stringify(field.key)}, "field"),`);
|
|
102
|
+
return ` ${propKey(entity.alias.toUpperCase())}: {\n${lines.join("\n")}\n },`;
|
|
103
|
+
});
|
|
104
|
+
return `export const F = {\n${blocks.join("\n")}\n} as const;`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Emit the `OPT` map: `{ ENTITY: { field: { opt: bound(binding.options, …) } } }`.
|
|
108
|
+
* Only fields carrying options appear (an option-less field/entity is omitted).
|
|
109
|
+
*/
|
|
110
|
+
function emitOptionMap(entities) {
|
|
111
|
+
const entityBlocks = [];
|
|
66
112
|
for (const entity of entities) {
|
|
67
|
-
const
|
|
68
|
-
for (const field of entity.fields
|
|
69
|
-
|
|
70
|
-
if (options.length === 0)
|
|
113
|
+
const fieldBlocks = [];
|
|
114
|
+
for (const field of entity.fields) {
|
|
115
|
+
if (field.options.length === 0)
|
|
71
116
|
continue;
|
|
72
|
-
const lines = options.map((option) => ` ${propKey(option.alias)}: bound(binding.options, ${JSON.stringify(
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
if (perField.length > 0) {
|
|
76
|
-
optionBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${perField.join("\n")}\n },`);
|
|
117
|
+
const lines = field.options.map((option) => ` ${propKey(option.alias)}: bound(binding.options, ${JSON.stringify(option.key)}, "option"),`);
|
|
118
|
+
fieldBlocks.push(` ${propKey(field.alias)}: {\n${lines.join("\n")}\n },`);
|
|
77
119
|
}
|
|
120
|
+
if (fieldBlocks.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
entityBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${fieldBlocks.join("\n")}\n },`);
|
|
78
123
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
if (entityBlocks.length === 0)
|
|
125
|
+
return `export const OPT = {} as const;`;
|
|
126
|
+
return `export const OPT = {\n${entityBlocks.join("\n")}\n} as const;`;
|
|
127
|
+
}
|
|
128
|
+
/** Emit the `ROLE` map: `{ role: bound(binding.roles, "role", "role") }`. */
|
|
129
|
+
function emitRoleMap(roles) {
|
|
130
|
+
if (roles.length === 0)
|
|
131
|
+
return `export const ROLE = {} as const;`;
|
|
132
|
+
const lines = roles.map((role) => ` ${propKey(role)}: bound(binding.roles, ${JSON.stringify(role)}, "role"),`);
|
|
133
|
+
return `export const ROLE = {\n${lines.join("\n")}\n} as const;`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Generate the full binding-resolved `.lotics/app_fields.ts` source from an
|
|
137
|
+
* installation's live binding. Pure — same binding → same bytes. Idempotent.
|
|
138
|
+
* An empty binding yields valid, empty `F`/`OPT`/`ROLE` maps so the file always
|
|
139
|
+
* compiles and imports resolve.
|
|
140
|
+
*/
|
|
141
|
+
export function generatePackageAppFields(binding) {
|
|
142
|
+
const { entities, roles } = parseBinding(binding);
|
|
90
143
|
return `${HEADER}
|
|
91
|
-
${
|
|
144
|
+
${emitFieldMap(entities)}
|
|
92
145
|
|
|
93
|
-
${
|
|
146
|
+
${emitOptionMap(entities)}
|
|
94
147
|
|
|
95
|
-
${
|
|
148
|
+
${emitRoleMap(roles)}
|
|
96
149
|
|
|
97
150
|
/** Field-id alias map: \`F[<ENTITY>][<field>]\` is this installation's \`fld_…\` id. */
|
|
98
151
|
export type AppFields = typeof F;
|