@lotics/cli 0.76.1 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -48
- package/dist/app_commands.d.ts +9 -4
- package/dist/app_commands.js +44 -10
- package/dist/app_commands.test.js +69 -0
- package/dist/args.d.ts +6 -5
- package/dist/args.js +18 -17
- package/dist/args.test.js +25 -17
- package/dist/cli.js +201 -253
- package/dist/cli_dispatch.test.js +77 -25
- package/dist/client.d.ts +105 -176
- package/dist/client.js +61 -95
- package/dist/generate_package_fields.d.ts +39 -38
- package/dist/generate_package_fields.js +113 -60
- package/dist/generate_package_fields.test.js +30 -22
- package/dist/package_commands.d.ts +100 -318
- package/dist/package_commands.js +303 -1298
- package/dist/package_commands.test.js +66 -542
- package/dist/src/cli.js +624 -1834
- package/dist/starter_template.d.ts +0 -19
- package/dist/starter_template.js +0 -389
- package/dist/starter_template.test.js +1 -69
- package/package.json +1 -1
package/dist/package_commands.js
CHANGED
|
@@ -1,228 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
const lotics = parsed.lotics;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
* info), one ` [<severity>] <area>: <message>` line each, and classify
|
|
223
|
-
* any `error` finding is present.
|
|
224
|
-
*
|
|
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
82
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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.
|
|
83
|
+
* Resolve the installation to operate on: an explicit app id is required (there
|
|
84
|
+
* is no dev-workspace pin to fall back to).
|
|
257
85
|
*/
|
|
258
|
-
function
|
|
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
|
-
/**
|
|
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.
|
|
779
|
-
*/
|
|
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
|
-
|
|
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
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
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
|
|
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
|
-
|
|
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(
|
|
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,30 @@ 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
|
|
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(
|
|
140
|
+
console.error(health.is_origin
|
|
141
|
+
? " Package artifacts: no changes since the last release."
|
|
142
|
+
: " Package artifacts: pristine — no local edits an upgrade would revert.");
|
|
143
|
+
}
|
|
144
|
+
else if (health.is_origin) {
|
|
145
|
+
// The origin IS the author's working copy — edits since the last release are
|
|
146
|
+
// the NEXT release's payload, not consumer drift, and never fail the check.
|
|
147
|
+
console.error(` Changed since v${health.installed_version} (${health.modified.length}) — a release will publish these:`);
|
|
148
|
+
for (const m of health.modified) {
|
|
149
|
+
console.error(` - ${m.kind}.${m.alias}`);
|
|
150
|
+
}
|
|
151
|
+
console.error(` Cut the next version: lotics app release ${app_id} -m "<what changed>"`);
|
|
992
152
|
}
|
|
993
153
|
else {
|
|
994
154
|
console.error(` Locally modified package artifacts (${health.modified.length}):`);
|
|
995
155
|
for (const m of health.modified) {
|
|
996
156
|
console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
|
|
997
157
|
}
|
|
998
|
-
console.error(` Consent while upgrading:\n lotics
|
|
158
|
+
console.error(` Consent while upgrading:\n lotics upgrade ${app_id}` +
|
|
999
159
|
` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
|
|
1000
160
|
process.exitCode = 1;
|
|
1001
161
|
}
|
|
@@ -1008,9 +168,17 @@ export async function packageDoctor(client, args) {
|
|
|
1008
168
|
}
|
|
1009
169
|
}
|
|
1010
170
|
if (health.knowledge_modified.length > 0) {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
171
|
+
if (health.is_origin) {
|
|
172
|
+
console.error(` Knowledge changed since v${health.installed_version} (${health.knowledge_modified.length}) — a release will re-snapshot these:`);
|
|
173
|
+
for (const m of health.knowledge_modified) {
|
|
174
|
+
console.error(` - ${m.alias} "${m.name}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
|
|
179
|
+
for (const m of health.knowledge_modified) {
|
|
180
|
+
console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
|
|
181
|
+
}
|
|
1014
182
|
}
|
|
1015
183
|
}
|
|
1016
184
|
if (health.missing_expected_docs.length > 0) {
|
|
@@ -1019,7 +187,9 @@ export async function packageDoctor(client, args) {
|
|
|
1019
187
|
console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
|
|
1020
188
|
}
|
|
1021
189
|
}
|
|
1022
|
-
|
|
190
|
+
// Drift is genuine breakage on the origin too (a bound id vanished); edited
|
|
191
|
+
// knowledge on the origin is staged release work, not a failure.
|
|
192
|
+
if (health.knowledge_drift.length > 0 || (health.knowledge_modified.length > 0 && !health.is_origin)) {
|
|
1023
193
|
process.exitCode = 1;
|
|
1024
194
|
}
|
|
1025
195
|
if (health.knowledge_drift.length === 0 &&
|
|
@@ -1028,7 +198,7 @@ export async function packageDoctor(client, args) {
|
|
|
1028
198
|
console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
|
|
1029
199
|
}
|
|
1030
200
|
if (health.update_available) {
|
|
1031
|
-
console.error(` Upgrade: lotics
|
|
201
|
+
console.error(` Upgrade: lotics upgrade ${app_id}`);
|
|
1032
202
|
}
|
|
1033
203
|
}
|
|
1034
204
|
/**
|
|
@@ -1037,11 +207,13 @@ export async function packageDoctor(client, args) {
|
|
|
1037
207
|
* the exact --resolve syntax) while any binding drift, modified core artifact, or
|
|
1038
208
|
* consent-requiring bundled-knowledge doc lacks a resolution.
|
|
1039
209
|
*
|
|
1040
|
-
* `--resolve`
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
210
|
+
* `--resolve` speaks ONE namespaced grammar, passed to the server verbatim: a
|
|
211
|
+
* drifted binding entry (`<namespace>.<alias>`), a modified artifact
|
|
212
|
+
* (`<kind>.<alias>`), a bundled knowledge doc (`knowledge.<alias>` —
|
|
213
|
+
* apply|keep|archive|recreate|unbind), or a live-role re-point
|
|
214
|
+
* (`roles.<alias>=<grp_id>`). `--bind-to` consents an added-knowledge-doc name
|
|
215
|
+
* collision; `--apply-all` accepts the package's version for every
|
|
216
|
+
* consent-requiring knowledge doc (overwriting local edits).
|
|
1045
217
|
*/
|
|
1046
218
|
export async function packageUpgrade(client, args) {
|
|
1047
219
|
const preview = await client.previewPackageUpgrade(args.app_id, {
|
|
@@ -1078,28 +250,23 @@ export async function packageUpgrade(client, args) {
|
|
|
1078
250
|
` before making the change: lotics package eject ${args.app_id}`);
|
|
1079
251
|
process.exit(1);
|
|
1080
252
|
}
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const resolutions = parseResolveFlags(coreResolve);
|
|
253
|
+
// ONE namespaced grammar — the flags pass to the server verbatim. `--bind-to`
|
|
254
|
+
// is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
|
|
255
|
+
// remaining consent-requiring knowledge entries with the accept verb.
|
|
256
|
+
const resolutions = parseResolveFlags(args.resolve);
|
|
257
|
+
for (const [alias, id] of Object.entries(parseBindToFlags(args.bindTo))) {
|
|
258
|
+
resolutions[`knowledge.${alias}`] = { bind_to: id };
|
|
259
|
+
}
|
|
1089
260
|
if (args.applyAll) {
|
|
1090
261
|
for (const entry of preview.knowledge) {
|
|
1091
|
-
if (knowledgeEntryNeedsConsent(entry) &&
|
|
1092
|
-
|
|
262
|
+
if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
|
|
263
|
+
resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
|
|
1093
264
|
}
|
|
1094
265
|
}
|
|
1095
266
|
}
|
|
1096
|
-
const knowledge_resolutions = {
|
|
1097
|
-
resolutions: knowledgeResolutions,
|
|
1098
|
-
bind_to: parseBindToFlags(args.bindTo),
|
|
1099
|
-
};
|
|
1100
267
|
const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
|
|
1101
268
|
const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
|
|
1102
|
-
const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) &&
|
|
269
|
+
const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && resolutions[`knowledge.${e.alias}`] === undefined);
|
|
1103
270
|
if (unresolvedDrift.length > 0 ||
|
|
1104
271
|
unresolvedModified.length > 0 ||
|
|
1105
272
|
unresolvedKnowledge.length > 0) {
|
|
@@ -1123,12 +290,9 @@ export async function packageUpgrade(client, args) {
|
|
|
1123
290
|
}
|
|
1124
291
|
process.exit(1);
|
|
1125
292
|
}
|
|
1126
|
-
const hasKnowledgeResolutions = Object.keys(knowledge_resolutions.resolutions).length > 0 ||
|
|
1127
|
-
Object.keys(knowledge_resolutions.bind_to).length > 0;
|
|
1128
293
|
const app = await client.upgradePackage(args.app_id, {
|
|
1129
294
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
1130
295
|
...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
|
|
1131
|
-
...(hasKnowledgeResolutions ? { knowledge_resolutions } : {}),
|
|
1132
296
|
});
|
|
1133
297
|
console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
|
|
1134
298
|
}
|
|
@@ -1202,99 +366,14 @@ function formatKnowledgeResolveHint(entry) {
|
|
|
1202
366
|
const note = entry.change === "changed" || entry.change === "removed"
|
|
1203
367
|
? " (a local edit — apply/archive overwrites it; keep retains it)"
|
|
1204
368
|
: " (bound doc is gone; recreate from the package, or unbind)";
|
|
1205
|
-
return ` --resolve
|
|
1206
|
-
}
|
|
1207
|
-
/**
|
|
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.
|
|
1217
|
-
*/
|
|
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
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
return { coreResolve, knowledgeResolutions };
|
|
1247
|
-
}
|
|
1248
|
-
/**
|
|
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.
|
|
1255
|
-
*/
|
|
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.`);
|
|
1289
|
-
}
|
|
1290
|
-
return { knowledge, templates };
|
|
369
|
+
return ` --resolve knowledge.${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
|
|
1291
370
|
}
|
|
1292
|
-
/** The `--resolve
|
|
371
|
+
/** The `--resolve template.<alias>=revert|keep` remediation line for a consent-requiring template. */
|
|
1293
372
|
function formatTemplateResolveHint(entry) {
|
|
1294
373
|
const note = entry.baseline_unknown
|
|
1295
374
|
? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
|
|
1296
375
|
: " (a local edit — revert overwrites it with the package's version; keep retains it)";
|
|
1297
|
-
return ` --resolve
|
|
376
|
+
return ` --resolve template.${entry.alias}=revert|keep${note}`;
|
|
1298
377
|
}
|
|
1299
378
|
/**
|
|
1300
379
|
* Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
|
|
@@ -1304,9 +383,10 @@ function formatTemplateResolveHint(entry) {
|
|
|
1304
383
|
* locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
|
|
1305
384
|
* by accepting the package's version (knowledge changed→apply, removed→archive,
|
|
1306
385
|
* drifted→recreate; template→revert) — an explicit bulk "take upstream" that
|
|
1307
|
-
* discards local edits. `--resolve
|
|
1308
|
-
* resolve
|
|
1309
|
-
*
|
|
386
|
+
* discards local edits. `--resolve knowledge.<alias>=<value>` /
|
|
387
|
+
* `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
|
|
388
|
+
* resolve entries individually — the ONE namespaced grammar, passed to the
|
|
389
|
+
* server verbatim.
|
|
1310
390
|
*/
|
|
1311
391
|
export async function packageUpgradeKnowledge(client, args) {
|
|
1312
392
|
const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
|
|
@@ -1327,7 +407,7 @@ export async function packageUpgradeKnowledge(client, args) {
|
|
|
1327
407
|
return;
|
|
1328
408
|
}
|
|
1329
409
|
console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
|
|
1330
|
-
await apply({
|
|
410
|
+
await apply({});
|
|
1331
411
|
return;
|
|
1332
412
|
}
|
|
1333
413
|
for (const entry of preview.entries) {
|
|
@@ -1336,26 +416,27 @@ export async function packageUpgradeKnowledge(client, args) {
|
|
|
1336
416
|
for (const entry of preview.templates) {
|
|
1337
417
|
console.error(` [template] ${entry.alias} (modified — needs consent)`);
|
|
1338
418
|
}
|
|
1339
|
-
//
|
|
1340
|
-
//
|
|
1341
|
-
|
|
1342
|
-
const resolutions =
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
}
|
|
419
|
+
// ONE namespaced grammar — the flags pass to the server verbatim. `--bind-to`
|
|
420
|
+
// is sugar for `knowledge.<alias>={bind_to}`; `--apply-all` fills the
|
|
421
|
+
// remaining consent entries with the accept verb (template → revert).
|
|
422
|
+
const resolutions = parseResolveFlags(args.resolve);
|
|
423
|
+
for (const [alias, id] of Object.entries(args.bind_to)) {
|
|
424
|
+
resolutions[`knowledge.${alias}`] = { bind_to: id };
|
|
425
|
+
}
|
|
1346
426
|
if (args.applyAll) {
|
|
1347
427
|
for (const entry of preview.entries) {
|
|
1348
|
-
if (knowledgeEntryNeedsConsent(entry) && resolutions
|
|
1349
|
-
resolutions
|
|
428
|
+
if (knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined) {
|
|
429
|
+
resolutions[`knowledge.${entry.alias}`] = knowledgeAcceptResolution(entry.change);
|
|
1350
430
|
}
|
|
1351
431
|
}
|
|
1352
432
|
for (const entry of preview.templates) {
|
|
1353
|
-
if (resolutions
|
|
1354
|
-
resolutions
|
|
433
|
+
if (resolutions[`template.${entry.alias}`] === undefined) {
|
|
434
|
+
resolutions[`template.${entry.alias}`] = "revert";
|
|
435
|
+
}
|
|
1355
436
|
}
|
|
1356
437
|
}
|
|
1357
|
-
const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions
|
|
1358
|
-
const unresolvedTemplates = preview.templates.filter((entry) => resolutions
|
|
438
|
+
const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions[`knowledge.${entry.alias}`] === undefined);
|
|
439
|
+
const unresolvedTemplates = preview.templates.filter((entry) => resolutions[`template.${entry.alias}`] === undefined);
|
|
1359
440
|
if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
|
|
1360
441
|
console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
|
|
1361
442
|
for (const entry of unresolvedKnowledge)
|
|
@@ -1413,8 +494,8 @@ export async function packageInstall(client, args) {
|
|
|
1413
494
|
console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
|
|
1414
495
|
}
|
|
1415
496
|
warnMissingExpectedDocs(warnings.missing_expected_docs);
|
|
1416
|
-
console.error(` Upgrade later: lotics
|
|
1417
|
-
console.error(` Uninstall: lotics
|
|
497
|
+
console.error(` Upgrade later: lotics upgrade ${installation.id}`);
|
|
498
|
+
console.error(` Uninstall: lotics uninstall ${installation.id} [--keep-content]`);
|
|
1418
499
|
return;
|
|
1419
500
|
}
|
|
1420
501
|
const { app, knowledge_warnings } = result;
|
|
@@ -1422,11 +503,16 @@ export async function packageInstall(client, args) {
|
|
|
1422
503
|
console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
|
|
1423
504
|
console.error(" The data model, queries, workflows, and agents are live.");
|
|
1424
505
|
warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
|
|
1425
|
-
|
|
506
|
+
// Follow-up ops key on the NEW installation id (app_), not the package id typed
|
|
507
|
+
// at `install` — signpost each one with the id inlined (the content branch above
|
|
508
|
+
// does the same), so the id switch is never a silent trap.
|
|
509
|
+
console.error(` Pull it for local editing: lotics app pull ${app.id}`);
|
|
510
|
+
console.error(` Upgrade later: lotics upgrade ${app.id}`);
|
|
511
|
+
console.error(` Health / uninstall: lotics package doctor ${app.id} · lotics uninstall ${app.id} [--archive-tables]`);
|
|
1426
512
|
}
|
|
1427
513
|
/**
|
|
1428
|
-
* `lotics
|
|
1429
|
-
* kinds, dispatched by the id form (mirrors `
|
|
514
|
+
* `lotics uninstall <app_id|pci_id>` — ONE top-level command over both installation
|
|
515
|
+
* kinds, dispatched by the id form (mirrors `lotics upgrade`):
|
|
1430
516
|
* - a `pci_` id → a STANDALONE CONTENT installation: deletes the row and (unless
|
|
1431
517
|
* `--keep-content`) archives its package-bound docs AND templates, listing each
|
|
1432
518
|
* archived id.
|
|
@@ -1482,10 +568,11 @@ export async function packageUninstall(client, args) {
|
|
|
1482
568
|
}
|
|
1483
569
|
}
|
|
1484
570
|
/**
|
|
1485
|
-
* `lotics package list-content` — list the selected workspace's
|
|
1486
|
-
* content installations (
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
571
|
+
* `lotics package list-content` — list the selected workspace's STANDALONE
|
|
572
|
+
* content installations (an app-bundled corpus rides its app's
|
|
573
|
+
* `binding.knowledge` and shows on the Apps surface instead), each with its
|
|
574
|
+
* registry status. The read surface that surfaces a `pci_` id for
|
|
575
|
+
* `upgrade` / `uninstall` (install prints it once; nothing else did before).
|
|
1489
576
|
*/
|
|
1490
577
|
export async function packageListContent(client) {
|
|
1491
578
|
const workspaceId = client.getWorkspaceId();
|
|
@@ -1502,11 +589,10 @@ export async function packageListContent(client) {
|
|
|
1502
589
|
const name = inst.package_registry?.name ?? "(unknown package)";
|
|
1503
590
|
const latest = inst.package_registry?.latest_version;
|
|
1504
591
|
const updateAvailable = inst.package_registry?.update_available ?? false;
|
|
1505
|
-
const source = inst.app_id === null ? "standalone" : `app-bundled (${inst.app_id})`;
|
|
1506
592
|
const versionLabel = latest !== undefined && latest !== inst.package_version
|
|
1507
593
|
? `v${inst.package_version} → latest v${latest}`
|
|
1508
594
|
: `v${inst.package_version}`;
|
|
1509
|
-
console.error(` ${inst.id} ${name} ${versionLabel}
|
|
595
|
+
console.error(` ${inst.id} ${name} ${versionLabel}` +
|
|
1510
596
|
(updateAvailable ? " → update available" : ""));
|
|
1511
597
|
}
|
|
1512
598
|
}
|
|
@@ -1520,7 +606,7 @@ export async function packageEject(client, args) {
|
|
|
1520
606
|
/**
|
|
1521
607
|
* Parse one `key=value` config assignment. When the knob's type is known (from
|
|
1522
608
|
* the stored value at `package config --set`) the value is parsed to that type
|
|
1523
|
-
* loudly; otherwise (`
|
|
609
|
+
* loudly; otherwise (`lotics install --config`) it is inferred (true/false →
|
|
1524
610
|
* boolean, numeric → number, else string) and the server validates it against
|
|
1525
611
|
* the contract.
|
|
1526
612
|
*/
|
|
@@ -1598,262 +684,174 @@ export async function packageConfig(client, args) {
|
|
|
1598
684
|
}
|
|
1599
685
|
}
|
|
1600
686
|
/**
|
|
1601
|
-
* `lotics
|
|
1602
|
-
*
|
|
687
|
+
* `lotics app unpublish <app_id|package_id> [--undo]` — take a published package
|
|
688
|
+
* off the shelf (or `--undo` restore it): new installs refuse it and it hides
|
|
689
|
+
* from other orgs, while existing installations keep working and may still
|
|
690
|
+
* upgrade. Given an app id (an installation of the package) it resolves the
|
|
691
|
+
* package from the app; a package id targets it directly. Owner-org admin-only.
|
|
1603
692
|
*/
|
|
1604
|
-
export async function
|
|
1605
|
-
|
|
693
|
+
export async function appUnpublish(client, args) {
|
|
694
|
+
let packageId = args.id;
|
|
695
|
+
if (args.id.startsWith("app_")) {
|
|
696
|
+
const app = await client.getApp(args.id);
|
|
697
|
+
if (!app.package_id) {
|
|
698
|
+
throw new Error(`App ${args.id} is not a package installation — it has no package to unpublish. ` +
|
|
699
|
+
`Pass the package id directly.`);
|
|
700
|
+
}
|
|
701
|
+
packageId = app.package_id;
|
|
702
|
+
}
|
|
703
|
+
const pkg = await client.retirePackage(packageId, { undo: args.undo });
|
|
1606
704
|
if (pkg.retired_at !== null) {
|
|
1607
|
-
console.error(`
|
|
1608
|
-
`existing installations keep working and may still upgrade
|
|
705
|
+
console.error(`Unpublished ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
|
|
706
|
+
`existing installations keep working and may still upgrade. Undo: lotics app unpublish ${pkg.id} --undo`);
|
|
1609
707
|
}
|
|
1610
708
|
else {
|
|
1611
|
-
console.error(`
|
|
709
|
+
console.error(`Re-published ${pkg.name} (${pkg.id}) — installable again.`);
|
|
1612
710
|
}
|
|
1613
711
|
}
|
|
1614
712
|
/**
|
|
1615
|
-
* `lotics
|
|
1616
|
-
* package
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
713
|
+
* `lotics app publish [app_id|.] [--rename old=new ...] [-m <changelog>] [--yes]` —
|
|
714
|
+
* FIRST-RELEASE a bespoke app as a package (docs/packages.md § Promotion). Nothing
|
|
715
|
+
* starts as a package. Mirrors `app release`'s preview→apply UX: it first shows the
|
|
716
|
+
* dry-run preview (GET, no writes) — the package name, the auto-minted RENAMABLE
|
|
717
|
+
* aliases (the exact `--rename` keys, so v1's frozen aliases are inspected first,
|
|
718
|
+
* never a blind publish), and the extract findings — then APPLIES only with
|
|
719
|
+
* `--yes` (else exits 1 with the re-run hint). On apply the server extracts the
|
|
720
|
+
* contract, creates the registry package, publishes v1 from the DEPLOYED source +
|
|
721
|
+
* dist, and pins the origin as installation #1. `--rename old=new` fixes an
|
|
722
|
+
* auto-minted alias before v1 freezes it; an `error` finding blocks the apply. An
|
|
723
|
+
* already-linked app releases with `lotics app release` instead. The app id is the
|
|
724
|
+
* positional (`.`/omitted → resolved from the local app project manifest).
|
|
1625
725
|
*/
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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);
|
|
726
|
+
export async function appPublish(client, args) {
|
|
727
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
728
|
+
const local = readLocalAppManifest(projectDir);
|
|
729
|
+
const explicit = args.app_id !== undefined && args.app_id !== "." ? args.app_id : undefined;
|
|
730
|
+
const appId = explicit ?? local?.app_id ?? null;
|
|
731
|
+
if (appId === null) {
|
|
732
|
+
throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
|
|
733
|
+
"or pass one: lotics app publish <app_id>.");
|
|
734
|
+
}
|
|
735
|
+
// Forward the app's package-managed knowledge declaration ONLY when the local
|
|
736
|
+
// manifest is this app's own project (agents reference docs by free text, so the
|
|
737
|
+
// author declares which the package owns). A bare id published from elsewhere
|
|
738
|
+
// ships without a bundled corpus — publish from the app dir to include it.
|
|
739
|
+
const knowledge = local && local.app_id === appId ? local.knowledge : [];
|
|
740
|
+
const renames = parseRenameFlags(args.renames);
|
|
741
|
+
// Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
|
|
742
|
+
// so the aliases v1 freezes forever are never a blind publish and `--rename`
|
|
743
|
+
// targets are inspectable before committing.
|
|
744
|
+
const preview = await client.previewPublishAppPackage(appId, { renames, knowledge });
|
|
745
|
+
const { lines, hasError } = formatExtractReport(preview.findings);
|
|
746
|
+
console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
|
|
747
|
+
const groups = [
|
|
748
|
+
["entities", preview.renamable_aliases.entities],
|
|
749
|
+
["fields", preview.renamable_aliases.fields],
|
|
750
|
+
["options", preview.renamable_aliases.options],
|
|
751
|
+
["roles", preview.renamable_aliases.roles],
|
|
752
|
+
["templates", preview.renamable_aliases.templates],
|
|
753
|
+
["workflows", preview.renamable_aliases.workflows],
|
|
754
|
+
];
|
|
755
|
+
if (groups.some(([, vals]) => vals.length > 0)) {
|
|
756
|
+
console.error(" Auto-minted aliases — rename any with --rename <alias>=<new> before v1 freezes them:");
|
|
757
|
+
for (const [label, vals] of groups) {
|
|
758
|
+
if (vals.length > 0)
|
|
759
|
+
console.error(` ${`${label}:`.padEnd(11)} ${vals.join(", ")}`);
|
|
760
|
+
}
|
|
761
|
+
console.error(" (query / app-workflow / agent runtime aliases are fixed — the shipped source calls them verbatim.)");
|
|
1685
762
|
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
fs.unlinkSync(tmpFile);
|
|
763
|
+
else {
|
|
764
|
+
console.error(" No renamable aliases.");
|
|
1689
765
|
}
|
|
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
766
|
if (lines.length > 0) {
|
|
1700
|
-
console.error(`
|
|
767
|
+
console.error(` Findings (${preview.findings.length}):`);
|
|
1701
768
|
for (const line of lines)
|
|
1702
769
|
console.error(line);
|
|
1703
770
|
}
|
|
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
771
|
if (hasError) {
|
|
1801
|
-
console.error("\
|
|
1802
|
-
|
|
772
|
+
console.error("\nExtract found error findings — the app cannot be published as-is. Fix them in the app, redeploy, and retry.");
|
|
773
|
+
process.exitCode = 1;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (!args.yes) {
|
|
777
|
+
const idArg = explicit ?? ".";
|
|
778
|
+
const renameArgs = args.renames.map((r) => ` --rename ${r}`).join("");
|
|
779
|
+
const mArg = args.changelog ? ` -m ${JSON.stringify(args.changelog)}` : "";
|
|
780
|
+
console.error(`\nRe-run with --yes to publish v1:`);
|
|
781
|
+
console.error(` lotics app publish ${idArg}${renameArgs}${mArg} --yes`);
|
|
1803
782
|
process.exitCode = 1;
|
|
1804
783
|
return;
|
|
1805
784
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
785
|
+
const result = await client.publishAppAsPackage(appId, {
|
|
786
|
+
renames,
|
|
787
|
+
changelog: args.changelog ?? null,
|
|
788
|
+
knowledge,
|
|
789
|
+
});
|
|
790
|
+
console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
|
|
791
|
+
console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
|
|
792
|
+
console.error(` lotics app pull ${appId} # edit, then lotics app deploy`);
|
|
793
|
+
console.error(` lotics app release ${appId} -m "<what changed>"`);
|
|
794
|
+
console.error(` Install it elsewhere: lotics install ${result.package_id}`);
|
|
1811
795
|
}
|
|
1812
796
|
/**
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
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.
|
|
797
|
+
* Resolve the release/publish target app id: an explicit `<app_id>` wins; `.` (or
|
|
798
|
+
* no positional) resolves the local app project manifest's `lotics.app_id` — the
|
|
799
|
+
* project `lotics app pull` writes — so a release from the pulled app dir needs
|
|
800
|
+
* no id.
|
|
1820
801
|
*/
|
|
1821
|
-
|
|
802
|
+
function resolveOriginAppId(projectDir, explicit) {
|
|
803
|
+
if (explicit !== undefined && explicit !== ".")
|
|
804
|
+
return explicit;
|
|
805
|
+
const local = readLocalAppManifest(projectDir);
|
|
806
|
+
if (local?.app_id)
|
|
807
|
+
return local.app_id;
|
|
808
|
+
throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* `lotics app release [app_id|.] -m <changelog> [--yes]` — snapshot an
|
|
812
|
+
* adopted/installed origin app into its next registry version (docs/packages.md
|
|
813
|
+
* § Promotion). The origin is the permanent working copy; a release binding-aware-
|
|
814
|
+
* extracts it (stable aliases), repackages its DEPLOYED source + dist as the
|
|
815
|
+
* bundle, publishes the next version, and re-pins the origin. Prints the preview
|
|
816
|
+
* first (next version, new + changed aliases, findings); applies only with
|
|
817
|
+
* `--yes`, else exits 1 so a review step can't be skipped. An `error` finding
|
|
818
|
+
* blocks the apply.
|
|
819
|
+
*/
|
|
820
|
+
export async function appRelease(client, args) {
|
|
1822
821
|
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
1823
|
-
const
|
|
1824
|
-
|
|
1825
|
-
|
|
822
|
+
const appId = resolveOriginAppId(projectDir, args.app_id);
|
|
823
|
+
const preview = await client.previewPackageRelease(appId);
|
|
824
|
+
const { lines, hasError } = formatExtractReport(preview.findings);
|
|
825
|
+
console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
|
|
826
|
+
if (preview.added_aliases.length > 0) {
|
|
827
|
+
console.error(` New (${preview.added_aliases.length}): ${preview.added_aliases.join(", ")}`);
|
|
1826
828
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
throw new Error("No version to adopt — publish this project first (lotics package publish) or pass --version N.");
|
|
829
|
+
if (preview.changed_artifacts.length > 0) {
|
|
830
|
+
console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
|
|
1830
831
|
}
|
|
1831
|
-
|
|
1832
|
-
|
|
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.`);
|
|
832
|
+
if (preview.added_aliases.length === 0 && preview.changed_artifacts.length === 0) {
|
|
833
|
+
console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
|
|
1835
834
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
835
|
+
if (lines.length > 0) {
|
|
836
|
+
console.error(` Findings (${preview.findings.length}):`);
|
|
837
|
+
for (const line of lines)
|
|
838
|
+
console.error(line);
|
|
839
|
+
}
|
|
840
|
+
if (hasError) {
|
|
841
|
+
console.error("\nExtract found error findings — the origin cannot be released as-is. Fix them in the app and retry.");
|
|
842
|
+
process.exitCode = 1;
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!args.yes) {
|
|
846
|
+
console.error(`\nRe-run with --yes to publish v${preview.version}:`);
|
|
847
|
+
console.error(` lotics app release ${args.app_id ?? "."} -m ${JSON.stringify(args.changelog)} --yes`);
|
|
848
|
+
process.exitCode = 1;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const result = await client.releasePackage(appId, { changelog: args.changelog });
|
|
852
|
+
console.error(`Released ${result.package_id} v${result.version}.`);
|
|
853
|
+
console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
|
|
1849
854
|
}
|
|
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
855
|
/**
|
|
1858
856
|
* `lotics package yank <package_id> <version> [--undo]` — mark a published
|
|
1859
857
|
* version uninstallable (or restore it). New installs/upgrades/adopts refuse a
|
|
@@ -1871,6 +869,13 @@ export async function packageYank(client, args) {
|
|
|
1871
869
|
}
|
|
1872
870
|
console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
|
|
1873
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
|
|
874
|
+
* installation of the package across the caller's org to the target version.
|
|
875
|
+
* Hands-off applies only where the preview is clean; skipped/failed
|
|
876
|
+
* installations are reported per line and the process exits 1 so a release
|
|
877
|
+
* script can gate on "fleet fully current".
|
|
878
|
+
*/
|
|
1874
879
|
export async function packageFleetUpgrade(client, args) {
|
|
1875
880
|
const result = await client.fleetUpgradePackage(args.package_id, {
|
|
1876
881
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
@@ -1887,7 +892,7 @@ export async function packageFleetUpgrade(client, args) {
|
|
|
1887
892
|
const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
|
|
1888
893
|
if (inst.outcome === "skipped" && inst.blockers) {
|
|
1889
894
|
console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
|
|
1890
|
-
console.error(` resolve via: lotics
|
|
895
|
+
console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
|
|
1891
896
|
}
|
|
1892
897
|
else if (inst.message) {
|
|
1893
898
|
console.error(`${line}: ${inst.message}`);
|