@lotics/cli 0.76.1 → 0.86.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,228 +1,76 @@
1
1
  /**
2
- * `lotics package *` subcommands the app-package authoring + dev/test/run loop
3
- * (see docs/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
- import { createHash } from "node:crypto";
28
23
  import "./client.js";
29
24
  import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
30
- import { buildStarterTemplate, buildPackageStarterOverrides, STARTER_FALLBACK_SDK_VERSION, } from "./starter_template.js";
31
- import { generatePackageAppFields, } from "./generate_package_fields.js";
32
- import { appDirName, fetchLatestNpmVersion, runNpm, runTar, writeAppDts } from "./app_commands.js";
33
- import { writeFileAtomic } from "./file_command_io.js";
34
- import { startDevServer, openBrowser } from "./dev/server.js";
35
- const CONTRACT_FILE = "contract.json";
36
- /**
37
- * The origin pin `lotics package extract` writes and `lotics package adopt`
38
- * reads back: `{ app_id, workspace_id, binding }`. Lives under `.lotics/`, which
39
- * is in `SOURCE_STAGE_EXCLUDES` — so it never rides along in a published bundle.
40
- */
41
- const ADOPT_BINDING_FILE = "adopt_binding.json";
42
- const PACKAGE_TEMPLATE_KINDS = new Set([
43
- "html",
44
- "email",
45
- "excel",
46
- "word",
47
- "pdf-form",
48
- ]);
49
25
  function packageJsonPath(projectDir) {
50
26
  return path.join(projectDir, "package.json");
51
27
  }
52
28
  function isPlainObject(value) {
53
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
54
30
  }
55
- /** Read the package project's manifest, failing loud when the dir isn't one. */
56
- 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) {
57
41
  const pkgPath = packageJsonPath(projectDir);
58
- if (!fs.existsSync(pkgPath)) {
59
- throw new Error(`No package.json in ${projectDir}. Run this inside a package project (lotics package new <name>).`);
60
- }
42
+ if (!fs.existsSync(pkgPath))
43
+ return null;
61
44
  const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
62
45
  if (!isPlainObject(parsed))
63
- throw new Error(`Malformed package.json in ${projectDir}.`);
64
- const lotics = parsed.lotics;
65
- const pkg = isPlainObject(lotics) ? lotics.package : undefined;
66
- if (!isPlainObject(pkg) || typeof pkg.name !== "string") {
67
- throw new Error(`${pkgPath} has no lotics.package manifest — not a package project. Use "lotics package new <name>".`);
68
- }
69
- const dev = {};
70
- if (isPlainObject(pkg.dev)) {
71
- for (const [ws, entry] of Object.entries(pkg.dev)) {
72
- if (isPlainObject(entry) && typeof entry.app_id === "string" && typeof entry.version === "number") {
73
- dev[ws] = { app_id: entry.app_id, version: entry.version };
74
- }
75
- }
76
- }
77
- const knowledge = {};
78
- if (isPlainObject(pkg.knowledge)) {
79
- for (const [alias, entry] of Object.entries(pkg.knowledge)) {
80
- if (!isPlainObject(entry) || typeof entry.name !== "string" || entry.name.length === 0) {
81
- throw new Error(`${pkgPath}: lotics.package.knowledge.${alias} must be an object with a non-empty "name".`);
82
- }
83
- knowledge[alias] = {
84
- name: entry.name,
85
- description: typeof entry.description === "string" ? entry.description : null,
86
- active_by_default: typeof entry.active_by_default === "boolean" ? entry.active_by_default : true,
87
- };
88
- }
89
- }
90
- const templates = {};
91
- if (isPlainObject(pkg.templates)) {
92
- for (const [alias, entry] of Object.entries(pkg.templates)) {
93
- if (!isPlainObject(entry) ||
94
- typeof entry.name !== "string" ||
95
- entry.name.length === 0 ||
96
- typeof entry.type !== "string" ||
97
- !PACKAGE_TEMPLATE_KINDS.has(entry.type) ||
98
- typeof entry.file !== "string" ||
99
- entry.file.length === 0) {
100
- throw new Error(`${pkgPath}: lotics.package.templates.${alias} must be an object with a non-empty "name", ` +
101
- `a "type" of html|email|excel|word|pdf-form, and a non-empty "file" (a name in templates/).`);
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 });
102
54
  }
103
- templates[alias] = {
104
- name: entry.name,
105
- type: entry.type,
106
- file: entry.file,
107
- };
108
55
  }
109
56
  }
110
- const knowledge_expects = Array.isArray(pkg.knowledge_expects)
111
- ? pkg.knowledge_expects.filter((v) => typeof v === "string")
112
- : [];
113
- const manifest = {
114
- id: typeof pkg.id === "string" ? pkg.id : null,
115
- name: pkg.name,
116
- description: typeof pkg.description === "string" ? pkg.description : null,
117
- kind: pkg.kind === "content" ? "content" : "app",
118
- version: typeof pkg.version === "number" ? pkg.version : null,
119
- knowledge,
120
- templates,
121
- knowledge_expects,
122
- dev,
123
- };
124
- return { pkgJson: parsed, manifest };
125
- }
126
- /** Persist an updated manifest back into the project's package.json (atomic write). */
127
- export function writePackageManifest(projectDir, project) {
128
- const next = {
129
- ...project.pkgJson,
130
- lotics: {
131
- ...(isPlainObject(project.pkgJson.lotics) ? project.pkgJson.lotics : {}),
132
- package: project.manifest,
133
- },
134
- };
135
- // Atomic write: a torn plain write could corrupt package.json and lose the
136
- // stamped registry `package_id` — which would orphan the published package
137
- // and create a duplicate on the next publish retry.
138
- writeFileAtomic(packageJsonPath(projectDir), new TextEncoder().encode(JSON.stringify(next, null, 2) + "\n"));
139
- }
140
- /**
141
- * The package.json to ship INSIDE `source.tar.gz`: the on-disk manifest with the
142
- * author-local `lotics.package.dev` map stripped. `dev` is the author's private
143
- * dev-workspace → installation bookkeeping; it must never reach a consumer (every
144
- * install carries the source, and `lotics app pull` ejects it). Returns a
145
- * sanitized copy — the on-disk package.json is left untouched. `id`/`name`/
146
- * `description`/`version` are the package's stable identity and stay.
147
- */
148
- export function sanitizePackageJsonForSource(pkgJson) {
149
- const lotics = isPlainObject(pkgJson.lotics) ? pkgJson.lotics : {};
150
- const pkg = isPlainObject(lotics.package) ? lotics.package : {};
151
- const { dev: _dev, ...pkgWithoutDev } = pkg;
152
- return {
153
- ...pkgJson,
154
- lotics: { ...lotics, package: pkgWithoutDev },
155
- };
57
+ return { app_id, knowledge };
156
58
  }
