@lotics/cli 0.86.2 → 0.87.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,1096 +0,0 @@
1
- /**
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:
8
- *
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.)
15
- *
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.
20
- */
21
- import fs from "node:fs";
22
- import path from "node:path";
23
- import "./client.js";
24
- import { contractConfigEntrySchema, knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
25
- function packageJsonPath(projectDir) {
26
- return path.join(projectDir, "package.json");
27
- }
28
- function isPlainObject(value) {
29
- return typeof value === "object" && value !== null && !Array.isArray(value);
30
- }
31
- /**
32
- * Read the local APP project's manifest (`package.json#lotics.app_id` +
33
- * `lotics.knowledge` + `lotics.config`) — what `lotics app pull` writes.
34
- * `lotics app publish` / `release` run from a pulled app project resolve the app
35
- * id from it and forward the package-managed knowledge declaration AND the config
36
- * knob declarations to the server — both are declarations extract cannot invert
37
- * from artifacts (agents reference docs by free text; a config knob has no concrete
38
- * artifact), so the author declares them. Each `lotics.config` entry is validated
39
- * against the shared `contractConfigEntrySchema` so a malformed knob fails LOUDLY
40
- * client-side naming the alias, never silently dropped. Returns null when the dir
41
- * has no package.json. `app_id` is null when the manifest is a package/non-app
42
- * project.
43
- */
44
- export function readLocalAppManifest(projectDir) {
45
- const pkgPath = packageJsonPath(projectDir);
46
- if (!fs.existsSync(pkgPath))
47
- return null;
48
- const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
49
- if (!isPlainObject(parsed))
50
- return null;
51
- const lotics = isPlainObject(parsed.lotics) ? parsed.lotics : {};
52
- const app_id = typeof lotics.app_id === "string" ? lotics.app_id : null;
53
- const knowledge = [];
54
- if (Array.isArray(lotics.knowledge)) {
55
- for (const entry of lotics.knowledge) {
56
- if (isPlainObject(entry) && typeof entry.alias === "string" && typeof entry.doc_id === "string") {
57
- knowledge.push({ alias: entry.alias, doc_id: entry.doc_id });
58
- }
59
- }
60
- }
61
- const config = [];
62
- if (Array.isArray(lotics.config)) {
63
- for (const entry of lotics.config) {
64
- const result = contractConfigEntrySchema.safeParse(entry);
65
- if (!result.success) {
66
- const alias = isPlainObject(entry) && typeof entry.alias === "string" ? entry.alias : "?";
67
- throw new Error(`Invalid lotics.config knob "${alias}" in package.json: ` +
68
- result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
69
- }
70
- config.push(result.data);
71
- }
72
- }
73
- return { app_id, knowledge, config };
74
- }
75
- /** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
76
- export function parseRenameFlags(renames) {
77
- return renames.map((entry) => {
78
- const eq = entry.indexOf("=");
79
- if (eq <= 0 || eq === entry.length - 1) {
80
- throw new Error(`Invalid --rename "${entry}" — expected old=new (an alias to rename before v1 freezes it).`);
81
- }
82
- return { from: entry.slice(0, eq), to: entry.slice(eq + 1) };
83
- });
84
- }
85
- /**
86
- * Render a package-extract report grouped by severity (errors, then warnings,
87
- * then info), one ` [<severity>] <area>: <message>` line each, and classify
88
- * whether any `error` finding is present. Shared by the release preview display.
89
- * Pure — the command prints `lines` to stderr and gates on `hasError`.
90
- */
91
- export function formatExtractReport(report) {
92
- const order = ["error", "warning", "info"];
93
- const lines = order.flatMap((severity) => report
94
- .filter((f) => f.severity === severity)
95
- .map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
96
- return { lines, hasError: report.some((f) => f.severity === "error") };
97
- }
98
- /**
99
- * Resolve the installation to operate on: an explicit app id is required (there
100
- * is no dev-workspace pin to fall back to).
101
- */
102
- function resolveInstallationAppId(explicit) {
103
- if (explicit)
104
- return explicit;
105
- throw new Error("Pass an app id — e.g. lotics package doctor <app_id>.");
106
- }
107
- const RESOLVE_VERBS = new Set(["recreate", "revert", "keep", "apply", "archive", "unbind"]);
108
- /**
109
- * Parse repeated `--resolve <key>=<value>` flags into the ONE namespaced
110
- * resolutions map every upgrade wire takes. Keys pass through verbatim — a
111
- * drifted binding entry (`<namespace>.<alias>`), a modified artifact
112
- * (`<kind>.<alias>`), a bundled/standalone knowledge doc (`knowledge.<alias>`),
113
- * or a live-role re-point (`roles.<alias>`). A value that is one of the
114
- * resolution verbs stays a verb; anything else is a `{ bind_to }` id. The
115
- * server validates value-kind against what each key resolves.
116
- */
117
- export function parseResolveFlags(resolve) {
118
- const resolutions = {};
119
- for (const entry of resolve) {
120
- const eq = entry.indexOf("=");
121
- if (eq <= 0 || eq === entry.length - 1) {
122
- throw new Error(`Invalid --resolve "${entry}" — expected <key>=<verb> (recreate|revert|keep|apply|archive|unbind) or <key>=<existing_id>.`);
123
- }
124
- const key = entry.slice(0, eq);
125
- const value = entry.slice(eq + 1);
126
- resolutions[key] = RESOLVE_VERBS.has(value)
127
- ? value
128
- : { bind_to: value };
129
- }
130
- return resolutions;
131
- }
132
- /**
133
- * Health check: version pin vs. registry latest + binding drift. Exits
134
- * non-zero when drift is found so scripts can gate on it. An `apg_` PACKAGE id
135
- * resolves THIS workspace's standalone content installation — the same
136
- * package-id addressing `upgrade`/`uninstall` speak.
137
- */
138
- export async function packageDoctor(client, args) {
139
- if (args.app_id !== undefined && args.app_id.startsWith("apg_")) {
140
- await contentDoctor(client, args.app_id);
141
- return;
142
- }
143
- const app_id = resolveInstallationAppId(args.app_id);
144
- const health = await client.getPackageHealth(app_id);
145
- console.error(`${health.package_name} — installation ${health.app_id}` +
146
- (health.is_origin ? " (origin — this app IS installation #1, the release working copy)" : ""));
147
- console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
148
- (health.update_available ? " → update available" : ""));
149
- if (health.drift.length === 0) {
150
- console.error(" Binding: healthy — every bound alias resolves.");
151
- }
152
- else {
153
- console.error(` Binding drift (${health.drift.length}):`);
154
- for (const d of health.drift) {
155
- console.error(` - ${d.namespace}.${d.alias} → ${d.id} (missing from the workspace)`);
156
- }
157
- console.error(` Resolve while upgrading:\n lotics upgrade ${app_id}` +
158
- ` --resolve <namespace.alias>=recreate (or =<existing_id> to re-point)`);
159
- process.exitCode = 1;
160
- }
161
- if (health.modified.length === 0) {
162
- console.error(health.is_origin
163
- ? " Package artifacts: no changes since the last release.\n" +
164
- ` (schema additions aren't fingerprinted — preview them with: lotics app release ${app_id})`
165
- : " Package artifacts: pristine — no local edits an upgrade would revert.");
166
- }
167
- else if (health.is_origin) {
168
- // The origin IS the author's working copy — edits since the last release are
169
- // the NEXT release's payload, not consumer drift, and never fail the check.
170
- console.error(` Changed since v${health.installed_version} (${health.modified.length}) — a release will publish these:`);
171
- for (const m of health.modified) {
172
- console.error(` - ${m.kind}.${m.alias}`);
173
- }
174
- console.error(` Cut the next version: lotics app release ${app_id} -m "<what changed>"`);
175
- }
176
- else {
177
- console.error(` Locally modified package artifacts (${health.modified.length}):`);
178
- for (const m of health.modified) {
179
- console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
180
- }
181
- console.error(` Consent while upgrading:\n lotics upgrade ${app_id}` +
182
- ` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
183
- process.exitCode = 1;
184
- }
185
- // Package-managed knowledge (bundled with this app install): drift + local edits
186
- // + unmet expects. All advisory; resolved through the app's package upgrade.
187
- if (health.knowledge_drift.length > 0) {
188
- console.error(` Knowledge binding drift (${health.knowledge_drift.length}):`);
189
- for (const d of health.knowledge_drift) {
190
- console.error(` - ${d.alias} "${d.name}" → ${d.doc_id ?? "(unbound)"} (missing from the workspace)`);
191
- }
192
- }
193
- if (health.knowledge_modified.length > 0) {
194
- if (health.is_origin) {
195
- console.error(` Knowledge changed since v${health.installed_version} (${health.knowledge_modified.length}) — a release will re-snapshot these:`);
196
- for (const m of health.knowledge_modified) {
197
- console.error(` - ${m.alias} "${m.name}"`);
198
- }
199
- }
200
- else {
201
- console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
202
- for (const m of health.knowledge_modified) {
203
- console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
204
- }
205
- }
206
- }
207
- if (health.missing_expected_docs.length > 0) {
208
- console.error(` Missing expected knowledge docs (${health.missing_expected_docs.length}):`);
209
- for (const name of health.missing_expected_docs) {
210
- console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
211
- }
212
- }
213
- // Drift is genuine breakage on the origin too (a bound id vanished); edited
214
- // knowledge on the origin is staged release work, not a failure.
215
- if (health.knowledge_drift.length > 0 || (health.knowledge_modified.length > 0 && !health.is_origin)) {
216
- process.exitCode = 1;
217
- }
218
- if (health.knowledge_drift.length === 0 &&
219
- health.knowledge_modified.length === 0 &&
220
- health.missing_expected_docs.length === 0) {
221
- console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
222
- }
223
- if (health.update_available) {
224
- console.error(` Upgrade: lotics upgrade ${app_id}`);
225
- }
226
- }
227
- /**
228
- * Preview-then-apply an app-installation upgrade. Prints the additive plan +
229
- * informational removals + any bundled-knowledge changes; refuses (exit 1, with
230
- * the exact --resolve syntax) while any binding drift, modified core artifact, or
231
- * consent-requiring bundled-knowledge doc lacks a resolution.
232
- *
233
- * `--resolve` speaks ONE namespaced grammar, passed to the server verbatim: a
234
- * drifted binding entry (`<namespace>.<alias>`), a modified artifact
235
- * (`<kind>.<alias>`), a bundled knowledge doc (`knowledge.<alias>` —
236
- * apply|keep|archive|recreate|unbind), or a live-role re-point
237
- * (`roles.<alias>=<grp_id>`). `--bind-to` consents an added-knowledge-doc name
238
- * collision; `--apply-all` accepts the package's version for every
239
- * consent-requiring knowledge doc (overwriting local edits).
240
- */
241
- export async function packageUpgrade(client, args) {
242
- const preview = await client.previewPackageUpgrade(args.app_id, {
243
- ...(args.version !== undefined ? { version: args.version } : {}),
244
- });
245
- console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
246
- (preview.changelog ? ` — ${preview.changelog}` : ""));
247
- const summarize = (sets) => Object.entries(sets)
248
- .filter(([, entries]) => entries.length > 0)
249
- .map(([kind, entries]) => `${entries.length} ${kind}`)
250
- .join(", ");
251
- const added = summarize(preview.diff.added);
252
- const removed = summarize(preview.diff.removed);
253
- if (added)
254
- console.error(` Adds: ${added}`);
255
- if (removed)
256
- console.error(` Unbinds (workspace data kept): ${removed}`);
257
- if (preview.knowledge.length > 0) {
258
- console.error(` Knowledge docs (${preview.knowledge.length}):`);
259
- for (const entry of preview.knowledge) {
260
- console.error(` ${formatKnowledgeEntryLine(entry)}`);
261
- }
262
- }
263
- // Breaking contract changes are NOT resolvable via --resolve — scaffold would
264
- // refuse them on a bound alias, and there is no in-flow remediation. Report
265
- // every entry and the only two real remedies, then hard-stop.
266
- if (preview.diff.breaking.length > 0) {
267
- console.error(" Breaking contract changes — NOT resolvable via --resolve (a bound alias's shape changed):");
268
- for (const b of preview.diff.breaking) {
269
- console.error(` - ${b.entity}.${b.alias} (${b.kind}): ${b.from} → ${b.to}`);
270
- }
271
- console.error(" There is no in-flow resolution for this class. The package author must publish a version\n" +
272
- " that keeps these aliases' shape stable, or eject this app (severing the package link)\n" +
273
- ` before making the change: lotics package eject ${args.app_id}`);
274
- process.exit(1);
275
- }
276
- // ONE namespaced grammar — the flags pass to the server verbatim. `--bind-to`
277
- // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
278
- // remaining consent-requiring knowledge entries with the accept verb.
279
- const resolutions = parseResolveFlags(args.resolve);
280
- for (const [alias, id] of Object.entries(parseBindToFlags(args.bindTo))) {
281
- resolutions[`knowledge.${alias}`] = { bind_to: id };
282
- }
283
- if (args.applyAll) {
284
- for (const entry of preview.knowledge) {
285
- if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
286
- resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
287
- }
288
- }
289
- }
290
- const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
291
- const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
292
- const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && resolutions[`knowledge.${e.alias}`] === undefined);
293
- if (unresolvedDrift.length > 0 ||
294
- unresolvedModified.length > 0 ||
295
- unresolvedKnowledge.length > 0) {
296
- if (unresolvedDrift.length > 0) {
297
- console.error(" Binding drift must be resolved before upgrading:");
298
- for (const d of unresolvedDrift) {
299
- console.error(` --resolve ${d.namespace}.${d.alias}=recreate (or =<existing_id> to re-point; was ${d.id})`);
300
- }
301
- }
302
- if (unresolvedModified.length > 0) {
303
- console.error(" Locally modified package artifacts need consent before upgrading:");
304
- for (const m of unresolvedModified) {
305
- console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
306
- }
307
- }
308
- if (unresolvedKnowledge.length > 0) {
309
- console.error(" Bundled knowledge docs need consent before upgrading (or --apply-all to accept the package's version for all):");
310
- for (const entry of unresolvedKnowledge) {
311
- console.error(formatKnowledgeResolveHint(entry));
312
- }
313
- }
314
- process.exit(1);
315
- }
316
- const app = await client.upgradePackage(args.app_id, {
317
- ...(args.version !== undefined ? { version: args.version } : {}),
318
- ...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
319
- });
320
- console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
321
- }
322
- /** `--apply-all`'s accept-the-package resolution for a consent-requiring entry. */
323
- function knowledgeAcceptResolution(change) {
324
- switch (change) {
325
- case "changed":
326
- return "apply";
327
- case "removed":
328
- return "archive";
329
- case "drifted":
330
- return "recreate";
331
- case "added":
332
- return "apply"; // unreachable (added never needs consent), kept total.
333
- }
334
- }
335
- /** The install-consent trust line: official > your own org > third-party. */
336
- function trustBadge(pkg) {
337
- if (pkg.is_official)
338
- return "official Lotics package";
339
- if (pkg.owned_by_caller === true)
340
- return "your organization's package";
341
- return "third-party package (runs under your authority once installed)";
342
- }
343
- /**
344
- * `lotics package show <package_id>` — registry metadata + version history
345
- * (trust badge, retirement, per-version channel/yank/changelog). The read
346
- * surface for "what is this package and what shipped when". Kind-agnostic — a
347
- * content package shows here the same way.
348
- */
349
- export async function packageShow(client, args) {
350
- const pkg = await client.getPackage(args.package_id);
351
- const { versions } = await client.listPackageVersions(args.package_id);
352
- console.error(`${pkg.name} (${pkg.id}) — ${trustBadge(pkg)}`);
353
- if (pkg.description)
354
- console.error(` ${pkg.description}`);
355
- if (pkg.retired_at)
356
- console.error(` RETIRED ${pkg.retired_at}`);
357
- console.error(` latest installable: ${pkg.latest_version > 0 ? `v${pkg.latest_version}` : "none"}`);
358
- console.error("");
359
- for (const v of versions) {
360
- const marks = [
361
- v.version === pkg.latest_version ? "*" : " ",
362
- v.channel === "dev" ? "dev" : " ",
363
- v.yanked_at ? "YANKED" : " ",
364
- ].join(" ");
365
- console.log(`${marks} v${v.version} ${v.created_at} ${v.changelog ?? ""}`.trimEnd());
366
- }
367
- if (versions.length === 0)
368
- console.error(" (no published versions)");
369
- }
370
- /**
371
- * One preview line for a knowledge upgrade entry — `[change] alias "name"` with
372
- * `modified` / `needs consent` marks. Shared verbatim by the standalone (`pci_`)
373
- * and the app-install upgrade previews so both speak one vocabulary; the caller
374
- * owns the leading indent.
375
- */
376
- function formatKnowledgeEntryLine(entry) {
377
- const marks = [
378
- entry.modified ? "modified" : null,
379
- knowledgeEntryNeedsConsent(entry) ? "needs consent" : null,
380
- ].filter((m) => m !== null);
381
- return `[${entry.change}] ${entry.alias} "${entry.name}"${marks.length ? ` (${marks.join(", ")})` : ""}`;
382
- }
383
- /**
384
- * The exact `--resolve <alias>=<valid options>` remediation line for a
385
- * consent-requiring knowledge entry (options from the shared
386
- * `validKnowledgeResolutions`). Shared by both upgrade paths' gate output.
387
- */
388
- function formatKnowledgeResolveHint(entry) {
389
- const note = entry.change === "changed" || entry.change === "removed"
390
- ? " (a local edit — apply/archive overwrites it; keep retains it)"
391
- : " (bound doc is gone; recreate from the package, or unbind)";
392
- return ` --resolve knowledge.${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
393
- }
394
- /** The `--resolve template.<alias>=revert|keep` remediation line for a consent-requiring template. */
395
- function formatTemplateResolveHint(entry) {
396
- const note = entry.baseline_unknown
397
- ? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
398
- : " (a local edit — revert overwrites it with the package's version; keep retains it)";
399
- return ` --resolve template.${entry.alias}=revert|keep${note}`;
400
- }
401
- /**
402
- * Health report for a STANDALONE content installation, addressed by PACKAGE id.
403
- * Composed client-side from the surfaces the consumer verbs already speak: the
404
- * list row (pin vs registry latest) and the upgrade preview against latest
405
- * (which carries per-alias drift + local-edit detection even when the pin is
406
- * current — the repair view). Exits non-zero on drift, mirroring the app
407
- * doctor's gate semantics.
408
- */
409
- async function contentDoctor(client, package_id) {
410
- const installation = await resolveWorkspaceContentInstallation(client, package_id);
411
- const preview = await client.previewContentInstallationUpgrade(installation.id, {});
412
- const name = installation.package_registry?.name ?? package_id;
413
- console.error(`${name} — content installation of ${package_id} (this workspace)`);
414
- console.error(` Installed: v${preview.from_version} Latest: v${preview.to_version}` +
415
- (preview.to_version > preview.from_version ? " → update available" : ""));
416
- const drifted = preview.entries.filter((e) => e.change === "drifted");
417
- if (drifted.length === 0) {
418
- console.error(" Binding: healthy — every bound doc resolves.");
419
- }
420
- else {
421
- console.error(` Binding drift (${drifted.length}):`);
422
- for (const entry of drifted) {
423
- console.error(` - knowledge.${entry.alias} "${entry.name}" (bound doc is gone)`);
424
- }
425
- console.error(` Resolve while upgrading:\n lotics upgrade ${package_id}` +
426
- ` --resolve knowledge.<alias>=recreate|unbind (or --bind-to <alias>=<kdc_id> to re-point)`);
427
- process.exitCode = 1;
428
- }
429
- const editedDocs = preview.entries.filter((e) => e.modified);
430
- const editedTemplates = preview.templates;
431
- if (editedDocs.length === 0 && editedTemplates.length === 0) {
432
- console.error(" Content: pristine — no local edits an upgrade would ask about.");
433
- }
434
- else {
435
- console.error(` Local edits (${editedDocs.length + editedTemplates.length}) — each takes a keep/overwrite consent at upgrade:`);
436
- for (const entry of editedDocs)
437
- console.error(` - knowledge.${entry.alias} "${entry.name}"`);
438
- for (const entry of editedTemplates)
439
- console.error(` - template.${entry.alias}`);
440
- }
441
- }
442
- /**
443
- * Resolve a STANDALONE content package's installation in the CURRENT workspace
444
- * from its PACKAGE id. `UNIQUE (workspace_id, package_id)` means the package id
445
- * fully determines the anchor row, so consumers address content by package id and
446
- * the `pci_` resource id never surfaces (the server keeps it — resource identity —
447
- * and the CLI resolves it here through the list endpoint; zero backend change).
448
- * Throws a clear "not installed" error (pointing at `lotics install`) when the
449
- * package has no content installation in this workspace.
450
- */
451
- async function resolveWorkspaceContentInstallation(client, package_id) {
452
- const workspaceId = client.getWorkspaceId();
453
- if (!workspaceId) {
454
- throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to operate on its content installation.");
455
- }
456
- const installations = await client.listContentInstallations(workspaceId);
457
- const match = installations.find((inst) => inst.package_id === package_id);
458
- if (!match) {
459
- throw new Error(`Package ${package_id} is not installed in this workspace — install it first: lotics install ${package_id}`);
460
- }
461
- return match;
462
- }
463
- /**
464
- * CLEAN BREAK for an explicit `pci_` argument on `upgrade` / `uninstall`: the
465
- * `pci_` resource id is retired from human sight — content installs are addressed
466
- * by their PACKAGE id. Prints a loud redirect, best-effort resolving the `pci_`
467
- * back to its package id (via list-content) so the exact command is spelled out.
468
- * The header prints synchronously first, so the redirect is observable even when
469
- * the best-effort resolution can't reach the registry.
470
- */
471
- export async function redirectContentPciForm(client, pci_id, verb) {
472
- console.error(`Content installations are addressed by their PACKAGE id now — ${pci_id} is a server-internal resource id.`);
473
- const workspaceId = client.getWorkspaceId();
474
- const match = workspaceId
475
- ? (await client.listContentInstallations(workspaceId)).find((inst) => inst.id === pci_id)
476
- : undefined;
477
- if (match) {
478
- console.error(` Run: lotics ${verb} ${match.package_id}`);
479
- }
480
- else {
481
- console.error(" Find the package id: lotics package list-content");
482
- console.error(` Then run: lotics ${verb} <package_id>`);
483
- }
484
- process.exit(1);
485
- }
486
- /**
487
- * `lotics upgrade <apg_>` — the package-id upgrade path, kind-branched. `kind` is a
488
- * DERIVED display hint (`contractHasAppSurface`): `'content'` means no app surface.
489
- * - a CONTENT package upgrades THIS workspace's standalone content installation
490
- * (resolved from the package id — the anchor is unique per workspace, so the
491
- * `pci_` never surfaces), through the same content review gate.
492
- * - an APP-surface package FLEET-upgrades every installation across the org
493
- * (unchanged) — the resolve/bind/apply-all flags don't apply to a fleet run.
494
- */
495
- export async function packageUpgradeByPackageId(client, args) {
496
- const pkg = await client.getPackage(args.package_id);
497
- if (pkg.kind === "content") {
498
- const installation = await resolveWorkspaceContentInstallation(client, args.package_id);
499
- await packageUpgradeKnowledge(client, {
500
- installation_id: installation.id,
501
- package_id: pkg.id,
502
- package_name: pkg.name,
503
- version: args.version,
504
- resolve: args.resolve,
505
- bind_to: parseBindToFlags(args.bindTo),
506
- applyAll: args.applyAll,
507
- });
508
- return;
509
- }
510
- await packageFleetUpgrade(client, {
511
- package_id: args.package_id,
512
- ...(args.version !== undefined ? { version: args.version } : {}),
513
- });
514
- }
515
- /**
516
- * Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
517
- * AND document templates behind one review gate. Prints the per-alias plan;
518
- * refuses (exit 1, with the exact `--resolve` syntax) while any consent-requiring
519
- * entry lacks a resolution — a modified knowledge change/removal or drift, or a
520
- * locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
521
- * by accepting the package's version (knowledge changed→apply, removed→archive,
522
- * drifted→recreate; template→revert) — an explicit bulk "take upstream" that
523
- * discards local edits. `--resolve knowledge.<alias>=<value>` /
524
- * `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
525
- * resolve entries individually — the ONE namespaced grammar, passed to the
526
- * server verbatim.
527
- */
528
- async function packageUpgradeKnowledge(client, args) {
529
- const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
530
- ...(args.version !== undefined ? { version: args.version } : {}),
531
- });
532
- console.error(`Content upgrade v${preview.from_version} → v${preview.to_version}` +
533
- (preview.changelog ? ` — ${preview.changelog}` : ""));
534
- const apply = async (resolutions) => {
535
- const updated = await client.applyContentInstallationUpgrade(args.installation_id, {
536
- ...(args.version !== undefined ? { version: args.version } : {}),
537
- resolutions,
538
- });
539
- console.error(`Upgraded ${args.package_name} (${args.package_id}) → v${updated.package_version}.`);
540
- };
541
- if (preview.entries.length === 0 && preview.templates.length === 0) {
542
- if (preview.to_version === preview.from_version) {
543
- console.error(" Already up to date — no content changes.");
544
- return;
545
- }
546
- console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
547
- await apply({});
548
- return;
549
- }
550
- for (const entry of preview.entries) {
551
- console.error(` ${formatKnowledgeEntryLine(entry)}`);
552
- }
553
- for (const entry of preview.templates) {
554
- console.error(` [template] ${entry.alias} (modified — needs consent)`);
555
- }
556
- // ONE namespaced grammar — the flags pass to the server verbatim. `--bind-to`
557
- // is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
558
- // remaining consent entries with the accept verb (template → revert).
559
- const resolutions = parseResolveFlags(args.resolve);
560
- for (const [alias, id] of Object.entries(args.bind_to)) {
561
- resolutions[`knowledge.${alias}`] = { bind_to: id };
562
- }
563
- if (args.applyAll) {
564
- for (const entry of preview.entries) {
565
- if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
566
- resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
567
- }
568
- }
569
- for (const entry of preview.templates) {
570
- if (resolutions[`template.${entry.alias}`] === undefined) {
571
- resolutions[`template.${entry.alias}`] = "revert";
572
- }
573
- }
574
- }
575
- const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined);
576
- const unresolvedTemplates = preview.templates.filter((entry) => resolutions[`template.${entry.alias}`] === undefined);
577
- if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
578
- console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
579
- for (const entry of unresolvedKnowledge)
580
- console.error(formatKnowledgeResolveHint(entry));
581
- for (const entry of unresolvedTemplates)
582
- console.error(formatTemplateResolveHint(entry));
583
- process.exit(1);
584
- }
585
- await apply(resolutions);
586
- }
587
- /** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
588
- export function parseBindToFlags(bindTo) {
589
- const map = {};
590
- for (const entry of bindTo) {
591
- const eq = entry.indexOf("=");
592
- if (eq <= 0 || eq === entry.length - 1) {
593
- throw new Error(`Invalid --bind-to "${entry}" — expected <alias>=<kdc_id>.`);
594
- }
595
- map[entry.slice(0, eq)] = entry.slice(eq + 1);
596
- }
597
- return map;
598
- }
599
- /** Print advisory `knowledge_expects` misses loudly (never blocks the install). */
600
- function warnMissingExpectedDocs(missing) {
601
- if (missing.length === 0)
602
- return;
603
- console.error(` ⚠ ${missing.length} expected knowledge doc(s) the package's agents route to are missing from this workspace:`);
604
- for (const name of missing)
605
- console.error(` - "${name}"`);
606
- console.error(" The package installed, but those agents will degrade until a doc with each name exists.");
607
- }
608
- export async function packageInstall(client, args) {
609
- // Surface the trust badge at the consent point: installing materializes the
610
- // package's workflows/agents (or its doc corpus) under YOUR authority.
611
- const pkg = await client.getPackage(args.package_id);
612
- // The kind hint only earns a mention when it adds information (content —
613
- // no app materializes); "(package)" after "third-party package" is noise.
614
- const kindSuffix = pkg.kind === "content" ? " (content — docs/templates, no app)" : "";
615
- console.error(`Installing ${pkg.name} — ${trustBadge(pkg)}${kindSuffix}...`);
616
- const result = await client.installPackage(args.package_id, {
617
- ...(args.version !== undefined ? { version: args.version } : {}),
618
- ...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
619
- ...(args.config && Object.keys(args.config).length > 0 ? { config: args.config } : {}),
620
- });
621
- if (result.kind === "content") {
622
- const { installation, warnings } = result;
623
- const docs = installation.binding.knowledge;
624
- const templates = installation.binding.templates;
625
- console.error(`Installed ${pkg.name} v${installation.package_version} (workspace ${installation.workspace_id}).`);
626
- const docAliases = Object.keys(docs);
627
- if (docAliases.length > 0) {
628
- console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
629
- }
630
- const templateAliases = Object.keys(templates);
631
- if (templateAliases.length > 0) {
632
- console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
633
- }
634
- warnMissingExpectedDocs(warnings.missing_expected_docs);
635
- console.error(` Upgrade later: lotics upgrade ${args.package_id}`);
636
- console.error(` Uninstall: lotics uninstall ${args.package_id} [--keep-content]`);
637
- return;
638
- }
639
- const { app, knowledge_warnings } = result;
640
- const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
641
- console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
642
- console.error(" The data model, queries, workflows, and agents are live.");
643
- warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
644
- // Follow-up ops key on the NEW installation id (app_), not the package id typed
645
- // at `install` — signpost each one with the id inlined (the content branch above
646
- // does the same), so the id switch is never a silent trap.
647
- console.error(` Pull it for local editing: lotics app pull ${app.id}`);
648
- console.error(` Upgrade later: lotics upgrade ${app.id}`);
649
- console.error(` Health / uninstall: lotics package doctor ${app.id} · lotics uninstall ${app.id} [--archive-tables]`);
650
- }
651
- /**
652
- * `lotics uninstall <app_id|package_id>` — ONE top-level command over both
653
- * installation kinds, dispatched by the id form (mirrors `lotics upgrade`):
654
- * - a package id (`apg_`) → THIS workspace's STANDALONE CONTENT installation
655
- * (content installs are addressed by package id, UNIQUE per workspace, so the
656
- * `pci_` resource id never surfaces): deletes the row and (unless
657
- * `--keep-content`) archives its package-bound docs AND templates, listing each
658
- * archived id.
659
- * - anything else (an `app_id`) → an APP installation: archives its workflow
660
- * artifacts and — with `--archive-tables` — the scaffolded entity tables
661
- * (provenance- + reference-gated server-side).
662
- * A flag used on the wrong path is a loud error, never silently ignored.
663
- */
664
- export async function packageUninstall(client, args) {
665
- if (args.id.startsWith("apg_")) {
666
- if (args.archive_tables) {
667
- throw new Error("--archive-tables applies only to an app installation (<app_id>). A content package " +
668
- "has no scaffolded tables — use --keep-content to retain its docs/templates.");
669
- }
670
- const pkg = await client.getPackage(args.id);
671
- // kind is a DERIVED display hint (contractHasAppSurface): 'content' = no app.
672
- if (pkg.kind !== "content") {
673
- throw new Error(`Package ${args.id} is an app package — uninstall an app installation by its app id: lotics uninstall <app_id>.`);
674
- }
675
- const installation = await resolveWorkspaceContentInstallation(client, args.id);
676
- const result = await client.uninstallContentPackage(installation.id, {
677
- keep_content: args.keep_content,
678
- });
679
- if (args.keep_content) {
680
- console.error(`Uninstalled ${pkg.name} (${pkg.id}) — its docs and templates were kept as ordinary workspace content.`);
681
- }
682
- else {
683
- console.error(`Uninstalled ${pkg.name} (${pkg.id}) — archived ` +
684
- `${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
685
- for (const docId of result.archived_doc_ids)
686
- console.error(` ${docId}`);
687
- for (const templateId of result.archived_template_ids)
688
- console.error(` ${templateId}`);
689
- }
690
- return;
691
- }
692
- if (args.keep_content) {
693
- throw new Error("--keep-content applies only to a content package (apg_). An app installation " +
694
- "(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
695
- }
696
- const app = await client.getApp(args.id);
697
- if (!app.package_id) {
698
- throw new Error(`App ${args.id} is not a package installation — use the app delete flow for a bespoke app.`);
699
- }
700
- const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
701
- const boundCount = Object.keys(app.workflows ?? {}).length;
702
- const tableCount = Object.keys(app.binding?.entities ?? {}).length;
703
- console.error(`Uninstalling ${app.name} (${app.id}):`);
704
- console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
705
- console.error(args.archive_tables
706
- ? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
707
- : ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
708
- const result = await client.uninstallAppPackage(args.id, {
709
- archive_tables: args.archive_tables,
710
- });
711
- console.error(`Uninstalled ${app.name} (${app.id}).`);
712
- if (result.archived_table_ids.length > 0) {
713
- console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
714
- }
715
- }
716
- /**
717
- * `lotics package list-content` — list the selected workspace's STANDALONE
718
- * content installations (an app-bundled corpus rides its app's
719
- * `binding.knowledge` and shows on the Apps surface instead), each with its
720
- * registry status. The what-is-installed listing: each row leads with the PACKAGE
721
- * id — the address for `lotics upgrade <package_id>` / `lotics uninstall
722
- * <package_id>` (the `pci_` resource id stays hidden).
723
- */
724
- export async function packageListContent(client) {
725
- const workspaceId = client.getWorkspaceId();
726
- if (!workspaceId) {
727
- throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to list its content installations.");
728
- }
729
- const installations = await client.listContentInstallations(workspaceId);
730
- if (installations.length === 0) {
731
- console.error(`No package-managed content installations in workspace ${workspaceId}.`);
732
- return;
733
- }
734
- console.error(`Content installations in workspace ${workspaceId} (${installations.length}):`);
735
- for (const inst of installations) {
736
- const name = inst.package_registry?.name ?? "(unknown package)";
737
- const latest = inst.package_registry?.latest_version;
738
- const updateAvailable = inst.package_registry?.update_available ?? false;
739
- const versionLabel = latest !== undefined && latest !== inst.package_version
740
- ? `v${inst.package_version} → latest v${latest}`
741
- : `v${inst.package_version}`;
742
- console.error(` ${inst.package_id} ${name} ${versionLabel}` +
743
- (updateAvailable ? " → update available" : ""));
744
- }
745
- }
746
- export async function packageEject(client, args) {
747
- const app = await client.ejectPackage(args.app_id);
748
- console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
749
- console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
750
- console.error(" Its data model, queries, workflows, and templates are unchanged.");
751
- console.error(" Pull the pinned source for local editing: lotics app pull " + app.id);
752
- }
753
- /**
754
- * Parse one `key=value` config assignment. When the knob's type is known (from
755
- * the stored value at `package config --set`) the value is parsed to that type
756
- * loudly; otherwise (`lotics install --config`) it is inferred (true/false →
757
- * boolean, numeric → number, else string) and the server validates it against
758
- * the contract.
759
- */
760
- function parseConfigAssignment(entry, flag, knownType) {
761
- const eq = entry.indexOf("=");
762
- if (eq <= 0 || eq === entry.length - 1) {
763
- throw new Error(`Invalid ${flag} "${entry}" — expected key=value.`);
764
- }
765
- const key = entry.slice(0, eq);
766
- const raw = entry.slice(eq + 1);
767
- if (knownType === "number") {
768
- const n = Number(raw);
769
- if (!Number.isFinite(n))
770
- throw new Error(`Config key "${key}" is a number knob — "${raw}" is not numeric.`);
771
- return { key, value: n };
772
- }
773
- if (knownType === "boolean") {
774
- if (raw !== "true" && raw !== "false") {
775
- throw new Error(`Config key "${key}" is a boolean knob — expected true or false, got "${raw}".`);
776
- }
777
- return { key, value: raw === "true" };
778
- }
779
- if (knownType === "string")
780
- return { key, value: raw };
781
- // Inferred (install override): the server re-validates against the contract.
782
- if (raw === "true" || raw === "false")
783
- return { key, value: raw === "true" };
784
- if (/^-?\d+(\.\d+)?$/.test(raw))
785
- return { key, value: Number(raw) };
786
- return { key, value: raw };
787
- }
788
- /** `--config key=value` (install): inferred types, server-validated. */
789
- export function parseInstallConfigFlags(config) {
790
- const out = {};
791
- for (const entry of config) {
792
- const { key, value } = parseConfigAssignment(entry, "--config");
793
- out[key] = value;
794
- }
795
- return out;
796
- }
797
- /**
798
- * `lotics package config <app_id>` — show the installation's effective config;
799
- * with `--set key=value` (repeatable) partial-merge edits, each value parsed by
800
- * the knob's current type. No `--set` prints the values.
801
- */
802
- export async function packageConfig(client, args) {
803
- const app = await client.getApp(args.app_id);
804
- if (!app.package_id) {
805
- throw new Error(`App ${args.app_id} is not a package installation — it has no config.`);
806
- }
807
- const current = app.config ?? {};
808
- if (args.sets.length === 0) {
809
- const keys = Object.keys(current).sort();
810
- if (keys.length === 0) {
811
- console.error(`${app.name} (${app.id}) — no config knobs.`);
812
- return;
813
- }
814
- console.error(`${app.name} (${app.id}) config:`);
815
- for (const key of keys)
816
- console.log(` ${key} = ${JSON.stringify(current[key])}`);
817
- return;
818
- }
819
- const overrides = {};
820
- for (const entry of args.sets) {
821
- const key = entry.slice(0, Math.max(0, entry.indexOf("=")));
822
- const knownType = typeof current[key];
823
- const { value } = parseConfigAssignment(entry, "--set", knownType === "number" || knownType === "boolean" || knownType === "string" ? knownType : undefined);
824
- overrides[key] = value;
825
- }
826
- const { config } = await client.updateAppPackageConfig(args.app_id, { config: overrides });
827
- console.error(`Updated config for ${app.name} (${app.id}):`);
828
- for (const key of Object.keys(config).sort()) {
829
- const marker = key in overrides ? " *" : "";
830
- console.log(` ${key} = ${JSON.stringify(config[key])}${marker}`);
831
- }
832
- }
833
- /**
834
- * `lotics app unpublish <app_id|package_id> [--undo]` — take a published package
835
- * off the shelf (or `--undo` restore it): new installs refuse it and it hides
836
- * from other orgs, while existing installations keep working and may still
837
- * upgrade. Given an app id (an installation of the package) it resolves the
838
- * package from the app; a package id targets it directly. Owner-org admin-only.
839
- */
840
- export async function appUnpublish(client, args) {
841
- let packageId = args.id;
842
- if (args.id.startsWith("app_")) {
843
- const app = await client.getApp(args.id);
844
- if (!app.package_id) {
845
- throw new Error(`App ${args.id} is not a package installation — it has no package to unpublish. ` +
846
- `Pass the package id directly.`);
847
- }
848
- packageId = app.package_id;
849
- }
850
- const pkg = await client.retirePackage(packageId, { undo: args.undo });
851
- if (pkg.retired_at !== null) {
852
- console.error(`Unpublished ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
853
- `existing installations keep working and may still upgrade. Undo: lotics app unpublish ${pkg.id} --undo`);
854
- }
855
- else {
856
- console.error(`Re-published ${pkg.name} (${pkg.id}) — installable again.`);
857
- }
858
- }
859
- /**
860
- * `lotics app publish [app_id|.] [--rename old=new ...] [-m <changelog>] [--yes]` —
861
- * FIRST-RELEASE a bespoke app as a package (docs/packages.md § Promotion). Nothing
862
- * starts as a package. Mirrors `app release`'s preview→apply UX: it first shows the
863
- * dry-run preview (GET, no writes) — the package name, the auto-minted RENAMABLE
864
- * aliases (the exact `--rename` keys, so v1's frozen aliases are inspected first,
865
- * never a blind publish), and the extract findings — then APPLIES only with
866
- * `--yes` (else exits 1 with the re-run hint). On apply the server extracts the
867
- * contract, creates the registry package, publishes v1 from the DEPLOYED source +
868
- * dist, and pins the origin as installation #1. `--rename old=new` fixes an
869
- * auto-minted alias before v1 freezes it; an `error` finding blocks the apply. An
870
- * already-linked app releases with `lotics app release` instead. The app id is the
871
- * positional (`.`/omitted → resolved from the local app project manifest).
872
- */
873
- export async function appPublish(client, args) {
874
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
875
- const local = readLocalAppManifest(projectDir);
876
- const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
877
- const appId = explicit ?? local?.app_id ?? null;
878
- if (appId === null) {
879
- throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
880
- "or pass one: lotics app publish <app_id>.");
881
- }
882
- // Forward the app's package-managed knowledge + config declarations ONLY when the
883
- // local manifest is this app's own project (both are declarations extract can't
884
- // invert, so the author declares them). A bare id published from elsewhere ships
885
- // without them — publish from the app dir to include them.
886
- const knowledge = local && local.app_id === appId ? local.knowledge : [];
887
- const config = local && local.app_id === appId ? local.config : [];
888
- const renames = parseRenameFlags(args.renames);
889
- // Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
890
- // so the aliases v1 freezes forever are never a blind publish and `--rename`
891
- // targets are inspectable before committing.
892
- const preview = await client.previewPublishAppPackage(appId, { renames, knowledge, config });
893
- const { lines, hasError } = formatExtractReport(preview.findings);
894
- console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
895
- const groups = [
896
- ["entities", preview.renamable_aliases.entities],
897
- ["fields", preview.renamable_aliases.fields],
898
- ["options", preview.renamable_aliases.options],
899
- ["roles", preview.renamable_aliases.roles],
900
- ["templates", preview.renamable_aliases.templates],
901
- ["workflows", preview.renamable_aliases.workflows],
902
- ];
903
- if (groups.some(([, vals]) => vals.length > 0)) {
904
- console.error(" Auto-minted aliases — rename any with --rename <alias>=<new> before v1 freezes them:");
905
- for (const [label, vals] of groups) {
906
- if (vals.length > 0)
907
- console.error(` ${`${label}:`.padEnd(11)} ${vals.join(", ")}`);
908
- }
909
- console.error(" (query / app-workflow / agent runtime aliases are fixed — the shipped source calls them verbatim.)");
910
- }
911
- else {
912
- console.error(" No renamable aliases.");
913
- }
914
- if (config.length > 0) {
915
- console.error(` Config knobs (${config.length}): ${config.map((c) => `${c.alias} (${c.type})`).join(", ")}`);
916
- }
917
- if (lines.length > 0) {
918
- console.error(` Findings (${preview.findings.length}):`);
919
- for (const line of lines)
920
- console.error(line);
921
- }
922
- if (hasError) {
923
- console.error("\nExtract found error findings — the app cannot be published as-is. Fix them in the app, redeploy, and retry.");
924
- process.exitCode = 1;
925
- return;
926
- }
927
- if (!args.yes) {
928
- const idArg = explicit ?? ".";
929
- const renameArgs = args.renames.map((r) => ` --rename ${r}`).join("");
930
- const mArg = args.changelog ? ` -m ${JSON.stringify(args.changelog)}` : "";
931
- console.error(`\nRe-run with --yes to publish v1:`);
932
- console.error(` lotics app publish ${idArg}${renameArgs}${mArg} --yes`);
933
- process.exitCode = 1;
934
- return;
935
- }
936
- const result = await client.publishAppAsPackage(appId, {
937
- renames,
938
- changelog: args.changelog ?? null,
939
- knowledge,
940
- config,
941
- });
942
- console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
943
- console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
944
- console.error(` lotics app pull ${appId} # edit, then lotics app deploy`);
945
- console.error(` lotics app release ${appId} -m "<what changed>"`);
946
- console.error(` Install it elsewhere: lotics install ${result.package_id}`);
947
- }
948
- /**
949
- * `lotics app release [app_id|.] -m <changelog> [--yes]` — snapshot an
950
- * adopted/installed origin app into its next registry version (docs/packages.md
951
- * § Promotion). The origin is the permanent working copy; a release binding-aware-
952
- * extracts it (stable aliases), repackages its DEPLOYED source + dist as the
953
- * bundle, publishes the next version, and re-pins the origin. Prints the preview
954
- * first (next version, new + changed aliases, the bundled-knowledge delta,
955
- * findings); applies only with `--yes`, else exits 1 so a review step can't be
956
- * skipped. An `error` finding blocks the apply.
957
- *
958
- * Run from the pulled app project, the manifest's `lotics.knowledge` (alias →
959
- * doc_id) is the bundle DECLARATION — it re-declares which docs the package owns
960
- * (add/drop/re-snapshot). Forwarded only when non-empty; empty (or a bare id from
961
- * elsewhere) sends nothing, so the current corpus is reconstructed from the pin
962
- * (never silently dropped).
963
- */
964
- export async function appRelease(client, args) {
965
- const projectDir = path.resolve(args.projectDir ?? process.cwd());
966
- const local = readLocalAppManifest(projectDir);
967
- const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
968
- const appId = explicit ?? local?.app_id ?? null;
969
- if (appId === null) {
970
- throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
971
- }
972
- // Forward the manifest's knowledge + config DECLARATIONS only from this app's own
973
- // project AND only when non-empty — an empty/absent declaration is "no change"
974
- // (reconstruct knowledge from the pin / carry config forward), never "drop them".
975
- const knowledge = local && local.app_id === appId && local.knowledge.length > 0 ? local.knowledge : undefined;
976
- const config = local && local.app_id === appId && local.config.length > 0 ? local.config : undefined;
977
- const preview = await client.previewPackageRelease(appId, { knowledge, config });
978
- const { lines, hasError } = formatExtractReport(preview.findings);
979
- console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
980
- if (preview.added_aliases.length > 0) {
981
- console.error(` New (${preview.added_aliases.length}): ${preview.added_aliases.join(", ")}`);
982
- }
983
- if (preview.changed_artifacts.length > 0) {
984
- console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
985
- }
986
- // Deploy-skew boundary: a pre-declaration server omits the echo field. Silent
987
- // skew is dangerous exactly when a declaration was SENT — the server ignored it
988
- // and the preview would misread as "no changes"; say so loudly, per surface.
989
- if (preview.knowledge === undefined && knowledge !== undefined) {
990
- console.error(" WARNING: the server ignored the knowledge declaration (it predates bundled-knowledge releases) — the bundle will NOT change. Retry after the platform deploy completes.");
991
- }
992
- if (preview.config === undefined && config !== undefined) {
993
- console.error(" WARNING: the server ignored the config declaration (it predates manifest config declarations) — the knobs will NOT change. Retry after the platform deploy completes.");
994
- }
995
- const k = preview.knowledge ?? { added: [], removed: [], changed: [] };
996
- if (k.added.length > 0 || k.removed.length > 0 || k.changed.length > 0) {
997
- const parts = [
998
- k.added.length > 0 ? `+${k.added.join(", ")}` : null,
999
- k.removed.length > 0 ? `dropped ${k.removed.join(", ")}` : null,
1000
- k.changed.length > 0 ? `changed ${k.changed.join(", ")}` : null,
1001
- ].filter((p) => p !== null);
1002
- console.error(` Knowledge: ${parts.join("; ")}`);
1003
- }
1004
- const cfg = preview.config ?? { added: [], removed: [], changed: [] };
1005
- if (cfg.added.length > 0 || cfg.removed.length > 0 || cfg.changed.length > 0) {
1006
- const parts = [
1007
- cfg.added.length > 0 ? `+${cfg.added.join(", ")}` : null,
1008
- cfg.removed.length > 0 ? `dropped ${cfg.removed.join(", ")}` : null,
1009
- cfg.changed.length > 0 ? `changed ${cfg.changed.join(", ")}` : null,
1010
- ].filter((p) => p !== null);
1011
- console.error(` Config: ${parts.join("; ")}`);
1012
- }
1013
- if (preview.added_aliases.length === 0 &&
1014
- preview.changed_artifacts.length === 0 &&
1015
- k.added.length === 0 &&
1016
- k.removed.length === 0 &&
1017
- k.changed.length === 0 &&
1018
- cfg.added.length === 0 &&
1019
- cfg.removed.length === 0 &&
1020
- cfg.changed.length === 0) {
1021
- console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
1022
- }
1023
- if (lines.length > 0) {
1024
- console.error(` Findings (${preview.findings.length}):`);
1025
- for (const line of lines)
1026
- console.error(line);
1027
- }
1028
- if (hasError) {
1029
- console.error("\nExtract found error findings — the origin cannot be released as-is. Fix them in the app and retry.");
1030
- process.exitCode = 1;
1031
- return;
1032
- }
1033
- if (!args.yes) {
1034
- console.error(`\nRe-run with --yes to publish v${preview.version}:`);
1035
- console.error(` lotics app release ${args.app_id ?? "."} -m ${JSON.stringify(args.changelog)} --yes`);
1036
- process.exitCode = 1;
1037
- return;
1038
- }
1039
- const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge, config });
1040
- console.error(`Released ${result.package_id} v${result.version}.`);
1041
- console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
1042
- }
1043
- /**
1044
- * `lotics package yank <package_id> <version> [--undo]` — mark a published
1045
- * version uninstallable (or restore it). New installs/upgrades/adopts refuse a
1046
- * yanked version and "latest" skips it; installations already pinned keep
1047
- * running. Owner-org admin-only.
1048
- */
1049
- export async function packageYank(client, args) {
1050
- const result = await client.yankPackageVersion(args.package_id, args.version, !args.undo);
1051
- if (result.yanked_at !== null) {
1052
- console.error(`Yanked ${result.package_id} v${result.version} (${result.yanked_at}). ` +
1053
- `New installs/upgrades refuse it; pinned installations keep running.`);
1054
- }
1055
- else {
1056
- console.error(`Restored ${result.package_id} v${result.version} — installable again.`);
1057
- }
1058
- console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
1059
- }
1060
- /**
1061
- * `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
1062
- * installation of the package across the caller's org to the target version.
1063
- * Hands-off applies only where the preview is clean; skipped/failed
1064
- * installations are reported per line and the process exits 1 so a release
1065
- * script can gate on "fleet fully current".
1066
- */
1067
- async function packageFleetUpgrade(client, args) {
1068
- const result = await client.fleetUpgradePackage(args.package_id, {
1069
- ...(args.version !== undefined ? { version: args.version } : {}),
1070
- });
1071
- console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);
1072
- if (result.installations.length === 0) {
1073
- console.error(" No installations of this package in your organization.");
1074
- return;
1075
- }
1076
- const counts = { upgraded: 0, up_to_date: 0, skipped: 0, failed: 0 };
1077
- for (const inst of result.installations) {
1078
- counts[inst.outcome]++;
1079
- const from = inst.from_version === null ? "?" : `v${inst.from_version}`;
1080
- const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
1081
- if (inst.outcome === "skipped" && inst.blockers) {
1082
- console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified} knowledge=${inst.blockers.knowledge}`);
1083
- console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
1084
- }
1085
- else if (inst.message) {
1086
- console.error(`${line}: ${inst.message}`);
1087
- }
1088
- else {
1089
- console.error(line);
1090
- }
1091
- }
1092
- console.error(`Done: ${counts.upgraded} upgraded, ${counts.up_to_date} already current, ${counts.skipped} skipped, ${counts.failed} failed.`);
1093
- if (counts.skipped > 0 || counts.failed > 0) {
1094
- process.exitCode = 1;
1095
- }
1096
- }