@lotics/cli 0.73.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.
@@ -1,4 +1,13 @@
1
1
  import { LoticsClient } from "./client.js";
2
+ /**
3
+ * Resolve the latest published version of a package from the npm registry.
4
+ * Returns null on any failure (network error, 404, malformed payload) so
5
+ * callers can fall back to a static pin rather than crashing `app create`.
6
+ *
7
+ * 1.5s timeout — npm registry is fast when reachable; the fallback is fine
8
+ * the rare times it isn't, and we don't want to block scaffold on a hang.
9
+ */
10
+ export declare function fetchLatestNpmVersion(packageName: string): Promise<string | null>;
2
11
  /**
3
12
  * Manifest declaration for one workflow alias:
4
13
  * `"alias": { workflow_id, inputs?: { key: { type, … } }, outputs?: { key: { type, … } } }`
@@ -32,7 +32,7 @@ import { loadProjectTypescript, checkWorkflowBodies, } from "./app_workflow_chec
32
32
  * 1.5s timeout — npm registry is fast when reachable; the fallback is fine
33
33
  * the rare times it isn't, and we don't want to block scaffold on a hang.
34
34
  */
35
- async function fetchLatestNpmVersion(packageName) {
35
+ export async function fetchLatestNpmVersion(packageName) {
36
36
  try {
37
37
  const controller = new AbortController();
38
38
  const timeout = setTimeout(() => controller.abort(), 1500);
package/dist/client.d.ts CHANGED
@@ -559,6 +559,12 @@ export declare class LoticsClient {
559
559
  image: string | null;
560
560
  }>;
561
561
  }>;
562
+ /** A package installation's alias→id maps (fields/options/roles) — the app's runtime F/OPT resolution. */
563
+ appBinding(app_id: string): Promise<{
564
+ fields: Record<string, string>;
565
+ options: Record<string, string>;
566
+ roles: Record<string, string>;
567
+ }>;
562
568
  /**
563
569
  * Resolve the full option set (key, label, color) of a named query's select
564
570
  * columns — the picker companion to `appQuery`. Mirrors
package/dist/client.js CHANGED
@@ -433,6 +433,10 @@ export class LoticsClient {
433
433
  const qs = group_id ? `?group_id=${encodeURIComponent(group_id)}` : "";
434
434
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/members${qs}`);
435
435
  }
436
+ /** A package installation's alias→id maps (fields/options/roles) — the app's runtime F/OPT resolution. */
437
+ async appBinding(app_id) {
438
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/binding`);
439
+ }
436
440
  /**
437
441
  * Resolve the full option set (key, label, color) of a named query's select
438
442
  * columns — the picker companion to `appQuery`. Mirrors
@@ -6,7 +6,7 @@
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
8
  import { LoticsClient } from "../client.js";
9
- export type RpcOp = "query" | "field_options" | "workflow" | "members" | "context" | "upload_url" | "upload_complete" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
9
+ export type RpcOp = "query" | "field_options" | "workflow" | "members" | "context" | "binding" | "upload_url" | "upload_complete" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
10
10
  export interface RpcRequest {
11
11
  app_id: string;
12
12
  op: RpcOp;
@@ -11,6 +11,7 @@ const SUPPORTED_OPS = new Set([
11
11
  "workflow",
12
12
  "members",
13
13
  "context",
14
+ "binding",
14
15
  "upload_url",
15
16
  "upload_complete",
16
17
  "comments.list",
@@ -76,6 +77,8 @@ export async function dispatchRpc(client, body, opts) {
76
77
  const p = body.payload;
77
78
  return client.appMembers(body.app_id, p?.group);
78
79
  }
80
+ case "binding":
81
+ return client.appBinding(body.app_id);
79
82
  case "upload_url": {
80
83
  const p = body.payload;
81
84
  if (!p ||
@@ -46,6 +46,8 @@ export interface TableSchema {
46
46
  * `upper` uppercases the result (TABLE aliases read as constants).
47
47
  */
48
48
  export declare function slugifyAlias(name: string, upper: boolean): string;
49
+ /** A property key for an object literal — bare when a valid identifier, else quoted. */
50
+ export declare function propKey(alias: string): string;
49
51
  /**
50
52
  * Generate the full `.lotics/app_fields.ts` source. `tables` is the resolved
51
53
  * workspace schema (the subset the app touches). An empty list yields valid,
@@ -68,7 +68,7 @@ function dedupeAliases(names, upper) {
68
68
  });
69
69
  }
70
70
  /** A property key for an object literal — bare when a valid identifier, else quoted. */
71
- function propKey(alias) {
71
+ export function propKey(alias) {
72
72
  return isValidIdentifier(alias) ? alias : JSON.stringify(alias);
73
73
  }
74
74
  /** Resolve table + field aliases once, so the `F`/`OPT` maps and the union types agree. */
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PACKAGE project from
3
+ * its `contract.json`.
4
+ *
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).
16
+ *
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.
20
+ *
21
+ * Requires the app build to target es2022+ (the starter's `build.target`) —
22
+ * top-level await does not exist below it.
23
+ */
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
+ }>;
39
+ }
40
+ /**
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.
47
+ */
48
+ export declare function generatePackageAppFields(contract: PackageContractShape): string;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Codegen: emit a RUNTIME `.lotics/app_fields.ts` for a PACKAGE project from
3
+ * its `contract.json`.
4
+ *
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).
16
+ *
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.
20
+ *
21
+ * Requires the app build to target es2022+ (the starter's `build.target`) —
22
+ * top-level await does not exist below it.
23
+ */
24
+ 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.
27
+ //
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.
32
+ import { getAppBinding } from "@lotics/app-sdk";
33
+
34
+ const binding = await getAppBinding();
35
+
36
+ function bound(map: Record<string, string>, key: string, kind: string): string {
37
+ const id = map[key];
38
+ if (id === undefined) {
39
+ throw new Error(
40
+ \`app_fields: \${kind} "\${key}" is not in this installation's binding — \` +
41
+ \`the generated app_fields.ts is stale relative to the installed contract version.\`,
42
+ );
43
+ }
44
+ return id;
45
+ }
46
+ `;
47
+ /**
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.
54
+ */
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)
62
+ continue;
63
+ fieldBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {\n${lines.join("\n")}\n },`);
64
+ }
65
+ const optionBlocks = [];
66
+ 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)
71
+ 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 },`);
77
+ }
78
+ }
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;`;
90
+ return `${HEADER}
91
+ ${fMap}
92
+
93
+ ${optMap}
94
+
95
+ ${roleMap}
96
+
97
+ /** Field-id alias map: \`F[<ENTITY>][<field>]\` is this installation's \`fld_…\` id. */
98
+ export type AppFields = typeof F;
99
+ /** Select-option alias map: \`OPT[<ENTITY>][<field>][<option>]\` is this installation's \`opt_…\` id. */
100
+ export type AppOptions = typeof OPT;
101
+ /** Role alias map: \`ROLE[<role>]\` is this installation's \`grp_…\` id. */
102
+ export type AppRoles = typeof ROLE;
103
+ `;
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generatePackageAppFields } from "./generate_package_fields.js";
3
+ const contract = {
4
+ entities: [
5
+ {
6
+ alias: "tasks",
7
+ fields: [
8
+ { alias: "title" },
9
+ {
10
+ alias: "status",
11
+ options: [{ alias: "to_do" }, { alias: "doing" }, { alias: "done" }],
12
+ },
13
+ ],
14
+ },
15
+ { alias: "projects", fields: [{ alias: "name" }] },
16
+ ],
17
+ roles: [{ alias: "approver" }],
18
+ };
19
+ describe("generatePackageAppFields", () => {
20
+ it("emits runtime-resolved F/OPT/ROLE keyed by contract aliases", () => {
21
+ const src = generatePackageAppFields(contract);
22
+ // Resolves through the SDK binding at module load (top-level await).
23
+ expect(src).toContain('import { getAppBinding } from "@lotics/app-sdk";');
24
+ expect(src).toContain("const binding = await getAppBinding();");
25
+ // F: uppercased entity alias, fully-qualified binding keys.
26
+ expect(src).toContain('title: bound(binding.fields, "tasks.title", "field")');
27
+ expect(src).toContain('name: bound(binding.fields, "projects.name", "field")');
28
+ expect(src).toMatch(/TASKS: \{/);
29
+ expect(src).toMatch(/PROJECTS: \{/);
30
+ // OPT: qualified entity.field:opt keys; option-less fields absent.
31
+ expect(src).toContain('to_do: bound(binding.options, "tasks.status:to_do", "option")');
32
+ expect(src).toContain('done: bound(binding.options, "tasks.status:done", "option")');
33
+ expect(src).not.toContain('"tasks.title:');
34
+ // ROLE map from contract roles.
35
+ expect(src).toContain('approver: bound(binding.roles, "approver", "role")');
36
+ // No concrete id in any VALUE position — the module is workspace-agnostic.
37
+ // (Doc comments legitimately name the id prefixes, so match quoted ids.)
38
+ expect(src).not.toMatch(/"(fld|opt|tbl|grp)_/);
39
+ });
40
+ it("emits valid empty maps for an empty contract", () => {
41
+ const src = generatePackageAppFields({});
42
+ expect(src).toContain("export const F = {} as const;");
43
+ expect(src).toContain("export const OPT = {} as const;");
44
+ expect(src).toContain("export const ROLE = {} as const;");
45
+ // Still imports the SDK so the module shape is uniform.
46
+ expect(src).toContain("const binding = await getAppBinding();");
47
+ });
48
+ it("is deterministic — same contract, same bytes", () => {
49
+ expect(generatePackageAppFields(contract)).toBe(generatePackageAppFields(contract));
50
+ });
51
+ });
@@ -25,8 +25,9 @@ import fs from "node:fs";
25
25
  import path from "node:path";
26
26
  import { tmpdir } from "node:os";
27
27
  import "./client.js";
28
- import { buildStarterTemplate } from "./starter_template.js";
29
- import { appDirName, runNpm, runTar } from "./app_commands.js";
28
+ import { buildStarterTemplate, STARTER_FALLBACK_SDK_VERSION } from "./starter_template.js";
29
+ import { generatePackageAppFields, } from "./generate_package_fields.js";
30
+ import { appDirName, fetchLatestNpmVersion, runNpm, runTar } from "./app_commands.js";
30
31
  import { writeFileAtomic } from "./file_command_io.js";
31
32
  import { startDevServer, openBrowser } from "./dev/server.js";
32
33
  const CONTRACT_FILE = "contract.json";
@@ -160,6 +161,36 @@ export function formatExtractReport(report) {
160
161
  .map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
161
162
  return { lines, hasError: report.some((f) => f.severity === "error") };
162
163
  }
164
+ function dotLoticsDirEnsured(projectDir) {
165
+ const dir = path.join(projectDir, ".lotics");
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ return dir;
168
+ }
169
+ /** Numeric "x.y.z" compare — no semver dep (the CLI has zero runtime deps). */
170
+ function cmpVersions(a, b) {
171
+ const pa = a.split(".").map(Number);
172
+ const pb = b.split(".").map(Number);
173
+ for (let i = 0; i < 3; i++) {
174
+ const d = (pa[i] || 0) - (pb[i] || 0);
175
+ if (d !== 0)
176
+ return d;
177
+ }
178
+ return 0;
179
+ }
180
+ /**
181
+ * The `@lotics/app-sdk` range a package project needs: the live npm latest,
182
+ * clamped to never fall below `STARTER_FALLBACK_SDK_VERSION` — the release
183
+ * that ships `getAppBinding`, which the generated `.lotics/app_fields.ts`
184
+ * imports. A `^0.x` caret range never crosses a minor, so pinning below the
185
+ * floor (offline, or npm not yet carrying the release) would permanently
186
+ * scaffold projects that cannot compile their own generated code.
187
+ */
188
+ function packageSdkRange(sdkLatest) {
189
+ const version = sdkLatest !== null && cmpVersions(sdkLatest, STARTER_FALLBACK_SDK_VERSION) > 0
190
+ ? sdkLatest
191
+ : STARTER_FALLBACK_SDK_VERSION;
192
+ return `^${version}`;
193
+ }
163
194
  /** Read + JSON-parse the project's contract.json (the alias-keyed declaration). */
164
195
  function readContract(projectDir) {
165
196
  const contractPath = path.join(projectDir, CONTRACT_FILE);
@@ -168,6 +199,21 @@ function readContract(projectDir) {
168
199
  }
169
200
  return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
170
201
  }
202
+ /**
203
+ * (Re)generate the package project's `.lotics/app_fields.ts` from its
204
+ * contract.json — the runtime-resolving F/OPT/ROLE surface (see
205
+ * `generate_package_fields.ts`). Runs on new/extract and on every dev/sync so
206
+ * contract edits keep the aliases addressable. `.lotics` is in
207
+ * SOURCE_STAGE_EXCLUDES: the compiled module ships inside `dist/`, and a
208
+ * post-eject `lotics app pull` regenerates the bespoke (baked) variant.
209
+ */
210
+ function writePackageAppFields(projectDir) {
211
+ const contract = readContract(projectDir);
212
+ const dotLotics = path.join(projectDir, ".lotics");
213
+ fs.mkdirSync(dotLotics, { recursive: true });
214
+ fs.writeFileSync(path.join(dotLotics, "app_fields.ts"), generatePackageAppFields(contract));
215
+ console.error("Wrote .lotics/app_fields.ts (contract-derived, runtime-resolved)");
216
+ }
171
217
  /** Top-level project entries that never ship in the source archive. */
172
218
  const SOURCE_STAGE_EXCLUDES = new Set([
173
219
  "node_modules",
@@ -299,10 +345,21 @@ export async function packageNew(args) {
299
345
  // a package is workspace-agnostic, so those placeholders are replaced with the
300
346
  // package manifest below. The rest of the starter (config, src, tests) is the
301
347
  // package's code surface verbatim.
348
+ // Resolve the live @lotics/ui + @lotics/app-sdk versions like `app create`
349
+ // does — the generated .lotics/app_fields.ts imports `getAppBinding`, and a
350
+ // ^0.x caret range never crosses a minor, so a stale starter fallback would
351
+ // permanently pin the project below the API it needs (offline still works
352
+ // off STARTER_FALLBACK_*, kept ≥ that floor).
353
+ const [uiLatest, sdkLatest] = await Promise.all([
354
+ fetchLatestNpmVersion("@lotics/ui"),
355
+ fetchLatestNpmVersion("@lotics/app-sdk"),
356
+ ]);
302
357
  const files = buildStarterTemplate({
303
358
  app_name: args.name,
304
359
  app_id: "",
305
360
  workspace_id: "",
361
+ ui_version: uiLatest ? `^${uiLatest}` : undefined,
362
+ sdk_version: packageSdkRange(sdkLatest),
306
363
  });
307
364
  for (const file of files) {
308
365
  const fullPath = path.join(targetPath, file.path);
@@ -324,6 +381,7 @@ export async function packageNew(args) {
324
381
  fs.writeFileSync(fullPath, file.content);
325
382
  }
326
383
  fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
384
+ writePackageAppFields(targetPath);
327
385
  console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
328
386
  console.error("Installing npm dependencies...");
329
387
  await runNpm(["install"], targetPath);
@@ -426,6 +484,9 @@ async function syncToDevWorkspace(client, projectDir) {
426
484
  throw new Error(`Workspace ${devWorkspaceId} is not accessible with these credentials. Pass --workspace <dev_ws> (a workspace created with --dev).`);
427
485
  }
428
486
  assertDevWorkspace(workspace);
487
+ // Contract may have been edited since the last sync — regenerate the
488
+ // runtime F/OPT/ROLE surface before the build that publishVersion runs.
489
+ writePackageAppFields(projectDir);
429
490
  const { package_id, version } = await publishVersion(client, projectDir, {
430
491
  changelog: "dev sync",
431
492
  });
@@ -741,10 +802,57 @@ export async function packageExtract(client, args) {
741
802
  // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
742
803
  // package manifest — atomic write via the existing manifest writer.
743
804
  const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
744
- writePackageManifest(targetPath, draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null }));
805
+ const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
806
+ // The origin app's package.json rides in the source archive with ITS OWN
807
+ // @lotics/app-sdk range — raise it to the getAppBinding floor the generated
808
+ // .lotics/app_fields.ts needs (never lower an already-newer range).
809
+ const deps = isPlainObject(draft.pkgJson.dependencies) ? draft.pkgJson.dependencies : {};
810
+ const originRange = typeof deps["@lotics/app-sdk"] === "string" ? deps["@lotics/app-sdk"] : null;
811
+ const originFloor = originRange?.replace(/^[\^~]/, "") ?? null;
812
+ if (originFloor === null || cmpVersions(originFloor, STARTER_FALLBACK_SDK_VERSION) < 0) {
813
+ const raised = packageSdkRange(await fetchLatestNpmVersion("@lotics/app-sdk"));
814
+ draft.pkgJson.dependencies = { ...deps, "@lotics/app-sdk": raised };
815
+ console.error(`Raised @lotics/app-sdk to ${raised} (generated app_fields needs getAppBinding)`);
816
+ }
817
+ writePackageManifest(targetPath, draft);
745
818
  // The alias-keyed draft contract — the reviewable master. Pretty + trailing newline.
746
819
  fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(extracted.contract, null, 2) + "\n");
747
820
  console.error(`Wrote ${CONTRACT_FILE}`);
821
+ // The origin's baked `.lotics/app_fields.ts` (concrete ids) never travels —
822
+ // regenerate it contract-derived so `F`/`OPT` resolve per-install at runtime
823
+ // and the extracted source compiles unchanged.
824
+ writePackageAppFields(targetPath);
825
+ // vite.config.ts is starter-owned machinery — refresh it wholesale so the
826
+ // package project carries the current starter config (notably
827
+ // `build.target: "es2022"`, which the generated app_fields' top-level await
828
+ // requires). Origin-side dev customizations (e.g. `lotics ui link` aliases)
829
+ // don't belong in a package.
830
+ const starterViteConfig = buildStarterTemplate({
831
+ app_name: app.name,
832
+ app_id: "",
833
+ workspace_id: "",
834
+ }).find((f) => f.path === "vite.config.ts");
835
+ if (starterViteConfig === undefined) {
836
+ throw new Error("starter template is missing vite.config.ts — cannot refresh the package project");
837
+ }
838
+ const viteConfigPath = path.join(targetPath, "vite.config.ts");
839
+ const originViteConfig = fs.existsSync(viteConfigPath)
840
+ ? fs.readFileSync(viteConfigPath, "utf-8")
841
+ : null;
842
+ fs.writeFileSync(viteConfigPath, starterViteConfig.content);
843
+ if (originViteConfig !== null && originViteConfig !== starterViteConfig.content) {
844
+ // The origin may carry legitimate customizations (optimizeDeps entries,
845
+ // plugins) beyond the dev-links that must not ship — never destroy them
846
+ // silently: stash the original for manual re-application.
847
+ const stash = path.join(dotLoticsDirEnsured(targetPath), "vite.config.origin.ts");
848
+ fs.writeFileSync(stash, originViteConfig);
849
+ console.error("Refreshed vite.config.ts from the starter (build.target es2022). The origin app's config " +
850
+ "differed — its original was saved to .lotics/vite.config.origin.ts; re-apply any " +
851
+ "custom optimizeDeps/plugins entries you still need (never dev-link aliases).");
852
+ }
853
+ else {
854
+ console.error("Refreshed vite.config.ts from the starter (build.target es2022)");
855
+ }
748
856
  // Stage each file-backed template at the `bytes_ref` the contract references.
749
857
  // The download lands in the target dir (fresh, so no name collision) under the
750
858
  // file's own name; rename it to the exact `bytes_ref` basename the contract
package/dist/src/cli.js CHANGED
@@ -30012,6 +30012,10 @@ var LoticsClient = class {
30012
30012
  const qs = group_id ? `?group_id=${encodeURIComponent(group_id)}` : "";
30013
30013
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/members${qs}`);
30014
30014
  }
