@lotics/cli 0.76.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,158 +1,76 @@
1
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:
2
+ * The package CONSUMER + registry surface (see docs/packages.md). Nothing starts
3
+ * as a package, and nothing is authored as a local package PROJECT: an app is
4
+ * first-released with `lotics app publish`, and a content package (a SET of live
5
+ * knowledge docs + templates) is published with the `publish_content` /
6
+ * `release_content` tools (`lotics run publish_content …`). This module
7
+ * implements only what operates published packages + installations:
6
8
  *
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).
9
+ * install / uninstall / upgrade the high-traffic consumer verbs, dispatched
10
+ * from the TOP LEVEL (`lotics install|uninstall|upgrade`). `upgrade` folds in
11
+ * the whole-fleet path (a `package_id` upgrades every org installation).
12
+ * doctor / eject / config / list-content / yank / show the low-traffic
13
+ * ops, under `lotics package <verb>`. (Re-pointing a live role rides
14
+ * `upgrade --resolve roles.<alias>=<grp_id>` no separate verb.)
16
15
  *
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.
16
+ * Author verbs live on `lotics app` (`publish` / `release` / `unpublish`), and the
17
+ * app-development loop lives entirely in `lotics app *`: pull an installed origin
18
+ * (`lotics app pull <app_id>`), edit + `lotics app deploy`, then
19
+ * `lotics app release <app_id>` cuts the next version.
23
20
  */
24
21
  import fs from "node:fs";
25
22
  import path from "node:path";
26
- import { tmpdir } from "node:os";
27
23
  import "./client.js";
28
- import { buildStarterTemplate, buildPackageStarterOverrides, STARTER_FALLBACK_SDK_VERSION, } from "./starter_template.js";
29
- import { generatePackageAppFields, } from "./generate_package_fields.js";
30
- import { appDirName, fetchLatestNpmVersion, runNpm, runTar, writeAppDts } 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";
24
+ import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
40
25
  function packageJsonPath(projectDir) {
41
26
  return path.join(projectDir, "package.json");
42
27
  }
43
28
  function isPlainObject(value) {
44
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
45
30
  }
46
- /** Read the package project's manifest, failing loud when the dir isn't one. */
47
- export function readPackageProject(projectDir) {
31
+ /**
32
+ * Read the local APP project's manifest (`package.json#lotics.app_id` +
33
+ * `lotics.knowledge`) — what `lotics app pull` writes. `lotics app publish` /
34
+ * `release` run from a pulled app project resolve the app id from it, and
35
+ * `publish` forwards the package-managed knowledge declaration to the server
36
+ * (agents reference docs by free text, so the author declares which the package
37
+ * owns). Returns null when the dir has no package.json. `app_id` is null when the
38
+ * manifest is a package/non-app project.
39
+ */
40
+ export function readLocalAppManifest(projectDir) {
48
41
  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
- }
42
+ if (!fs.existsSync(pkgPath))
43
+ return null;
52
44
  const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
53
45
  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 };
46
+ return null;
47
+ const lotics = isPlainObject(parsed.lotics) ? parsed.lotics : {};
48
+ const app_id = typeof lotics.app_id === "string" ? lotics.app_id : null;
49
+ const knowledge = [];
50
+ if (Array.isArray(lotics.knowledge)) {
51
+ for (const entry of lotics.knowledge) {
52
+ if (isPlainObject(entry) && typeof entry.alias === "string" && typeof entry.doc_id === "string") {
53
+ knowledge.push({ alias: entry.alias, doc_id: entry.doc_id });
65
54
  }
66
55
  }
67
56
  }
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
- };
57
+ return { app_id, knowledge };
123
58
  }
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
- };
59
+ /** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
60
+ export function parseRenameFlags(renames) {
61
+ return renames.map((entry) => {
62
+ const eq = entry.indexOf("=");
63
+ if (eq <= 0 || eq === entry.length - 1) {
64
+ throw new Error(`Invalid --rename "${entry}" — expected old=new (an alias to rename before v1 freezes it).`);
65
+ }
66
+ return { from: entry.slice(0, eq), to: entry.slice(eq + 1) };
67
+ });
149
68
  }
150
69
  /**
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`.
70
+ * Render a package-extract report grouped by severity (errors, then warnings,
71
+ * then info), one ` [<severity>] <area>: <message>` line each, and classify
72
+ * whether any `error` finding is present. Shared by the release preview display.
73
+ * Pure the command prints `lines` to stderr and gates on `hasError`.
156
74
  */