157
- /**
158
- * Transform an app project's package.json (the source archive `lotics app pull`
159
- * downloads, carrying the `lotics.app_id`/`workspace_id` app manifest) into a
160
- * package project's `PackageProjectFile`: the app manifest is stripped ENTIRELY
161
- * and a fresh, unpublished `lotics.package` manifest (id/version null) is
162
- * grafted. Purereturns a new value, never mutates the input;
163
- * `writePackageManifest` writes it (atomic). The bespoke→package promotion's
164
- * manifest inversion.
165
- */
166
- export function draftPackageProjectFromApp(appPkgJson, args) {
167
- const { lotics: _appManifest, ...rest } = appPkgJson;
168
- return {
169
- pkgJson: rest,
170
- manifest: {
171
- id: null,
172
- name: args.name,
173
- description: args.description,
174
- // Extraction promotes a bespoke APP to a package — always app kind.
175
- kind: "app",
176
- version: null,
177
- knowledge: {},
178
- templates: {},
179
- knowledge_expects: [],
180
- dev: {},
181
- },
182
- };
183
- }
184
- /**
185
- * Parse + validate `.lotics/adopt_binding.json` against the app being adopted.
186
- * REFUSES a pin recorded for a different app: a binding maps ONE workspace's
187
- * concrete ids, so replaying it onto another app would bind the wrong objects.
188
- * Pure; the CLI never interprets the binding, only round-trips it to the server.
189
- */
190
- export function parseAdoptBindingFile(raw, expectedAppId) {
191
- if (!isPlainObject(raw) ||
192
- typeof raw.app_id !== "string" ||
193
- typeof raw.workspace_id !== "string" ||
194
- !isPlainObject(raw.binding)) {
195
- throw new Error("Malformed .lotics/adopt_binding.json — expected { app_id, workspace_id, binding }.");
196
- }
197
- if (raw.app_id !== expectedAppId) {
198
- throw new Error(`.lotics/adopt_binding.json records app ${raw.app_id}, but you are adopting ${expectedAppId}. ` +
199
- `A binding maps one app's concrete ids and must never be applied to another — ` +
200
- `run "lotics package extract ${expectedAppId}" to produce the right pin.`);
201
- }
202
- // Boundary adapter: the file is untyped JSON; `isPlainObject` proved the shape
203
- // and the CLI passes the maps straight to the server (the validating authority).
204
- // `knowledge_binding` is optional (absent for a pre-knowledge extract / an app
205
- // with no package-managed docs) → defaults to {}.
206
- const knowledge_binding = {};
207
- if (isPlainObject(raw.knowledge_binding)) {
208
- for (const [alias, id] of Object.entries(raw.knowledge_binding)) {
209
- if (typeof id === "string")
210
- knowledge_binding[alias] = id;
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).`);
211
65
  }
212
- }
213
- return {
214
- app_id: raw.app_id,
215
- workspace_id: raw.workspace_id,
216
- binding: raw.binding,
217
- knowledge_binding,
218
- };
66
+ return { from: entry.slice(0, eq), to: entry.slice(eq + 1) };
67
+ });
219
68
  }
220
69
  /**
221
- * Render an extraction report grouped by severity (errors, then warnings, then
222
- * info), one ` [<severity>] <area>: <message>` line each, and classify whether
223
- * any `error` finding is present. An `error` the draft is not publishable
224
- * as-is, so `lotics package extract` exits non-zero. Pure the command prints
225
- * `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`.
226
74
  */
227
75
  export function formatExtractReport(report) {
228
76
  const order = ["error", "warning", "info"];
@@ -231,737 +79,37 @@ export function formatExtractReport(report) {
231
79
  .map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
232
80
  return { lines, hasError: report.some((f) => f.severity === "error") };
233
81
  }
234
- function dotLoticsDirEnsured(projectDir) {
235
- const dir = path.join(projectDir, ".lotics");
236
- fs.mkdirSync(dir, { recursive: true });
237
- return dir;
238
- }
239
- /** Numeric "x.y.z" compare — no semver dep (the CLI has zero runtime deps). */
240
- function cmpVersions(a, b) {
241
- const pa = a.split(".").map(Number);
242
- const pb = b.split(".").map(Number);
243
- for (let i = 0; i < 3; i++) {
244
- const d = (pa[i] || 0) - (pb[i] || 0);
245
- if (d !== 0)
246
- return d;
247
- }
248
- return 0;
249
- }
250
- /**
251
- * The `@lotics/app-sdk` range a package project needs: the live npm latest,
252
- * clamped to never fall below `STARTER_FALLBACK_SDK_VERSION` — the release
253
- * that ships `getAppBinding`, which the generated `.lotics/app_fields.ts`
254
- * imports. A `^0.x` caret range never crosses a minor, so pinning below the
255
- * floor (offline, or npm not yet carrying the release) would permanently
256
- * scaffold projects that cannot compile their own generated code.
257
- */
258
- function packageSdkRange(sdkLatest) {
259
- const version = sdkLatest !== null && cmpVersions(sdkLatest, STARTER_FALLBACK_SDK_VERSION) > 0
260
- ? sdkLatest
261
- : STARTER_FALLBACK_SDK_VERSION;
262
- return `^${version}`;
263
- }
264
- /** Read + JSON-parse the project's contract.json (the alias-keyed declaration). */
265
- function readContract(projectDir) {
266
- const contractPath = path.join(projectDir, CONTRACT_FILE);
267
- if (!fs.existsSync(contractPath)) {
268
- throw new Error(`No ${CONTRACT_FILE} in ${projectDir}. A package project declares its data model there.`);
269
- }
270
- return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
271
- }
272
- /**
273
- * Fold a package project's knowledge corpus into its contract before publish:
274
- * per `lotics.package.knowledge` alias, read `knowledge/<alias>.md`, sha256 the
275
- * bytes, and emit the contract's `knowledge` namespace (alias → {name,
276
- * description, content_ref, content_sha256, active_by_default}) +
277
- * `knowledge_expects` (from `lotics.package.knowledge_expects`). This is the ONE
278
- * place the sha is computed — it can't be hand-authored, which is why knowledge
279
- * (unlike a template `bytes_ref`) is derived rather than written into
280
- * contract.json. The content files themselves ride in `source.tar.gz`
281
- * automatically: `knowledge/` is a top-level dir, so `stagePackageSource` copies
282
- * it like any other source (the same mechanism that carries template bytes). Pure
283
- * w.r.t. `contract` — returns a new object; the server re-validates the folded
284
- * namespace at publish.
285
- */
286
- export function foldKnowledgeIntoContract(projectDir, project, contract) {
287
- const aliases = Object.keys(project.manifest.knowledge);
288
- const expects = project.manifest.knowledge_expects;
289
- if (aliases.length === 0 && expects.length === 0)
290
- return contract;
291
- const knowledge = {};
292
- for (const alias of aliases) {
293
- const entry = project.manifest.knowledge[alias];
294
- const contentRef = `knowledge/${alias}.md`;
295
- const filePath = path.join(projectDir, "knowledge", `${alias}.md`);
296
- if (!fs.existsSync(filePath)) {
297
- throw new Error(`Declared knowledge doc "${alias}" (lotics.package.knowledge) has no ${contentRef} in the project. ` +
298
- `Create the file, or remove the manifest entry.`);
299
- }
300
- const bytes = fs.readFileSync(filePath);
301
- knowledge[alias] = {
302
- name: entry.name,
303
- description: entry.description ?? "",
304
- content_ref: contentRef,
305
- content_sha256: createHash("sha256").update(bytes).digest("hex"),
306
- active_by_default: entry.active_by_default,
307
- };
308
- }
309
- return { ...contract, knowledge, knowledge_expects: expects };
310
- }
311
- /**
312
- * Fold a CONTENT package's manifest-declared templates into its contract before
313
- * publish: per `lotics.package.templates` alias, an INLINE kind (html/email) reads
314
- * `templates/<file>` into the contract template's `content`; a FILE-BACKED kind
315
- * (excel/word/pdf-form) records `bytes_ref: templates/<file>` (the bytes ride in
316
- * `source.tar.gz` — `templates/` is a top-level dir `stagePackageSource` copies,
317
- * the same mechanism that carries knowledge content + app-package template bytes).
318
- * The per-template `content_sha256` is NOT computed here — `foldTemplateShasIntoContract`
319
- * runs after and derives it (inline: the utf-8 content; file-backed: the file bytes).
320
- *
321
- * Appends to any templates the contract already declares (an app package authors
322
- * templates in `contract.json` directly; the manifest path is how a content
323
- * package — with no `contract.json` templates — declares them). An alias in BOTH
324
- * is a loud error. Pure w.r.t. `contract` — returns a new object.
325
- */
326
- export function foldTemplatesIntoContract(projectDir, project, contract) {
327
- if (!isPlainObject(contract)) {
328
- throw new Error(`${CONTRACT_FILE} in ${projectDir} must be a JSON object.`);
329
- }
330
- const aliases = Object.keys(project.manifest.templates);
331
- if (aliases.length === 0)
332
- return contract;
333
- const existing = Array.isArray(contract.templates) ? contract.templates : [];
334
- const existingAliases = new Set(existing
335
- .filter((t) => isPlainObject(t))
336
- .map((t) => t.alias)
337
- .filter((a) => typeof a === "string"));
338
- const folded = aliases.map((alias) => {
339
- if (existingAliases.has(alias)) {
340
- throw new Error(`Template alias "${alias}" is declared in both lotics.package.templates and ${CONTRACT_FILE} — ` +
341
- `declare each template once.`);
342
- }
343
- const entry = project.manifest.templates[alias];
344
- const filePath = path.join(projectDir, "templates", entry.file);
345
- if (!fs.existsSync(filePath)) {
346
- throw new Error(`Declared template "${alias}" (lotics.package.templates) has no templates/${entry.file} in the project. ` +
347
- `Stage the file, then rebuild.`);
348
- }
349
- if (entry.type === "html" || entry.type === "email") {
350
- return { alias, label: entry.name, type: entry.type, content: fs.readFileSync(filePath, "utf-8") };
351
- }
352
- return { alias, label: entry.name, type: entry.type, bytes_ref: `templates/${entry.file}` };
353
- });
354
- return { ...contract, templates: [...existing, ...folded] };
355
- }
356
- /**
357
- * Fold a freshly-computed `content_sha256` into every template of a package
358
- * contract before publish — the modified-detection reference the install/upgrade
359
- * flow compares live content against. Like knowledge's sha (foldKnowledge…), it
360
- * can't be hand-authored: an INLINE template's sha covers its utf-8 `content`; a
361
- * FILE-BACKED template's sha covers the `bytes_ref` file's bytes (the same bytes
362
- * that ride in `source.tar.gz` and install stores verbatim). Always recomputed
363
- * (never trusted from contract.json), so the published contract's sha is exact.
364
- * Pure w.r.t. `contract` — returns a new object; the server re-validates.
365
- */
366
- export function foldTemplateShasIntoContract(projectDir, contract) {
367
- const templates = contract.templates;
368
- if (!Array.isArray(templates) || templates.length === 0)
369
- return contract;
370
- const withShas = templates.map((template) => {
371
- if (!isPlainObject(template) ||
372
- typeof template.type !== "string" ||
373
- typeof template.alias !== "string") {
374
- // Malformed — leave as-is; the server's contract validator rejects it.
375
- return template;
376
- }
377
- if (template.type === "html" || template.type === "email") {
378
- if (typeof template.content !== "string") {
379
- throw new Error(`Inline template "${template.alias}" must carry a string "content".`);
380
- }
381
- return {
382
- ...template,
383
- content_sha256: createHash("sha256").update(Buffer.from(template.content, "utf-8")).digest("hex"),
384
- };
385
- }
386
- if (typeof template.bytes_ref !== "string") {
387
- throw new Error(`File-backed template "${template.alias}" must carry a string "bytes_ref".`);
388
- }
389
- const filePath = path.join(projectDir, template.bytes_ref);
390
- if (!fs.existsSync(filePath)) {
391
- throw new Error(`Template "${template.alias}" references "${template.bytes_ref}", which is not in the project. ` +
392
- `Stage the file, then rebuild.`);
393
- }
394
- const bytes = fs.readFileSync(filePath);
395
- return { ...template, content_sha256: createHash("sha256").update(bytes).digest("hex") };
396
- });
397
- return { ...contract, templates: withShas };
398
- }
399
- /**
400
- * (Re)generate the package project's `.lotics/app_fields.ts` from its
401
- * contract.json — the runtime-resolving F/OPT/ROLE surface (see
402
- * `generate_package_fields.ts`). Runs on new/extract and on every dev/sync so
403
- * contract edits keep the aliases addressable. `.lotics` is in
404
- * SOURCE_STAGE_EXCLUDES: the compiled module ships inside `dist/`, and a
405
- * post-eject `lotics app pull` regenerates the bespoke (baked) variant.
406
- */
407
- function writePackageAppFields(projectDir) {
408
- const contract = readContract(projectDir);
409
- const dotLotics = path.join(projectDir, ".lotics");
410
- fs.mkdirSync(dotLotics, { recursive: true });
411
- fs.writeFileSync(path.join(dotLotics, "app_fields.ts"), generatePackageAppFields(contract));
412
- console.error("Wrote .lotics/app_fields.ts (contract-derived, runtime-resolved)");
413
- }
414
- /** Top-level project entries that never ship in the source archive. */
415
- const SOURCE_STAGE_EXCLUDES = new Set([
416
- "node_modules",
417
- "dist",
418
- ".lotics",
419
- ".git",
420
- "bundle.tar.gz",
421
- "package.json",
422
- ]);
423
- /**
424
- * Copy the project's source tree into `sourceStage` with explicit TOP-LEVEL
425
- * excludes, writing a sanitized `package.json` in place of the on-disk one.
426
- * Deliberately not tar `--exclude` flags: those match at any depth (a nested
427
- * `templates/dist/` would be silently dropped) and GNU tar vs bsdtar (macOS)
428
- * disagree on `./`-prefixed patterns, which broke the sanitized-package.json
429
- * graft on macOS.
430
- */
431
- export function stagePackageSource(projectDir, sourceStage) {
432
- const project = readPackageProject(projectDir);
433
- fs.mkdirSync(sourceStage, { recursive: true });
434
- for (const entry of fs.readdirSync(projectDir)) {
435
- if (SOURCE_STAGE_EXCLUDES.has(entry) || entry.endsWith(".tsbuildinfo"))
436
- continue;
437
- fs.cpSync(path.join(projectDir, entry), path.join(sourceStage, entry), { recursive: true });
438
- }
439
- fs.writeFileSync(path.join(sourceStage, "package.json"), JSON.stringify(sanitizePackageJsonForSource(project.pkgJson), null, 2) + "\n");
440
- }
441
- /**
442
- * Build the publishable bundle: a gzipped tarball carrying `source.tar.gz` (the
443
- * pullable project tree) + `dist.tar.gz` (the prebuilt assets) as its two
444
- * top-level members — the publish↔install contract the backend's
445
- * `extractPackageBundle` reads (both members are required). Returns the bytes.
446
- *
447
- * An `app` package compiles its frontend (`npm run build` → `dist/`). A
448
- * `content` package has NO app source to compile, so the build is skipped and a
449
- * minimal placeholder `dist.tar.gz` is shipped: the bundle format requires the
450
- * member, but a content install never deploys a frontend (it only reads the
451
- * `knowledge/*.md` files from `source.tar.gz`), so nothing ever reads it.
452
- */
453
- async function buildPackageBundle(projectDir) {
454
- const { manifest } = readPackageProject(projectDir);
455
- // Stage the two member archives in a temp dir, then tar them into the bundle.
456
- const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-"));
457
- const bundlePath = path.join(tmpdir(), `lotics-bundle-${Date.now()}.tar.gz`);
458
- try {
459
- let distDir;
460
- if (manifest.kind === "content") {
461
- console.error("Content package — skipping app build.");
462
- distDir = path.join(stage, "empty-dist");
463
- fs.mkdirSync(distDir, { recursive: true });
464
- fs.writeFileSync(path.join(distDir, ".content-package"), "Content package — no frontend bundle. Content ships in source.tar.gz under knowledge/.\n");
465
- }
466
- else {
467
- console.error("Building...");
468
- await runNpm(["run", "build"], projectDir);
469
- distDir = path.join(projectDir, "dist");
470
- if (!fs.existsSync(distDir)) {
471
- throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
472
- `Check that 'npm run build' is configured correctly.`);
473
- }
474
- }
475
- console.error("Packaging source...");
476
- // Stage a copy of the source tree with EXPLICIT top-level excludes, then
477
- // tar the stage with no --exclude flags at all. tar exclude patterns are
478
- // the wrong tool twice over: they match at ANY depth (a nested
479
- // `templates/dist/` would be silently dropped from the bundle), and GNU
480
- // tar vs bsdtar (macOS) disagree on whether `./package.json` and
481
- // `package.json` are the same pattern — bsdtar strips the leading `./`,
482
- // which would also exclude the sanitized package.json grafted below.
483
- //
484
- // The on-disk package.json carries the author-local `lotics.package.dev`
485
- // map; the stage gets a sanitized copy in its place, so that bookkeeping
486
- // never ships to a consumer nor lands in an `lotics app pull` eject.
487
- const sourceStage = path.join(stage, "source");
488
- stagePackageSource(projectDir, sourceStage);
489
- await runTar(["-czf", path.join(stage, "source.tar.gz"), "-C", sourceStage, "."], projectDir);
490
- console.error("Packaging dist...");
491
- await runTar(["-czf", path.join(stage, "dist.tar.gz"), "-C", distDir, "."], projectDir);
492
- console.error("Bundling...");
493
- await runTar(["-czf", bundlePath, "source.tar.gz", "dist.tar.gz"], stage);
494
- return fs.readFileSync(bundlePath);
495
- }
496
- finally {
497
- fs.rmSync(stage, { recursive: true, force: true });
498
- if (fs.existsSync(bundlePath))
499
- fs.unlinkSync(bundlePath);
500
- }
501
- }
502
- /** The minimal, valid starting contract a `package new` scaffold ships. */
503
- export function starterContract(name) {
504
- return {
505
- entities: [
506
- {
507
- alias: "item",
508
- label: name,
509
- description: `Records managed by the ${name} package.`,
510
- fields: [
511
- { alias: "name", label: "Name", type: "text", required: true },
512
- { alias: "notes", label: "Notes", type: "text" },
513
- {
514
- alias: "status",
515
- label: "Status",
516
- type: "select",
517
- options: [
518
- { alias: "open", label: "Open", color: "blue" },
519
- { alias: "done", label: "Done", color: "green" },
520
- ],
521
- },
522
- ],
523
- },
524
- ],
525
- roles: [],
526
- templates: [],
527
- queries: [
528
- {
529
- alias: "items",
530
- ast: {
531
- kind: "from_table",
532
- from_entity: "item",
533
- sort: [{ field_key: "name", order: "asc" }],
534
- },
535
- },
536
- ],
537
- workflows: [],
538
- agents: [],
539
- config: [
540
- { alias: "heading", label: "List heading", type: "text", default: name },
541
- ],
542
- };
543
- }
544
- /** An empty contract — all app namespaces empty (knowledge is folded in at build). */
545
- function emptyContract() {
546
- return {
547
- entities: [],
548
- roles: [],
549
- templates: [],
550
- queries: [],
551
- workflows: [],
552
- agents: [],
553
- config: [],
554
- };
555
- }
556
- /**
557
- * Scaffold a CONTENT package project — a standalone doc corpus, NO app source.
558
- * Just a `lotics.package` manifest (kind: "content"), an empty base
559
- * `contract.json` (the app namespaces MUST stay empty for a content package;
560
- * `build` folds the docs into the `knowledge` namespace), and a `knowledge/` dir
561
- * with one example doc wired into the manifest so the scaffold is immediately
562
- * publishable (publish refuses a content package with zero docs). No npm
563
- * install — a content package has no dependencies to compile.
564
- */
565
- function scaffoldContentPackage(name, targetPath) {
566
- const exampleAlias = "overview";
567
- const manifest = {
568
- id: null,
569
- name,
570
- description: null,
571
- kind: "content",
572
- version: null,
573
- knowledge: {
574
- [exampleAlias]: {
575
- name: `${name} Overview`,
576
- description: "What this corpus covers — replace with your doc's summary.",
577
- active_by_default: true,
578
- },
579
- },
580
- // A content package can also ship standalone document templates — declare them
581
- // under `lotics.package.templates` with the files in a `templates/` dir. See
582
- // the commented example printed by the scaffold + docs/packages.md § Content.
583
- templates: {},
584
- knowledge_expects: [],
585
- dev: {},
586
- };
587
- const pkgJson = {
588
- name: appDirName(name),
589
- private: true,
590
- version: "0.0.0",
591
- lotics: { package: manifest },
592
- };
593
- fs.writeFileSync(packageJsonPath(targetPath), JSON.stringify(pkgJson, null, 2) + "\n");
594
- fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(emptyContract(), null, 2) + "\n");
595
- const knowledgeDir = path.join(targetPath, "knowledge");
596
- fs.mkdirSync(knowledgeDir, { recursive: true });
597
- fs.writeFileSync(path.join(knowledgeDir, `${exampleAlias}.md`), `# ${name}\n\nReplace this file with the corpus content. Structure it under clear\n` +
598
- `Markdown headers so an agent can outline and read it by section.\n`);
599
- console.error(`Scaffolded a content package into ${targetPath}`);
600
- console.error(`\nReady. Next steps:`);
601
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
602
- console.error(` # add docs: create knowledge/<alias>.md + a lotics.package.knowledge.<alias> entry`);
603
- console.error(` # (name/description/active_by_default), then publish:`);
604
- console.error(` # add templates: create templates/<file> + a lotics.package.templates.<alias> entry:`);
605
- console.error(` # "templates": { "quote": { "name": "Quote", "type": "html", "file": "quote.html" } }`);
606
- console.error(` # inline kinds html|email read templates/<file> as content; file-backed`);
607
- console.error(` # excel|word|pdf-form pack the bytes into the bundle (bytes_ref = templates/<file>).`);
608
- console.error(` lotics package publish -m "v1"`);
609
- console.error(` lotics package install <package_id>`);
610
- }
611
- /**
612
- * `lotics package new <name> [path] [--kind content]` — scaffold a package
613
- * project. An `app` package reuses the app starter (Vite+React+TS) for the code
614
- * surface, swaps its app manifest for a package manifest, and adds a starter
615
- * `contract.json`; a `content` package scaffolds a docs-only corpus (no app
616
- * source). The project publishes with `lotics package publish`.
617
- */
618
- export async function packageNew(args) {
619
- const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
620
- if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
621
- throw new Error(`Target directory ${targetPath} is not empty.`);
622
- }
623
- fs.mkdirSync(targetPath, { recursive: true });
624
- if ((args.kind ?? "app") === "content") {
625
- scaffoldContentPackage(args.name, targetPath);
626
- return;
627
- }
628
- // The starter bakes an app manifest (app_id/workspace_id) into package.json;
629
- // a package is workspace-agnostic, so those placeholders are replaced with the
630
- // package manifest below. The rest of the starter (config, src, tests) is the
631
- // package's code surface verbatim.
632
- // Resolve the live @lotics/ui + @lotics/app-sdk versions like `app create`
633
- // does — the generated .lotics/app_fields.ts imports `getAppBinding`, and a
634
- // ^0.x caret range never crosses a minor, so a stale starter fallback would
635
- // permanently pin the project below the API it needs (offline still works
636
- // off STARTER_FALLBACK_*, kept ≥ that floor).
637
- const [uiLatest, sdkLatest] = await Promise.all([
638
- fetchLatestNpmVersion("@lotics/ui"),
639
- fetchLatestNpmVersion("@lotics/app-sdk"),
640
- ]);
641
- const files = buildStarterTemplate({
642
- app_name: args.name,
643
- app_id: "",
644
- workspace_id: "",
645
- ui_version: uiLatest ? `^${uiLatest}` : undefined,
646
- sdk_version: packageSdkRange(sdkLatest),
647
- });
648
- // Package-specific code surface swapped in BY PATH (same mechanism as the
649
- // package.json manifest swap below): the shared app starter's App.tsx demos
650
- // in-app routing over static data, wrong for a package. The package starter's
651
- // App.tsx exercises the binding (F/OPT + useQuery/useConfig), its test mocks the
652
- // SDK boundary, and its README carries the contract reference.
653
- const overrides = new Map(buildPackageStarterOverrides({ app_name: args.name }).map((f) => [f.path, f.content]));
654
- for (const file of files) {
655
- const fullPath = path.join(targetPath, file.path);
656
- if (file.path === "package.json") {
657
- const pkg = JSON.parse(file.content);
658
- const manifest = {
659
- id: null,
660
- name: args.name,
661
- description: null,
662
- kind: "app",
663
- version: null,
664
- knowledge: {},
665
- templates: {},
666
- knowledge_expects: [],
667
- dev: {},
668
- };
669
- pkg.lotics = { package: manifest };
670
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
671
- fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + "\n");
672
- continue;
673
- }
674
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
675
- fs.writeFileSync(fullPath, overrides.get(file.path) ?? file.content);
676
- }
677
- fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
678
- writePackageAppFields(targetPath);
679
- console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
680
- console.error("Installing npm dependencies...");
681
- await runNpm(["install"], targetPath);
682
- console.error(`\nReady. Next steps:`);
683
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
684
- console.error(` # edit contract.json + src/App.tsx, then run it against a dev workspace:`);
685
- console.error(` lotics workspace create "<name> dev" --dev`);
686
- console.error(` lotics package dev --workspace <dev_ws>`);
687
- }
688
- /**
689
- * `lotics package build [path]` — build the publishable bundle and write it to
690
- * `bundle.tar.gz` in the project. Mostly a local sanity check / CI artifact;
691
- * `publish` and `dev`/`sync` build the bundle in memory directly.
692
- */
693
- export async function packageBuild(args) {
694
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
695
- readPackageProject(projectDir); // assert it's a package project
696
- const bundle = await buildPackageBundle(projectDir);
697
- const out = path.join(projectDir, "bundle.tar.gz");
698
- fs.writeFileSync(out, bundle);
699
- console.error(`Built ${out} (${(bundle.byteLength / 1024).toFixed(1)} KB)`);
700
- }
701
- /**
702
- * Build + publish a new immutable package version from the project's contract +
703
- * bundle. Creates the registry package on first publish (stamping its id into
704
- * the manifest), then publishes the next monotonic version. Returns the new
705
- * version number and the resolved package id.
706
- */
707
- async function publishVersion(client, projectDir, opts) {
708
- const project = readPackageProject(projectDir);
709
- const rawContract = readContract(projectDir);
710
- if (!isPlainObject(rawContract)) {
711
- throw new Error(`${CONTRACT_FILE} in ${projectDir} must be a JSON object.`);
712
- }
713
- // Fold the manifest-declared templates + the knowledge corpus + the per-template
714
- // content shas into the contract before it is sent. Order: manifest templates
715
- // FIRST (so their bytes/content land in the contract), then knowledge, then the
716
- // sha fold LAST (it hashes whatever templates now exist — manifest-folded +
717
- // contract-authored). All shas are derived (never hand-authored) so the
718
- // published contract's modified-detection references are exact.
719
- const contract = foldTemplateShasIntoContract(projectDir, foldKnowledgeIntoContract(projectDir, project, foldTemplatesIntoContract(projectDir, project, rawContract)));
720
- let packageId = project.manifest.id;
721
- if (packageId === null) {
722
- const pkg = await client.createPackage({
723
- name: project.manifest.name,
724
- description: project.manifest.description,
725
- // Kind is fixed at package creation and immutable after — a knowledge
726
- // package must be created as such so publish enforces its constraint.
727
- kind: project.manifest.kind,
728
- });
729
- packageId = pkg.id;
730
- project.manifest.id = packageId;
731
- writePackageManifest(projectDir, project);
732
- console.error(`Created package ${pkg.name} (${pkg.id}).`);
733
- }
734
- const bundle = await buildPackageBundle(projectDir);
735
- console.error("Publishing version...");
736
- const version = await client.publishPackageVersion(packageId, {
737
- contract,
738
- bundle,
739
- changelog: opts.changelog ?? null,
740
- channel: opts.channel ?? "release",
741
- });
742
- project.manifest.version = version.version;
743
- writePackageManifest(projectDir, project);
744
- return { package_id: packageId, version: version.version };
745
- }
746
- /**
747
- * `lotics package publish [path] [-m <changelog>]` — publish a new version.
748
- */
749
- export async function packagePublish(client, args) {
750
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
751
- const { package_id, version } = await publishVersion(client, projectDir, {
752
- changelog: args.changelog,
753
- });
754
- console.error(`Published ${package_id} v${version}.`);
755
- console.error(` Install it: lotics package install ${package_id} --version ${version}`);
756
- }
757
- /**
758
- * Fail loud unless `workspace` is a throwaway dev workspace. The dev/sync
759
- * scaffold path publishes a new version and scaffold-installs package tables into
760
- * the resolved workspace, so the target MUST be a dev workspace — this mirrors
761
- * the server-side `package reset` gate so a forgotten `--workspace` (prod
762
- * selected) can never scaffold package tables into prod. Fails closed: an absent
763
- * `is_dev` (a server that doesn't yet serialize it) is treated as non-dev.
764
- */
765
- export function assertDevWorkspace(workspace) {
766
- if (workspace.is_dev === true)
767
- return;
768
- throw new Error(`Workspace "${workspace.name}" (${workspace.id}) is not a dev workspace. ` +
769
- `"lotics package dev"/"sync" scaffold package tables into the target workspace, so it must be a throwaway dev workspace. ` +
770
- `Create one with "lotics workspace create <name> --dev" and pass --workspace <dev_ws>.`);
771
- }
772
82
  /**
773
- * Scaffold-sync the local package into the dev workspace: publish the current
774
- * contract + bundle as a new version, then install it (first time) or upgrade
775
- * the recorded installation (subsequent runs) so the dev workspace goes through
776
- * the exact scaffold + materialize the real install/upgrade run. Records the
777
- * installation pin per dev workspace in the manifest. Returns the dev
778
- * installation's app id + name.
83
+ * Resolve the installation to operate on: an explicit app id is required (there
84
+ * is no dev-workspace pin to fall back to).
779
85
  */
780
- async function syncToDevWorkspace(client, projectDir) {
781
- const devWorkspaceId = client.getWorkspaceId();
782
- if (!devWorkspaceId) {
783
- throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
784
- }
785
- // Guard BEFORE publishing: dev/sync scaffold-installs package tables into the
786
- // resolved workspace, so it must be a throwaway dev workspace — a developer who
787
- // forgot --workspace with prod selected would otherwise scaffold into prod.
788
- // Mirrors the server-side `package reset` gate (which refuses any non-`is_dev`
789
- // workspace).
790
- const workspace = await client.getWorkspaceInfo(devWorkspaceId);
791
- if (!workspace) {
792
- throw new Error(`Workspace ${devWorkspaceId} is not accessible with these credentials. Pass --workspace <dev_ws> (a workspace created with --dev).`);
793
- }
794
- assertDevWorkspace(workspace);
795
- // Contract may have been edited since the last sync — regenerate the
796
- // runtime F/OPT/ROLE surface before the build that publishVersion runs.
797
- writePackageAppFields(projectDir);
798
- // 'dev' channel: a real immutable version this dev installation pins by
799
- // number, but never what "install latest" resolves to — a broken
800
- // mid-iteration contract must not become the org-wide installable default.
801
- const { package_id, version } = await publishVersion(client, projectDir, {
802
- changelog: "dev sync",
803
- channel: "dev",
804
- });
805
- // Re-read after publish (publishVersion may have stamped the package id).
806
- const project = readPackageProject(projectDir);
807
- const existing = project.manifest.dev[devWorkspaceId];
808
- let appId;
809
- if (existing) {
810
- console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
811
- const app = await client.upgradePackage(existing.app_id, { version });
812
- appId = app.id;
813
- }
814
- else {
815
- console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
816
- const result = await client.installPackage(package_id, { version });
817
- // The dev/sync loop scaffolds an APP installation to run the dev server
818
- // against; a content package has no app to run.
819
- if (result.kind !== "app") {
820
- throw new Error(`Package ${package_id} is a content package — "lotics package dev/sync" run an app installation. ` +
821
- `Install a content package with "lotics package install ${package_id}".`);
822
- }
823
- appId = result.app.id;
824
- }
825
- project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
826
- writePackageManifest(projectDir, project);
827
- const installed = await client.getApp(appId);
828
- // Regenerate the typed `.lotics/app_{queries,workflows,agents}.d.ts` companions
829
- // from the live installation — the SAME maps (`apps.queries`/`workflows`/`agents`,
830
- // keyed by the contract's runtime aliases) `lotics app pull` codegens from — so
831
- // `useQuery("items")` / `useWorkflow` are typed in the package dev loop, not just
832
- // in pulled app projects. (`.lotics/app_fields.ts`, the runtime F/OPT surface, was
833
- // already refreshed from the contract before publish.) `writeAppDts` also heals the
834
- // tsconfig include/exclude so the generated `.d.ts` actually load.
835
- writeAppDts(projectDir, {
836
- workflows: installed.workflows ?? undefined,
837
- queries: installed.queries ?? undefined,
838
- agents: installed.agents ?? undefined,
839
- });
840
- return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
841
- }
842
- /**
843
- * `lotics package sync [path]` — re-run the scaffold-sync into the dev workspace
844
- * (the continuous loop: edit contract → sync additively migrates + re-materializes).
845
- */
846
- export async function packageSync(client, args) {
847
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
848
- const result = await syncToDevWorkspace(client, projectDir);
849
- console.error(`Synced ${result.app_name} v${result.version} → ${result.app_id} (workspace ${result.workspace_id}).`);
850
- }
851
- /**
852
- * `lotics package dev [path] [--workspace <dev_ws>] [--view-as <member>]` —
853
- * scaffold-sync the package into the dev workspace, then run the existing app
854
- * dev server against the resulting installation (HMR over the local source, RPC
855
- * forwarded to the live installation). The inner loop: edit contract → re-run to
856
- * sync; edit code → hot reload.
857
- */
858
- export async function packageDev(client, args) {
859
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
860
- const synced = await syncToDevWorkspace(client, projectDir);
861
- const handle = await startDevServer({
862
- projectDir,
863
- app_id: synced.app_id,
864
- app_name: synced.app_name,
865
- workspace_id: synced.workspace_id,
866
- api_url: client.baseUrl,
867
- port: args.port,
868
- vitePort: args.vitePort,
869
- client,
870
- });
871
- await handle.ready;
872
- const url = `http://localhost:${handle.port}`;
873
- console.error(`\n lotics package dev`);
874
- console.error(` package: ${synced.app_name} (dev installation ${synced.app_id} v${synced.version})`);
875
- console.error(` workspace: ${synced.workspace_id} (dev)`);
876
- if (client.viewAsMemberId) {
877
- console.error(` view as: ${client.viewAsMemberId}`);
878
- }
879
- console.error(` vite: http://localhost:${handle.vitePort}/`);
880
- console.error(` open: ${url}`);
881
- console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
882
- console.error(` Edit contract.json then re-run "lotics package dev" / "lotics package sync" to migrate.`);
883
- console.error(` Ctrl-C to stop.\n`);
884
- openBrowser(url);
885
- await new Promise((resolve) => {
886
- const onSig = () => {
887
- process.off("SIGINT", onSig);
888
- process.off("SIGTERM", onSig);
889
- resolve();
890
- };
891
- process.on("SIGINT", onSig);
892
- process.on("SIGTERM", onSig);
893
- });
894
- console.error("\nStopping…");
895
- await handle.stop();
896
- }
897
- /**
898
- * `lotics package reset [path]` — DEV-ONLY, hard-gated. Drops the dev
899
- * installation's package-owned scaffolded tables and re-scaffolds them clean. The
900
- * backend refuses any workspace not flagged as a dev workspace, so this can never
901
- * erase a real workspace's data. The dev installation is resolved from the
902
- * project manifest's pin for the selected workspace.
903
- */
904
- export async function packageReset(client, args) {
905
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
906
- const devWorkspaceId = client.getWorkspaceId();
907
- if (!devWorkspaceId) {
908
- throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
909
- }
910
- const { manifest } = readPackageProject(projectDir);
911
- const pin = manifest.dev[devWorkspaceId];
912
- if (!pin) {
913
- throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
914
- }
915
- console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
916
- const app = await client.resetPackage(pin.app_id);
917
- console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
918
- }
919
- /**
920
- * Resolve the installation to operate on: an explicit app id wins; otherwise
921
- * fall back to the project manifest's dev pin for the selected workspace (the
922
- * same resolution `package reset` uses).
923
- */
924
- function resolveInstallationAppId(client, explicit) {
86
+ function resolveInstallationAppId(explicit) {
925
87
  if (explicit)
926
88
  return explicit;
927
- const workspaceId = client.getWorkspaceId();
928
- if (!workspaceId) {
929
- throw new Error("Pass an app id (lotics package doctor <app_id>) or select a workspace.");
930
- }
931
- let manifest;
932
- try {
933
- ({ manifest } = readPackageProject(path.resolve(process.cwd())));
934
- }
935
- catch {
936
- // CLI usage boundary: translate "this directory isn't a package project"
937
- // into the action the caller actually needs.
938
- throw new Error("No app id given and the current directory is not a package project. " +
939
- "Pass one explicitly: lotics package doctor <app_id>.");
940
- }
941
- const pin = manifest.dev[workspaceId];
942
- if (!pin) {
943
- throw new Error(`No app id given and no dev installation recorded for workspace ${workspaceId}. ` +
944
- "Pass one explicitly: lotics package doctor <app_id>.");
945
- }
946
- return pin.app_id;
89
+ throw new Error("Pass an app id — e.g. lotics package doctor <app_id>.");
947
90
  }
91
+ const RESOLVE_VERBS = new Set(["recreate", "revert", "keep", "apply", "archive", "unbind"]);
948
92
  /**
949
- * Parse repeated `--resolve <key>=<value>` flags. Drift entries
950
- * (`<namespace>.<alias>`) take `recreate` or an existing id; modified
951
- * artifacts (`<kind>.<alias>`) take `revert` or `keep`. Any other value is a
952
- * 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.
953
100
  */
954
101
  export function parseResolveFlags(resolve) {
955
102
  const resolutions = {};
956
103
  for (const entry of resolve) {
957
104
  const eq = entry.indexOf("=");
958
105
  if (eq <= 0 || eq === entry.length - 1) {
959
- 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>.`);
960
107
  }
961
108
  const key = entry.slice(0, eq);
962
109
  const value = entry.slice(eq + 1);
963
- resolutions[key] =
964
- value === "recreate" || value === "revert" || value === "keep" ? value : { bind_to: value };
110
+ resolutions[key] = RESOLVE_VERBS.has(value)
111
+ ? value
112
+ : { bind_to: value };
965
113
  }
966
114
  return resolutions;
967
115
  }
@@ -970,9 +118,10 @@ export function parseResolveFlags(resolve) {
970
118
  * non-zero when drift is found so scripts can gate on it.
971
119
  */
972
120
  export async function packageDoctor(client, args) {
973
- const app_id = resolveInstallationAppId(client, args.app_id);
121
+ const app_id = resolveInstallationAppId(args.app_id);
974
122
  const health = await client.getPackageHealth(app_id);
975
- console.error(`${health.package_name} — installation ${health.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)" : ""));
976
125
  console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
977
126
  (health.update_available ? " → update available" : ""));
978
127
  if (health.drift.length === 0) {
@@ -983,19 +132,31 @@ export async function packageDoctor(client, args) {
983
132
  for (const d of health.drift) {
984
133
  console.error(` - ${d.namespace}.${d.alias} → ${d.id} (missing from the workspace)`);
985
134
  }
986
- console.error(` Resolve while upgrading:\n lotics package upgrade ${app_id}` +
135
+ console.error(` Resolve while upgrading:\n lotics upgrade ${app_id}` +
987
136
  ` --resolve <namespace.alias>=recreate (or =<existing_id> to re-point)`);
988
137
  process.exitCode = 1;
989
138
  }
990
139
  if (health.modified.length === 0) {
991
- 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.\n" +
142
+ ` (schema additions aren't fingerprinted — preview them with: lotics app release ${app_id})`
143
+ : " Package artifacts: pristine — no local edits an upgrade would revert.");
144
+ }
145
+ else if (health.is_origin) {
146
+ // The origin IS the author's working copy — edits since the last release are
147
+ // the NEXT release's payload, not consumer drift, and never fail the check.
148
+ console.error(` Changed since v${health.installed_version} (${health.modified.length}) — a release will publish these:`);
149
+ for (const m of health.modified) {
150
+ console.error(` - ${m.kind}.${m.alias}`);
151
+ }
152
+ console.error(` Cut the next version: lotics app release ${app_id} -m "<what changed>"`);
992
153
  }
993
154
  else {
994
155
  console.error(` Locally modified package artifacts (${health.modified.length}):`);
995
156
  for (const m of health.modified) {
996
157
  console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
997
158
  }
998
- console.error(` Consent while upgrading:\n lotics package upgrade ${app_id}` +
159
+ console.error(` Consent while upgrading:\n lotics upgrade ${app_id}` +
999
160
  ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
1000
161
  process.exitCode = 1;
1001
162
  }
@@ -1008,9 +169,17 @@ export async function packageDoctor(client, args) {
1008
169
  }
1009
170
  }
1010
171
  if (health.knowledge_modified.length > 0) {
1011
- console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
1012
- for (const m of health.knowledge_modified) {
1013
- console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
172
+ if (health.is_origin) {
173
+ console.error(` Knowledge changed since v${health.installed_version} (${health.knowledge_modified.length}) — a release will re-snapshot these:`);
174
+ for (const m of health.knowledge_modified) {
175
+ console.error(` - ${m.alias} "${m.name}"`);
176
+ }
177
+ }
178
+ else {
179
+ console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
180
+ for (const m of health.knowledge_modified) {
181
+ console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
182
+ }
1014
183
  }
1015
184
  }
1016
185
  if (health.missing_expected_docs.length > 0) {
@@ -1019,7 +188,9 @@ export async function packageDoctor(client, args) {
1019
188
  console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
1020
189
  }
1021
190
  }
1022
- if (health.knowledge_drift.length > 0 || health.knowledge_modified.length > 0) {
191
+ // Drift is genuine breakage on the origin too (a bound id vanished); edited
192
+ // knowledge on the origin is staged release work, not a failure.
193
+ if (health.knowledge_drift.length > 0 || (health.knowledge_modified.length > 0 && !health.is_origin)) {
1023
194
  process.exitCode = 1;
1024
195
  }
1025
196
  if (health.knowledge_drift.length === 0 &&
@@ -1028,7 +199,7 @@ export async function packageDoctor(client, args) {
1028
199
  console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
1029
200
  }
1030
201
  if (health.update_available) {
1031
- console.error(` Upgrade: lotics package upgrade ${app_id}`);
202
+ console.error(` Upgrade: lotics upgrade ${app_id}`);
1032
203
  }
1033
204
  }
1034
205
  /**
@@ -1037,11 +208,13 @@ export async function packageDoctor(client, args) {
1037
208
  * the exact --resolve syntax) while any binding drift, modified core artifact, or
1038
209
  * consent-requiring bundled-knowledge doc lacks a resolution.
1039
210
  *
1040
- * `--resolve` is shared across the finding classes: a key naming a bundled
1041
- * knowledge doc routes to `knowledge_resolutions` (apply|keep|archive|recreate|
1042
- * unbind), everything else to core `resolutions`; `--bind-to` consents an
1043
- * added-knowledge-doc name collision; `--apply-all` accepts the package's version
1044
- * for every consent-requiring knowledge doc (overwriting local edits).
211
+ * `--resolve` speaks ONE namespaced grammar, passed to the server verbatim: a
212
+ * drifted binding entry (`<namespace>.<alias>`), a modified artifact
213
+ * (`<kind>.<alias>`), a bundled knowledge doc (`knowledge.<alias>`
214
+ * apply|keep|archive|recreate|unbind), or a live-role re-point
215
+ * (`roles.<alias>=<grp_id>`). `--bind-to` consents an added-knowledge-doc name
216
+ * collision; `--apply-all` accepts the package's version for every
217
+ * consent-requiring knowledge doc (overwriting local edits).
1045
218
  */
1046
219
  export async function packageUpgrade(client, args) {
1047
220
  const preview = await client.previewPackageUpgrade(args.app_id, {
@@ -1078,28 +251,23 @@ export async function packageUpgrade(client, args) {
1078
251
  ` before making the change: lotics package eject ${args.app_id}`);
1079
252
  process.exit(1);
1080
253
  }
1081
- // Route the shared --resolve flags: a knowledge-doc alias resolves the doc, a
1082
- // core artifact key resolves the artifact; a key that is BOTH throws.
1083
- const coreKeys = new Set([
1084
- ...preview.drift.map((d) => `${d.namespace}.${d.alias}`),
1085
- ...preview.modified.map((m) => `${m.kind}.${m.alias}`),
1086
- ]);
1087
- const { coreResolve, knowledgeResolutions } = routeAppUpgradeResolve(args.resolve, preview.knowledge, coreKeys);
1088
- const resolutions = parseResolveFlags(coreResolve);
254
+ // ONE namespaced grammar the flags pass to the server verbatim. `--bind-to`
255
+ // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
256
+ // remaining consent-requiring knowledge entries with the accept verb.
257
+ const resolutions = parseResolveFlags(args.resolve);
258
+ for (const [alias, id] of Object.entries(parseBindToFlags(args.bindTo))) {
259
+ resolutions[`knowledge.${alias}`] = { bind_to: id };
260
+ }
1089
261
  if (args.applyAll) {
1090
262
  for (const entry of preview.knowledge) {
1091
- if (knowledgeEntryNeedsConsent(entry) && knowledgeResolutions[entry.alias] === undefined) {
1092
- knowledgeResolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
263
+ if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
264
+ resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
1093
265
  }
1094
266
  }
1095
267
  }
1096
- const knowledge_resolutions = {
1097
- resolutions: knowledgeResolutions,
1098
- bind_to: parseBindToFlags(args.bindTo),
1099
- };
1100
268
  const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
1101
269
  const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
1102
- const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && knowledgeResolutions[e.alias] === undefined);
270
+ const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && resolutions[`knowledge.${e.alias}`] === undefined);
1103
271
  if (unresolvedDrift.length > 0 ||
1104
272
  unresolvedModified.length > 0 ||
1105
273
  unresolvedKnowledge.length > 0) {
@@ -1123,12 +291,9 @@ export async function packageUpgrade(client, args) {
1123
291
  }
1124
292
  process.exit(1);
1125
293
  }
1126
- const hasKnowledgeResolutions = Object.keys(knowledge_resolutions.resolutions).length > 0 ||
1127
- Object.keys(knowledge_resolutions.bind_to).length > 0;
1128
294
  const app = await client.upgradePackage(args.app_id, {
1129
295
  ...(args.version !== undefined ? { version: args.version } : {}),
1130
296
  ...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
1131
- ...(hasKnowledgeResolutions ? { knowledge_resolutions } : {}),
1132
297
  });
1133
298
  console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
1134
299
  }
@@ -1202,99 +367,87 @@ function formatKnowledgeResolveHint(entry) {
1202
367
  const note = entry.change === "changed" || entry.change === "removed"
1203
368
  ? " (a local edit — apply/archive overwrites it; keep retains it)"
1204
369
  : " (bound doc is gone; recreate from the package, or unbind)";
1205
- return ` --resolve ${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
370
+ return ` --resolve knowledge.${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
371
+ }
372
+ /** The `--resolve template.<alias>=revert|keep` remediation line for a consent-requiring template. */
373
+ function formatTemplateResolveHint(entry) {
374
+ const note = entry.baseline_unknown
375
+ ? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
376
+ : " (a local edit — revert overwrites it with the package's version; keep retains it)";
377
+ return ` --resolve template.${entry.alias}=revert|keep${note}`;
1206
378
  }
1207
379
  /**
1208
- * Route an app-install upgrade's `--resolve` entries into the core-artifact
1209
- * bucket vs the bundled-knowledge bucket. `--resolve` is shared between the two
1210
- * finding classes, so each key is matched against the preview: a key that names a
1211
- * bundled knowledge doc resolves that doc (its value validated against the doc's
1212
- * change class here the app path has no separate knowledge-only parser);
1213
- * anything else stays a core resolution (a `<namespace>.<alias>` drift, a
1214
- * `<kind>.<alias>` modified core, or a key the server validates). A key that is
1215
- * BOTH a knowledge alias and a core-artifact key is ambiguous — throw rather than
1216
- * guess which the operator meant.
380
+ * Resolve a STANDALONE content package's installation in the CURRENT workspace
381
+ * from its PACKAGE id. `UNIQUE (workspace_id, package_id)` means the package id
382
+ * fully determines the anchor row, so consumers address content by package id and
383
+ * the `pci_` resource id never surfaces (the server keeps it resource identity —
384
+ * and the CLI resolves it here through the list endpoint; zero backend change).
385
+ * Throws a clear "not installed" error (pointing at `lotics install`) when the
386
+ * package has no content installation in this workspace.
1217
387
  */
1218
- export function routeAppUpgradeResolve(resolve, knowledgeEntries, coreKeys) {
1219
- const byAlias = new Map(knowledgeEntries.map((e) => [e.alias, e]));
1220
- const coreResolve = [];
1221
- const knowledgeResolutions = {};
1222
- for (const entry of resolve) {
1223
- const eq = entry.indexOf("=");
1224
- if (eq <= 0 || eq === entry.length - 1) {
1225
- throw new Error(`Invalid --resolve "${entry}" — expected <key>=<value>.`);
1226
- }
1227
- const key = entry.slice(0, eq);
1228
- const value = entry.slice(eq + 1);
1229
- const known = byAlias.get(key);
1230
- if (known && coreKeys.has(key)) {
1231
- throw new Error(`--resolve "${key}" is ambiguous — it names both a bundled knowledge doc and a core artifact in this upgrade. ` +
1232
- `Rename one alias so it is unambiguous; refusing to guess.`);
1233
- }
1234
- if (known) {
1235
- const valid = validKnowledgeResolutions(known.change);
1236
- const match = valid.find((v) => v === value);
1237
- if (match === undefined) {
1238
- throw new Error(`Invalid --resolve value "${value}" for knowledge doc "${key}" (${known.change}) — expected ${valid.join("|")}.`);
1239
- }
1240
- knowledgeResolutions[key] = match;
1241
- }
1242
- else {
1243
- coreResolve.push(entry);
1244
- }
388
+ async function resolveWorkspaceContentInstallation(client, package_id) {
389
+ const workspaceId = client.getWorkspaceId();
390
+ if (!workspaceId) {
391
+ throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to operate on its content installation.");
392
+ }
393
+ const installations = await client.listContentInstallations(workspaceId);
394
+ const match = installations.find((inst) => inst.package_id === package_id);
395
+ if (!match) {
396
+ throw new Error(`Package ${package_id} is not installed in this workspace — install it first: lotics install ${package_id}`);
1245
397
  }
1246
- return { coreResolve, knowledgeResolutions };
398
+ return match;
1247
399
  }
1248
400
  /**
1249
- * Route a STANDALONE content upgrade's shared `--resolve <alias>=<value>` flags
1250
- * into the knowledge vs templates buckets by matching each alias against the two
1251
- * preview namespaces. A doc alias takes apply|keep|archive|recreate|unbind; a
1252
- * template alias takes revert|keep. An alias present in BOTH namespaces is
1253
- * ambiguous loud error (no guess). An alias in NEITHER error. The value is
1254
- * validated against the namespace it routes to. Exported for testing.
401
+ * CLEAN BREAK for an explicit `pci_` argument on `upgrade` / `uninstall`: the
402
+ * `pci_` resource id is retired from human sight content installs are addressed
403
+ * by their PACKAGE id. Prints a loud redirect, best-effort resolving the `pci_`
404
+ * back to its package id (via list-content) so the exact command is spelled out.
405
+ * The header prints synchronously first, so the redirect is observable even when
406
+ * the best-effort resolution can't reach the registry.
1255
407
  */
1256
- export function routeContentResolveFlags(resolve, knowledgeAliases, templateAliases) {
1257
- const knowledge = {};
1258
- const templates = {};
1259
- const knowledgeValid = new Set(["apply", "keep", "archive", "recreate", "unbind"]);
1260
- const templateValid = new Set(["revert", "keep"]);
1261
- for (const entry of resolve) {
1262
- const eq = entry.indexOf("=");
1263
- if (eq <= 0 || eq === entry.length - 1) {
1264
- throw new Error(`Invalid --resolve "${entry}" — expected <alias>=<value>.`);
1265
- }
1266
- const key = entry.slice(0, eq);
1267
- const value = entry.slice(eq + 1);
1268
- const isKnowledge = knowledgeAliases.has(key);
1269
- const isTemplate = templateAliases.has(key);
1270
- if (isKnowledge && isTemplate) {
1271
- throw new Error(`--resolve "${key}" is ambiguous — it names both a knowledge doc and a template in this upgrade. ` +
1272
- `Rename one alias in the package so the two content namespaces are disjoint.`);
1273
- }
1274
- if (isTemplate) {
1275
- if (!templateValid.has(value)) {
1276
- throw new Error(`Invalid --resolve value "${value}" for template "${key}" — expected revert|keep.`);
1277
- }
1278
- templates[key] = value;
1279
- continue;
1280
- }
1281
- if (isKnowledge) {
1282
- if (!knowledgeValid.has(value)) {
1283
- throw new Error(`Invalid --resolve value "${value}" for knowledge alias "${key}" — expected apply|keep|archive|recreate|unbind.`);
1284
- }
1285
- knowledge[key] = value;
1286
- continue;
1287
- }
1288
- throw new Error(`--resolve "${key}" does not name a knowledge doc or template in this upgrade.`);
408
+ export async function redirectContentPciForm(client, pci_id, verb) {
409
+ console.error(`Content installations are addressed by their PACKAGE id now — ${pci_id} is a server-internal resource id.`);
410
+ const workspaceId = client.getWorkspaceId();
411
+ const match = workspaceId
412
+ ? (await client.listContentInstallations(workspaceId)).find((inst) => inst.id === pci_id)
413
+ : undefined;
414
+ if (match) {
415
+ console.error(` Run: lotics ${verb} ${match.package_id}`);
1289
416
  }
1290
- return { knowledge, templates };
417
+ else {
418
+ console.error(" Find the package id: lotics package list-content");
419
+ console.error(` Then run: lotics ${verb} <package_id>`);
420
+ }
421
+ process.exit(1);
1291
422
  }
1292
- /** The `--resolve <alias>=revert|keep` remediation line for a consent-requiring template. */
1293
- function formatTemplateResolveHint(entry) {
1294
- const note = entry.baseline_unknown
1295
- ? " (can't verify the local edit older package version; revert overwrites, keep retains)"
1296
- : " (a local edit revert overwrites it with the package's version; keep retains it)";
1297
- return ` --resolve ${entry.alias}=revert|keep${note}`;
423
+ /**
424
+ * `lotics upgrade <apg_>` — the package-id upgrade path, kind-branched. `kind` is a
425
+ * DERIVED display hint (`contractHasAppSurface`): `'content'` means no app surface.
426
+ * - a CONTENT package upgrades THIS workspace's standalone content installation
427
+ * (resolved from the package id the anchor is unique per workspace, so the
428
+ * `pci_` never surfaces), through the same content review gate.
429
+ * - an APP-surface package FLEET-upgrades every installation across the org
430
+ * (unchanged) — the resolve/bind/apply-all flags don't apply to a fleet run.
431
+ */
432
+ export async function packageUpgradeByPackageId(client, args) {
433
+ const pkg = await client.getPackage(args.package_id);
434
+ if (pkg.kind === "content") {
435
+ const installation = await resolveWorkspaceContentInstallation(client, args.package_id);
436
+ await packageUpgradeKnowledge(client, {
437
+ installation_id: installation.id,
438
+ package_id: pkg.id,
439
+ package_name: pkg.name,
440
+ version: args.version,
441
+ resolve: args.resolve,
442
+ bind_to: parseBindToFlags(args.bindTo),
443
+ applyAll: args.applyAll,
444
+ });
445
+ return;
446
+ }
447
+ await packageFleetUpgrade(client, {
448
+ package_id: args.package_id,
449
+ ...(args.version !== undefined ? { version: args.version } : {}),
450
+ });
1298
451
  }
1299
452
  /**
1300
453
  * Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
@@ -1304,11 +457,12 @@ function formatTemplateResolveHint(entry) {
1304
457
  * locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
1305
458
  * by accepting the package's version (knowledge changed→apply, removed→archive,
1306
459
  * drifted→recreate; template→revert) — an explicit bulk "take upstream" that
1307
- * discards local edits. `--resolve <alias>=<value>` and `--bind-to <alias>=<kdc_id>`
1308
- * resolve entries individually (`--resolve` is routed to the right namespace by
1309
- * matching the alias against the preview).
460
+ * discards local edits. `--resolve knowledge.<alias>=<value>` /
461
+ * `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
462
+ * resolve entries individually — the ONE namespaced grammar, passed to the
463
+ * server verbatim.
1310
464
  */
1311
- export async function packageUpgradeKnowledge(client, args) {
465
+ async function packageUpgradeKnowledge(client, args) {
1312
466
  const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
1313
467
  ...(args.version !== undefined ? { version: args.version } : {}),
1314
468
  });
@@ -1319,7 +473,7 @@ export async function packageUpgradeKnowledge(client, args) {
1319
473
  ...(args.version !== undefined ? { version: args.version } : {}),
1320
474
  resolutions,
1321
475
  });
1322
- console.error(`Upgraded ${args.installation_id} → v${updated.package_version}.`);
476
+ console.error(`Upgraded ${args.package_name} (${args.package_id}) → v${updated.package_version}.`);
1323
477
  };
1324
478
  if (preview.entries.length === 0 && preview.templates.length === 0) {
1325
479
  if (preview.to_version === preview.from_version) {
@@ -1327,7 +481,7 @@ export async function packageUpgradeKnowledge(client, args) {
1327
481
  return;
1328
482
  }
1329
483
  console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
1330
- await apply({ knowledge: { resolutions: {}, bind_to: {} }, templates: {} });
484
+ await apply({});
1331
485
  return;
1332
486
  }
1333
487
  for (const entry of preview.entries) {
@@ -1336,26 +490,27 @@ export async function packageUpgradeKnowledge(client, args) {
1336
490
  for (const entry of preview.templates) {
1337
491
  console.error(` [template] ${entry.alias} (modified — needs consent)`);
1338
492
  }
1339
- // Route the shared --resolve flags across the two namespaces (loud on an alias
1340
- // in both), then optionally bulk-accept every remaining consent entry.
1341
- const routed = routeContentResolveFlags(args.resolve, new Set(preview.entries.map((e) => e.alias)), new Set(preview.templates.map((t) => t.alias)));
1342
- const resolutions = {
1343
- knowledge: { resolutions: { ...routed.knowledge }, bind_to: { ...args.bind_to } },
1344
- templates: { ...routed.templates },
1345
- };
493
+ // ONE namespaced grammar the flags pass to the server verbatim. `--bind-to`
494
+ // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
495
+ // remaining consent entries with the accept verb (template revert).
496
+ const resolutions = parseResolveFlags(args.resolve);
497
+ for (const [alias, id] of Object.entries(args.bind_to)) {
498
+ resolutions[`knowledge.${alias}`] = { bind_to: id };
499
+ }
1346
500
  if (args.applyAll) {
1347
501
  for (const entry of preview.entries) {
1348
- if (knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined) {
1349
- resolutions.knowledge.resolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
502
+ if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
503
+ resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
1350
504
  }
1351
505
  }
1352
506
  for (const entry of preview.templates) {
1353
- if (resolutions.templates[entry.alias] === undefined)
1354
- resolutions.templates[entry.alias] = "revert";
507
+ if (resolutions[`template.${entry.alias}`] === undefined) {
508
+ resolutions[`template.${entry.alias}`] = "revert";
509
+ }
1355
510
  }
1356
511
  }
1357
- const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined);
1358
- const unresolvedTemplates = preview.templates.filter((entry) => resolutions.templates[entry.alias] === undefined);
512
+ const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined);
513
+ const unresolvedTemplates = preview.templates.filter((entry) => resolutions[`template.${entry.alias}`] === undefined);
1359
514
  if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
1360
515
  console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
1361
516
  for (const entry of unresolvedKnowledge)
@@ -1391,8 +546,10 @@ export async function packageInstall(client, args) {
1391
546
  // Surface the trust badge at the consent point: installing materializes the
1392
547
  // package's workflows/agents (or its doc corpus) under YOUR authority.
1393
548
  const pkg = await client.getPackage(args.package_id);
1394
- const kindLabel = pkg.kind === "content" ? "content package" : "package";
1395
- console.error(`Installing ${pkg.name} ${trustBadge(pkg)} (${kindLabel})...`);
549
+ // The kind hint only earns a mention when it adds information (content —
550
+ // no app materializes); "(package)" after "third-party package" is noise.
551
+ const kindSuffix = pkg.kind === "content" ? " (content — docs/templates, no app)" : "";
552
+ console.error(`Installing ${pkg.name} — ${trustBadge(pkg)}${kindSuffix}...`);
1396
553
  const result = await client.installPackage(args.package_id, {
1397
554
  ...(args.version !== undefined ? { version: args.version } : {}),
1398
555
  ...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
@@ -1402,8 +559,7 @@ export async function packageInstall(client, args) {
1402
559
  const { installation, warnings } = result;
1403
560
  const docs = installation.binding.knowledge;
1404
561
  const templates = installation.binding.templates;
1405
- console.error(`Installed ${pkg.name} v${installation.package_version} content installation ${installation.id} ` +
1406
- `(workspace ${installation.workspace_id}).`);
562
+ console.error(`Installed ${pkg.name} v${installation.package_version} (workspace ${installation.workspace_id}).`);
1407
563
  const docAliases = Object.keys(docs);
1408
564
  if (docAliases.length > 0) {
1409
565
  console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
@@ -1413,8 +569,8 @@ export async function packageInstall(client, args) {
1413
569
  console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
1414
570
  }
1415
571
  warnMissingExpectedDocs(warnings.missing_expected_docs);
1416
- console.error(` Upgrade later: lotics package upgrade ${installation.id}`);
1417
- console.error(` Uninstall: lotics package uninstall ${installation.id} [--keep-content]`);
572
+ console.error(` Upgrade later: lotics upgrade ${args.package_id}`);
573
+ console.error(` Uninstall: lotics uninstall ${args.package_id} [--keep-content]`);
1418
574
  return;
1419
575
  }
1420
576
  const { app, knowledge_warnings } = result;
@@ -1422,12 +578,19 @@ export async function packageInstall(client, args) {
1422
578
  console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
1423
579
  console.error(" The data model, queries, workflows, and agents are live.");
1424
580
  warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
1425
- console.error(" Pull it for local editing: lotics app pull " + app.id);
1426
- }
1427
- /**
1428
- * `lotics package uninstall <app_id|pci_id>` ONE command over both installation
1429
- * kinds, dispatched by the id form (mirrors `package upgrade`):
1430
- * - a `pci_` id a STANDALONE CONTENT installation: deletes the row and (unless
581
+ // Follow-up ops key on the NEW installation id (app_), not the package id typed
582
+ // at `install` — signpost each one with the id inlined (the content branch above
583
+ // does the same), so the id switch is never a silent trap.
584
+ console.error(` Pull it for local editing: lotics app pull ${app.id}`);
585
+ console.error(` Upgrade later: lotics upgrade ${app.id}`);
586
+ console.error(` Health / uninstall: lotics package doctor ${app.id} · lotics uninstall ${app.id} [--archive-tables]`);
587
+ }
588
+ /**
589
+ * `lotics uninstall <app_id|package_id>` — ONE top-level command over both
590
+ * installation kinds, dispatched by the id form (mirrors `lotics upgrade`):
591
+ * - a package id (`apg_`) → THIS workspace's STANDALONE CONTENT installation
592
+ * (content installs are addressed by package id, UNIQUE per workspace, so the
593
+ * `pci_` resource id never surfaces): deletes the row and (unless
1431
594
  * `--keep-content`) archives its package-bound docs AND templates, listing each
1432
595
  * archived id.
1433
596
  * - anything else (an `app_id`) → an APP installation: archives its workflow
@@ -1436,19 +599,25 @@ export async function packageInstall(client, args) {
1436
599
  * A flag used on the wrong path is a loud error, never silently ignored.
1437
600
  */
1438
601
  export async function packageUninstall(client, args) {
1439
- if (args.id.startsWith("pci_")) {
602
+ if (args.id.startsWith("apg_")) {
1440
603
  if (args.archive_tables) {
1441
- throw new Error("--archive-tables applies only to an app installation (<app_id>). A content installation " +
1442
- "(pci_) has no scaffolded tables — use --keep-content to retain its docs/templates.");
604
+ throw new Error("--archive-tables applies only to an app installation (<app_id>). A content package " +
605
+ "has no scaffolded tables — use --keep-content to retain its docs/templates.");
606
+ }
607
+ const pkg = await client.getPackage(args.id);
608
+ // kind is a DERIVED display hint (contractHasAppSurface): 'content' = no app.
609
+ if (pkg.kind !== "content") {
610
+ throw new Error(`Package ${args.id} is an app package — uninstall an app installation by its app id: lotics uninstall <app_id>.`);
1443
611
  }
1444
- const result = await client.uninstallContentPackage(args.id, {
612
+ const installation = await resolveWorkspaceContentInstallation(client, args.id);
613
+ const result = await client.uninstallContentPackage(installation.id, {
1445
614
  keep_content: args.keep_content,
1446
615
  });
1447
616
  if (args.keep_content) {
1448
- console.error(`Uninstalled content installation ${result.installation_id} — its docs and templates were kept as ordinary workspace content.`);
617
+ console.error(`Uninstalled ${pkg.name} (${pkg.id}) — its docs and templates were kept as ordinary workspace content.`);
1449
618
  }
1450
619
  else {
1451
- console.error(`Uninstalled content installation ${result.installation_id} — archived ` +
620
+ console.error(`Uninstalled ${pkg.name} (${pkg.id}) — archived ` +
1452
621
  `${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
1453
622
  for (const docId of result.archived_doc_ids)
1454
623
  console.error(` ${docId}`);
@@ -1458,7 +627,7 @@ export async function packageUninstall(client, args) {
1458
627
  return;
1459
628
  }
1460
629
  if (args.keep_content) {
1461
- throw new Error("--keep-content applies only to a standalone content installation (pci_). An app installation " +
630
+ throw new Error("--keep-content applies only to a content package (apg_). An app installation " +
1462
631
  "(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
1463
632
  }
1464
633
  const app = await client.getApp(args.id);
@@ -1482,10 +651,12 @@ export async function packageUninstall(client, args) {
1482
651
  }
1483
652
  }
1484
653
  /**
1485
- * `lotics package list-content` — list the selected workspace's package-managed
1486
- * content installations (standalone `pci_` corpora + app-bundled ones), each
1487
- * with its registry status. The read surface that surfaces a standalone `pci_` id
1488
- * for `upgrade` / `uninstall` (install prints it once; nothing else did before).
654
+ * `lotics package list-content` — list the selected workspace's STANDALONE
655
+ * content installations (an app-bundled corpus rides its app's
656
+ * `binding.knowledge` and shows on the Apps surface instead), each with its
657
+ * registry status. The what-is-installed listing: each row leads with the PACKAGE
658
+ * id — the address for `lotics upgrade <package_id>` / `lotics uninstall
659
+ * <package_id>` (the `pci_` resource id stays hidden).
1489
660
  */
1490
661
  export async function packageListContent(client) {
1491
662
  const workspaceId = client.getWorkspaceId();
@@ -1502,11 +673,10 @@ export async function packageListContent(client) {
1502
673
  const name = inst.package_registry?.name ?? "(unknown package)";
1503
674
  const latest = inst.package_registry?.latest_version;
1504
675
  const updateAvailable = inst.package_registry?.update_available ?? false;
1505
- const source = inst.app_id === null ? "standalone" : `app-bundled (${inst.app_id})`;
1506
676
  const versionLabel = latest !== undefined && latest !== inst.package_version
1507
677
  ? `v${inst.package_version} → latest v${latest}`
1508
678
  : `v${inst.package_version}`;
1509
- console.error(` ${inst.id} ${name} ${versionLabel} ${source}` +
679
+ console.error(` ${inst.package_id} ${name} ${versionLabel}` +
1510
680
  (updateAvailable ? " → update available" : ""));
1511
681
  }
1512
682
  }
@@ -1520,7 +690,7 @@ export async function packageEject(client, args) {
1520
690
  /**
1521
691
  * Parse one `key=value` config assignment. When the knob's type is known (from
1522
692
  * the stored value at `package config --set`) the value is parsed to that type
1523
- * loudly; otherwise (`package install --config`) it is inferred (true/false →
693
+ * loudly; otherwise (`lotics install --config`) it is inferred (true/false →
1524
694
  * boolean, numeric → number, else string) and the server validates it against
1525
695
  * the contract.
1526
696
  */
@@ -1598,262 +768,189 @@ export async function packageConfig(client, args) {
1598
768
  }
1599
769
  }
1600
770
  /**
1601
- * `lotics package retire <package_id> [--undo]` — retire (or un-retire) a
1602
- * registry package. Owner-org admin-only.
771
+ * `lotics app unpublish <app_id|package_id> [--undo]` — take a published package
772
+ * off the shelf (or `--undo` restore it): new installs refuse it and it hides
773
+ * from other orgs, while existing installations keep working and may still
774
+ * upgrade. Given an app id (an installation of the package) it resolves the
775
+ * package from the app; a package id targets it directly. Owner-org admin-only.
1603
776
  */
1604
- export async function packageRetire(client, args) {
1605
- const pkg = await client.retirePackage(args.package_id, { undo: args.undo });
777
+ export async function appUnpublish(client, args) {
778
+ let packageId = args.id;
779
+ if (args.id.startsWith("app_")) {
780
+ const app = await client.getApp(args.id);
781
+ if (!app.package_id) {
782
+ throw new Error(`App ${args.id} is not a package installation — it has no package to unpublish. ` +
783
+ `Pass the package id directly.`);
784
+ }
785
+ packageId = app.package_id;
786
+ }
787
+ const pkg = await client.retirePackage(packageId, { undo: args.undo });
1606
788
  if (pkg.retired_at !== null) {
1607
- console.error(`Retired ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
1608
- `existing installations keep working and may still upgrade.`);
789
+ console.error(`Unpublished ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
790
+ `existing installations keep working and may still upgrade. Undo: lotics app unpublish ${pkg.id} --undo`);
1609
791
  }
1610
792
  else {
1611
- console.error(`Un-retired ${pkg.name} (${pkg.id}) — installable again.`);
793
+ console.error(`Re-published ${pkg.name} (${pkg.id}) — installable again.`);
1612
794
  }
1613
795
  }
1614
796
  /**
1615
- * `lotics package extract <app_id> [path]` promote a bespoke app to a DRAFT
1616
- * package project (docs/packages.md § Promotion). Calls the extract read,
1617
- * prints the findings report grouped by severity, then ALWAYS emits the draft
1618
- * project (a broken contract is still the reviewable starting point): the app's
1619
- * current source archive (same mechanics as `lotics app pull`) with the app
1620
- * manifest swapped for an unpublished package manifest, `contract.json`, the
1621
- * file-backed templates staged at their `bytes_ref` paths, and
1622
- * `.lotics/adopt_binding.json` (the origin pin the `adopt` step reads back).
1623
- * Exits non-zero when any `error` finding exists the draft is written, but
1624
- * publish re-validates and nothing should ship unreviewed.
1625
- */
1626
- /**
1627
- * Read an APP project's package.json `lotics.knowledge` (`[{ alias, doc_id }]`)
1628
- * + `lotics.knowledge_expects` (`string[]`) authoring declaration — the docs the
1629
- * package will own + the doc NAMES its agents route to but do not own. Distinct
1630
- * from a PACKAGE project's `lotics.package.knowledge` (a metadata record).
797
+ * `lotics app publish [app_id|.] [--rename old=new ...] [-m <changelog>] [--yes]`
798
+ * FIRST-RELEASE a bespoke app as a package (docs/packages.md § Promotion). Nothing
799
+ * starts as a package. Mirrors `app release`'s preview→apply UX: it first shows the
800
+ * dry-run preview (GET, no writes) the package name, the auto-minted RENAMABLE
801
+ * aliases (the exact `--rename` keys, so v1's frozen aliases are inspected first,
802
+ * never a blind publish), and the extract findings — then APPLIES only with
803
+ * `--yes` (else exits 1 with the re-run hint). On apply the server extracts the
804
+ * contract, creates the registry package, publishes v1 from the DEPLOYED source +
805
+ * dist, and pins the origin as installation #1. `--rename old=new` fixes an
806
+ * auto-minted alias before v1 freezes it; an `error` finding blocks the apply. An
807
+ * already-linked app releases with `lotics app release` instead. The app id is the
808
+ * positional (`.`/omitted → resolved from the local app project manifest).
1631
809
  */
1632
- function readAppKnowledgeDeclaration(appPkgJson) {
1633
- const lotics = isPlainObject(appPkgJson.lotics) ? appPkgJson.lotics : {};
1634
- const knowledge = [];
1635
- if (Array.isArray(lotics.knowledge)) {
1636
- for (const entry of lotics.knowledge) {
1637
- if (isPlainObject(entry) && typeof entry.alias === "string" && typeof entry.doc_id === "string") {
1638
- knowledge.push({ alias: entry.alias, doc_id: entry.doc_id });
1639
- }
1640
- }
810
+ export async function appPublish(client, args) {
811
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
812
+ const local = readLocalAppManifest(projectDir);
813
+ const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
814
+ const appId = explicit ?? local?.app_id ?? null;
815
+ if (appId === null) {
816
+ throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
817
+ "or pass one: lotics app publish <app_id>.");
818
+ }
819
+ // Forward the app's package-managed knowledge declaration ONLY when the local
820
+ // manifest is this app's own project (agents reference docs by free text, so the
821
+ // author declares which the package owns). A bare id published from elsewhere
822
+ // ships without a bundled corpus — publish from the app dir to include it.
823
+ const knowledge = local && local.app_id === appId ? local.knowledge : [];
824
+ const renames = parseRenameFlags(args.renames);
825
+ // Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
826
+ // so the aliases v1 freezes forever are never a blind publish and `--rename`
827
+ // targets are inspectable before committing.
828
+ const preview = await client.previewPublishAppPackage(appId, { renames, knowledge });
829
+ const { lines, hasError } = formatExtractReport(preview.findings);
830
+ console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
831
+ const groups = [
832
+ ["entities", preview.renamable_aliases.entities],
833
+ ["fields", preview.renamable_aliases.fields],
834
+ ["options", preview.renamable_aliases.options],
835
+ ["roles", preview.renamable_aliases.roles],
836
+ ["templates", preview.renamable_aliases.templates],
837
+ ["workflows", preview.renamable_aliases.workflows],
838
+ ];
839
+ if (groups.some(([, vals]) => vals.length > 0)) {
840
+ console.error(" Auto-minted aliases — rename any with --rename <alias>=<new> before v1 freezes them:");
841
+ for (const [label, vals] of groups) {
842
+ if (vals.length > 0)
843
+ console.error(` ${`${label}:`.padEnd(11)} ${vals.join(", ")}`);
844
+ }
845
+ console.error(" (query / app-workflow / agent runtime aliases are fixed — the shipped source calls them verbatim.)");
1641
846
  }
1642
- const knowledge_expects = Array.isArray(lotics.knowledge_expects)
1643
- ? lotics.knowledge_expects.filter((v) => typeof v === "string")
1644
- : [];
1645
- return { knowledge, knowledge_expects };
1646
- }
1647
- /** Derive the package project's `lotics.package.knowledge` metadata from the extracted contract's knowledge namespace. */
1648
- function knowledgeManifestFromContract(contract) {
1649
- const out = {};
1650
- if (!isPlainObject(contract) || !isPlainObject(contract.knowledge))
1651
- return out;
1652
- for (const [alias, entry] of Object.entries(contract.knowledge)) {
1653
- if (!isPlainObject(entry) || typeof entry.name !== "string")
1654
- continue;
1655
- out[alias] = {
1656
- name: entry.name,
1657
- description: typeof entry.description === "string" ? entry.description : null,
1658
- active_by_default: typeof entry.active_by_default === "boolean" ? entry.active_by_default : true,
1659
- };
847
+ else {
848
+ console.error(" No renamable aliases.");
1660
849
  }
1661
- return out;
1662
- }
1663
- export async function packageExtract(client, args) {
1664
- const app = await client.getApp(args.app_id);
1665
- if (!app.current_version_id) {
1666
- throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
1667
- }
1668
- const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
1669
- if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
1670
- throw new Error(`Target directory ${targetPath} is not empty.`);
1671
- }
1672
- fs.mkdirSync(targetPath, { recursive: true });
1673
- // Pull the app's current source archive FIRST — same mechanics as `lotics app
1674
- // pull` (getAppVersion → presigned source URL → download → untar). The app's
1675
- // package.json (in the archive) carries the `lotics.knowledge` declaration
1676
- // extract needs, so the download precedes the extract call.
1677
- const version = await client.getAppVersion(app.id, app.current_version_id);
1678
- const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
1679
- const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
1680
- console.error("Downloading source archive...");
1681
- await client.downloadFile(sourceUrl, tmpFile);
1682
- try {
1683
- console.error(`Extracting to ${targetPath}...`);
1684
- await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
1685
- }
1686
- finally {
1687
- if (fs.existsSync(tmpFile))
1688
- fs.unlinkSync(tmpFile);
1689
- }
1690
- const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
1691
- const appKnowledge = readAppKnowledgeDeclaration(appPkgJson);
1692
- // Agents reference docs in free text (uninvertible), so the author DECLARED the
1693
- // package-managed docs in the app manifest; pass them to extract, which loads
1694
- // each live doc + emits its content bytes to write locally.
1695
- const extracted = await client.extractPackage(args.app_id, {
1696
- knowledge: appKnowledge.knowledge,
1697
- });
1698
- const { lines, hasError } = formatExtractReport(extracted.report);
1699
850
  if (lines.length > 0) {
1700
- console.error(`Extraction report (${extracted.report.length}):`);
851
+ console.error(` Findings (${preview.findings.length}):`);
1701
852
  for (const line of lines)
1702
853
  console.error(line);
1703
854
  }
1704
- else {
1705
- console.error("Extraction report: no findings.");
1706
- }
1707
- // Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
1708
- // package manifest — atomic write via the existing manifest writer.
1709
- const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
1710
- // Fold the extracted knowledge metadata + the app's `knowledge_expects` into the
1711
- // package manifest (the content bytes are written to knowledge/<alias>.md below;
1712
- // build re-derives the shas from them).
1713
- draft.manifest.knowledge = knowledgeManifestFromContract(extracted.contract);
1714
- draft.manifest.knowledge_expects = appKnowledge.knowledge_expects;
1715
- // The origin app's package.json rides in the source archive with ITS OWN
1716
- // @lotics/app-sdk range — raise it to the getAppBinding floor the generated
1717
- // .lotics/app_fields.ts needs (never lower an already-newer range).
1718
- const deps = isPlainObject(draft.pkgJson.dependencies) ? draft.pkgJson.dependencies : {};
1719
- const originRange = typeof deps["@lotics/app-sdk"] === "string" ? deps["@lotics/app-sdk"] : null;
1720
- const originFloor = originRange?.replace(/^[\^~]/, "") ?? null;
1721
- if (originFloor === null || cmpVersions(originFloor, STARTER_FALLBACK_SDK_VERSION) < 0) {
1722
- const raised = packageSdkRange(await fetchLatestNpmVersion("@lotics/app-sdk"));
1723
- draft.pkgJson.dependencies = { ...deps, "@lotics/app-sdk": raised };
1724
- console.error(`Raised @lotics/app-sdk to ${raised} (generated app_fields needs getAppBinding)`);
1725
- }
1726
- writePackageManifest(targetPath, draft);
1727
- // The alias-keyed draft contract — the reviewable master. Pretty + trailing newline.
1728
- fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(extracted.contract, null, 2) + "\n");
1729
- console.error(`Wrote ${CONTRACT_FILE}`);
1730
- // The origin's baked `.lotics/app_fields.ts` (concrete ids) never travels —
1731
- // regenerate it contract-derived so `F`/`OPT` resolve per-install at runtime
1732
- // and the extracted source compiles unchanged.
1733
- writePackageAppFields(targetPath);
1734
- // vite.config.ts is starter-owned machinery — refresh it wholesale so the
1735
- // package project carries the current starter config (notably
1736
- // `build.target: "es2022"`, which the generated app_fields' top-level await
1737
- // requires). Origin-side dev customizations (e.g. `lotics ui link` aliases)
1738
- // don't belong in a package.
1739
- const starterViteConfig = buildStarterTemplate({
1740
- app_name: app.name,
1741
- app_id: "",
1742
- workspace_id: "",
1743
- }).find((f) => f.path === "vite.config.ts");
1744
- if (starterViteConfig === undefined) {
1745
- throw new Error("starter template is missing vite.config.ts — cannot refresh the package project");
1746
- }
1747
- const viteConfigPath = path.join(targetPath, "vite.config.ts");
1748
- const originViteConfig = fs.existsSync(viteConfigPath)
1749
- ? fs.readFileSync(viteConfigPath, "utf-8")
1750
- : null;
1751
- fs.writeFileSync(viteConfigPath, starterViteConfig.content);
1752
- if (originViteConfig !== null && originViteConfig !== starterViteConfig.content) {
1753
- // The origin may carry legitimate customizations (optimizeDeps entries,
1754
- // plugins) beyond the dev-links that must not ship — never destroy them
1755
- // silently: stash the original for manual re-application.
1756
- const stash = path.join(dotLoticsDirEnsured(targetPath), "vite.config.origin.ts");
1757
- fs.writeFileSync(stash, originViteConfig);
1758
- console.error("Refreshed vite.config.ts from the starter (build.target es2022). The origin app's config " +
1759
- "differed — its original was saved to .lotics/vite.config.origin.ts; re-apply any " +
1760
- "custom optimizeDeps/plugins entries you still need (never dev-link aliases).");
1761
- }
1762
- else {
1763
- console.error("Refreshed vite.config.ts from the starter (build.target es2022)");
1764
- }
1765
- // Stage each file-backed template at the `bytes_ref` the contract references.
1766
- // The download lands in the target dir (fresh, so no name collision) under the
1767
- // file's own name; rename it to the exact `bytes_ref` basename the contract
1768
- // points at. Processed sequentially so a prior rename frees the name.
1769
- for (const tf of extracted.template_files) {
1770
- const dest = path.join(targetPath, tf.bytes_ref);
1771
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1772
- const { path: downloaded } = await client.downloadFileById(tf.file_id, path.dirname(dest));
1773
- if (path.resolve(downloaded) !== path.resolve(dest))
1774
- fs.renameSync(downloaded, dest);
1775
- console.error(`Wrote ${tf.bytes_ref}`);
1776
- }
1777
- // Write each package-managed knowledge doc's content at `knowledge/<alias>.md`
1778
- // (the path the contract's content_ref names) — build re-derives the sha from
1779
- // these bytes, so they are the reviewable master alongside contract.json.
1780
- for (const kf of extracted.knowledge_files) {
1781
- const dest = path.join(targetPath, kf.content_ref);
1782
- fs.mkdirSync(path.dirname(dest), { recursive: true });
1783
- fs.writeFileSync(dest, kf.content);
1784
- console.error(`Wrote ${kf.content_ref}`);
1785
- }
1786
- // The origin pin the `adopt` step reads back — incl. the knowledge binding
1787
- // (alias → origin kdc_ id) so adopt can pin the corpus as installation #1.
1788
- // `.lotics` is in SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
1789
- const dotLotics = path.join(targetPath, ".lotics");
1790
- fs.mkdirSync(dotLotics, { recursive: true });
1791
- fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({
1792
- app_id: app.id,
1793
- workspace_id: app.workspace_id,
1794
- binding: extracted.binding,
1795
- knowledge_binding: extracted.knowledge_binding,
1796
- }, null, 2) + "\n");
1797
- console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
1798
- console.error("Installing npm dependencies...");
1799
- await runNpm(["install"], targetPath);
1800
855
  if (hasError) {
1801
- console.error("\nExtraction produced error findings — the draft is written but NOT publishable as-is.");
1802
- console.error("Fix the reported items above, then publish (which re-validates the contract).");
856
+ console.error("\nExtract found error findings — the app cannot be published as-is. Fix them in the app, redeploy, and retry.");
1803
857
  process.exitCode = 1;
1804
858
  return;
1805
859
  }
1806
- console.error("\nDraft package project written. Next steps:");
1807
- console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
1808
- console.error(" # review the aliases in contract.json, then:");
1809
- console.error(` lotics package publish -m "v1"`);
1810
- console.error(` lotics package adopt ${app.id} (from this project, same workspace)`);
1811
- }
1812
- /**
1813
- * `lotics package adopt <app_id> [path]` — bind the published package project
1814
- * onto the origin app (docs/packages.md § Promotion). Reads the project
1815
- * manifest (must be published — refuses otherwise), resolves the version
1816
- * (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
1817
- * REFUSING a pin recorded for a different app. On success the app becomes
1818
- * installation #1 and is upgradeable again; a server ConflictError (naming the
1819
- * unfaithful aliases) surfaces verbatim.
860
+ if (!args.yes) {
861
+ const idArg = explicit ?? ".";
862
+ const renameArgs = args.renames.map((r) => ` --rename ${r}`).join("");
863
+ const mArg = args.changelog ? ` -m ${JSON.stringify(args.changelog)}` : "";
864
+ console.error(`\nRe-run with --yes to publish v1:`);
865
+ console.error(` lotics app publish ${idArg}${renameArgs}${mArg} --yes`);
866
+ process.exitCode = 1;
867
+ return;
868
+ }
869
+ const result = await client.publishAppAsPackage(appId, {
870
+ renames,
871
+ changelog: args.changelog ?? null,
872
+ knowledge,
873
+ });
874
+ console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
875
+ console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
876
+ console.error(` lotics app pull ${appId} # edit, then lotics app deploy`);
877
+ console.error(` lotics app release ${appId} -m "<what changed>"`);
878
+ console.error(` Install it elsewhere: lotics install ${result.package_id}`);
879
+ }
880
+ /**
881
+ * `lotics app release [app_id|.] -m <changelog> [--yes]` — snapshot an
882
+ * adopted/installed origin app into its next registry version (docs/packages.md
883
+ * § Promotion). The origin is the permanent working copy; a release binding-aware-
884
+ * extracts it (stable aliases), repackages its DEPLOYED source + dist as the
885
+ * bundle, publishes the next version, and re-pins the origin. Prints the preview
886
+ * first (next version, new + changed aliases, the bundled-knowledge delta,
887
+ * findings); applies only with `--yes`, else exits 1 so a review step can't be
888
+ * skipped. An `error` finding blocks the apply.
889
+ *
890
+ * Run from the pulled app project, the manifest's `lotics.knowledge` (alias →
891
+ * doc_id) is the bundle DECLARATION — it re-declares which docs the package owns
892
+ * (add/drop/re-snapshot). Forwarded only when non-empty; empty (or a bare id from
893
+ * elsewhere) sends nothing, so the current corpus is reconstructed from the pin
894
+ * (never silently dropped).
1820
895
  */
1821
- export async function packageAdopt(client, args) {
896
+ export async function appRelease(client, args) {
1822
897
  const projectDir = path.resolve(args.projectDir ?? process.cwd());
1823
- const { manifest } = readPackageProject(projectDir);
1824
- if (manifest.id === null) {
1825
- throw new Error("This package project has never been published (no package id). Publish first:\n lotics package publish");
1826
- }
1827
- const version = args.version ?? manifest.version;
1828
- if (version === null) {
1829
- throw new Error("No version to adopt publish this project first (lotics package publish) or pass --version N.");
1830
- }
1831
- const bindingPath = path.join(projectDir, ".lotics", ADOPT_BINDING_FILE);
1832
- if (!fs.existsSync(bindingPath)) {
1833
- throw new Error(`No .lotics/${ADOPT_BINDING_FILE} in ${projectDir}. Adopt binds the origin app that ` +
1834
- `"lotics package extract" recorded run extract to produce this project.`);
1835
- }
1836
- const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
1837
- const app = await client.adoptPackage(args.app_id, {
1838
- package_id: manifest.id,
1839
- version,
1840
- binding: pin.binding,
1841
- // The origin knowledge binding (empty when the app owns no package-managed
1842
- // docs) the server verifies it maps the contract's knowledge aliases faithfully.
1843
- ...(Object.keys(pin.knowledge_binding).length > 0
1844
- ? { knowledge_binding: pin.knowledge_binding }
1845
- : {}),
1846
- });
1847
- console.error(`Adopted ${app.name} installation of ${app.package_id} v${app.package_version}.`);
1848
- console.error(` Verify the installation: lotics package doctor ${app.id}`);
898
+ const local = readLocalAppManifest(projectDir);
899
+ const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
900
+ const appId = explicit ?? local?.app_id ?? null;
901
+ if (appId === null) {
902
+ throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
903
+ }
904
+ // Forward the manifest's knowledge DECLARATION only from this app's own project
905
+ // AND only when it lists docs — an empty/absent declaration is "no change"
906
+ // (reconstruct from the pin), never "drop the bundle".
907
+ const knowledge = local && local.app_id === appId && local.knowledge.length > 0 ? local.knowledge : undefined;
908
+ const preview = await client.previewPackageRelease(appId, { knowledge });
909
+ const { lines, hasError } = formatExtractReport(preview.findings);
910
+ console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
911
+ if (preview.added_aliases.length > 0) {
912
+ console.error(` New (${preview.added_aliases.length}): ${preview.added_aliases.join(", ")}`);
913
+ }
914
+ if (preview.changed_artifacts.length > 0) {
915
+ console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
916
+ }
917
+ // Deploy-skew boundary: a pre-knowledge-declaration server omits the field.
918
+ const k = preview.knowledge ?? { added: [], removed: [], changed: [] };
919
+ if (k.added.length > 0 || k.removed.length > 0 || k.changed.length > 0) {
920
+ const parts = [
921
+ k.added.length > 0 ? `+${k.added.join(", ")}` : null,
922
+ k.removed.length > 0 ? `dropped ${k.removed.join(", ")}` : null,
923
+ k.changed.length > 0 ? `changed ${k.changed.join(", ")}` : null,
924
+ ].filter((p) => p !== null);
925
+ console.error(` Knowledge: ${parts.join("; ")}`);
926
+ }
927
+ if (preview.added_aliases.length === 0 &&
928
+ preview.changed_artifacts.length === 0 &&
929
+ k.added.length === 0 &&
930
+ k.removed.length === 0 &&
931
+ k.changed.length === 0) {
932
+ console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
933
+ }
934
+ if (lines.length > 0) {
935
+ console.error(` Findings (${preview.findings.length}):`);
936
+ for (const line of lines)
937
+ console.error(line);
938
+ }
939
+ if (hasError) {
940
+ console.error("\nExtract found error findings — the origin cannot be released as-is. Fix them in the app and retry.");
941
+ process.exitCode = 1;
942
+ return;
943
+ }
944
+ if (!args.yes) {
945
+ console.error(`\nRe-run with --yes to publish v${preview.version}:`);
946
+ console.error(` lotics app release ${args.app_id ?? "."} -m ${JSON.stringify(args.changelog)} --yes`);
947
+ process.exitCode = 1;
948
+ return;
949
+ }
950
+ const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge });
951
+ console.error(`Released ${result.package_id} v${result.version}.`);
952
+ console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
1849
953
  }
1850
- /**
1851
- * `lotics package fleet-upgrade <package_id> [--version N]` — bring every
1852
- * installation of the package across the caller's org to the target version.
1853
- * Hands-off applies only where the preview is clean; skipped/failed
1854
- * installations are reported per line and the process exits 1 so a release
1855
- * script can gate on "fleet fully current".
1856
- */
1857
954
  /**
1858
955
  * `lotics package yank <package_id> <version> [--undo]` — mark a published
1859
956
  * version uninstallable (or restore it). New installs/upgrades/adopts refuse a
@@ -1871,7 +968,14 @@ export async function packageYank(client, args) {
1871
968
  }
1872
969
  console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
1873
970
  }
1874
- export async function packageFleetUpgrade(client, args) {
971
+ /**
972
+ * `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
973
+ * installation of the package across the caller's org to the target version.
974
+ * Hands-off applies only where the preview is clean; skipped/failed
975
+ * installations are reported per line and the process exits 1 so a release
976
+ * script can gate on "fleet fully current".
977
+ */
978
+ async function packageFleetUpgrade(client, args) {
1875
979
  const result = await client.fleetUpgradePackage(args.package_id, {
1876
980
  ...(args.version !== undefined ? { version: args.version } : {}),
1877
981
  });
@@ -1887,7 +991,7 @@ export async function packageFleetUpgrade(client, args) {
1887
991
  const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
1888
992
  if (inst.outcome === "skipped" && inst.blockers) {
1889
993
  console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
1890
- console.error(` resolve via: lotics package upgrade ${inst.app_id} --version ${result.target_version} ...`);
994
+ console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
1891
995
  }
1892
996
  else if (inst.message) {
1893
997
  console.error(`${line}: ${inst.message}`);