@lotics/cli 0.71.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,227 @@
1
+ import { LoticsClient, type PackageBinding, type ExtractFinding } from "./client.js";
2
+ /** One dev workspace's pinned installation of this package (manifest bookkeeping). */
3
+ interface PackageDevInstallation {
4
+ app_id: string;
5
+ version: number;
6
+ }
7
+ /**
8
+ * The `package.json#lotics.package` block of a package project. `id` is null
9
+ * until the first publish (which creates the registry package); `version` tracks
10
+ * the latest version this project has published; `dev` maps a dev workspace id to
11
+ * the installation it scaffold-syncs into.
12
+ */
13
+ interface PackageManifest {
14
+ id: string | null;
15
+ name: string;
16
+ description: string | null;
17
+ version: number | null;
18
+ dev: Record<string, PackageDevInstallation>;
19
+ }
20
+ interface PackageProjectFile {
21
+ /** Parsed package.json, carrying the `lotics.package` manifest under `lotics`. */
22
+ pkgJson: Record<string, unknown>;
23
+ manifest: PackageManifest;
24
+ }
25
+ /** Read the package project's manifest, failing loud when the dir isn't one. */
26
+ export declare function readPackageProject(projectDir: string): PackageProjectFile;
27
+ /** Persist an updated manifest back into the project's package.json (atomic write). */
28
+ export declare function writePackageManifest(projectDir: string, project: PackageProjectFile): void;
29
+ /**
30
+ * The package.json to ship INSIDE `source.tar.gz`: the on-disk manifest with the
31
+ * author-local `lotics.package.dev` map stripped. `dev` is the author's private
32
+ * dev-workspace → installation bookkeeping; it must never reach a consumer (every
33
+ * install carries the source, and `lotics app pull` ejects it). Returns a
34
+ * sanitized copy — the on-disk package.json is left untouched. `id`/`name`/
35
+ * `description`/`version` are the package's stable identity and stay.
36
+ */
37
+ export declare function sanitizePackageJsonForSource(pkgJson: Record<string, unknown>): Record<string, unknown>;
38
+ /**
39
+ * Transform an app project's package.json (the source archive `lotics app pull`
40
+ * downloads, carrying the `lotics.app_id`/`workspace_id` app manifest) into a
41
+ * package project's `PackageProjectFile`: the app manifest is stripped ENTIRELY
42
+ * and a fresh, unpublished `lotics.package` manifest (id/version null) is
43
+ * grafted. Pure — returns a new value, never mutates the input;
44
+ * `writePackageManifest` writes it (atomic). The bespoke→package promotion's
45
+ * manifest inversion.
46
+ */
47
+ export declare function draftPackageProjectFromApp(appPkgJson: Record<string, unknown>, args: {
48
+ name: string;
49
+ description: string | null;
50
+ }): PackageProjectFile;
51
+ /**
52
+ * Parse + validate `.lotics/adopt_binding.json` against the app being adopted.
53
+ * REFUSES a pin recorded for a different app: a binding maps ONE workspace's
54
+ * concrete ids, so replaying it onto another app would bind the wrong objects.
55
+ * Pure; the CLI never interprets the binding, only round-trips it to the server.
56
+ */
57
+ export declare function parseAdoptBindingFile(raw: unknown, expectedAppId: string): {
58
+ app_id: string;
59
+ workspace_id: string;
60
+ binding: PackageBinding;
61
+ };
62
+ /**
63
+ * Render an extraction report grouped by severity (errors, then warnings, then
64
+ * info), one ` [<severity>] <area>: <message>` line each, and classify whether
65
+ * any `error` finding is present. An `error` ⇒ the draft is not publishable
66
+ * as-is, so `lotics package extract` exits non-zero. Pure — the command prints
67
+ * `lines` to stderr and gates on `hasError`.
68
+ */
69
+ export declare function formatExtractReport(report: ExtractFinding[]): {
70
+ lines: string[];
71
+ hasError: boolean;
72
+ };
73
+ /**
74
+ * Copy the project's source tree into `sourceStage` with explicit TOP-LEVEL
75
+ * excludes, writing a sanitized `package.json` in place of the on-disk one.
76
+ * Deliberately not tar `--exclude` flags: those match at any depth (a nested
77
+ * `templates/dist/` would be silently dropped) and GNU tar vs bsdtar (macOS)
78
+ * disagree on `./`-prefixed patterns, which broke the sanitized-package.json
79
+ * graft on macOS.
80
+ */
81
+ export declare function stagePackageSource(projectDir: string, sourceStage: string): void;
82
+ /** The minimal, valid starting contract a `package new` scaffold ships. */
83
+ export declare function starterContract(name: string): Record<string, unknown>;
84
+ /**
85
+ * `lotics package new <name> [path]` — scaffold a package project. Reuses the
86
+ * app starter (Vite+React+TS) for the code surface, swaps its app manifest for a
87
+ * package manifest, and adds a starter `contract.json`. The project publishes
88
+ * with `lotics package publish` and runs against a dev workspace with
89
+ * `lotics package dev`.
90
+ */
91
+ export declare function packageNew(args: {
92
+ name: string;
93
+ targetPath?: string;
94
+ }): Promise<void>;
95
+ /**
96
+ * `lotics package build [path]` — build the publishable bundle and write it to
97
+ * `bundle.tar.gz` in the project. Mostly a local sanity check / CI artifact;
98
+ * `publish` and `dev`/`sync` build the bundle in memory directly.
99
+ */
100
+ export declare function packageBuild(args: {
101
+ projectDir?: string;
102
+ }): Promise<void>;
103
+ /**
104
+ * `lotics package publish [path] [-m <changelog>]` — publish a new version.
105
+ */
106
+ export declare function packagePublish(client: LoticsClient, args: {
107
+ projectDir?: string;
108
+ changelog?: string;
109
+ }): Promise<void>;
110
+ /**
111
+ * Fail loud unless `workspace` is a throwaway dev workspace. The dev/sync
112
+ * scaffold path publishes a new version and scaffold-installs package tables into
113
+ * the resolved workspace, so the target MUST be a dev workspace — this mirrors
114
+ * the server-side `package reset` gate so a forgotten `--workspace` (prod
115
+ * selected) can never scaffold package tables into prod. Fails closed: an absent
116
+ * `is_dev` (a server that doesn't yet serialize it) is treated as non-dev.
117
+ */
118
+ export declare function assertDevWorkspace(workspace: {
119
+ id: string;
120
+ name: string;
121
+ is_dev?: boolean;
122
+ }): void;
123
+ /**
124
+ * `lotics package sync [path]` — re-run the scaffold-sync into the dev workspace
125
+ * (the continuous loop: edit contract → sync additively migrates + re-materializes).
126
+ */
127
+ export declare function packageSync(client: LoticsClient, args: {
128
+ projectDir?: string;
129
+ }): Promise<void>;
130
+ /**
131
+ * `lotics package dev [path] [--workspace <dev_ws>] [--view-as <member>]` —
132
+ * scaffold-sync the package into the dev workspace, then run the existing app
133
+ * dev server against the resulting installation (HMR over the local source, RPC
134
+ * forwarded to the live installation). The inner loop: edit contract → re-run to
135
+ * sync; edit code → hot reload.
136
+ */
137
+ export declare function packageDev(client: LoticsClient, args: {
138
+ projectDir?: string;
139
+ port?: number;
140
+ vitePort?: number;
141
+ }): Promise<void>;
142
+ /**
143
+ * `lotics package reset [path]` — DEV-ONLY, hard-gated. Drops the dev
144
+ * installation's package-owned scaffolded tables and re-scaffolds them clean. The
145
+ * backend refuses any workspace not flagged as a dev workspace, so this can never
146
+ * erase a real workspace's data. The dev installation is resolved from the
147
+ * project manifest's pin for the selected workspace.
148
+ */
149
+ export declare function packageReset(client: LoticsClient, args: {
150
+ projectDir?: string;
151
+ }): Promise<void>;
152
+ export type UpgradeResolutionValue = "recreate" | "revert" | "keep" | {
153
+ bind_to: string;
154
+ };
155
+ /**
156
+ * Parse repeated `--resolve <key>=<value>` flags. Drift entries
157
+ * (`<namespace>.<alias>`) take `recreate` or an existing id; modified
158
+ * artifacts (`<kind>.<alias>`) take `revert` or `keep`. Any other value is a
159
+ * bind_to id; the server validates value-kind against what the key resolves.
160
+ */
161
+ export declare function parseResolveFlags(resolve: string[]): Record<string, UpgradeResolutionValue>;
162
+ /**
163
+ * Health check: version pin vs. registry latest + binding drift. Exits
164
+ * non-zero when drift is found so scripts can gate on it.
165
+ */
166
+ export declare function packageDoctor(client: LoticsClient, args: {
167
+ app_id?: string;
168
+ }): Promise<void>;
169
+ /**
170
+ * Preview-then-apply upgrade. Prints the additive plan + informational
171
+ * removals; refuses (exit 1, with the exact --resolve syntax) while any
172
+ * binding drift lacks a resolution.
173
+ */
174
+ export declare function packageUpgrade(client: LoticsClient, args: {
175
+ app_id: string;
176
+ version?: number;
177
+ resolutions: Record<string, UpgradeResolutionValue>;
178
+ }): Promise<void>;
179
+ export declare function packageInstall(client: LoticsClient, args: {
180
+ package_id: string;
181
+ version?: number;
182
+ }): Promise<void>;
183
+ export declare function packageEject(client: LoticsClient, args: {
184
+ app_id: string;
185
+ }): Promise<void>;
186
+ /**
187
+ * `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
188
+ * package project (docs/app_packages.md § Promotion). Calls the extract read,
189
+ * prints the findings report grouped by severity, then ALWAYS emits the draft
190
+ * project (a broken contract is still the reviewable starting point): the app's
191
+ * current source archive (same mechanics as `lotics app pull`) with the app
192
+ * manifest swapped for an unpublished package manifest, `contract.json`, the
193
+ * file-backed templates staged at their `bytes_ref` paths, and
194
+ * `.lotics/adopt_binding.json` (the origin pin the `adopt` step reads back).
195
+ * Exits non-zero when any `error` finding exists — the draft is written, but
196
+ * publish re-validates and nothing should ship unreviewed.
197
+ */
198
+ export declare function packageExtract(client: LoticsClient, args: {
199
+ app_id: string;
200
+ targetPath?: string;
201
+ }): Promise<void>;
202
+ /**
203
+ * `lotics package adopt <app_id> [path]` — bind the published package project
204
+ * onto the origin app (docs/app_packages.md § Promotion). Reads the project
205
+ * manifest (must be published — refuses otherwise), resolves the version
206
+ * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
207
+ * REFUSING a pin recorded for a different app. On success the app becomes
208
+ * installation #1 and is upgradeable again; a server ConflictError (naming the
209
+ * unfaithful aliases) surfaces verbatim.
210
+ */
211
+ export declare function packageAdopt(client: LoticsClient, args: {
212
+ app_id: string;
213
+ version?: number;
214
+ projectDir?: string;
215
+ }): Promise<void>;
216
+ /**
217
+ * `lotics package fleet-upgrade <package_id> [--version N]` — bring every
218
+ * installation of the package across the caller's org to the target version.
219
+ * Hands-off applies only where the preview is clean; skipped/failed
220
+ * installations are reported per line and the process exits 1 so a release
221
+ * script can gate on "fleet fully current".
222
+ */
223
+ export declare function packageFleetUpgrade(client: LoticsClient, args: {
224
+ package_id: string;
225
+ version?: number;
226
+ }): Promise<void>;
227
+ export {};