157
75
  export function formatExtractReport(report) {
158
76
  const order = ["error", "warning", "info"];
@@ -161,500 +79,37 @@ export function formatExtractReport(report) {
161
79
  .map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
162
80
  return { lines, hasError: report.some((f) => f.severity === "error") };
163
81
  }
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
82
  /**
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.
83
+ * Resolve the installation to operate on: an explicit app id is required (there
84
+ * is no dev-workspace pin to fall back to).
209
85
  */
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
- // Package-specific code surface swapped in BY PATH (same mechanism as the
365
- // package.json manifest swap below): the shared app starter's App.tsx demos
366
- // in-app routing over static data, wrong for a package. The package starter's
367
- // App.tsx exercises the binding (F/OPT + useQuery/useConfig), its test mocks the
368
- // SDK boundary, and its README carries the contract reference.
369
- const overrides = new Map(buildPackageStarterOverrides({ app_name: args.name }).map((f) => [f.path, f.content]));
370
- for (const file of files) {
371
- const fullPath = path.join(targetPath, file.path);
372
- if (file.path === "package.json") {
373
- const pkg = JSON.parse(file.content);
374
- const manifest = {
375
- id: null,
376
- name: args.name,
377
- description: null,
378
- version: null,
379
- dev: {},
380
- };
381
- pkg.lotics = { package: manifest };
382
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
383
- fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + "\n");
384
- continue;
385
- }
386
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
387
- fs.writeFileSync(fullPath, overrides.get(file.path) ?? file.content);
388
- }
389
- fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
390
- writePackageAppFields(targetPath);
391
- console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
392
- console.error("Installing npm dependencies...");
393
- await runNpm(["install"], targetPath);
394
- console.error(`\nReady. Next steps:`);
395
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
396
- console.error(` # edit contract.json + src/App.tsx, then run it against a dev workspace:`);
397
- console.error(` lotics workspace create "<name> dev" --dev`);
398
- console.error(` lotics package dev --workspace <dev_ws>`);
399
- }
400
- /**
401
- * `lotics package build [path]` — build the publishable bundle and write it to
402
- * `bundle.tar.gz` in the project. Mostly a local sanity check / CI artifact;
403
- * `publish` and `dev`/`sync` build the bundle in memory directly.
404
- */
405
- export async function packageBuild(args) {
406
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
407
- readPackageProject(projectDir); // assert it's a package project
408
- const bundle = await buildPackageBundle(projectDir);
409
- const out = path.join(projectDir, "bundle.tar.gz");
410
- fs.writeFileSync(out, bundle);
411
- console.error(`Built ${out} (${(bundle.byteLength / 1024).toFixed(1)} KB)`);
412
- }
413
- /**
414
- * Build + publish a new immutable package version from the project's contract +
415
- * bundle. Creates the registry package on first publish (stamping its id into
416
- * the manifest), then publishes the next monotonic version. Returns the new
417
- * version number and the resolved package id.
418
- */
419
- async function publishVersion(client, projectDir, opts) {
420
- const project = readPackageProject(projectDir);
421
- const contract = readContract(projectDir);
422
- let packageId = project.manifest.id;
423
- if (packageId === null) {
424
- const pkg = await client.createAppPackage({
425
- name: project.manifest.name,
426
- description: project.manifest.description,
427
- });
428
- packageId = pkg.id;
429
- project.manifest.id = packageId;
430
- writePackageManifest(projectDir, project);
431
- console.error(`Created package ${pkg.name} (${pkg.id}).`);
432
- }
433
- const bundle = await buildPackageBundle(projectDir);
434
- console.error("Publishing version...");
435
- const version = await client.publishAppPackageVersion(packageId, {
436
- contract,
437
- bundle,
438
- changelog: opts.changelog ?? null,
439
- channel: opts.channel ?? "release",
440
- });
441
- project.manifest.version = version.version;
442
- writePackageManifest(projectDir, project);
443
- return { package_id: packageId, version: version.version };
444
- }
445
- /**
446
- * `lotics package publish [path] [-m <changelog>]` — publish a new version.
447
- */
448
- export async function packagePublish(client, args) {
449
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
450
- const { package_id, version } = await publishVersion(client, projectDir, {
451
- changelog: args.changelog,
452
- });
453
- console.error(`Published ${package_id} v${version}.`);
454
- console.error(` Install it: lotics package install ${package_id} --version ${version}`);
455
- }
456
- /**
457
- * Fail loud unless `workspace` is a throwaway dev workspace. The dev/sync
458
- * scaffold path publishes a new version and scaffold-installs package tables into
459
- * the resolved workspace, so the target MUST be a dev workspace — this mirrors
460
- * the server-side `package reset` gate so a forgotten `--workspace` (prod
461
- * selected) can never scaffold package tables into prod. Fails closed: an absent
462
- * `is_dev` (a server that doesn't yet serialize it) is treated as non-dev.
463
- */
464
- export function assertDevWorkspace(workspace) {
465
- if (workspace.is_dev === true)
466
- return;
467
- throw new Error(`Workspace "${workspace.name}" (${workspace.id}) is not a dev workspace. ` +
468
- `"lotics package dev"/"sync" scaffold package tables into the target workspace, so it must be a throwaway dev workspace. ` +
469
- `Create one with "lotics workspace create <name> --dev" and pass --workspace <dev_ws>.`);
470
- }
471
- /**
472
- * Scaffold-sync the local package into the dev workspace: publish the current
473
- * contract + bundle as a new version, then install it (first time) or upgrade
474
- * the recorded installation (subsequent runs) so the dev workspace goes through
475
- * the exact scaffold + materialize the real install/upgrade run. Records the
476
- * installation pin per dev workspace in the manifest. Returns the dev
477
- * installation's app id + name.
478
- */
479
- async function syncToDevWorkspace(client, projectDir) {
480
- const devWorkspaceId = client.getWorkspaceId();
481
- if (!devWorkspaceId) {
482
- throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
483
- }
484
- // Guard BEFORE publishing: dev/sync scaffold-installs package tables into the
485
- // resolved workspace, so it must be a throwaway dev workspace — a developer who
486
- // forgot --workspace with prod selected would otherwise scaffold into prod.
487
- // Mirrors the server-side `package reset` gate (which refuses any non-`is_dev`
488
- // workspace).
489
- const workspace = await client.getWorkspaceInfo(devWorkspaceId);
490
- if (!workspace) {
491
- throw new Error(`Workspace ${devWorkspaceId} is not accessible with these credentials. Pass --workspace <dev_ws> (a workspace created with --dev).`);
492
- }
493
- assertDevWorkspace(workspace);
494
- // Contract may have been edited since the last sync — regenerate the
495
- // runtime F/OPT/ROLE surface before the build that publishVersion runs.
496
- writePackageAppFields(projectDir);
497
- // 'dev' channel: a real immutable version this dev installation pins by
498
- // number, but never what "install latest" resolves to — a broken
499
- // mid-iteration contract must not become the org-wide installable default.
500
- const { package_id, version } = await publishVersion(client, projectDir, {
501
- changelog: "dev sync",
502
- channel: "dev",
503
- });
504
- // Re-read after publish (publishVersion may have stamped the package id).
505
- const project = readPackageProject(projectDir);
506
- const existing = project.manifest.dev[devWorkspaceId];
507
- let appId;
508
- if (existing) {
509
- console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
510
- const app = await client.upgradeAppPackage(existing.app_id, { version });
511
- appId = app.id;
512
- }
513
- else {
514
- console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
515
- const app = await client.installAppPackage(package_id, { version });
516
- appId = app.id;
517
- }
518
- project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
519
- writePackageManifest(projectDir, project);
520
- const installed = await client.getApp(appId);
521
- // Regenerate the typed `.lotics/app_{queries,workflows,agents}.d.ts` companions
522
- // from the live installation — the SAME maps (`apps.queries`/`workflows`/`agents`,
523
- // keyed by the contract's runtime aliases) `lotics app pull` codegens from — so
524
- // `useQuery("items")` / `useWorkflow` are typed in the package dev loop, not just
525
- // in pulled app projects. (`.lotics/app_fields.ts`, the runtime F/OPT surface, was
526
- // already refreshed from the contract before publish.) `writeAppDts` also heals the
527
- // tsconfig include/exclude so the generated `.d.ts` actually load.
528
- writeAppDts(projectDir, {
529
- workflows: installed.workflows ?? undefined,
530
- queries: installed.queries ?? undefined,
531
- agents: installed.agents ?? undefined,
532
- });
533
- return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
534
- }
535
- /**
536
- * `lotics package sync [path]` — re-run the scaffold-sync into the dev workspace
537
- * (the continuous loop: edit contract → sync additively migrates + re-materializes).
538
- */
539
- export async function packageSync(client, args) {
540
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
541
- const result = await syncToDevWorkspace(client, projectDir);
542
- console.error(`Synced ${result.app_name} v${result.version} → ${result.app_id} (workspace ${result.workspace_id}).`);
543
- }
544
- /**
545
- * `lotics package dev [path] [--workspace <dev_ws>] [--view-as <member>]` —
546
- * scaffold-sync the package into the dev workspace, then run the existing app
547
- * dev server against the resulting installation (HMR over the local source, RPC
548
- * forwarded to the live installation). The inner loop: edit contract → re-run to
549
- * sync; edit code → hot reload.
550
- */
551
- export async function packageDev(client, args) {
552
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
553
- const synced = await syncToDevWorkspace(client, projectDir);
554
- const handle = await startDevServer({
555
- projectDir,
556
- app_id: synced.app_id,
557
- app_name: synced.app_name,
558
- workspace_id: synced.workspace_id,
559
- api_url: client.baseUrl,
560
- port: args.port,
561
- vitePort: args.vitePort,
562
- client,
563
- });
564
- await handle.ready;
565
- const url = `http://localhost:${handle.port}`;
566
- console.error(`\n lotics package dev`);
567
- console.error(` package: ${synced.app_name} (dev installation ${synced.app_id} v${synced.version})`);
568
- console.error(` workspace: ${synced.workspace_id} (dev)`);
569
- if (client.viewAsMemberId) {
570
- console.error(` view as: ${client.viewAsMemberId}`);
571
- }
572
- console.error(` vite: http://localhost:${handle.vitePort}/`);
573
- console.error(` open: ${url}`);
574
- console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
575
- console.error(` Edit contract.json then re-run "lotics package dev" / "lotics package sync" to migrate.`);
576
- console.error(` Ctrl-C to stop.\n`);
577
- openBrowser(url);
578
- await new Promise((resolve) => {
579
- const onSig = () => {
580
- process.off("SIGINT", onSig);
581
- process.off("SIGTERM", onSig);
582
- resolve();
583
- };
584
- process.on("SIGINT", onSig);
585
- process.on("SIGTERM", onSig);
586
- });
587
- console.error("\nStopping…");
588
- await handle.stop();
589
- }
590
- /**
591
- * `lotics package reset [path]` — DEV-ONLY, hard-gated. Drops the dev
592
- * installation's package-owned scaffolded tables and re-scaffolds them clean. The
593
- * backend refuses any workspace not flagged as a dev workspace, so this can never
594
- * erase a real workspace's data. The dev installation is resolved from the
595
- * project manifest's pin for the selected workspace.
596
- */
597
- export async function packageReset(client, args) {
598
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
599
- const devWorkspaceId = client.getWorkspaceId();
600
- if (!devWorkspaceId) {
601
- throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
602
- }
603
- const { manifest } = readPackageProject(projectDir);
604
- const pin = manifest.dev[devWorkspaceId];
605
- if (!pin) {
606
- throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
607
- }
608
- console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
609
- const app = await client.resetAppPackage(pin.app_id);
610
- console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
611
- }
612
- /**
613
- * Resolve the installation to operate on: an explicit app id wins; otherwise
614
- * fall back to the project manifest's dev pin for the selected workspace (the
615
- * same resolution `package reset` uses).
616
- */
617
- function resolveInstallationAppId(client, explicit) {
86
+ function resolveInstallationAppId(explicit) {
618
87
  if (explicit)
619
88
  return explicit;
620
- const workspaceId = client.getWorkspaceId();
621
- if (!workspaceId) {
622
- throw new Error("Pass an app id (lotics package doctor <app_id>) or select a workspace.");
623
- }
624
- let manifest;
625
- try {
626
- ({ manifest } = readPackageProject(path.resolve(process.cwd())));
627
- }
628
- catch {
629
- // CLI usage boundary: translate "this directory isn't a package project"
630
- // into the action the caller actually needs.
631
- throw new Error("No app id given and the current directory is not a package project. " +
632
- "Pass one explicitly: lotics package doctor <app_id>.");
633
- }
634
- const pin = manifest.dev[workspaceId];
635
- if (!pin) {
636
- throw new Error(`No app id given and no dev installation recorded for workspace ${workspaceId}. ` +
637
- "Pass one explicitly: lotics package doctor <app_id>.");
638
- }
639
- return pin.app_id;
89
+ throw new Error("Pass an app id — e.g. lotics package doctor <app_id>.");
640
90
  }
91
+ const RESOLVE_VERBS = new Set(["recreate", "revert", "keep", "apply", "archive", "unbind"]);
641
92
  /**
642
- * Parse repeated `--resolve <key>=<value>` flags. Drift entries
643
- * (`<namespace>.<alias>`) take `recreate` or an existing id; modified
644
- * artifacts (`<kind>.<alias>`) take `revert` or `keep`. Any other value is a
645
- * bind_to id; the server validates value-kind against what the key resolves.
93
+ * Parse repeated `--resolve <key>=<value>` flags into the ONE namespaced
94
+ * resolutions map every upgrade wire takes. Keys pass through verbatim — a
95
+ * drifted binding entry (`<namespace>.<alias>`), a modified artifact
96
+ * (`<kind>.<alias>`), a bundled/standalone knowledge doc (`knowledge.<alias>`),
97
+ * or a live-role re-point (`roles.<alias>`). A value that is one of the
98
+ * resolution verbs stays a verb; anything else is a `{ bind_to }` id. The
99
+ * server validates value-kind against what each key resolves.
646
100
  */
647
101
  export function parseResolveFlags(resolve) {
648
102
  const resolutions = {};
649
103
  for (const entry of resolve) {
650
104
  const eq = entry.indexOf("=");
651
105
  if (eq <= 0 || eq === entry.length - 1) {
652
- throw new Error(`Invalid --resolve "${entry}" — expected <key>=recreate|revert|keep or <key>=<existing_id>.`);
106
+ throw new Error(`Invalid --resolve "${entry}" — expected <key>=<verb> (recreate|revert|keep|apply|archive|unbind) or <key>=<existing_id>.`);
653
107
  }
654
108
  const key = entry.slice(0, eq);
655
109
  const value = entry.slice(eq + 1);
656
- resolutions[key] =
657
- value === "recreate" || value === "revert" || value === "keep" ? value : { bind_to: value };
110
+ resolutions[key] = RESOLVE_VERBS.has(value)
111
+ ? value
112
+ : { bind_to: value };
658
113
  }
659
114
  return resolutions;
660
115
  }
@@ -663,9 +118,10 @@ export function parseResolveFlags(resolve) {
663
118
  * non-zero when drift is found so scripts can gate on it.
664
119
  */
665
120
  export async function packageDoctor(client, args) {
666
- const app_id = resolveInstallationAppId(client, args.app_id);
667
- const health = await client.getAppPackageHealth(app_id);
668
- console.error(`${health.package_name} — installation ${health.app_id}`);
121
+ const app_id = resolveInstallationAppId(args.app_id);
122
+ const health = await client.getPackageHealth(app_id);
123
+ console.error(`${health.package_name} — installation ${health.app_id}` +
124
+ (health.is_origin ? " (origin — this app IS installation #1, the release working copy)" : ""));
669
125
  console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
670
126
  (health.update_available ? " → update available" : ""));
671
127
  if (health.drift.length === 0) {
@@ -676,33 +132,91 @@ export async function packageDoctor(client, args) {
676
132
  for (const d of health.drift) {
677
133
  console.error(` - ${d.namespace}.${d.alias} → ${d.id} (missing from the workspace)`);
678
134
  }
679
- console.error(` Resolve while upgrading:\n lotics package upgrade ${app_id}` +
135
+ console.error(` Resolve while upgrading:\n lotics upgrade ${app_id}` +
680
136
  ` --resolve <namespace.alias>=recreate (or =<existing_id> to re-point)`);
681
137
  process.exitCode = 1;
682
138
  }
683
139
  if (health.modified.length === 0) {
684
- console.error(" Package artifacts: pristine — no local edits an upgrade would revert.");
140
+ console.error(health.is_origin
141
+ ? " Package artifacts: no changes since the last release."
142
+ : " Package artifacts: pristine — no local edits an upgrade would revert.");
143
+ }
144
+ else if (health.is_origin) {
145
+ // The origin IS the author's working copy — edits since the last release are
146
+ // the NEXT release's payload, not consumer drift, and never fail the check.
147
+ console.error(` Changed since v${health.installed_version} (${health.modified.length}) — a release will publish these:`);
148
+ for (const m of health.modified) {
149
+ console.error(` - ${m.kind}.${m.alias}`);
150
+ }
151
+ console.error(` Cut the next version: lotics app release ${app_id} -m "<what changed>"`);
685
152
  }
686
153
  else {
687
154
  console.error(` Locally modified package artifacts (${health.modified.length}):`);
688
155
  for (const m of health.modified) {
689
156
  console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
690
157
  }
691
- console.error(` Consent while upgrading:\n lotics package upgrade ${app_id}` +
158
+ console.error(` Consent while upgrading:\n lotics upgrade ${app_id}` +
692
159
  ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
693
160
  process.exitCode = 1;
694
161
  }
162
+ // Package-managed knowledge (bundled with this app install): drift + local edits
163
+ // + unmet expects. All advisory; resolved through the app's package upgrade.
164
+ if (health.knowledge_drift.length > 0) {
165
+ console.error(` Knowledge binding drift (${health.knowledge_drift.length}):`);
166
+ for (const d of health.knowledge_drift) {
167
+ console.error(` - ${d.alias} "${d.name}" → ${d.doc_id ?? "(unbound)"} (missing from the workspace)`);
168
+ }
169
+ }
170
+ if (health.knowledge_modified.length > 0) {
171
+ if (health.is_origin) {
172
+ console.error(` Knowledge changed since v${health.installed_version} (${health.knowledge_modified.length}) — a release will re-snapshot these:`);
173
+ for (const m of health.knowledge_modified) {
174
+ console.error(` - ${m.alias} "${m.name}"`);
175
+ }
176
+ }
177
+ else {
178
+ console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
179
+ for (const m of health.knowledge_modified) {
180
+ console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
181
+ }
182
+ }
183
+ }
184
+ if (health.missing_expected_docs.length > 0) {
185
+ console.error(` Missing expected knowledge docs (${health.missing_expected_docs.length}):`);
186
+ for (const name of health.missing_expected_docs) {
187
+ console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
188
+ }
189
+ }
190
+ // Drift is genuine breakage on the origin too (a bound id vanished); edited
191
+ // knowledge on the origin is staged release work, not a failure.
192
+ if (health.knowledge_drift.length > 0 || (health.knowledge_modified.length > 0 && !health.is_origin)) {
193
+ process.exitCode = 1;
194
+ }
195
+ if (health.knowledge_drift.length === 0 &&
196
+ health.knowledge_modified.length === 0 &&
197
+ health.missing_expected_docs.length === 0) {
198
+ console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
199
+ }
695
200
  if (health.update_available) {
696
- console.error(` Upgrade: lotics package upgrade ${app_id}`);
201
+ console.error(` Upgrade: lotics upgrade ${app_id}`);
697
202
  }
698
203
  }
699
204
  /**
700
- * Preview-then-apply upgrade. Prints the additive plan + informational
701
- * removals; refuses (exit 1, with the exact --resolve syntax) while any
702
- * binding drift lacks a resolution.
205
+ * Preview-then-apply an app-installation upgrade. Prints the additive plan +
206
+ * informational removals + any bundled-knowledge changes; refuses (exit 1, with
207
+ * the exact --resolve syntax) while any binding drift, modified core artifact, or
208
+ * consent-requiring bundled-knowledge doc lacks a resolution.
209
+ *
210
+ * `--resolve` speaks ONE namespaced grammar, passed to the server verbatim: a
211
+ * drifted binding entry (`<namespace>.<alias>`), a modified artifact
212
+ * (`<kind>.<alias>`), a bundled knowledge doc (`knowledge.<alias>` —
213
+ * apply|keep|archive|recreate|unbind), or a live-role re-point
214
+ * (`roles.<alias>=<grp_id>`). `--bind-to` consents an added-knowledge-doc name
215
+ * collision; `--apply-all` accepts the package's version for every
216
+ * consent-requiring knowledge doc (overwriting local edits).
703
217
  */
704
218
  export async function packageUpgrade(client, args) {
705
- const preview = await client.previewAppPackageUpgrade(args.app_id, {
219
+ const preview = await client.previewPackageUpgrade(args.app_id, {
706
220
  ...(args.version !== undefined ? { version: args.version } : {}),
707
221
  });
708
222
  console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
@@ -717,6 +231,12 @@ export async function packageUpgrade(client, args) {
717
231
  console.error(` Adds: ${added}`);
718
232
  if (removed)
719
233
  console.error(` Unbinds (workspace data kept): ${removed}`);
234
+ if (preview.knowledge.length > 0) {
235
+ console.error(` Knowledge docs (${preview.knowledge.length}):`);
236
+ for (const entry of preview.knowledge) {
237
+ console.error(` ${formatKnowledgeEntryLine(entry)}`);
238
+ }
239
+ }
720
240
  // Breaking contract changes are NOT resolvable via --resolve — scaffold would
721
241
  // refuse them on a bound alias, and there is no in-flow remediation. Report
722
242
  // every entry and the only two real remedies, then hard-stop.
@@ -730,9 +250,26 @@ export async function packageUpgrade(client, args) {
730
250
  ` before making the change: lotics package eject ${args.app_id}`);
731
251
  process.exit(1);
732
252
  }
733
- const unresolvedDrift = preview.drift.filter((d) => args.resolutions[`${d.namespace}.${d.alias}`] === undefined);
734
- const unresolvedModified = preview.modified.filter((m) => args.resolutions[`${m.kind}.${m.alias}`] === undefined);
735
- if (unresolvedDrift.length > 0 || unresolvedModified.length > 0) {
253
+ // ONE namespaced grammar the flags pass to the server verbatim. `--bind-to`
254
+ // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
255
+ // remaining consent-requiring knowledge entries with the accept verb.
256
+ const resolutions = parseResolveFlags(args.resolve);
257
+ for (const [alias, id] of Object.entries(parseBindToFlags(args.bindTo))) {
258
+ resolutions[`knowledge.${alias}`] = { bind_to: id };
259
+ }
260
+ if (args.applyAll) {
261
+ for (const entry of preview.knowledge) {
262
+ if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
263
+ resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
264
+ }
265
+ }
266
+ }
267
+ const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
268
+ const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
269
+ const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && resolutions[`knowledge.${e.alias}`] === undefined);
270
+ if (unresolvedDrift.length > 0 ||
271
+ unresolvedModified.length > 0 ||
272
+ unresolvedKnowledge.length > 0) {
736
273
  if (unresolvedDrift.length > 0) {
737
274
  console.error(" Binding drift must be resolved before upgrading:");
738
275
  for (const d of unresolvedDrift) {
@@ -745,14 +282,33 @@ export async function packageUpgrade(client, args) {
745
282
  console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
746
283
  }
747
284
  }
285
+ if (unresolvedKnowledge.length > 0) {
286
+ console.error(" Bundled knowledge docs need consent before upgrading (or --apply-all to accept the package's version for all):");
287
+ for (const entry of unresolvedKnowledge) {
288
+ console.error(formatKnowledgeResolveHint(entry));
289
+ }
290
+ }
748
291
  process.exit(1);
749
292
  }
750
- const app = await client.upgradeAppPackage(args.app_id, {
293
+ const app = await client.upgradePackage(args.app_id, {
751
294
  ...(args.version !== undefined ? { version: args.version } : {}),
752
- ...(Object.keys(args.resolutions).length > 0 ? { resolutions: args.resolutions } : {}),
295
+ ...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
753
296
  });
754
297
  console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
755
298
  }
299
+ /** `--apply-all`'s accept-the-package resolution for a consent-requiring entry. */
300
+ function knowledgeAcceptResolution(change) {
301
+ switch (change) {
302
+ case "changed":
303
+ return "apply";
304
+ case "removed":
305
+ return "archive";
306
+ case "drifted":
307
+ return "recreate";
308
+ case "added":
309
+ return "apply"; // unreachable (added never needs consent), kept total.
310
+ }
311
+ }
756
312
  /** The install-consent trust line: official > your own org > third-party. */
757
313
  function trustBadge(pkg) {
758
314
  if (pkg.is_official)
@@ -764,11 +320,12 @@ function trustBadge(pkg) {
764
320
  /**
765
321
  * `lotics package show <package_id>` — registry metadata + version history
766
322
  * (trust badge, retirement, per-version channel/yank/changelog). The read
767
- * surface for "what is this package and what shipped when".
323
+ * surface for "what is this package and what shipped when". Kind-agnostic — a
324
+ * content package shows here the same way.
768
325
  */
769
326
  export async function packageShow(client, args) {
770
- const pkg = await client.getAppPackage(args.package_id);
771
- const { versions } = await client.listAppPackageVersions(args.package_id);
327
+ const pkg = await client.getPackage(args.package_id);
328
+ const { versions } = await client.listPackageVersions(args.package_id);
772
329
  console.error(`${pkg.name} (${pkg.id}) — ${trustBadge(pkg)}`);
773
330
  if (pkg.description)
774
331
  console.error(` ${pkg.description}`);
@@ -787,22 +344,260 @@ export async function packageShow(client, args) {
787
344
  if (versions.length === 0)
788
345
  console.error(" (no published versions)");
789
346
  }
347
+ /**
348
+ * One preview line for a knowledge upgrade entry — `[change] alias "name"` with
349
+ * `modified` / `needs consent` marks. Shared verbatim by the standalone (`pci_`)
350
+ * and the app-install upgrade previews so both speak one vocabulary; the caller
351
+ * owns the leading indent.
352
+ */
353
+ function formatKnowledgeEntryLine(entry) {
354
+ const marks = [
355
+ entry.modified ? "modified" : null,
356
+ knowledgeEntryNeedsConsent(entry) ? "needs consent" : null,
357
+ ].filter((m) => m !== null);
358
+ return `[${entry.change}] ${entry.alias} "${entry.name}"${marks.length ? ` (${marks.join(", ")})` : ""}`;
359
+ }
360
+ /**
361
+ * The exact `--resolve <alias>=<valid options>` remediation line for a
362
+ * consent-requiring knowledge entry (options from the shared
363
+ * `validKnowledgeResolutions`). Shared by both upgrade paths' gate output.
364
+ */
365
+ function formatKnowledgeResolveHint(entry) {
366
+ const note = entry.change === "changed" || entry.change === "removed"
367
+ ? " (a local edit — apply/archive overwrites it; keep retains it)"
368
+ : " (bound doc is gone; recreate from the package, or unbind)";
369
+ return ` --resolve knowledge.${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
370
+ }
371
+ /** The `--resolve template.<alias>=revert|keep` remediation line for a consent-requiring template. */
372
+ function formatTemplateResolveHint(entry) {
373
+ const note = entry.baseline_unknown
374
+ ? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
375
+ : " (a local edit — revert overwrites it with the package's version; keep retains it)";
376
+ return ` --resolve template.${entry.alias}=revert|keep${note}`;
377
+ }
378
+ /**
379
+ * Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
380
+ * AND document templates behind one review gate. Prints the per-alias plan;
381
+ * refuses (exit 1, with the exact `--resolve` syntax) while any consent-requiring
382
+ * entry lacks a resolution — a modified knowledge change/removal or drift, or a
383
+ * locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
384
+ * by accepting the package's version (knowledge changed→apply, removed→archive,
385
+ * drifted→recreate; template→revert) — an explicit bulk "take upstream" that
386
+ * discards local edits. `--resolve knowledge.<alias>=<value>` /
387
+ * `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
388
+ * resolve entries individually — the ONE namespaced grammar, passed to the
389
+ * server verbatim.
390
+ */
391
+ export async function packageUpgradeKnowledge(client, args) {
392
+ const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
393
+ ...(args.version !== undefined ? { version: args.version } : {}),
394
+ });
395
+ console.error(`Content upgrade v${preview.from_version} → v${preview.to_version}` +
396
+ (preview.changelog ? ` — ${preview.changelog}` : ""));
397
+ const apply = async (resolutions) => {
398
+ const updated = await client.applyContentInstallationUpgrade(args.installation_id, {
399
+ ...(args.version !== undefined ? { version: args.version } : {}),
400
+ resolutions,
401
+ });
402
+ console.error(`Upgraded ${args.installation_id} → v${updated.package_version}.`);
403
+ };
404
+ if (preview.entries.length === 0 && preview.templates.length === 0) {
405
+ if (preview.to_version === preview.from_version) {
406
+ console.error(" Already up to date — no content changes.");
407
+ return;
408
+ }
409
+ console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
410
+ await apply({});
411
+ return;
412
+ }
413
+ for (const entry of preview.entries) {
414
+ console.error(` ${formatKnowledgeEntryLine(entry)}`);
415
+ }
416
+ for (const entry of preview.templates) {
417
+ console.error(` [template] ${entry.alias} (modified — needs consent)`);
418
+ }
419
+ // ONE namespaced grammar — the flags pass to the server verbatim. `--bind-to`
420
+ // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
421
+ // remaining consent entries with the accept verb (template → revert).
422
+ const resolutions = parseResolveFlags(args.resolve);
423
+ for (const [alias, id] of Object.entries(args.bind_to)) {
424
+ resolutions[`knowledge.${alias}`] = { bind_to: id };
425
+ }
426
+ if (args.applyAll) {
427
+ for (const entry of preview.entries) {
428
+ if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
429
+ resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
430
+ }
431
+ }
432
+ for (const entry of preview.templates) {
433
+ if (resolutions[`template.${entry.alias}`] === undefined) {
434
+ resolutions[`template.${entry.alias}`] = "revert";
435
+ }
436
+ }
437
+ }
438
+ const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined);
439
+ const unresolvedTemplates = preview.templates.filter((entry) => resolutions[`template.${entry.alias}`] === undefined);
440
+ if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
441
+ console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
442
+ for (const entry of unresolvedKnowledge)
443
+ console.error(formatKnowledgeResolveHint(entry));
444
+ for (const entry of unresolvedTemplates)
445
+ console.error(formatTemplateResolveHint(entry));
446
+ process.exit(1);
447
+ }
448
+ await apply(resolutions);
449
+ }
450
+ /** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
451
+ export function parseBindToFlags(bindTo) {
452
+ const map = {};
453
+ for (const entry of bindTo) {
454
+ const eq = entry.indexOf("=");
455
+ if (eq <= 0 || eq === entry.length - 1) {
456
+ throw new Error(`Invalid --bind-to "${entry}" — expected <alias>=<kdc_id>.`);
457
+ }
458
+ map[entry.slice(0, eq)] = entry.slice(eq + 1);
459
+ }
460
+ return map;
461
+ }
462
+ /** Print advisory `knowledge_expects` misses loudly (never blocks the install). */
463
+ function warnMissingExpectedDocs(missing) {
464
+ if (missing.length === 0)
465
+ return;
466
+ console.error(` ⚠ ${missing.length} expected knowledge doc(s) the package's agents route to are missing from this workspace:`);
467
+ for (const name of missing)
468
+ console.error(` - "${name}"`);
469
+ console.error(" The package installed, but those agents will degrade until a doc with each name exists.");
470
+ }
790
471
  export async function packageInstall(client, args) {
791
472
  // Surface the trust badge at the consent point: installing materializes the
792
- // package's workflows/agents under YOUR authority, so say whose package it is.
793
- const pkg = await client.getAppPackage(args.package_id);
794
- console.error(`Installing ${pkg.name} ${trustBadge(pkg)}...`);
795
- const app = await client.installAppPackage(args.package_id, {
473
+ // package's workflows/agents (or its doc corpus) under YOUR authority.
474
+ const pkg = await client.getPackage(args.package_id);
475
+ const kindLabel = pkg.kind === "content" ? "content package" : "package";
476
+ console.error(`Installing ${pkg.name} ${trustBadge(pkg)} (${kindLabel})...`);
477
+ const result = await client.installPackage(args.package_id, {
796
478
  ...(args.version !== undefined ? { version: args.version } : {}),
797
- ...(args.config !== undefined ? { config: args.config } : {}),
479
+ ...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
480
+ ...(args.config && Object.keys(args.config).length > 0 ? { config: args.config } : {}),
798
481
  });
482
+ if (result.kind === "content") {
483
+ const { installation, warnings } = result;
484
+ const docs = installation.binding.knowledge;
485
+ const templates = installation.binding.templates;
486
+ console.error(`Installed ${pkg.name} v${installation.package_version} → content installation ${installation.id} ` +
487
+ `(workspace ${installation.workspace_id}).`);
488
+ const docAliases = Object.keys(docs);
489
+ if (docAliases.length > 0) {
490
+ console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
491
+ }
492
+ const templateAliases = Object.keys(templates);
493
+ if (templateAliases.length > 0) {
494
+ console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
495
+ }
496
+ warnMissingExpectedDocs(warnings.missing_expected_docs);
497
+ console.error(` Upgrade later: lotics upgrade ${installation.id}`);
498
+ console.error(` Uninstall: lotics uninstall ${installation.id} [--keep-content]`);
499
+ return;
500
+ }
501
+ const { app, knowledge_warnings } = result;
799
502
  const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
800
503
  console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
801
504
  console.error(" The data model, queries, workflows, and agents are live.");
802
- console.error(" Pull it for local editing: lotics app pull " + app.id);
505
+ warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
506
+ // Follow-up ops key on the NEW installation id (app_), not the package id typed
507
+ // at `install` — signpost each one with the id inlined (the content branch above
508
+ // does the same), so the id switch is never a silent trap.
509
+ console.error(` Pull it for local editing: lotics app pull ${app.id}`);
510
+ console.error(` Upgrade later: lotics upgrade ${app.id}`);
511
+ console.error(` Health / uninstall: lotics package doctor ${app.id} · lotics uninstall ${app.id} [--archive-tables]`);
512
+ }
513
+ /**
514
+ * `lotics uninstall <app_id|pci_id>` — ONE top-level command over both installation
515
+ * kinds, dispatched by the id form (mirrors `lotics upgrade`):
516
+ * - a `pci_` id → a STANDALONE CONTENT installation: deletes the row and (unless
517
+ * `--keep-content`) archives its package-bound docs AND templates, listing each
518
+ * archived id.
519
+ * - anything else (an `app_id`) → an APP installation: archives its workflow
520
+ * artifacts and — with `--archive-tables` — the scaffolded entity tables
521
+ * (provenance- + reference-gated server-side).
522
+ * A flag used on the wrong path is a loud error, never silently ignored.
523
+ */
524
+ export async function packageUninstall(client, args) {
525
+ if (args.id.startsWith("pci_")) {
526
+ if (args.archive_tables) {
527
+ throw new Error("--archive-tables applies only to an app installation (<app_id>). A content installation " +
528
+ "(pci_) has no scaffolded tables — use --keep-content to retain its docs/templates.");
529
+ }
530
+ const result = await client.uninstallContentPackage(args.id, {
531
+ keep_content: args.keep_content,
532
+ });
533
+ if (args.keep_content) {
534
+ console.error(`Uninstalled content installation ${result.installation_id} — its docs and templates were kept as ordinary workspace content.`);
535
+ }
536
+ else {
537
+ console.error(`Uninstalled content installation ${result.installation_id} — archived ` +
538
+ `${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
539
+ for (const docId of result.archived_doc_ids)
540
+ console.error(` ${docId}`);
541
+ for (const templateId of result.archived_template_ids)
542
+ console.error(` ${templateId}`);
543
+ }
544
+ return;
545
+ }
546
+ if (args.keep_content) {
547
+ throw new Error("--keep-content applies only to a standalone content installation (pci_). An app installation " +
548
+ "(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
549
+ }
550
+ const app = await client.getApp(args.id);
551
+ if (!app.package_id) {
552
+ throw new Error(`App ${args.id} is not a package installation — use the app delete flow for a bespoke app.`);
553
+ }
554
+ const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
555
+ const boundCount = Object.keys(app.workflows ?? {}).length;
556
+ const tableCount = Object.keys(app.binding?.entities ?? {}).length;
557
+ console.error(`Uninstalling ${app.name} (${app.id}):`);
558
+ console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
559
+ console.error(args.archive_tables
560
+ ? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
561
+ : ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
562
+ const result = await client.uninstallAppPackage(args.id, {
563
+ archive_tables: args.archive_tables,
564
+ });
565
+ console.error(`Uninstalled ${app.name} (${app.id}).`);
566
+ if (result.archived_table_ids.length > 0) {
567
+ console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
568
+ }
569
+ }
570
+ /**
571
+ * `lotics package list-content` — list the selected workspace's STANDALONE
572
+ * content installations (an app-bundled corpus rides its app's
573
+ * `binding.knowledge` and shows on the Apps surface instead), each with its
574
+ * registry status. The read surface that surfaces a `pci_` id for
575
+ * `upgrade` / `uninstall` (install prints it once; nothing else did before).
576
+ */
577
+ export async function packageListContent(client) {
578
+ const workspaceId = client.getWorkspaceId();
579
+ if (!workspaceId) {
580
+ throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to list its content installations.");
581
+ }
582
+ const installations = await client.listContentInstallations(workspaceId);
583
+ if (installations.length === 0) {
584
+ console.error(`No package-managed content installations in workspace ${workspaceId}.`);
585
+ return;
586
+ }
587
+ console.error(`Content installations in workspace ${workspaceId} (${installations.length}):`);
588
+ for (const inst of installations) {
589
+ const name = inst.package_registry?.name ?? "(unknown package)";
590
+ const latest = inst.package_registry?.latest_version;
591
+ const updateAvailable = inst.package_registry?.update_available ?? false;
592
+ const versionLabel = latest !== undefined && latest !== inst.package_version
593
+ ? `v${inst.package_version} → latest v${latest}`
594
+ : `v${inst.package_version}`;
595
+ console.error(` ${inst.id} ${name} ${versionLabel}` +
596
+ (updateAvailable ? " → update available" : ""));
597
+ }
803
598
  }
804
599
  export async function packageEject(client, args) {
805
- const app = await client.ejectAppPackage(args.app_id);
600
+ const app = await client.ejectPackage(args.app_id);
806
601
  console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
807
602
  console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
808
603
  console.error(" Its data model, queries, workflows, and templates are unchanged.");
@@ -811,7 +606,7 @@ export async function packageEject(client, args) {
811
606
  /**
812
607
  * Parse one `key=value` config assignment. When the knob's type is known (from
813
608
  * the stored value at `package config --set`) the value is parsed to that type
814
- * loudly; otherwise (`package install --config`) it is inferred (true/false →
609
+ * loudly; otherwise (`lotics install --config`) it is inferred (true/false →
815
610
  * boolean, numeric → number, else string) and the server validates it against
816
611
  * the contract.
817
612
  */
@@ -889,218 +684,174 @@ export async function packageConfig(client, args) {
889
684
  }
890
685
  }
891
686
  /**
892
- * `lotics package uninstall <app_id> [--archive-tables]` — remove a package
893
- * installation. Prints what will be archived (workflow artifacts, plus the
894
- * scaffolded tables when the flag is set), then uninstalls.
687
+ * `lotics app unpublish <app_id|package_id> [--undo]` — take a published package
688
+ * off the shelf (or `--undo` restore it): new installs refuse it and it hides
689
+ * from other orgs, while existing installations keep working and may still
690
+ * upgrade. Given an app id (an installation of the package) it resolves the
691
+ * package from the app; a package id targets it directly. Owner-org admin-only.
895
692
  */
896
- export async function packageUninstall(client, args) {
897
- const app = await client.getApp(args.app_id);
898
- if (!app.package_id) {
899
- throw new Error(`App ${args.app_id} is not a package installation — use the app delete flow for a bespoke app.`);
900
- }
901
- const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
902
- const boundCount = Object.keys(app.workflows ?? {}).length;
903
- const tableCount = Object.keys(app.binding?.entities ?? {}).length;
904
- console.error(`Uninstalling ${app.name} (${app.id}):`);
905
- console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
906
- console.error(args.archive_tables
907
- ? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
908
- : ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
909
- const result = await client.uninstallAppPackage(args.app_id, {
910
- archive_tables: args.archive_tables,
911
- });
912
- console.error(`Uninstalled ${app.name} (${app.id}).`);
913
- if (result.archived_table_ids.length > 0) {
914
- console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
693
+ export async function appUnpublish(client, args) {
694
+ let packageId = args.id;
695
+ if (args.id.startsWith("app_")) {
696
+ const app = await client.getApp(args.id);
697
+ if (!app.package_id) {
698
+ throw new Error(`App ${args.id} is not a package installation — it has no package to unpublish. ` +
699
+ `Pass the package id directly.`);
700
+ }
701
+ packageId = app.package_id;
915
702
  }
916
- }
917
- /**
918
- * `lotics package retire <package_id> [--undo]` — retire (or un-retire) a
919
- * registry package. Owner-org admin-only.
920
- */
921
- export async function packageRetire(client, args) {
922
- const pkg = await client.retireAppPackage(args.package_id, { undo: args.undo });
703
+ const pkg = await client.retirePackage(packageId, { undo: args.undo });
923
704
  if (pkg.retired_at !== null) {
924
- console.error(`Retired ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
925
- `existing installations keep working and may still upgrade.`);
705
+ console.error(`Unpublished ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
706
+ `existing installations keep working and may still upgrade. Undo: lotics app unpublish ${pkg.id} --undo`);
926
707
  }
927
708
  else {
928
- console.error(`Un-retired ${pkg.name} (${pkg.id}) — installable again.`);
709
+ console.error(`Re-published ${pkg.name} (${pkg.id}) — installable again.`);
929
710
  }
930
711
  }
931
712
  /**
932
- * `lotics package extract <app_id> [path]` promote a bespoke app to a DRAFT
933
- * package project (docs/app_packages.md § Promotion). Calls the extract read,
934
- * prints the findings report grouped by severity, then ALWAYS emits the draft
935
- * project (a broken contract is still the reviewable starting point): the app's
936
- * current source archive (same mechanics as `lotics app pull`) with the app
937
- * manifest swapped for an unpublished package manifest, `contract.json`, the
938
- * file-backed templates staged at their `bytes_ref` paths, and
939
- * `.lotics/adopt_binding.json` (the origin pin the `adopt` step reads back).
940
- * Exits non-zero when any `error` finding exists the draft is written, but
941
- * publish re-validates and nothing should ship unreviewed.
713
+ * `lotics app publish [app_id|.] [--rename old=new ...] [-m <changelog>] [--yes]`
714
+ * FIRST-RELEASE a bespoke app as a package (docs/packages.md § Promotion). Nothing
715
+ * starts as a package. Mirrors `app release`'s preview→apply UX: it first shows the
716
+ * dry-run preview (GET, no writes) the package name, the auto-minted RENAMABLE
717
+ * aliases (the exact `--rename` keys, so v1's frozen aliases are inspected first,
718
+ * never a blind publish), and the extract findings — then APPLIES only with
719
+ * `--yes` (else exits 1 with the re-run hint). On apply the server extracts the
720
+ * contract, creates the registry package, publishes v1 from the DEPLOYED source +
721
+ * dist, and pins the origin as installation #1. `--rename old=new` fixes an
722
+ * auto-minted alias before v1 freezes it; an `error` finding blocks the apply. An
723
+ * already-linked app releases with `lotics app release` instead. The app id is the
724
+ * positional (`.`/omitted → resolved from the local app project manifest).
942
725
  */
943
- export async function packageExtract(client, args) {
944
- const app = await client.getApp(args.app_id);
945
- if (!app.current_version_id) {
946
- throw new Error(`App ${app.id} has no deployed version extract stages its source archive, so deploy it first.`);
726
+ export async function appPublish(client, args) {
727
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
728
+ const local = readLocalAppManifest(projectDir);
729
+ const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
730
+ const appId = explicit ?? local?.app_id ?? null;
731
+ if (appId === null) {
732
+ throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
733
+ "or pass one: lotics app publish <app_id>.");
734
+ }
735
+ // Forward the app's package-managed knowledge declaration ONLY when the local
736
+ // manifest is this app's own project (agents reference docs by free text, so the
737
+ // author declares which the package owns). A bare id published from elsewhere
738
+ // ships without a bundled corpus — publish from the app dir to include it.
739
+ const knowledge = local && local.app_id === appId ? local.knowledge : [];
740
+ const renames = parseRenameFlags(args.renames);
741
+ // Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
742
+ // so the aliases v1 freezes forever are never a blind publish and `--rename`
743
+ // targets are inspectable before committing.
744
+ const preview = await client.previewPublishAppPackage(appId, { renames, knowledge });
745
+ const { lines, hasError } = formatExtractReport(preview.findings);
746
+ console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
747
+ const groups = [
748
+ ["entities", preview.renamable_aliases.entities],
749
+ ["fields", preview.renamable_aliases.fields],
750
+ ["options", preview.renamable_aliases.options],
751
+ ["roles", preview.renamable_aliases.roles],
752
+ ["templates", preview.renamable_aliases.templates],
753
+ ["workflows", preview.renamable_aliases.workflows],
754
+ ];
755
+ if (groups.some(([, vals]) => vals.length > 0)) {
756
+ console.error(" Auto-minted aliases — rename any with --rename <alias>=<new> before v1 freezes them:");
757
+ for (const [label, vals] of groups) {
758
+ if (vals.length > 0)
759
+ console.error(` ${`${label}:`.padEnd(11)} ${vals.join(", ")}`);
760
+ }
761
+ console.error(" (query / app-workflow / agent runtime aliases are fixed — the shipped source calls them verbatim.)");
762
+ }
763
+ else {
764
+ console.error(" No renamable aliases.");
947
765
  }
948
- const extracted = await client.extractAppPackage(args.app_id);
949
- const { lines, hasError } = formatExtractReport(extracted.report);
950
766
  if (lines.length > 0) {
951
- console.error(`Extraction report (${extracted.report.length}):`);
767
+ console.error(` Findings (${preview.findings.length}):`);
952
768
  for (const line of lines)
953
769
  console.error(line);
954
770
  }
955
- else {
956
- console.error("Extraction report: no findings.");
957
- }
958
- const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
959
- if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
960
- throw new Error(`Target directory ${targetPath} is not empty.`);
961
- }
962
- fs.mkdirSync(targetPath, { recursive: true });
963
- // Pull the app's current source archive — same mechanics as `lotics app pull`
964
- // (getAppVersion → presigned source URL → download → untar into the project).
965
- const version = await client.getAppVersion(app.id, app.current_version_id);
966
- const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
967
- const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
968
- console.error("Downloading source archive...");
969
- await client.downloadFile(sourceUrl, tmpFile);
970
- try {
971
- console.error(`Extracting to ${targetPath}...`);
972
- await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
973
- }
974
- finally {
975
- if (fs.existsSync(tmpFile))
976
- fs.unlinkSync(tmpFile);
977
- }
978
- // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
979
- // package manifest — atomic write via the existing manifest writer.
980
- const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
981
- const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
982
- // The origin app's package.json rides in the source archive with ITS OWN
983
- // @lotics/app-sdk range — raise it to the getAppBinding floor the generated
984
- // .lotics/app_fields.ts needs (never lower an already-newer range).
985
- const deps = isPlainObject(draft.pkgJson.dependencies) ? draft.pkgJson.dependencies : {};
986
- const originRange = typeof deps["@lotics/app-sdk"] === "string" ? deps["@lotics/app-sdk"] : null;
987
- const originFloor = originRange?.replace(/^[\^~]/, "") ?? null;
988
- if (originFloor === null || cmpVersions(originFloor, STARTER_FALLBACK_SDK_VERSION) < 0) {
989
- const raised = packageSdkRange(await fetchLatestNpmVersion("@lotics/app-sdk"));
990
- draft.pkgJson.dependencies = { ...deps, "@lotics/app-sdk": raised };
991
- console.error(`Raised @lotics/app-sdk to ${raised} (generated app_fields needs getAppBinding)`);
992
- }
993
- writePackageManifest(targetPath, draft);
994
- // The alias-keyed draft contract — the reviewable master. Pretty + trailing newline.
995
- fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(extracted.contract, null, 2) + "\n");
996
- console.error(`Wrote ${CONTRACT_FILE}`);
997
- // The origin's baked `.lotics/app_fields.ts` (concrete ids) never travels —
998
- // regenerate it contract-derived so `F`/`OPT` resolve per-install at runtime
999
- // and the extracted source compiles unchanged.
1000
- writePackageAppFields(targetPath);
1001
- // vite.config.ts is starter-owned machinery — refresh it wholesale so the
1002
- // package project carries the current starter config (notably
1003
- // `build.target: "es2022"`, which the generated app_fields' top-level await
1004
- // requires). Origin-side dev customizations (e.g. `lotics ui link` aliases)
1005
- // don't belong in a package.
1006
- const starterViteConfig = buildStarterTemplate({
1007
- app_name: app.name,
1008
- app_id: "",
1009
- workspace_id: "",
1010
- }).find((f) => f.path === "vite.config.ts");
1011
- if (starterViteConfig === undefined) {
1012
- throw new Error("starter template is missing vite.config.ts — cannot refresh the package project");
1013
- }
1014
- const viteConfigPath = path.join(targetPath, "vite.config.ts");
1015
- const originViteConfig = fs.existsSync(viteConfigPath)
1016
- ? fs.readFileSync(viteConfigPath, "utf-8")
1017
- : null;
1018
- fs.writeFileSync(viteConfigPath, starterViteConfig.content);
1019
- if (originViteConfig !== null && originViteConfig !== starterViteConfig.content) {
1020
- // The origin may carry legitimate customizations (optimizeDeps entries,
1021
- // plugins) beyond the dev-links that must not ship — never destroy them
1022
- // silently: stash the original for manual re-application.
1023
- const stash = path.join(dotLoticsDirEnsured(targetPath), "vite.config.origin.ts");
1024
- fs.writeFileSync(stash, originViteConfig);
1025
- console.error("Refreshed vite.config.ts from the starter (build.target es2022). The origin app's config " +
1026
- "differed — its original was saved to .lotics/vite.config.origin.ts; re-apply any " +
1027
- "custom optimizeDeps/plugins entries you still need (never dev-link aliases).");
1028
- }
1029
- else {
1030
- console.error("Refreshed vite.config.ts from the starter (build.target es2022)");
1031
- }
1032
- // Stage each file-backed template at the `bytes_ref` the contract references.
1033
- // The download lands in the target dir (fresh, so no name collision) under the
1034
- // file's own name; rename it to the exact `bytes_ref` basename the contract
1035
- // points at. Processed sequentially so a prior rename frees the name.
1036
- for (const tf of extracted.template_files) {
1037
- const dest = path.join(targetPath, tf.bytes_ref);
1038
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1039
- const { path: downloaded } = await client.downloadFileById(tf.file_id, path.dirname(dest));
1040
- if (path.resolve(downloaded) !== path.resolve(dest))
1041
- fs.renameSync(downloaded, dest);
1042
- console.error(`Wrote ${tf.bytes_ref}`);
1043
- }
1044
- // The origin pin the `adopt` step reads back. `.lotics` is in
1045
- // SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
1046
- const dotLotics = path.join(targetPath, ".lotics");
1047
- fs.mkdirSync(dotLotics, { recursive: true });
1048
- 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");
1049
- console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
1050
- console.error("Installing npm dependencies...");
1051
- await runNpm(["install"], targetPath);
1052
771
  if (hasError) {
1053
- console.error("\nExtraction produced error findings — the draft is written but NOT publishable as-is.");
1054
- console.error("Fix the reported items above, then publish (which re-validates the contract).");
772
+ console.error("\nExtract found error findings — the app cannot be published as-is. Fix them in the app, redeploy, and retry.");
1055
773
  process.exitCode = 1;
1056
774
  return;
1057
775
  }
1058
- console.error("\nDraft package project written. Next steps:");
1059
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
1060
- console.error(" # review the aliases in contract.json, then:");
1061
- console.error(` lotics package publish -m "v1"`);
1062
- console.error(` lotics package adopt ${app.id} (from this project, same workspace)`);
776
+ if (!args.yes) {
777
+ const idArg = explicit ?? ".";
778
+ const renameArgs = args.renames.map((r) => ` --rename ${r}`).join("");
779
+ const mArg = args.changelog ? ` -m ${JSON.stringify(args.changelog)}` : "";
780
+ console.error(`\nRe-run with --yes to publish v1:`);
781
+ console.error(` lotics app publish ${idArg}${renameArgs}${mArg} --yes`);
782
+ process.exitCode = 1;
783
+ return;
784
+ }
785
+ const result = await client.publishAppAsPackage(appId, {
786
+ renames,
787
+ changelog: args.changelog ?? null,
788
+ knowledge,
789
+ });
790
+ console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
791
+ console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
792
+ console.error(` lotics app pull ${appId} # edit, then lotics app deploy`);
793
+ console.error(` lotics app release ${appId} -m "<what changed>"`);
794
+ console.error(` Install it elsewhere: lotics install ${result.package_id}`);
1063
795
  }
1064
796
  /**
1065
- * `lotics package adopt <app_id> [path]` bind the published package project
1066
- * onto the origin app (docs/app_packages.md § Promotion). Reads the project
1067
- * manifest (must be publishedrefuses otherwise), resolves the version
1068
- * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
1069
- * REFUSING a pin recorded for a different app. On success the app becomes
1070
- * installation #1 and is upgradeable again; a server ConflictError (naming the
1071
- * unfaithful aliases) surfaces verbatim.
797
+ * Resolve the release/publish target app id: an explicit `<app_id>` wins; `.` (or
798
+ * no positional) resolves the local app project manifest's `lotics.app_id` the
799
+ * project `lotics app pull` writes so a release from the pulled app dir needs
800
+ * no id.
1072
801
  */
1073
- export async function packageAdopt(client, args) {
802
+ function resolveOriginAppId(projectDir, explicit) {
803
+ if (explicit !== undefined && explicit !== ".")
804
+ return explicit;
805
+ const local = readLocalAppManifest(projectDir);
806
+ if (local?.app_id)
807
+ return local.app_id;
808
+ throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
809
+ }
810
+ /**
811
+ * `lotics app release [app_id|.] -m <changelog> [--yes]` — snapshot an
812
+ * adopted/installed origin app into its next registry version (docs/packages.md
813
+ * § Promotion). The origin is the permanent working copy; a release binding-aware-
814
+ * extracts it (stable aliases), repackages its DEPLOYED source + dist as the
815
+ * bundle, publishes the next version, and re-pins the origin. Prints the preview
816
+ * first (next version, new + changed aliases, findings); applies only with
817
+ * `--yes`, else exits 1 so a review step can't be skipped. An `error` finding
818
+ * blocks the apply.
819
+ */
820
+ export async function appRelease(client, args) {
1074
821
  const projectDir = path.resolve(args.projectDir ?? process.cwd());
1075
- const { manifest } = readPackageProject(projectDir);
1076
- if (manifest.id === null) {
1077
- throw new Error("This package project has never been published (no package id). Publish first:\n lotics package publish");
822
+ const appId = resolveOriginAppId(projectDir, args.app_id);
823
+ const preview = await client.previewPackageRelease(appId);
824
+ const { lines, hasError } = formatExtractReport(preview.findings);
825
+ console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
826
+ if (preview.added_aliases.length > 0) {
827
+ console.error(` New (${preview.added_aliases.length}): ${preview.added_aliases.join(", ")}`);
1078
828
  }
1079
- const version = args.version ?? manifest.version;
1080
- if (version === null) {
1081
- throw new Error("No version to adopt — publish this project first (lotics package publish) or pass --version N.");
829
+ if (preview.changed_artifacts.length > 0) {
830
+ console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
1082
831
  }
1083
- const bindingPath = path.join(projectDir, ".lotics", ADOPT_BINDING_FILE);
1084
- if (!fs.existsSync(bindingPath)) {
1085
- throw new Error(`No .lotics/${ADOPT_BINDING_FILE} in ${projectDir}. Adopt binds the origin app that ` +
1086
- `"lotics package extract" recorded — run extract to produce this project.`);
832
+ if (preview.added_aliases.length === 0 && preview.changed_artifacts.length === 0) {
833
+ console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
1087
834
  }
1088
- const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
1089
- const app = await client.adoptAppPackage(args.app_id, {
1090
- package_id: manifest.id,
1091
- version,
1092
- binding: pin.binding,
1093
- });
1094
- console.error(`Adopted ${app.name} installation of ${app.package_id} v${app.package_version}.`);
1095
- console.error(` Verify the installation: lotics package doctor ${app.id}`);
835
+ if (lines.length > 0) {
836
+ console.error(` Findings (${preview.findings.length}):`);
837
+ for (const line of lines)
838
+ console.error(line);
839
+ }
840
+ if (hasError) {
841
+ console.error("\nExtract found error findings — the origin cannot be released as-is. Fix them in the app and retry.");
842
+ process.exitCode = 1;
843
+ return;
844
+ }
845
+ if (!args.yes) {
846
+ console.error(`\nRe-run with --yes to publish v${preview.version}:`);
847
+ console.error(` lotics app release ${args.app_id ?? "."} -m ${JSON.stringify(args.changelog)} --yes`);
848
+ process.exitCode = 1;
849
+ return;
850
+ }
851
+ const result = await client.releasePackage(appId, { changelog: args.changelog });
852
+ console.error(`Released ${result.package_id} v${result.version}.`);
853
+ console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
1096
854
  }
1097
- /**
1098
- * `lotics package fleet-upgrade <package_id> [--version N]` — bring every
1099
- * installation of the package across the caller's org to the target version.
1100
- * Hands-off applies only where the preview is clean; skipped/failed
1101
- * installations are reported per line and the process exits 1 so a release
1102
- * script can gate on "fleet fully current".
1103
- */
1104
855
  /**
1105
856
  * `lotics package yank <package_id> <version> [--undo]` — mark a published
1106
857
  * version uninstallable (or restore it). New installs/upgrades/adopts refuse a
@@ -1108,7 +859,7 @@ export async function packageAdopt(client, args) {
1108
859
  * running. Owner-org admin-only.
1109
860
  */
1110
861
  export async function packageYank(client, args) {
1111
- const result = await client.yankAppPackageVersion(args.package_id, args.version, !args.undo);
862
+ const result = await client.yankPackageVersion(args.package_id, args.version, !args.undo);
1112
863
  if (result.yanked_at !== null) {
1113
864
  console.error(`Yanked ${result.package_id} v${result.version} (${result.yanked_at}). ` +
1114
865
  `New installs/upgrades refuse it; pinned installations keep running.`);
@@ -1118,8 +869,15 @@ export async function packageYank(client, args) {
1118
869
  }
1119
870
  console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
1120
871
  }
872
+ /**
873
+ * `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
874
+ * installation of the package across the caller's org to the target version.
875
+ * Hands-off applies only where the preview is clean; skipped/failed
876
+ * installations are reported per line and the process exits 1 so a release
877
+ * script can gate on "fleet fully current".
878
+ */
1121
879
  export async function packageFleetUpgrade(client, args) {
1122
- const result = await client.fleetUpgradeAppPackage(args.package_id, {
880
+ const result = await client.fleetUpgradePackage(args.package_id, {
1123
881
  ...(args.version !== undefined ? { version: args.version } : {}),
1124
882
  });
1125
883
  console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);
@@ -1134,7 +892,7 @@ export async function packageFleetUpgrade(client, args) {
1134
892
  const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
1135
893
  if (inst.outcome === "skipped" && inst.blockers) {
1136
894
  console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
1137
- console.error(` resolve via: lotics package upgrade ${inst.app_id} --version ${result.target_version} ...`);
895
+ console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
1138
896
  }
1139
897
  else if (inst.message) {
1140
898
  console.error(`${line}: ${inst.message}`);