30015
+ /** A package installation's alias→id maps (fields/options/roles) — the app's runtime F/OPT resolution. */
30016
+ async appBinding(app_id) {
30017
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/binding`);
30018
+ }
30015
30019
  /**
30016
30020
  * Resolve the full option set (key, label, color) of a named query's select
30017
30021
  * columns — the picker companion to `appQuery`. Mirrors
@@ -30554,7 +30558,7 @@ import { tmpdir } from "node:os";
30554
30558
 
30555
30559
  // src/starter_template.ts
30556
30560
  var STARTER_FALLBACK_UI_VERSION = "6.1.0";
30557
- var STARTER_FALLBACK_SDK_VERSION = "0.40.0";
30561
+ var STARTER_FALLBACK_SDK_VERSION = "0.51.0";
30558
30562
  var STARTER_REACT_NATIVE_VERSION = "0.85.3";
30559
30563
  function buildStarterTemplate(args) {
30560
30564
  const uiVersion = args.ui_version ?? `^${STARTER_FALLBACK_UI_VERSION}`;
@@ -30774,6 +30778,11 @@ export default defineConfig({
30774
30778
  build: {
30775
30779
  outDir: "dist",
30776
30780
  sourcemap: true,
30781
+ // Top-level await (package projects' generated .lotics/app_fields.ts
30782
+ // resolves the installation binding at module load) needs es2022 \u2014 Vite's
30783
+ // default 'modules' baseline is es2020 and esbuild hard-fails TLA there.
30784
+ // Dev already transforms at esnext, so this only aligns the prod build.
30785
+ target: "es2022",
30777
30786
  },
30778
30787
  server: {
30779
30788
  // Allow the sandboxed null-origin iframe used by \`lotics app dev\` to
@@ -31277,6 +31286,7 @@ var SUPPORTED_OPS = /* @__PURE__ */ new Set([
31277
31286
  "workflow",
31278
31287
  "members",
31279
31288
  "context",
31289
+ "binding",
31280
31290
  "upload_url",
31281
31291
  "upload_complete",
31282
31292
  "comments.list",
@@ -31334,6 +31344,8 @@ async function dispatchRpc(client, body, opts) {
31334
31344
  const p = body.payload;
31335
31345
  return client.appMembers(body.app_id, p?.group);
31336
31346
  }
31347
+ case "binding":
31348
+ return client.appBinding(body.app_id);
31337
31349
  case "upload_url": {
31338
31350
  const p = body.payload;
31339
31351
  if (!p || typeof p.filename !== "string" || typeof p.mime_type !== "string" || typeof p.file_size !== "number") {
@@ -33351,6 +33363,86 @@ import fs6 from "node:fs";
33351
33363
  import path7 from "node:path";
33352
33364
  import { tmpdir as tmpdir2 } from "node:os";
33353
33365
 
33366
+ // src/generate_package_fields.ts
33367
+ var HEADER5 = `// Auto-generated by 'lotics package new/extract/dev/sync' from contract.json.
33368
+ // DO NOT EDIT \u2014 regenerated whenever the contract changes.
33369
+ //
33370
+ // Package apps resolve F/OPT/ROLE at MODULE LOAD from the installation's
33371
+ // binding (contract alias \u2192 THIS workspace's concrete id) via the SDK's
33372
+ // \`binding\` RPC. Top-level await: the module graph waits for the binding
33373
+ // before any importer evaluates, so every entry is a plain string.
33374
+ import { getAppBinding } from "@lotics/app-sdk";
33375
+
33376
+ const binding = await getAppBinding();
33377
+
33378
+ function bound(map: Record<string, string>, key: string, kind: string): string {
33379
+ const id = map[key];
33380
+ if (id === undefined) {
33381
+ throw new Error(
33382
+ \`app_fields: \${kind} "\${key}" is not in this installation's binding \u2014 \` +
33383
+ \`the generated app_fields.ts is stale relative to the installed contract version.\`,
33384
+ );
33385
+ }
33386
+ return id;
33387
+ }
33388
+ `;
33389
+ function generatePackageAppFields(contract) {
33390
+ const entities = contract.entities ?? [];
33391
+ const roles = contract.roles ?? [];
33392
+ const fieldBlocks = [];
33393
+ for (const entity of entities) {
33394
+ const lines = (entity.fields ?? []).map(
33395
+ (field) => ` ${propKey(field.alias)}: bound(binding.fields, ${JSON.stringify(`${entity.alias}.${field.alias}`)}, "field"),`
33396
+ );
33397
+ if (lines.length === 0) continue;
33398
+ fieldBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {
33399
+ ${lines.join("\n")}
33400
+ },`);
33401
+ }
33402
+ const optionBlocks = [];
33403
+ for (const entity of entities) {
33404
+ const perField = [];
33405
+ for (const field of entity.fields ?? []) {
33406
+ const options = field.options ?? [];
33407
+ if (options.length === 0) continue;
33408
+ const lines = options.map(
33409
+ (option) => ` ${propKey(option.alias)}: bound(binding.options, ${JSON.stringify(`${entity.alias}.${field.alias}:${option.alias}`)}, "option"),`
33410
+ );
33411
+ perField.push(` ${propKey(field.alias)}: {
33412
+ ${lines.join("\n")}
33413
+ },`);
33414
+ }
33415
+ if (perField.length > 0) {
33416
+ optionBlocks.push(` ${propKey(entity.alias.toUpperCase())}: {
33417
+ ${perField.join("\n")}
33418
+ },`);
33419
+ }
33420
+ }
33421
+ const fMap = fieldBlocks.length > 0 ? `export const F = {
33422
+ ${fieldBlocks.join("\n")}
33423
+ } as const;` : `export const F = {} as const;`;
33424
+ const optMap = optionBlocks.length > 0 ? `export const OPT = {
33425
+ ${optionBlocks.join("\n")}
33426
+ } as const;` : `export const OPT = {} as const;`;
33427
+ const roleMap = roles.length > 0 ? `export const ROLE = {
33428
+ ${roles.map((role) => ` ${propKey(role.alias)}: bound(binding.roles, ${JSON.stringify(role.alias)}, "role"),`).join("\n")}
33429
+ } as const;` : `export const ROLE = {} as const;`;
33430
+ return `${HEADER5}
33431
+ ${fMap}
33432
+
33433
+ ${optMap}
33434
+
33435
+ ${roleMap}
33436
+
33437
+ /** Field-id alias map: \`F[<ENTITY>][<field>]\` is this installation's \`fld_\u2026\` id. */
33438
+ export type AppFields = typeof F;
33439
+ /** Select-option alias map: \`OPT[<ENTITY>][<field>][<option>]\` is this installation's \`opt_\u2026\` id. */
33440
+ export type AppOptions = typeof OPT;
33441
+ /** Role alias map: \`ROLE[<role>]\` is this installation's \`grp_\u2026\` id. */
33442
+ export type AppRoles = typeof ROLE;
33443
+ `;
33444
+ }
33445
+
33354
33446
  // src/file_command_io.ts
33355
33447
  import fs5 from "node:fs";
33356
33448
  import path6 from "node:path";
@@ -33474,6 +33566,24 @@ function formatExtractReport(report) {
33474
33566
  );
33475
33567
  return { lines, hasError: report.some((f) => f.severity === "error") };
33476
33568
  }
33569
+ function dotLoticsDirEnsured(projectDir) {
33570
+ const dir = path7.join(projectDir, ".lotics");
33571
+ fs6.mkdirSync(dir, { recursive: true });
33572
+ return dir;
33573
+ }
33574
+ function cmpVersions(a, b) {
33575
+ const pa = a.split(".").map(Number);
33576
+ const pb = b.split(".").map(Number);
33577
+ for (let i2 = 0; i2 < 3; i2++) {
33578
+ const d = (pa[i2] || 0) - (pb[i2] || 0);
33579
+ if (d !== 0) return d;
33580
+ }
33581
+ return 0;
33582
+ }
33583
+ function packageSdkRange(sdkLatest) {
33584
+ const version = sdkLatest !== null && cmpVersions(sdkLatest, STARTER_FALLBACK_SDK_VERSION) > 0 ? sdkLatest : STARTER_FALLBACK_SDK_VERSION;
33585
+ return `^${version}`;
33586
+ }
33477
33587
  function readContract(projectDir) {
33478
33588
  const contractPath = path7.join(projectDir, CONTRACT_FILE);
33479
33589
  if (!fs6.existsSync(contractPath)) {
@@ -33483,6 +33593,13 @@ function readContract(projectDir) {
33483
33593
  }
33484
33594
  return JSON.parse(fs6.readFileSync(contractPath, "utf-8"));
33485
33595
  }
33596
+ function writePackageAppFields(projectDir) {
33597
+ const contract = readContract(projectDir);
33598
+ const dotLotics = path7.join(projectDir, ".lotics");
33599
+ fs6.mkdirSync(dotLotics, { recursive: true });
33600
+ fs6.writeFileSync(path7.join(dotLotics, "app_fields.ts"), generatePackageAppFields(contract));
33601
+ console.error("Wrote .lotics/app_fields.ts (contract-derived, runtime-resolved)");
33602
+ }
33486
33603
  var SOURCE_STAGE_EXCLUDES = /* @__PURE__ */ new Set([
33487
33604
  "node_modules",
33488
33605
  "dist",
@@ -33576,10 +33693,16 @@ async function packageNew(args) {
33576
33693
  throw new Error(`Target directory ${targetPath} is not empty.`);
33577
33694
  }
33578
33695
  fs6.mkdirSync(targetPath, { recursive: true });
33696
+ const [uiLatest, sdkLatest] = await Promise.all([
33697
+ fetchLatestNpmVersion("@lotics/ui"),
33698
+ fetchLatestNpmVersion("@lotics/app-sdk")
33699
+ ]);
33579
33700
  const files = buildStarterTemplate({
33580
33701
  app_name: args.name,
33581
33702
  app_id: "",
33582
- workspace_id: ""
33703
+ workspace_id: "",
33704
+ ui_version: uiLatest ? `^${uiLatest}` : void 0,
33705
+ sdk_version: packageSdkRange(sdkLatest)
33583
33706
  });
33584
33707
  for (const file of files) {
33585
33708
  const fullPath = path7.join(targetPath, file.path);
@@ -33604,6 +33727,7 @@ async function packageNew(args) {
33604
33727
  path7.join(targetPath, CONTRACT_FILE),
33605
33728
  JSON.stringify(starterContract(args.name), null, 2) + "\n"
33606
33729
  );
33730
+ writePackageAppFields(targetPath);
33607
33731
  console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
33608
33732
  console.error("Installing npm dependencies...");
33609
33733
  await runNpm(["install"], targetPath);
@@ -33675,6 +33799,7 @@ async function syncToDevWorkspace(client, projectDir) {
33675
33799
  );
33676
33800
  }
33677
33801
  assertDevWorkspace(workspace);
33802
+ writePackageAppFields(projectDir);
33678
33803
  const { package_id, version } = await publishVersion(client, projectDir, {
33679
33804
  changelog: "dev sync"
33680
33805
  });
@@ -33950,15 +34075,42 @@ async function packageExtract(client, args) {
33950
34075
  const appPkgJson = JSON.parse(
33951
34076
  fs6.readFileSync(packageJsonPath(targetPath), "utf-8")
33952
34077
  );
33953
- writePackageManifest(
33954
- targetPath,
33955
- draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null })
33956
- );
34078
+ const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
34079
+ const deps = isPlainObject(draft.pkgJson.dependencies) ? draft.pkgJson.dependencies : {};
34080
+ const originRange = typeof deps["@lotics/app-sdk"] === "string" ? deps["@lotics/app-sdk"] : null;
34081
+ const originFloor = originRange?.replace(/^[\^~]/, "") ?? null;
34082
+ if (originFloor === null || cmpVersions(originFloor, STARTER_FALLBACK_SDK_VERSION) < 0) {
34083
+ const raised = packageSdkRange(await fetchLatestNpmVersion("@lotics/app-sdk"));
34084
+ draft.pkgJson.dependencies = { ...deps, "@lotics/app-sdk": raised };
34085
+ console.error(`Raised @lotics/app-sdk to ${raised} (generated app_fields needs getAppBinding)`);
34086
+ }
34087
+ writePackageManifest(targetPath, draft);
33957
34088
  fs6.writeFileSync(
33958
34089
  path7.join(targetPath, CONTRACT_FILE),
33959
34090
  JSON.stringify(extracted.contract, null, 2) + "\n"
33960
34091
  );
33961
34092
  console.error(`Wrote ${CONTRACT_FILE}`);
34093
+ writePackageAppFields(targetPath);
34094
+ const starterViteConfig = buildStarterTemplate({
34095
+ app_name: app.name,
34096
+ app_id: "",
34097
+ workspace_id: ""
34098
+ }).find((f) => f.path === "vite.config.ts");
34099
+ if (starterViteConfig === void 0) {
34100
+ throw new Error("starter template is missing vite.config.ts \u2014 cannot refresh the package project");
34101
+ }
34102
+ const viteConfigPath = path7.join(targetPath, "vite.config.ts");
34103
+ const originViteConfig = fs6.existsSync(viteConfigPath) ? fs6.readFileSync(viteConfigPath, "utf-8") : null;
34104
+ fs6.writeFileSync(viteConfigPath, starterViteConfig.content);
34105
+ if (originViteConfig !== null && originViteConfig !== starterViteConfig.content) {
34106
+ const stash = path7.join(dotLoticsDirEnsured(targetPath), "vite.config.origin.ts");
34107
+ fs6.writeFileSync(stash, originViteConfig);
34108
+ console.error(
34109
+ "Refreshed vite.config.ts from the starter (build.target es2022). The origin app's config differed \u2014 its original was saved to .lotics/vite.config.origin.ts; re-apply any custom optimizeDeps/plugins entries you still need (never dev-link aliases)."
34110
+ );
34111
+ } else {
34112
+ console.error("Refreshed vite.config.ts from the starter (build.target es2022)");
34113
+ }
33962
34114
  for (const tf of extracted.template_files) {
33963
34115
  const dest = path7.join(targetPath, tf.bytes_ref);
33964
34116
  fs6.mkdirSync(path7.dirname(dest), { recursive: true });
@@ -42,7 +42,7 @@ export interface StarterFile {
42
42
  * fall back here when the lookup fails.
43
43
  */
44
44
  export declare const STARTER_FALLBACK_UI_VERSION = "6.1.0";
45
- export declare const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
45
+ export declare const STARTER_FALLBACK_SDK_VERSION = "0.51.0";
46
46
  /**
47
47
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
48
48
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -38,10 +38,11 @@
38
38
  * fall back here when the lookup fails.
39
39
  */
40
40
  export const STARTER_FALLBACK_UI_VERSION = "6.1.0";
41
- // Must be ≥ the release that added `@lotics/app-sdk/router` (`AppRouter`, used by
42
- // the scaffolded App.tsx) — an older offline pin won't resolve the subpath and
43
- // the scaffold won't typecheck.
44
- export const STARTER_FALLBACK_SDK_VERSION = "0.40.0";
41
+ // Must be ≥ the release that added `getAppBinding` (0.51.0, used by package
42
+ // projects' generated .lotics/app_fields.ts) — and note ^0.x caret ranges never
43
+ // cross a minor, so a stale floor here permanently pins scaffolds below the
44
+ // APIs the generated code imports.
45
+ export const STARTER_FALLBACK_SDK_VERSION = "0.51.0";
45
46
  /**
46
47
  * react-native pin for scaffolded apps. Matches the monorepo frontend's pin so
47
48
  * an app deep-typechecks `@lotics/ui`'s `.tsx` source against the SAME RN types
@@ -267,6 +268,11 @@ export default defineConfig({
267
268
  build: {
268
269
  outDir: "dist",
269
270
  sourcemap: true,
271
+ // Top-level await (package projects' generated .lotics/app_fields.ts
272
+ // resolves the installation binding at module load) needs es2022 — Vite's
273
+ // default 'modules' baseline is es2020 and esbuild hard-fails TLA there.
274
+ // Dev already transforms at esnext, so this only aligns the prod build.
275
+ target: "es2022",
270
276
  },
271
277
  server: {
272
278
  // Allow the sandboxed null-origin iframe used by \`lotics app dev\` to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {