@lotics/cli 0.76.0 → 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.
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
  }
@@ -204,16 +195,56 @@ export class LoticsClient {
204
195
  return this.request("POST", "/v1/apps", body);
205
196
  }
206
197
  /**
207
- * Install an app package version into the current workspace — scaffolds the
208
- * data model, deploys the package bundle, materializes its
209
- * queries/workflows/agents, and pins the installation. Returns the resulting
210
- * installation app. `version` omitted installs the latest published version.
198
+ * Install a package version into the current workspace — ONE endpoint,
199
+ * kind-branched into a discriminated response (`kind`). An `app` package
200
+ * scaffolds the data model / deploys / materializes and returns the installation
201
+ * app (+ advisory knowledge warnings); a `content` package installs only its
202
+ * doc corpus and returns the installation row. `bind_to` (content only)
203
+ * resolves a name collision by adopting an existing same-named doc as
204
+ * package-managed. `version` omitted installs the latest published version.
205
+ */
206
+ async installPackage(package_id, body) {
207
+ return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/installations`, body);
208
+ }
209
+ /**
210
+ * Uninstall a standalone content package — delete the installation row. By
211
+ * default the package-bound docs are ARCHIVED; `keep_content` retains them as
212
+ * ordinary workspace docs. Admin-only. Backs `lotics uninstall`.
213
+ */
214
+ async uninstallContentPackage(installation_id, opts = {}) {
215
+ const qs = opts.keep_content ? "?keep_content=true" : "";
216
+ return this.request("DELETE", `/v1/content-installations/${encodeURIComponent(installation_id)}${qs}`);
217
+ }
218
+ /**
219
+ * Preview upgrading a standalone content installation — the knowledge namespace's
220
+ * per-alias `entries` (added/changed/removed/drifted + a `modified` flag) plus the
221
+ * consent-requiring `templates` (a template the target changed whose live content
222
+ * was locally edited; a clean one auto-updates and is absent). No writes.
223
+ * Admin-only.
224
+ */
225
+ async previewContentInstallationUpgrade(installation_id, opts = {}) {
226
+ const qs = opts.version !== undefined ? `?version=${opts.version}` : "";
227
+ return this.request("GET", `/v1/content-installations/${encodeURIComponent(installation_id)}/upgrade${qs}`);
228
+ }
229
+ /**
230
+ * Apply a standalone content upgrade — propagate the target version's content per
231
+ * the resolutions (`knowledge`: a modified change/removal or drift needs consent;
232
+ * `templates`: a locally-edited changed template takes revert|keep), then advance
233
+ * the pin + binding. Returns the updated installation row. Admin-only.
211
234
  */
212
- async installAppPackage(package_id, body) {
213
- return this.request("POST", `/v1/app-packages/${encodeURIComponent(package_id)}/installations`, body);
235
+ async applyContentInstallationUpgrade(installation_id, body) {
236
+ return this.request("POST", `/v1/content-installations/${encodeURIComponent(installation_id)}/upgrade`, body);
214
237
  }
215
238
  /**
216
- * Uninstall a package installation (backs `lotics package uninstall`). Does
239
+ * List a workspace's package-managed knowledge installations, each folded with
240
+ * registry status (name, kind, official badge, latest version, update-available).
241
+ * Member-accessible (workspace-scoped); backs the settings "Managed by" badge.
242
+ */
243
+ async listContentInstallations(workspace_id) {
244
+ return this.request("GET", `/v1/workspaces/${encodeURIComponent(workspace_id)}/content-installations`);
245
+ }
246
+ /**
247
+ * Uninstall a package installation (backs `lotics uninstall`). Does
217
248
  * everything DELETE does plus archives the installation's lifecycle
218
249
  * artifacts; with `archive_tables` it also archives the scaffolded entity
219
250
  * tables — refused server-side unless this installation created them
@@ -231,13 +262,14 @@ export class LoticsClient {
231
262
  return this.request("PATCH", `/v1/apps/${encodeURIComponent(app_id)}/package-config`, body);
232
263
  }
233
264
  /**
234
- * Retire (or `undo` un-retire) a registry package (backs `lotics package
235
- * retire`). Retiring refuses NEW installs and hides the package from
236
- * non-owning orgs; existing installations keep working and may still upgrade.
237
- * Owner-org admin-only.
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.
238
270
  */
239
- async retireAppPackage(package_id, body) {
240
- return this.request("POST", `/v1/app-packages/${encodeURIComponent(package_id)}/retire`, body);
271
+ async retirePackage(package_id, body) {
272
+ return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/retire`, body);
241
273
  }
242
274
  /**
243
275
  * Eject an installation from its package — re-deploy the pinned version's
@@ -246,40 +278,15 @@ export class LoticsClient {
246
278
  * app becomes a normal bespoke app and can no longer be upgraded. Returns the
247
279
  * resulting app.
248
280
  */
249
- async ejectAppPackage(app_id) {
281
+ async ejectPackage(app_id) {
250
282
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/eject`);
251
283
  }
252
- /**
253
- * Extract a DRAFT app package from an existing bespoke app — the promotion
254
- * read (docs/app_packages.md § Promotion). Pure: nothing is written. Returns
255
- * the alias-keyed draft `contract` (opaque to the CLI — the server is the
256
- * validating authority), the origin workspace's `binding` (which doubles as
257
- * the adopt binding), a findings `report` (any `error` ⇒ not publishable
258
- * as-is), and the file-backed `template_files` the CLI must stage into the
259
- * project at their `bytes_ref` paths. Backs `lotics package extract`.
260
- * Admin-only.
261
- */
262
- async extractAppPackage(app_id) {
263
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-extract`);
264
- }
265
- /**
266
- * Adopt a published package onto an EXISTING (bespoke or ejected) app — the
267
- * final promotion step. The workspace already holds the concrete objects, so
268
- * nothing is scaffolded or rewritten: the server verifies the `binding` is
269
- * complete, live, and FAITHFUL to the version's contract, then writes only the
270
- * installation pin (the app becomes installation #1, upgradeable again). A
271
- * ConflictError names the aliases that diverge. `version` omitted adopts the
272
- * latest. Backs `lotics package adopt`. Admin-only.
273
- */
274
- async adoptAppPackage(app_id, body) {
275
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-adopt`, body);
276
- }
277
284
  /**
278
285
  * Fleet upgrade — bring every installation of a package across the CALLER'S
279
286
  * org to the target version (latest when omitted) in one call. Hands-off
280
287
  * applies only where the preview is clean; installations with breaking/
281
288
  * drift/modified-core findings are skipped and reported for the normal
282
- * per-installation consent flow. Backs `lotics package fleet-upgrade`.
289
+ * per-installation consent flow. Backs `lotics upgrade <package_id>` (fleet).
283
290
  * Admin-only; org-scoped (no workspace header needed).
284
291
  */
285
292
  /**
@@ -287,57 +294,26 @@ export class LoticsClient {
287
294
  * adopts targeting it; pinned installations keep running. Owner-org
288
295
  * admin-only. Backs `lotics package yank`.
289
296
  */
290
- async yankAppPackageVersion(package_id, version, yanked) {
291
- return this.request("POST", `/v1/app-packages/${encodeURIComponent(package_id)}/versions/${version}/yank`, { yanked });
297
+ async yankPackageVersion(package_id, version, yanked) {
298
+ return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/versions/${version}/yank`, { yanked });
292
299
  }
293
- async fleetUpgradeAppPackage(package_id, body) {
294
- return this.request("POST", `/v1/app-packages/${encodeURIComponent(package_id)}/fleet-upgrade`, body);
295
- }
296
- // --- App packages (registry authoring + dev harness) ---
297
- /**
298
- * Create a registry app package — the Lotics-owned, workspace-agnostic
299
- * blueprint. `lotics package publish` calls this on first publish (when the
300
- * local manifest has no `package_id`), then publishes version 1 against the
301
- * returned id. Admin-only.
302
- */
303
- async createAppPackage(body) {
304
- return this.request("POST", "/v1/app-packages", body);
300
+ async fleetUpgradePackage(package_id, body) {
301
+ return this.request("POST", `/v1/packages/${encodeURIComponent(package_id)}/fleet-upgrade`, body);
305
302
  }
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.
306
307
  /**
307
- * Fetch a registry app package's metadata (incl. `latest_version` and the
308
- * Lotics-backed `is_official` trust badge). Admin-only; cross-tenant by id.
308
+ * Fetch a registry app package's metadata (incl. `kind`, `latest_version` and
309
+ * the Lotics-backed `is_official` trust badge). Admin-only; cross-tenant by id.
309
310
  */
310
- async getAppPackage(package_id) {
311
- return this.request("GET", `/v1/app-packages/${encodeURIComponent(package_id)}`);
311
+ async getPackage(package_id) {
312
+ return this.request("GET", `/v1/packages/${encodeURIComponent(package_id)}`);
312
313
  }
313
314
  /** Version history newest-first (no contract payloads) — backs `lotics package show`. Admin-only. */
314
- async listAppPackageVersions(package_id) {
315
- return this.request("GET", `/v1/app-packages/${encodeURIComponent(package_id)}/versions`);
316
- }
317
- /**
318
- * Publish a new immutable package version — multipart upload of the alias-keyed
319
- * contract (JSON) + the prebuilt code bundle (a gzipped tarball carrying
320
- * `source.tar.gz` + `dist.tar.gz` members). The server validates the contract +
321
- * bundle, then allocates the next monotonic version. The `contract` is opaque
322
- * JSON to the transport (the server is the validating authority). Admin-only.
323
- */
324
- async publishAppPackageVersion(package_id, args) {
325
- const formData = new FormData();
326
- formData.append("contract", JSON.stringify(args.contract));
327
- formData.append("bundle", new Blob([new Uint8Array(args.bundle)], { type: "application/gzip" }), "bundle.tar.gz");
328
- if (args.changelog)
329
- formData.append("changelog", args.changelog);
330
- if (args.channel)
331
- formData.append("channel", args.channel);
332
- const url = `${this.baseUrl}/v1/app-packages/${encodeURIComponent(package_id)}/versions`;
333
- const response = await fetch(url, {
334
- method: "POST",
335
- headers: this.buildHeaders(), // no Content-Type — fetch sets the multipart boundary
336
- body: formData,
337
- });
338
- if (!response.ok)
339
- await this.throwResponseError(response);
340
- return response.json();
315
+ async listPackageVersions(package_id) {
316
+ return this.request("GET", `/v1/packages/${encodeURIComponent(package_id)}/versions`);
341
317
  }
342
318
  /**
343
319
  * Upgrade a package installation to a newer published version — extends the
@@ -346,7 +322,7 @@ export class LoticsClient {
346
322
  * package artifacts, and bumps the pin. `version` omitted upgrades to the
347
323
  * latest. Returns the resulting app. Admin-only.
348
324
  */
349
- async upgradeAppPackage(app_id, body) {
325
+ async upgradePackage(app_id, body) {
350
326
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-upgrade`, body);
351
327
  }
352
328
  /**
@@ -354,18 +330,10 @@ export class LoticsClient {
354
330
  * breaking contract changes, the binding drift report, and the modified-core
355
331
  * report — with no writes. Admin-only.
356
332
  */
357
- async previewAppPackageUpgrade(app_id, opts = {}) {
333
+ async previewPackageUpgrade(app_id, opts = {}) {
358
334
  const query = opts.version !== undefined ? `?version=${opts.version}` : "";
359
335
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-upgrade${query}`);
360
336
  }
361
- /**
362
- * Re-bind a package role to a different workspace group (the current group
363
- * still exists). Re-materializes at the pinned version; refuses over
364
- * modified-core findings. Admin-only.
365
- */
366
- async rebindAppPackageRole(app_id, body) {
367
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-rebind-role`, body);
368
- }
369
337
  /**
370
338
  * Workspace-wide dangling-reference sweep — active app/workflow artifacts
371
339
  * whose prefixed schema ids no longer resolve. Backs
@@ -379,17 +347,60 @@ export class LoticsClient {
379
347
  * binding drift, and locally modified core artifacts. Read-only; backs
380
348
  * `lotics package doctor`. Admin-only.
381
349
  */
382
- async getAppPackageHealth(app_id) {
350
+ async getPackageHealth(app_id) {
383
351
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-health`);
384
352
  }
385
353
  /**
386
- * Reset a package installation in a DEV workspace drop the package-owned
387
- * scaffolded tables and re-scaffold clean. Hard-gated server-side to dev
388
- * workspaces (a non-dev workspace is refused). Returns the resulting app.
389
- * Admin-only.
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, and
357
+ * any extract findings (an `error` blocks the apply). No writes. Admin,
358
+ * owning-org only.
359
+ */
360
+ async previewPackageRelease(app_id) {
361
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release`);
362
+ }
363
+ /**
364
+ * Release — snapshot the origin app into the next registry version. The server
365
+ * binding-aware-extracts it, repackages its deployed source + dist as the
366
+ * bundle, publishes the next `release`-channel version with the changelog, and
367
+ * re-pins the origin. Error findings from extract surface as a 409. Admin,
368
+ * owning-org only. Backs `lotics app release --yes`.
369
+ */
370
+ async releasePackage(app_id, body) {
371
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-release`, body);
372
+ }
373
+ /**
374
+ * Dry-run preview of a first-release — the `GET` behind `lotics app publish`
375
+ * (no `--yes`), the publish-side analogue of `previewPackageRelease`. The
376
+ * server runs the same fresh-alias extract + `src/` scan the apply runs
377
+ * (through any `renames`) and returns the package name it would mint, the
378
+ * auto-minted RENAMABLE aliases (the exact `--rename` keys), and the extract
379
+ * findings (an `error` blocks the apply). No writes. Admin-only.
380
+ */
381
+ async previewPublishAppPackage(app_id, opts = {}) {
382
+ const params = new URLSearchParams();
383
+ if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
384
+ params.set("knowledge", JSON.stringify(opts.knowledge));
385
+ }
386
+ if (opts.renames !== undefined && opts.renames.length > 0) {
387
+ params.set("renames", JSON.stringify(opts.renames));
388
+ }
389
+ const query = params.toString();
390
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-publish${query ? `?${query}` : ""}`);
391
+ }
392
+ /**
393
+ * First-release apply — mint a package from a BESPOKE app and publish v1 in one
394
+ * call (the `POST` behind `lotics app publish --yes`). The server extracts an
395
+ * alias-keyed contract from the app (fresh aliases; `renames` fixes them before
396
+ * v1 freezes), creates the registry package (name/description from the app),
397
+ * publishes v1 from the app's deployed source + dist, and pins the origin as
398
+ * installation #1. Error findings from extract surface as a 409. An
399
+ * already-linked app must use `releasePackage` instead. Admin-only. Backs
400
+ * `lotics app publish <app_id>`.
390
401
  */
391
- async resetAppPackage(app_id) {
392
- return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-reset`);
402
+ async publishAppAsPackage(app_id, body) {
403
+ return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/package-publish`, body);
393
404
  }
394
405
  /**
395
406
  * Resolve the display name + fields (incl. select options) of the given tables
@@ -1,48 +1,49 @@
1
1
  /**
2
- * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PACKAGE project from
3
- * its `contract.json`.
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 this workspace's concrete
6
- * `fld_…`/`opt_…` ids at authoring time — correct when source and workspace
7
- * co-live, fundamentally wrong for a package (one bundle, many workspaces,
8
- * different ids in each). A package project instead resolves the SAME `F` /
9
- * `OPT` surface at MODULE LOAD from the installation's binding via the SDK's
10
- * `getAppBinding()` (the `binding` RPC op), using top-level await — the ESM
11
- * graph waits for the binding before any importer evaluates, so
12
- * `F.TASKS.title` is a plain string everywhere, including module-top-level
13
- * constants. Extracted bespoke sources therefore compile unchanged: same
14
- * import path, same shapes, same alias naming (contract aliases derive from
15
- * the same display names the bespoke codegen slugifies).
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
- * A missing alias fails LOUD at boot (the binding is adopt/install-verified
18
- * complete, so a miss means the generated file is stale relative to the
19
- * installed contract version) never a silent undefined key in a write.
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
- /** The subset of a contract this generator reads (structural, not zod-validated —
25
- * publish is the validating boundary; codegen must work on drafts too). */
26
- export interface PackageContractShape {
27
- entities?: Array<{
28
- alias: string;
29
- fields?: Array<{
30
- alias: string;
31
- options?: Array<{
32
- alias: string;
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 package `.lotics/app_fields.ts` source. Valid contract
42
- * aliases are slug-grammar (`^[a-z][a-z0-9_]*$`) and emit as bare identifiers
43
- * but this also runs on unvalidated DRAFTS (dev/sync pre-publish), so every
44
- * key goes through `propKey`, which quotes anything that isn't a valid
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(contract: PackageContractShape): string;
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 PACKAGE project from
3
- * its `contract.json`.
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 this workspace's concrete
6
- * `fld_…`/`opt_…` ids at authoring time — correct when source and workspace
7
- * co-live, fundamentally wrong for a package (one bundle, many workspaces,
8
- * different ids in each). A package project instead resolves the SAME `F` /
9
- * `OPT` surface at MODULE LOAD from the installation's binding via the SDK's
10
- * `getAppBinding()` (the `binding` RPC op), using top-level await — the ESM
11
- * graph waits for the binding before any importer evaluates, so
12
- * `F.TASKS.title` is a plain string everywhere, including module-top-level
13
- * constants. Extracted bespoke sources therefore compile unchanged: same
14
- * import path, same shapes, same alias naming (contract aliases derive from
15
- * the same display names the bespoke codegen slugifies).
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
- * A missing alias fails LOUD at boot (the binding is adopt/install-verified
18
- * complete, so a miss means the generated file is stale relative to the
19
- * installed contract version) never a silent undefined key in a write.
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 package new/extract/dev/sync' from contract.json.
26
- // DO NOT EDIT — regenerated whenever the contract changes.
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
- // Package apps resolve F/OPT/ROLE at MODULE LOAD from the installation's
29
- // binding (contract alias → THIS workspace's concrete id) via the SDK's
30
- // \`binding\` RPC. Top-level await: the module graph waits for the binding
31
- // before any importer evaluates, so every entry is a plain string.
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
- * Generate the full package `.lotics/app_fields.ts` source. Valid contract
49
- * aliases are slug-grammar (`^[a-z][a-z0-9_]*$`) and emit as bare identifiers
50
- * but this also runs on unvalidated DRAFTS (dev/sync pre-publish), so every
51
- * key goes through `propKey`, which quotes anything that isn't a valid
52
- * identifier instead of emitting broken TypeScript into a "DO NOT EDIT" file.
53
- * Pure; same contract same bytes.
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
- export function generatePackageAppFields(contract) {
56
- const entities = contract.entities ?? [];
57
- const roles = contract.roles ?? [];
58
- const fieldBlocks = [];
59
- for (const entity of entities) {
60
- const lines = (entity.fields ?? []).map((field) => ` ${propKey(field.alias)}: bound(binding.fields, ${JSON.stringify(`${entity.alias}.${field.alias}`)}, "field"),`);
61
- if (lines.length === 0)
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
- fieldBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${lines.join("\n")}\n },`);
92
+ field.options.push({ alias: optionAlias, key });
64
93
  }
65
- const optionBlocks = [];
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 perField = [];
68
- for (const field of entity.fields ?? []) {
69
- const options = field.options ?? [];
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(`${entity.alias}.${field.alias}:${option.alias}`)}, "option"),`);
73
- perField.push(` ${propKey(field.alias)}: {\n${lines.join("\n")}\n },`);
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
- const fMap = fieldBlocks.length > 0
80
- ? `export const F = {\n${fieldBlocks.join("\n")}\n} as const;`
81
- : `export const F = {} as const;`;
82
- const optMap = optionBlocks.length > 0
83
- ? `export const OPT = {\n${optionBlocks.join("\n")}\n} as const;`
84
- : `export const OPT = {} as const;`;
85
- const roleMap = roles.length > 0
86
- ? `export const ROLE = {\n${roles
87
- .map((role) => ` ${propKey(role.alias)}: bound(binding.roles, ${JSON.stringify(role.alias)}, "role"),`)
88
- .join("\n")}\n} as const;`
89
- : `export const ROLE = {} as const;`;
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
- ${fMap}
144
+ ${emitFieldMap(entities)}
92
145
 
93
- ${optMap}
146
+ ${emitOptionMap(entities)}
94
147
 
95
- ${roleMap}
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;