@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,957 @@
1
+ /**
2
+ * `lotics package *` subcommands — the app-package authoring + dev/test/run loop
3
+ * (see docs/app_packages.md). An app *package* is a versioned, workspace-agnostic
4
+ * blueprint Lotics maintains once and installs into many workspaces. This module
5
+ * implements the full harness:
6
+ *
7
+ * new scaffold a package project (contract + app source).
8
+ * build build the publishable bundle (source + dist).
9
+ * publish create a new immutable package version from the contract + bundle.
10
+ * dev scaffold-sync the package into a dev workspace (install/upgrade) and
11
+ * run the existing app dev server against the resulting installation.
12
+ * sync re-run the scaffold-sync (additive migrate + materialize) without dev.
13
+ * reset DEV-ONLY, hard-gated: drop the scaffolded tables + re-scaffold clean.
14
+ * install materialize a published version into the current workspace.
15
+ * eject sever an installation's package link (last rung of customization).
16
+ *
17
+ * The dev loop reuses the *published* path end-to-end: `dev`/`sync` build →
18
+ * publish a new version → install (first time) or upgrade (subsequent) into the
19
+ * dev workspace, so the dev installation goes through the exact scaffold +
20
+ * materialize the real install/upgrade run (no parallel dev-only materializer).
21
+ * The local installation pin per dev workspace is recorded in the project
22
+ * manifest so a re-sync upgrades in place.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { tmpdir } from "node:os";
27
+ import "./client.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";
31
+ import { writeFileAtomic } from "./file_command_io.js";
32
+ import { startDevServer, openBrowser } from "./dev/server.js";
33
+ const CONTRACT_FILE = "contract.json";
34
+ /**
35
+ * The origin pin `lotics package extract` writes and `lotics package adopt`
36
+ * reads back: `{ app_id, workspace_id, binding }`. Lives under `.lotics/`, which
37
+ * is in `SOURCE_STAGE_EXCLUDES` — so it never rides along in a published bundle.
38
+ */
39
+ const ADOPT_BINDING_FILE = "adopt_binding.json";
40
+ function packageJsonPath(projectDir) {
41
+ return path.join(projectDir, "package.json");
42
+ }
43
+ function isPlainObject(value) {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+ /** Read the package project's manifest, failing loud when the dir isn't one. */
47
+ export function readPackageProject(projectDir) {
48
+ const pkgPath = packageJsonPath(projectDir);
49
+ if (!fs.existsSync(pkgPath)) {
50
+ throw new Error(`No package.json in ${projectDir}. Run this inside a package project (lotics package new <name>).`);
51
+ }
52
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
53
+ if (!isPlainObject(parsed))
54
+ throw new Error(`Malformed package.json in ${projectDir}.`);
55
+ const lotics = parsed.lotics;
56
+ const pkg = isPlainObject(lotics) ? lotics.package : undefined;
57
+ if (!isPlainObject(pkg) || typeof pkg.name !== "string") {
58
+ throw new Error(`${pkgPath} has no lotics.package manifest — not a package project. Use "lotics package new <name>".`);
59
+ }
60
+ const dev = {};
61
+ if (isPlainObject(pkg.dev)) {
62
+ for (const [ws, entry] of Object.entries(pkg.dev)) {
63
+ if (isPlainObject(entry) && typeof entry.app_id === "string" && typeof entry.version === "number") {
64
+ dev[ws] = { app_id: entry.app_id, version: entry.version };
65
+ }
66
+ }
67
+ }
68
+ const manifest = {
69
+ id: typeof pkg.id === "string" ? pkg.id : null,
70
+ name: pkg.name,
71
+ description: typeof pkg.description === "string" ? pkg.description : null,
72
+ version: typeof pkg.version === "number" ? pkg.version : null,
73
+ dev,
74
+ };
75
+ return { pkgJson: parsed, manifest };
76
+ }
77
+ /** Persist an updated manifest back into the project's package.json (atomic write). */
78
+ export function writePackageManifest(projectDir, project) {
79
+ const next = {
80
+ ...project.pkgJson,
81
+ lotics: {
82
+ ...(isPlainObject(project.pkgJson.lotics) ? project.pkgJson.lotics : {}),
83
+ package: project.manifest,
84
+ },
85
+ };
86
+ // Atomic write: a torn plain write could corrupt package.json and lose the
87
+ // stamped registry `package_id` — which would orphan the published package
88
+ // and create a duplicate on the next publish retry.
89
+ writeFileAtomic(packageJsonPath(projectDir), new TextEncoder().encode(JSON.stringify(next, null, 2) + "\n"));
90
+ }
91
+ /**
92
+ * The package.json to ship INSIDE `source.tar.gz`: the on-disk manifest with the
93
+ * author-local `lotics.package.dev` map stripped. `dev` is the author's private
94
+ * dev-workspace → installation bookkeeping; it must never reach a consumer (every
95
+ * install carries the source, and `lotics app pull` ejects it). Returns a
96
+ * sanitized copy — the on-disk package.json is left untouched. `id`/`name`/
97
+ * `description`/`version` are the package's stable identity and stay.
98
+ */
99
+ export function sanitizePackageJsonForSource(pkgJson) {
100
+ const lotics = isPlainObject(pkgJson.lotics) ? pkgJson.lotics : {};
101
+ const pkg = isPlainObject(lotics.package) ? lotics.package : {};
102
+ const { dev: _dev, ...pkgWithoutDev } = pkg;
103
+ return {
104
+ ...pkgJson,
105
+ lotics: { ...lotics, package: pkgWithoutDev },
106
+ };
107
+ }
108
+ /**
109
+ * Transform an app project's package.json (the source archive `lotics app pull`
110
+ * downloads, carrying the `lotics.app_id`/`workspace_id` app manifest) into a
111
+ * package project's `PackageProjectFile`: the app manifest is stripped ENTIRELY
112
+ * and a fresh, unpublished `lotics.package` manifest (id/version null) is
113
+ * grafted. Pure — returns a new value, never mutates the input;
114
+ * `writePackageManifest` writes it (atomic). The bespoke→package promotion's
115
+ * manifest inversion.
116
+ */
117
+ export function draftPackageProjectFromApp(appPkgJson, args) {
118
+ const { lotics: _appManifest, ...rest } = appPkgJson;
119
+ return {
120
+ pkgJson: rest,
121
+ manifest: { id: null, name: args.name, description: args.description, version: null, dev: {} },
122
+ };
123
+ }
124
+ /**
125
+ * Parse + validate `.lotics/adopt_binding.json` against the app being adopted.
126
+ * REFUSES a pin recorded for a different app: a binding maps ONE workspace's
127
+ * concrete ids, so replaying it onto another app would bind the wrong objects.
128
+ * Pure; the CLI never interprets the binding, only round-trips it to the server.
129
+ */
130
+ export function parseAdoptBindingFile(raw, expectedAppId) {
131
+ if (!isPlainObject(raw) ||
132
+ typeof raw.app_id !== "string" ||
133
+ typeof raw.workspace_id !== "string" ||
134
+ !isPlainObject(raw.binding)) {
135
+ throw new Error("Malformed .lotics/adopt_binding.json — expected { app_id, workspace_id, binding }.");
136
+ }
137
+ if (raw.app_id !== expectedAppId) {
138
+ throw new Error(`.lotics/adopt_binding.json records app ${raw.app_id}, but you are adopting ${expectedAppId}. ` +
139
+ `A binding maps one app's concrete ids and must never be applied to another — ` +
140
+ `run "lotics package extract ${expectedAppId}" to produce the right pin.`);
141
+ }
142
+ // Boundary adapter: the file is untyped JSON; `isPlainObject` proved the shape
143
+ // and the CLI passes the map straight to the server (the validating authority).
144
+ return {
145
+ app_id: raw.app_id,
146
+ workspace_id: raw.workspace_id,
147
+ binding: raw.binding,
148
+ };
149
+ }
150
+ /**
151
+ * Render an extraction report grouped by severity (errors, then warnings, then
152
+ * info), one ` [<severity>] <area>: <message>` line each, and classify whether
153
+ * any `error` finding is present. An `error` ⇒ the draft is not publishable
154
+ * as-is, so `lotics package extract` exits non-zero. Pure — the command prints
155
+ * `lines` to stderr and gates on `hasError`.
156
+ */
157
+ export function formatExtractReport(report) {
158
+ const order = ["error", "warning", "info"];
159
+ const lines = order.flatMap((severity) => report
160
+ .filter((f) => f.severity === severity)
161
+ .map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
162
+ return { lines, hasError: report.some((f) => f.severity === "error") };
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
+ }
194
+ /** Read + JSON-parse the project's contract.json (the alias-keyed declaration). */
195
+ function readContract(projectDir) {
196
+ const contractPath = path.join(projectDir, CONTRACT_FILE);
197
+ if (!fs.existsSync(contractPath)) {
198
+ throw new Error(`No ${CONTRACT_FILE} in ${projectDir}. A package project declares its data model there.`);
199
+ }
200
+ return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
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
+ }
217
+ /** Top-level project entries that never ship in the source archive. */
218
+ const SOURCE_STAGE_EXCLUDES = new Set([
219
+ "node_modules",
220
+ "dist",
221
+ ".lotics",
222
+ ".git",
223
+ "bundle.tar.gz",
224
+ "package.json",
225
+ ]);
226
+ /**
227
+ * Copy the project's source tree into `sourceStage` with explicit TOP-LEVEL
228
+ * excludes, writing a sanitized `package.json` in place of the on-disk one.
229
+ * Deliberately not tar `--exclude` flags: those match at any depth (a nested
230
+ * `templates/dist/` would be silently dropped) and GNU tar vs bsdtar (macOS)
231
+ * disagree on `./`-prefixed patterns, which broke the sanitized-package.json
232
+ * graft on macOS.
233
+ */
234
+ export function stagePackageSource(projectDir, sourceStage) {
235
+ const project = readPackageProject(projectDir);
236
+ fs.mkdirSync(sourceStage, { recursive: true });
237
+ for (const entry of fs.readdirSync(projectDir)) {
238
+ if (SOURCE_STAGE_EXCLUDES.has(entry) || entry.endsWith(".tsbuildinfo"))
239
+ continue;
240
+ fs.cpSync(path.join(projectDir, entry), path.join(sourceStage, entry), { recursive: true });
241
+ }
242
+ fs.writeFileSync(path.join(sourceStage, "package.json"), JSON.stringify(sanitizePackageJsonForSource(project.pkgJson), null, 2) + "\n");
243
+ }
244
+ /**
245
+ * Build the publishable bundle: `npm run build` (vite → dist/), then a gzipped
246
+ * tarball carrying `source.tar.gz` (the pullable project tree) + `dist.tar.gz`
247
+ * (the prebuilt assets) as its two top-level members — the publish↔install
248
+ * contract the backend's `extractPackageBundle` reads. Returns the bytes.
249
+ */
250
+ async function buildPackageBundle(projectDir) {
251
+ console.error("Building...");
252
+ await runNpm(["run", "build"], projectDir);
253
+ const distDir = path.join(projectDir, "dist");
254
+ if (!fs.existsSync(distDir)) {
255
+ throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
256
+ `Check that 'npm run build' is configured correctly.`);
257
+ }
258
+ // Stage the two member archives in a temp dir, then tar them into the bundle.
259
+ const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-"));
260
+ const bundlePath = path.join(tmpdir(), `lotics-bundle-${Date.now()}.tar.gz`);
261
+ try {
262
+ console.error("Packaging source...");
263
+ // Stage a copy of the source tree with EXPLICIT top-level excludes, then
264
+ // tar the stage with no --exclude flags at all. tar exclude patterns are
265
+ // the wrong tool twice over: they match at ANY depth (a nested
266
+ // `templates/dist/` would be silently dropped from the bundle), and GNU
267
+ // tar vs bsdtar (macOS) disagree on whether `./package.json` and
268
+ // `package.json` are the same pattern — bsdtar strips the leading `./`,
269
+ // which would also exclude the sanitized package.json grafted below.
270
+ //
271
+ // The on-disk package.json carries the author-local `lotics.package.dev`
272
+ // map; the stage gets a sanitized copy in its place, so that bookkeeping
273
+ // never ships to a consumer nor lands in an `lotics app pull` eject.
274
+ const sourceStage = path.join(stage, "source");
275
+ stagePackageSource(projectDir, sourceStage);
276
+ await runTar(["-czf", path.join(stage, "source.tar.gz"), "-C", sourceStage, "."], projectDir);
277
+ console.error("Packaging dist...");
278
+ await runTar(["-czf", path.join(stage, "dist.tar.gz"), "-C", distDir, "."], projectDir);
279
+ console.error("Bundling...");
280
+ await runTar(["-czf", bundlePath, "source.tar.gz", "dist.tar.gz"], stage);
281
+ return fs.readFileSync(bundlePath);
282
+ }
283
+ finally {
284
+ fs.rmSync(stage, { recursive: true, force: true });
285
+ if (fs.existsSync(bundlePath))
286
+ fs.unlinkSync(bundlePath);
287
+ }
288
+ }
289
+ /** The minimal, valid starting contract a `package new` scaffold ships. */
290
+ export function starterContract(name) {
291
+ return {
292
+ entities: [
293
+ {
294
+ alias: "item",
295
+ label: name,
296
+ description: `Records managed by the ${name} package.`,
297
+ fields: [
298
+ { alias: "name", label: "Name", type: "text", required: true },
299
+ { alias: "notes", label: "Notes", type: "text" },
300
+ {
301
+ alias: "status",
302
+ label: "Status",
303
+ type: "select",
304
+ options: [
305
+ { alias: "open", label: "Open", color: "blue" },
306
+ { alias: "done", label: "Done", color: "green" },
307
+ ],
308
+ },
309
+ ],
310
+ },
311
+ ],
312
+ roles: [],
313
+ templates: [],
314
+ queries: [
315
+ {
316
+ alias: "items",
317
+ ast: {
318
+ kind: "from_table",
319
+ from_entity: "item",
320
+ sort: [{ field_key: "name", order: "asc" }],
321
+ },
322
+ },
323
+ ],
324
+ workflows: [],
325
+ agents: [],
326
+ config: [
327
+ { alias: "heading", label: "List heading", type: "text", default: name },
328
+ ],
329
+ };
330
+ }
331
+ /**
332
+ * `lotics package new <name> [path]` — scaffold a package project. Reuses the
333
+ * app starter (Vite+React+TS) for the code surface, swaps its app manifest for a
334
+ * package manifest, and adds a starter `contract.json`. The project publishes
335
+ * with `lotics package publish` and runs against a dev workspace with
336
+ * `lotics package dev`.
337
+ */
338
+ export async function packageNew(args) {
339
+ const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
340
+ if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
341
+ throw new Error(`Target directory ${targetPath} is not empty.`);
342
+ }
343
+ fs.mkdirSync(targetPath, { recursive: true });
344
+ // The starter bakes an app manifest (app_id/workspace_id) into package.json;
345
+ // a package is workspace-agnostic, so those placeholders are replaced with the
346
+ // package manifest below. The rest of the starter (config, src, tests) is the
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
+ ]);
357
+ const files = buildStarterTemplate({
358
+ app_name: args.name,
359
+ app_id: "",
360
+ workspace_id: "",
361
+ ui_version: uiLatest ? `^${uiLatest}` : undefined,
362
+ sdk_version: packageSdkRange(sdkLatest),
363
+ });
364
+ for (const file of files) {
365
+ const fullPath = path.join(targetPath, file.path);
366
+ if (file.path === "package.json") {
367
+ const pkg = JSON.parse(file.content);
368
+ const manifest = {
369
+ id: null,
370
+ name: args.name,
371
+ description: null,
372
+ version: null,
373
+ dev: {},
374
+ };
375
+ pkg.lotics = { package: manifest };
376
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
377
+ fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + "\n");
378
+ continue;
379
+ }
380
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
381
+ fs.writeFileSync(fullPath, file.content);
382
+ }
383
+ fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
384
+ writePackageAppFields(targetPath);
385
+ console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
386
+ console.error("Installing npm dependencies...");
387
+ await runNpm(["install"], targetPath);
388
+ console.error(`\nReady. Next steps:`);
389
+ console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
390
+ console.error(` # edit contract.json + src/App.tsx, then run it against a dev workspace:`);
391
+ console.error(` lotics workspace create "<name> dev" --dev`);
392
+ console.error(` lotics package dev --workspace <dev_ws>`);
393
+ }
394
+ /**
395
+ * `lotics package build [path]` — build the publishable bundle and write it to
396
+ * `bundle.tar.gz` in the project. Mostly a local sanity check / CI artifact;
397
+ * `publish` and `dev`/`sync` build the bundle in memory directly.
398
+ */
399
+ export async function packageBuild(args) {
400
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
401
+ readPackageProject(projectDir); // assert it's a package project
402
+ const bundle = await buildPackageBundle(projectDir);
403
+ const out = path.join(projectDir, "bundle.tar.gz");
404
+ fs.writeFileSync(out, bundle);
405
+ console.error(`Built ${out} (${(bundle.byteLength / 1024).toFixed(1)} KB)`);
406
+ }
407
+ /**
408
+ * Build + publish a new immutable package version from the project's contract +
409
+ * bundle. Creates the registry package on first publish (stamping its id into
410
+ * the manifest), then publishes the next monotonic version. Returns the new
411
+ * version number and the resolved package id.
412
+ */
413
+ async function publishVersion(client, projectDir, opts) {
414
+ const project = readPackageProject(projectDir);
415
+ const contract = readContract(projectDir);
416
+ let packageId = project.manifest.id;
417
+ if (packageId === null) {
418
+ const pkg = await client.createAppPackage({
419
+ name: project.manifest.name,
420
+ description: project.manifest.description,
421
+ });
422
+ packageId = pkg.id;
423
+ project.manifest.id = packageId;
424
+ writePackageManifest(projectDir, project);
425
+ console.error(`Created package ${pkg.name} (${pkg.id}).`);
426
+ }
427
+ const bundle = await buildPackageBundle(projectDir);
428
+ console.error("Publishing version...");
429
+ const version = await client.publishAppPackageVersion(packageId, {
430
+ contract,
431
+ bundle,
432
+ changelog: opts.changelog ?? null,
433
+ });
434
+ project.manifest.version = version.version;
435
+ writePackageManifest(projectDir, project);
436
+ return { package_id: packageId, version: version.version };
437
+ }
438
+ /**
439
+ * `lotics package publish [path] [-m <changelog>]` — publish a new version.
440
+ */
441
+ export async function packagePublish(client, args) {
442
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
443
+ const { package_id, version } = await publishVersion(client, projectDir, {
444
+ changelog: args.changelog,
445
+ });
446
+ console.error(`Published ${package_id} v${version}.`);
447
+ console.error(` Install it: lotics package install ${package_id} --version ${version}`);
448
+ }
449
+ /**
450
+ * Fail loud unless `workspace` is a throwaway dev workspace. The dev/sync
451
+ * scaffold path publishes a new version and scaffold-installs package tables into
452
+ * the resolved workspace, so the target MUST be a dev workspace — this mirrors
453
+ * the server-side `package reset` gate so a forgotten `--workspace` (prod
454
+ * selected) can never scaffold package tables into prod. Fails closed: an absent
455
+ * `is_dev` (a server that doesn't yet serialize it) is treated as non-dev.
456
+ */
457
+ export function assertDevWorkspace(workspace) {
458
+ if (workspace.is_dev === true)
459
+ return;
460
+ throw new Error(`Workspace "${workspace.name}" (${workspace.id}) is not a dev workspace. ` +
461
+ `"lotics package dev"/"sync" scaffold package tables into the target workspace, so it must be a throwaway dev workspace. ` +
462
+ `Create one with "lotics workspace create <name> --dev" and pass --workspace <dev_ws>.`);
463
+ }
464
+ /**
465
+ * Scaffold-sync the local package into the dev workspace: publish the current
466
+ * contract + bundle as a new version, then install it (first time) or upgrade
467
+ * the recorded installation (subsequent runs) so the dev workspace goes through
468
+ * the exact scaffold + materialize the real install/upgrade run. Records the
469
+ * installation pin per dev workspace in the manifest. Returns the dev
470
+ * installation's app id + name.
471
+ */
472
+ async function syncToDevWorkspace(client, projectDir) {
473
+ const devWorkspaceId = client.getWorkspaceId();
474
+ if (!devWorkspaceId) {
475
+ throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
476
+ }
477
+ // Guard BEFORE publishing: dev/sync scaffold-installs package tables into the
478
+ // resolved workspace, so it must be a throwaway dev workspace — a developer who
479
+ // forgot --workspace with prod selected would otherwise scaffold into prod.
480
+ // Mirrors the server-side `package reset` gate (which refuses any non-`is_dev`
481
+ // workspace).
482
+ const workspace = await client.getWorkspaceInfo(devWorkspaceId);
483
+ if (!workspace) {
484
+ throw new Error(`Workspace ${devWorkspaceId} is not accessible with these credentials. Pass --workspace <dev_ws> (a workspace created with --dev).`);
485
+ }
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);
490
+ const { package_id, version } = await publishVersion(client, projectDir, {
491
+ changelog: "dev sync",
492
+ });
493
+ // Re-read after publish (publishVersion may have stamped the package id).
494
+ const project = readPackageProject(projectDir);
495
+ const existing = project.manifest.dev[devWorkspaceId];
496
+ let appId;
497
+ if (existing) {
498
+ console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
499
+ const app = await client.upgradeAppPackage(existing.app_id, { version });
500
+ appId = app.id;
501
+ }
502
+ else {
503
+ console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
504
+ const app = await client.installAppPackage(package_id, { version });
505
+ appId = app.id;
506
+ }
507
+ project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
508
+ writePackageManifest(projectDir, project);
509
+ const installed = await client.getApp(appId);
510
+ return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
511
+ }
512
+ /**
513
+ * `lotics package sync [path]` — re-run the scaffold-sync into the dev workspace
514
+ * (the continuous loop: edit contract → sync additively migrates + re-materializes).
515
+ */
516
+ export async function packageSync(client, args) {
517
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
518
+ const result = await syncToDevWorkspace(client, projectDir);
519
+ console.error(`Synced ${result.app_name} v${result.version} → ${result.app_id} (workspace ${result.workspace_id}).`);
520
+ }
521
+ /**
522
+ * `lotics package dev [path] [--workspace <dev_ws>] [--view-as <member>]` —
523
+ * scaffold-sync the package into the dev workspace, then run the existing app
524
+ * dev server against the resulting installation (HMR over the local source, RPC
525
+ * forwarded to the live installation). The inner loop: edit contract → re-run to
526
+ * sync; edit code → hot reload.
527
+ */
528
+ export async function packageDev(client, args) {
529
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
530
+ const synced = await syncToDevWorkspace(client, projectDir);
531
+ const handle = await startDevServer({
532
+ projectDir,
533
+ app_id: synced.app_id,
534
+ app_name: synced.app_name,
535
+ workspace_id: synced.workspace_id,
536
+ api_url: client.baseUrl,
537
+ port: args.port,
538
+ vitePort: args.vitePort,
539
+ client,
540
+ });
541
+ await handle.ready;
542
+ const url = `http://localhost:${handle.port}`;
543
+ console.error(`\n lotics package dev`);
544
+ console.error(` package: ${synced.app_name} (dev installation ${synced.app_id} v${synced.version})`);
545
+ console.error(` workspace: ${synced.workspace_id} (dev)`);
546
+ if (client.viewAsMemberId) {
547
+ console.error(` view as: ${client.viewAsMemberId}`);
548
+ }
549
+ console.error(` vite: http://localhost:${handle.vitePort}/`);
550
+ console.error(` open: ${url}`);
551
+ console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
552
+ console.error(` Edit contract.json then re-run "lotics package dev" / "lotics package sync" to migrate.`);
553
+ console.error(` Ctrl-C to stop.\n`);
554
+ openBrowser(url);
555
+ await new Promise((resolve) => {
556
+ const onSig = () => {
557
+ process.off("SIGINT", onSig);
558
+ process.off("SIGTERM", onSig);
559
+ resolve();
560
+ };
561
+ process.on("SIGINT", onSig);
562
+ process.on("SIGTERM", onSig);
563
+ });
564
+ console.error("\nStopping…");
565
+ await handle.stop();
566
+ }
567
+ /**
568
+ * `lotics package reset [path]` — DEV-ONLY, hard-gated. Drops the dev
569
+ * installation's package-owned scaffolded tables and re-scaffolds them clean. The
570
+ * backend refuses any workspace not flagged as a dev workspace, so this can never
571
+ * erase a real workspace's data. The dev installation is resolved from the
572
+ * project manifest's pin for the selected workspace.
573
+ */
574
+ export async function packageReset(client, args) {
575
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
576
+ const devWorkspaceId = client.getWorkspaceId();
577
+ if (!devWorkspaceId) {
578
+ throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
579
+ }
580
+ const { manifest } = readPackageProject(projectDir);
581
+ const pin = manifest.dev[devWorkspaceId];
582
+ if (!pin) {
583
+ throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
584
+ }
585
+ console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
586
+ const app = await client.resetAppPackage(pin.app_id);
587
+ console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
588
+ }
589
+ /**
590
+ * Resolve the installation to operate on: an explicit app id wins; otherwise
591
+ * fall back to the project manifest's dev pin for the selected workspace (the
592
+ * same resolution `package reset` uses).
593
+ */
594
+ function resolveInstallationAppId(client, explicit) {
595
+ if (explicit)
596
+ return explicit;
597
+ const workspaceId = client.getWorkspaceId();
598
+ if (!workspaceId) {
599
+ throw new Error("Pass an app id (lotics package doctor <app_id>) or select a workspace.");
600
+ }
601
+ let manifest;
602
+ try {
603
+ ({ manifest } = readPackageProject(path.resolve(process.cwd())));
604
+ }
605
+ catch {
606
+ // CLI usage boundary: translate "this directory isn't a package project"
607
+ // into the action the caller actually needs.
608
+ throw new Error("No app id given and the current directory is not a package project. " +
609
+ "Pass one explicitly: lotics package doctor <app_id>.");
610
+ }
611
+ const pin = manifest.dev[workspaceId];
612
+ if (!pin) {
613
+ throw new Error(`No app id given and no dev installation recorded for workspace ${workspaceId}. ` +
614
+ "Pass one explicitly: lotics package doctor <app_id>.");
615
+ }
616
+ return pin.app_id;
617
+ }
618
+ /**
619
+ * Parse repeated `--resolve <key>=<value>` flags. Drift entries
620
+ * (`<namespace>.<alias>`) take `recreate` or an existing id; modified
621
+ * artifacts (`<kind>.<alias>`) take `revert` or `keep`. Any other value is a
622
+ * bind_to id; the server validates value-kind against what the key resolves.
623
+ */
624
+ export function parseResolveFlags(resolve) {
625
+ const resolutions = {};
626
+ for (const entry of resolve) {
627
+ const eq = entry.indexOf("=");
628
+ if (eq <= 0 || eq === entry.length - 1) {
629
+ throw new Error(`Invalid --resolve "${entry}" — expected <key>=recreate|revert|keep or <key>=<existing_id>.`);
630
+ }
631
+ const key = entry.slice(0, eq);
632
+ const value = entry.slice(eq + 1);
633
+ resolutions[key] =
634
+ value === "recreate" || value === "revert" || value === "keep" ? value : { bind_to: value };
635
+ }
636
+ return resolutions;
637
+ }
638
+ /**
639
+ * Health check: version pin vs. registry latest + binding drift. Exits
640
+ * non-zero when drift is found so scripts can gate on it.
641
+ */
642
+ export async function packageDoctor(client, args) {
643
+ const app_id = resolveInstallationAppId(client, args.app_id);
644
+ const health = await client.getAppPackageHealth(app_id);
645
+ console.error(`${health.package_name} — installation ${health.app_id}`);
646
+ console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
647
+ (health.update_available ? " → update available" : ""));
648
+ if (health.drift.length === 0) {
649
+ console.error(" Binding: healthy — every bound alias resolves.");
650
+ }
651
+ else {
652
+ console.error(` Binding drift (${health.drift.length}):`);
653
+ for (const d of health.drift) {
654
+ console.error(` - ${d.namespace}.${d.alias} → ${d.id} (missing from the workspace)`);
655
+ }
656
+ console.error(` Resolve while upgrading:\n lotics package upgrade ${app_id}` +
657
+ ` --resolve <namespace.alias>=recreate (or =<existing_id> to re-point)`);
658
+ process.exitCode = 1;
659
+ }
660
+ if (health.modified.length === 0) {
661
+ console.error(" Package artifacts: pristine — no local edits an upgrade would revert.");
662
+ }
663
+ else {
664
+ console.error(` Locally modified package artifacts (${health.modified.length}):`);
665
+ for (const m of health.modified) {
666
+ console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
667
+ }
668
+ console.error(` Consent while upgrading:\n lotics package upgrade ${app_id}` +
669
+ ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
670
+ process.exitCode = 1;
671
+ }
672
+ if (health.update_available) {
673
+ console.error(` Upgrade: lotics package upgrade ${app_id}`);
674
+ }
675
+ }
676
+ /**
677
+ * Preview-then-apply upgrade. Prints the additive plan + informational
678
+ * removals; refuses (exit 1, with the exact --resolve syntax) while any
679
+ * binding drift lacks a resolution.
680
+ */
681
+ export async function packageUpgrade(client, args) {
682
+ const preview = await client.previewAppPackageUpgrade(args.app_id, {
683
+ ...(args.version !== undefined ? { version: args.version } : {}),
684
+ });
685
+ console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
686
+ (preview.changelog ? ` — ${preview.changelog}` : ""));
687
+ const summarize = (sets) => Object.entries(sets)
688
+ .filter(([, entries]) => entries.length > 0)
689
+ .map(([kind, entries]) => `${entries.length} ${kind}`)
690
+ .join(", ");
691
+ const added = summarize(preview.diff.added);
692
+ const removed = summarize(preview.diff.removed);
693
+ if (added)
694
+ console.error(` Adds: ${added}`);
695
+ if (removed)
696
+ console.error(` Unbinds (workspace data kept): ${removed}`);
697
+ // Breaking contract changes are NOT resolvable via --resolve — scaffold would
698
+ // refuse them on a bound alias, and there is no in-flow remediation. Report
699
+ // every entry and the only two real remedies, then hard-stop.
700
+ if (preview.diff.breaking.length > 0) {
701
+ console.error(" Breaking contract changes — NOT resolvable via --resolve (a bound alias's shape changed):");
702
+ for (const b of preview.diff.breaking) {
703
+ console.error(` - ${b.entity}.${b.alias} (${b.kind}): ${b.from} → ${b.to}`);
704
+ }
705
+ console.error(" There is no in-flow resolution for this class. The package author must publish a version\n" +
706
+ " that keeps these aliases' shape stable, or eject this app (severing the package link)\n" +
707
+ ` before making the change: lotics package eject ${args.app_id}`);
708
+ process.exit(1);
709
+ }
710
+ const unresolvedDrift = preview.drift.filter((d) => args.resolutions[`${d.namespace}.${d.alias}`] === undefined);
711
+ const unresolvedModified = preview.modified.filter((m) => args.resolutions[`${m.kind}.${m.alias}`] === undefined);
712
+ if (unresolvedDrift.length > 0 || unresolvedModified.length > 0) {
713
+ if (unresolvedDrift.length > 0) {
714
+ console.error(" Binding drift must be resolved before upgrading:");
715
+ for (const d of unresolvedDrift) {
716
+ console.error(` --resolve ${d.namespace}.${d.alias}=recreate (or =<existing_id> to re-point; was ${d.id})`);
717
+ }
718
+ }
719
+ if (unresolvedModified.length > 0) {
720
+ console.error(" Locally modified package artifacts need consent before upgrading:");
721
+ for (const m of unresolvedModified) {
722
+ console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
723
+ }
724
+ }
725
+ process.exit(1);
726
+ }
727
+ const app = await client.upgradeAppPackage(args.app_id, {
728
+ ...(args.version !== undefined ? { version: args.version } : {}),
729
+ ...(Object.keys(args.resolutions).length > 0 ? { resolutions: args.resolutions } : {}),
730
+ });
731
+ console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
732
+ }
733
+ export async function packageInstall(client, args) {
734
+ // Surface the trust badge at the consent point: installing materializes the
735
+ // package's workflows/agents under YOUR authority, so say whose package it is.
736
+ const pkg = await client.getAppPackage(args.package_id);
737
+ console.error(pkg.is_official
738
+ ? `Installing ${pkg.name} — official Lotics package...`
739
+ : `Installing ${pkg.name} — third-party package (runs under your authority once installed)...`);
740
+ const app = await client.installAppPackage(args.package_id, {
741
+ ...(args.version !== undefined ? { version: args.version } : {}),
742
+ });
743
+ const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
744
+ console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
745
+ console.error(" The data model, queries, workflows, and agents are live.");
746
+ console.error(" Pull it for local editing: lotics app pull " + app.id);
747
+ }
748
+ export async function packageEject(client, args) {
749
+ const app = await client.ejectAppPackage(args.app_id);
750
+ console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
751
+ console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
752
+ console.error(" Its data model, queries, workflows, and templates are unchanged.");
753
+ console.error(" Pull the pinned source for local editing: lotics app pull " + app.id);
754
+ }
755
+ /**
756
+ * `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
757
+ * package project (docs/app_packages.md § Promotion). Calls the extract read,
758
+ * prints the findings report grouped by severity, then ALWAYS emits the draft
759
+ * project (a broken contract is still the reviewable starting point): the app's
760
+ * current source archive (same mechanics as `lotics app pull`) with the app
761
+ * manifest swapped for an unpublished package manifest, `contract.json`, the
762
+ * file-backed templates staged at their `bytes_ref` paths, and
763
+ * `.lotics/adopt_binding.json` (the origin pin the `adopt` step reads back).
764
+ * Exits non-zero when any `error` finding exists — the draft is written, but
765
+ * publish re-validates and nothing should ship unreviewed.
766
+ */
767
+ export async function packageExtract(client, args) {
768
+ const app = await client.getApp(args.app_id);
769
+ if (!app.current_version_id) {
770
+ throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
771
+ }
772
+ const extracted = await client.extractAppPackage(args.app_id);
773
+ const { lines, hasError } = formatExtractReport(extracted.report);
774
+ if (lines.length > 0) {
775
+ console.error(`Extraction report (${extracted.report.length}):`);
776
+ for (const line of lines)
777
+ console.error(line);
778
+ }
779
+ else {
780
+ console.error("Extraction report: no findings.");
781
+ }
782
+ const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
783
+ if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
784
+ throw new Error(`Target directory ${targetPath} is not empty.`);
785
+ }
786
+ fs.mkdirSync(targetPath, { recursive: true });
787
+ // Pull the app's current source archive — same mechanics as `lotics app pull`
788
+ // (getAppVersion → presigned source URL → download → untar into the project).
789
+ const version = await client.getAppVersion(app.id, app.current_version_id);
790
+ const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
791
+ const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
792
+ console.error("Downloading source archive...");
793
+ await client.downloadFile(sourceUrl, tmpFile);
794
+ try {
795
+ console.error(`Extracting to ${targetPath}...`);
796
+ await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
797
+ }
798
+ finally {
799
+ if (fs.existsSync(tmpFile))
800
+ fs.unlinkSync(tmpFile);
801
+ }
802
+ // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
803
+ // package manifest — atomic write via the existing manifest writer.
804
+ const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
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);
818
+ // The alias-keyed draft contract — the reviewable master. Pretty + trailing newline.
819
+ fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(extracted.contract, null, 2) + "\n");
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
+ }
856
+ // Stage each file-backed template at the `bytes_ref` the contract references.
857
+ // The download lands in the target dir (fresh, so no name collision) under the
858
+ // file's own name; rename it to the exact `bytes_ref` basename the contract
859
+ // points at. Processed sequentially so a prior rename frees the name.
860
+ for (const tf of extracted.template_files) {
861
+ const dest = path.join(targetPath, tf.bytes_ref);
862
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
863
+ const { path: downloaded } = await client.downloadFileById(tf.file_id, path.dirname(dest));
864
+ if (path.resolve(downloaded) !== path.resolve(dest))
865
+ fs.renameSync(downloaded, dest);
866
+ console.error(`Wrote ${tf.bytes_ref}`);
867
+ }
868
+ // The origin pin the `adopt` step reads back. `.lotics` is in
869
+ // SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
870
+ const dotLotics = path.join(targetPath, ".lotics");
871
+ fs.mkdirSync(dotLotics, { recursive: true });
872
+ fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({ app_id: app.id, workspace_id: app.workspace_id, binding: extracted.binding }, null, 2) + "\n");
873
+ console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
874
+ console.error("Installing npm dependencies...");
875
+ await runNpm(["install"], targetPath);
876
+ if (hasError) {
877
+ console.error("\nExtraction produced error findings — the draft is written but NOT publishable as-is.");
878
+ console.error("Fix the reported items above, then publish (which re-validates the contract).");
879
+ process.exitCode = 1;
880
+ return;
881
+ }
882
+ console.error("\nDraft package project written. Next steps:");
883
+ console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
884
+ console.error(" # review the aliases in contract.json, then:");
885
+ console.error(` lotics package publish -m "v1"`);
886
+ console.error(` lotics package adopt ${app.id} (from this project, same workspace)`);
887
+ }
888
+ /**
889
+ * `lotics package adopt <app_id> [path]` — bind the published package project
890
+ * onto the origin app (docs/app_packages.md § Promotion). Reads the project
891
+ * manifest (must be published — refuses otherwise), resolves the version
892
+ * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
893
+ * REFUSING a pin recorded for a different app. On success the app becomes
894
+ * installation #1 and is upgradeable again; a server ConflictError (naming the
895
+ * unfaithful aliases) surfaces verbatim.
896
+ */
897
+ export async function packageAdopt(client, args) {
898
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
899
+ const { manifest } = readPackageProject(projectDir);
900
+ if (manifest.id === null) {
901
+ throw new Error("This package project has never been published (no package id). Publish first:\n lotics package publish");
902
+ }
903
+ const version = args.version ?? manifest.version;
904
+ if (version === null) {
905
+ throw new Error("No version to adopt — publish this project first (lotics package publish) or pass --version N.");
906
+ }
907
+ const bindingPath = path.join(projectDir, ".lotics", ADOPT_BINDING_FILE);
908
+ if (!fs.existsSync(bindingPath)) {
909
+ throw new Error(`No .lotics/${ADOPT_BINDING_FILE} in ${projectDir}. Adopt binds the origin app that ` +
910
+ `"lotics package extract" recorded — run extract to produce this project.`);
911
+ }
912
+ const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
913
+ const app = await client.adoptAppPackage(args.app_id, {
914
+ package_id: manifest.id,
915
+ version,
916
+ binding: pin.binding,
917
+ });
918
+ console.error(`Adopted ${app.name} → installation of ${app.package_id} v${app.package_version}.`);
919
+ console.error(` Verify the installation: lotics package doctor ${app.id}`);
920
+ }
921
+ /**
922
+ * `lotics package fleet-upgrade <package_id> [--version N]` — bring every
923
+ * installation of the package across the caller's org to the target version.
924
+ * Hands-off applies only where the preview is clean; skipped/failed
925
+ * installations are reported per line and the process exits 1 so a release
926
+ * script can gate on "fleet fully current".
927
+ */
928
+ export async function packageFleetUpgrade(client, args) {
929
+ const result = await client.fleetUpgradeAppPackage(args.package_id, {
930
+ ...(args.version !== undefined ? { version: args.version } : {}),
931
+ });
932
+ console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);
933
+ if (result.installations.length === 0) {
934
+ console.error(" No installations of this package in your organization.");
935
+ return;
936
+ }
937
+ const counts = { upgraded: 0, up_to_date: 0, skipped: 0, failed: 0 };
938
+ for (const inst of result.installations) {
939
+ counts[inst.outcome]++;
940
+ const from = inst.from_version === null ? "?" : `v${inst.from_version}`;
941
+ const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
942
+ if (inst.outcome === "skipped" && inst.blockers) {
943
+ console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
944
+ console.error(` resolve via: lotics package upgrade ${inst.app_id} --version ${result.target_version} ...`);
945
+ }
946
+ else if (inst.message) {
947
+ console.error(`${line}: ${inst.message}`);
948
+ }
949
+ else {
950
+ console.error(line);
951
+ }
952
+ }
953
+ console.error(`Done: ${counts.upgraded} upgraded, ${counts.up_to_date} already current, ${counts.skipped} skipped, ${counts.failed} failed.`);
954
+ if (counts.skipped > 0 || counts.failed > 0) {
955
+ process.exitCode = 1;
956
+ }
957
+ }