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