@lotics/cli 0.75.0 → 0.76.1
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 +36 -1
- package/dist/app_commands.d.ts +16 -0
- package/dist/app_commands.js +1 -1
- package/dist/args.d.ts +22 -1
- package/dist/args.js +47 -0
- package/dist/cli.js +139 -34
- package/dist/cli_dispatch.test.d.ts +1 -0
- package/dist/cli_dispatch.test.js +90 -0
- package/dist/client.d.ts +230 -26
- package/dist/client.js +106 -28
- package/dist/package_commands.d.ts +219 -20
- package/dist/package_commands.js +996 -67
- package/dist/package_commands.test.js +354 -5
- package/dist/src/cli.js +19507 -1572
- package/dist/starter_template.d.ts +19 -0
- package/dist/starter_template.js +389 -0
- package/dist/starter_template.test.js +69 -1
- package/package.json +1 -1
package/dist/package_commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `lotics package *` subcommands — the app-package authoring + dev/test/run loop
|
|
3
|
-
* (see docs/
|
|
3
|
+
* (see docs/packages.md). An app *package* is a versioned, workspace-agnostic
|
|
4
4
|
* blueprint Lotics maintains once and installs into many workspaces. This module
|
|
5
5
|
* implements the full harness:
|
|
6
6
|
*
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { tmpdir } from "node:os";
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
27
28
|
import "./client.js";
|
|
28
|
-
import {
|
|
29
|
+
import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
|
|
30
|
+
import { buildStarterTemplate, buildPackageStarterOverrides, STARTER_FALLBACK_SDK_VERSION, } from "./starter_template.js";
|
|
29
31
|
import { generatePackageAppFields, } from "./generate_package_fields.js";
|
|
30
|
-
import { appDirName, fetchLatestNpmVersion, runNpm, runTar } from "./app_commands.js";
|
|
32
|
+
import { appDirName, fetchLatestNpmVersion, runNpm, runTar, writeAppDts } from "./app_commands.js";
|
|
31
33
|
import { writeFileAtomic } from "./file_command_io.js";
|
|
32
34
|
import { startDevServer, openBrowser } from "./dev/server.js";
|
|
33
35
|
const CONTRACT_FILE = "contract.json";
|
|
@@ -37,6 +39,13 @@ const CONTRACT_FILE = "contract.json";
|
|
|
37
39
|
* is in `SOURCE_STAGE_EXCLUDES` — so it never rides along in a published bundle.
|
|
38
40
|
*/
|
|
39
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
|
+
]);
|
|
40
49
|
function packageJsonPath(projectDir) {
|
|
41
50
|
return path.join(projectDir, "package.json");
|
|
42
51
|
}
|
|
@@ -65,11 +74,51 @@ export function readPackageProject(projectDir) {
|
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
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/).`);
|
|
102
|
+
}
|
|
103
|
+
templates[alias] = {
|
|
104
|
+
name: entry.name,
|
|
105
|
+
type: entry.type,
|
|
106
|
+
file: entry.file,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const knowledge_expects = Array.isArray(pkg.knowledge_expects)
|
|
111
|
+
? pkg.knowledge_expects.filter((v) => typeof v === "string")
|
|
112
|
+
: [];
|
|
68
113
|
const manifest = {
|
|
69
114
|
id: typeof pkg.id === "string" ? pkg.id : null,
|
|
70
115
|
name: pkg.name,
|
|
71
116
|
description: typeof pkg.description === "string" ? pkg.description : null,
|
|
117
|
+
kind: pkg.kind === "content" ? "content" : "app",
|
|
72
118
|
version: typeof pkg.version === "number" ? pkg.version : null,
|
|
119
|
+
knowledge,
|
|
120
|
+
templates,
|
|
121
|
+
knowledge_expects,
|
|
73
122
|
dev,
|
|
74
123
|
};
|
|
75
124
|
return { pkgJson: parsed, manifest };
|
|
@@ -118,7 +167,18 @@ export function draftPackageProjectFromApp(appPkgJson, args) {
|
|
|
118
167
|
const { lotics: _appManifest, ...rest } = appPkgJson;
|
|
119
168
|
return {
|
|
120
169
|
pkgJson: rest,
|
|
121
|
-
manifest: {
|
|
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
|
+
},
|
|
122
182
|
};
|
|
123
183
|
}
|
|
124
184
|
/**
|
|
@@ -140,11 +200,21 @@ export function parseAdoptBindingFile(raw, expectedAppId) {
|
|
|
140
200
|
`run "lotics package extract ${expectedAppId}" to produce the right pin.`);
|
|
141
201
|
}
|
|
142
202
|
// Boundary adapter: the file is untyped JSON; `isPlainObject` proved the shape
|
|
143
|
-
// and the CLI passes the
|
|
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;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
144
213
|
return {
|
|
145
214
|
app_id: raw.app_id,
|
|
146
215
|
workspace_id: raw.workspace_id,
|
|
147
216
|
binding: raw.binding,
|
|
217
|
+
knowledge_binding,
|
|
148
218
|
};
|
|
149
219
|
}
|
|
150
220
|
/**
|
|
@@ -199,6 +269,133 @@ function readContract(projectDir) {
|
|
|
199
269
|
}
|
|
200
270
|
return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
|
|
201
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
|
+
}
|
|
202
399
|
/**
|
|
203
400
|
* (Re)generate the package project's `.lotics/app_fields.ts` from its
|
|
204
401
|
* contract.json — the runtime-resolving F/OPT/ROLE surface (see
|
|
@@ -242,23 +439,39 @@ export function stagePackageSource(projectDir, sourceStage) {
|
|
|
242
439
|
fs.writeFileSync(path.join(sourceStage, "package.json"), JSON.stringify(sanitizePackageJsonForSource(project.pkgJson), null, 2) + "\n");
|
|
243
440
|
}
|
|
244
441
|
/**
|
|
245
|
-
* Build the publishable bundle:
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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.
|
|
249
452
|
*/
|
|
250
453
|
async function buildPackageBundle(projectDir) {
|
|
251
|
-
|
|
252
|
-
await runNpm(["run", "build"], projectDir);
|
|
253
|
-
const distDir = path.join(projectDir, "dist");
|
|
254
|
-
if (!fs.existsSync(distDir)) {
|
|
255
|
-
throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
|
|
256
|
-
`Check that 'npm run build' is configured correctly.`);
|
|
257
|
-
}
|
|
454
|
+
const { manifest } = readPackageProject(projectDir);
|
|
258
455
|
// Stage the two member archives in a temp dir, then tar them into the bundle.
|
|
259
456
|
const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-"));
|
|
260
457
|
const bundlePath = path.join(tmpdir(), `lotics-bundle-${Date.now()}.tar.gz`);
|
|
261
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
|
+
}
|
|
262
475
|
console.error("Packaging source...");
|
|
263
476
|
// Stage a copy of the source tree with EXPLICIT top-level excludes, then
|
|
264
477
|
// tar the stage with no --exclude flags at all. tar exclude patterns are
|
|
@@ -328,12 +541,79 @@ export function starterContract(name) {
|
|
|
328
541
|
],
|
|
329
542
|
};
|
|
330
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
|
+
}
|
|
331
611
|
/**
|
|
332
|
-
* `lotics package new <name> [path]` — scaffold a package
|
|
333
|
-
* app starter (Vite+React+TS) for the code
|
|
334
|
-
* package manifest, and adds a starter
|
|
335
|
-
*
|
|
336
|
-
* `lotics package
|
|
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`.
|
|
337
617
|
*/
|
|
338
618
|
export async function packageNew(args) {
|
|
339
619
|
const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
|
|
@@ -341,6 +621,10 @@ export async function packageNew(args) {
|
|
|
341
621
|
throw new Error(`Target directory ${targetPath} is not empty.`);
|
|
342
622
|
}
|
|
343
623
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
624
|
+
if ((args.kind ?? "app") === "content") {
|
|
625
|
+
scaffoldContentPackage(args.name, targetPath);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
344
628
|
// The starter bakes an app manifest (app_id/workspace_id) into package.json;
|
|
345
629
|
// a package is workspace-agnostic, so those placeholders are replaced with the
|
|
346
630
|
// package manifest below. The rest of the starter (config, src, tests) is the
|
|
@@ -361,6 +645,12 @@ export async function packageNew(args) {
|
|
|
361
645
|
ui_version: uiLatest ? `^${uiLatest}` : undefined,
|
|
362
646
|
sdk_version: packageSdkRange(sdkLatest),
|
|
363
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]));
|
|
364
654
|
for (const file of files) {
|
|
365
655
|
const fullPath = path.join(targetPath, file.path);
|
|
366
656
|
if (file.path === "package.json") {
|
|
@@ -369,7 +659,11 @@ export async function packageNew(args) {
|
|
|
369
659
|
id: null,
|
|
370
660
|
name: args.name,
|
|
371
661
|
description: null,
|
|
662
|
+
kind: "app",
|
|
372
663
|
version: null,
|
|
664
|
+
knowledge: {},
|
|
665
|
+
templates: {},
|
|
666
|
+
knowledge_expects: [],
|
|
373
667
|
dev: {},
|
|
374
668
|
};
|
|
375
669
|
pkg.lotics = { package: manifest };
|
|
@@ -378,7 +672,7 @@ export async function packageNew(args) {
|
|
|
378
672
|
continue;
|
|
379
673
|
}
|
|
380
674
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
381
|
-
fs.writeFileSync(fullPath, file.content);
|
|
675
|
+
fs.writeFileSync(fullPath, overrides.get(file.path) ?? file.content);
|
|
382
676
|
}
|
|
383
677
|
fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
|
|
384
678
|
writePackageAppFields(targetPath);
|
|
@@ -412,12 +706,25 @@ export async function packageBuild(args) {
|
|
|
412
706
|
*/
|
|
413
707
|
async function publishVersion(client, projectDir, opts) {
|
|
414
708
|
const project = readPackageProject(projectDir);
|
|
415
|
-
const
|
|
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)));
|
|
416
720
|
let packageId = project.manifest.id;
|
|
417
721
|
if (packageId === null) {
|
|
418
|
-
const pkg = await client.
|
|
722
|
+
const pkg = await client.createPackage({
|
|
419
723
|
name: project.manifest.name,
|
|
420
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,
|
|
421
728
|
});
|
|
422
729
|
packageId = pkg.id;
|
|
423
730
|
project.manifest.id = packageId;
|
|
@@ -426,10 +733,11 @@ async function publishVersion(client, projectDir, opts) {
|
|
|
426
733
|
}
|
|
427
734
|
const bundle = await buildPackageBundle(projectDir);
|
|
428
735
|
console.error("Publishing version...");
|
|
429
|
-
const version = await client.
|
|
736
|
+
const version = await client.publishPackageVersion(packageId, {
|
|
430
737
|
contract,
|
|
431
738
|
bundle,
|
|
432
739
|
changelog: opts.changelog ?? null,
|
|
740
|
+
channel: opts.channel ?? "release",
|
|
433
741
|
});
|
|
434
742
|
project.manifest.version = version.version;
|
|
435
743
|
writePackageManifest(projectDir, project);
|
|
@@ -487,8 +795,12 @@ async function syncToDevWorkspace(client, projectDir) {
|
|
|
487
795
|
// Contract may have been edited since the last sync — regenerate the
|
|
488
796
|
// runtime F/OPT/ROLE surface before the build that publishVersion runs.
|
|
489
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.
|
|
490
801
|
const { package_id, version } = await publishVersion(client, projectDir, {
|
|
491
802
|
changelog: "dev sync",
|
|
803
|
+
channel: "dev",
|
|
492
804
|
});
|
|
493
805
|
// Re-read after publish (publishVersion may have stamped the package id).
|
|
494
806
|
const project = readPackageProject(projectDir);
|
|
@@ -496,17 +808,35 @@ async function syncToDevWorkspace(client, projectDir) {
|
|
|
496
808
|
let appId;
|
|
497
809
|
if (existing) {
|
|
498
810
|
console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
|
|
499
|
-
const app = await client.
|
|
811
|
+
const app = await client.upgradePackage(existing.app_id, { version });
|
|
500
812
|
appId = app.id;
|
|
501
813
|
}
|
|
502
814
|
else {
|
|
503
815
|
console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
|
|
504
|
-
const
|
|
505
|
-
|
|
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;
|
|
506
824
|
}
|
|
507
825
|
project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
|
|
508
826
|
writePackageManifest(projectDir, project);
|
|
509
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
|
+
});
|
|
510
840
|
return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
|
|
511
841
|
}
|
|
512
842
|
/**
|
|
@@ -583,7 +913,7 @@ export async function packageReset(client, args) {
|
|
|
583
913
|
throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
|
|
584
914
|
}
|
|
585
915
|
console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
|
|
586
|
-
const app = await client.
|
|
916
|
+
const app = await client.resetPackage(pin.app_id);
|
|
587
917
|
console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
|
|
588
918
|
}
|
|
589
919
|
/**
|
|
@@ -641,7 +971,7 @@ export function parseResolveFlags(resolve) {
|
|
|
641
971
|
*/
|
|
642
972
|
export async function packageDoctor(client, args) {
|
|
643
973
|
const app_id = resolveInstallationAppId(client, args.app_id);
|
|
644
|
-
const health = await client.
|
|
974
|
+
const health = await client.getPackageHealth(app_id);
|
|
645
975
|
console.error(`${health.package_name} — installation ${health.app_id}`);
|
|
646
976
|
console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
|
|
647
977
|
(health.update_available ? " → update available" : ""));
|
|
@@ -669,17 +999,52 @@ export async function packageDoctor(client, args) {
|
|
|
669
999
|
` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
|
|
670
1000
|
process.exitCode = 1;
|
|
671
1001
|
}
|
|
1002
|
+
// Package-managed knowledge (bundled with this app install): drift + local edits
|
|
1003
|
+
// + unmet expects. All advisory; resolved through the app's package upgrade.
|
|
1004
|
+
if (health.knowledge_drift.length > 0) {
|
|
1005
|
+
console.error(` Knowledge binding drift (${health.knowledge_drift.length}):`);
|
|
1006
|
+
for (const d of health.knowledge_drift) {
|
|
1007
|
+
console.error(` - ${d.alias} "${d.name}" → ${d.doc_id ?? "(unbound)"} (missing from the workspace)`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (health.knowledge_modified.length > 0) {
|
|
1011
|
+
console.error(` Locally edited package knowledge (${health.knowledge_modified.length}):`);
|
|
1012
|
+
for (const m of health.knowledge_modified) {
|
|
1013
|
+
console.error(` - ${m.alias} "${m.name}" (an upgrade overwrites this unless kept)`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (health.missing_expected_docs.length > 0) {
|
|
1017
|
+
console.error(` Missing expected knowledge docs (${health.missing_expected_docs.length}):`);
|
|
1018
|
+
for (const name of health.missing_expected_docs) {
|
|
1019
|
+
console.error(` - "${name}" (the package's agents route to this name; no matching doc exists)`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (health.knowledge_drift.length > 0 || health.knowledge_modified.length > 0) {
|
|
1023
|
+
process.exitCode = 1;
|
|
1024
|
+
}
|
|
1025
|
+
if (health.knowledge_drift.length === 0 &&
|
|
1026
|
+
health.knowledge_modified.length === 0 &&
|
|
1027
|
+
health.missing_expected_docs.length === 0) {
|
|
1028
|
+
console.error(" Knowledge: healthy — bound docs resolve, none locally edited, expects met.");
|
|
1029
|
+
}
|
|
672
1030
|
if (health.update_available) {
|
|
673
1031
|
console.error(` Upgrade: lotics package upgrade ${app_id}`);
|
|
674
1032
|
}
|
|
675
1033
|
}
|
|
676
1034
|
/**
|
|
677
|
-
* Preview-then-apply upgrade. Prints the additive plan +
|
|
678
|
-
* removals; refuses (exit 1, with
|
|
679
|
-
* binding drift
|
|
1035
|
+
* Preview-then-apply an app-installation upgrade. Prints the additive plan +
|
|
1036
|
+
* informational removals + any bundled-knowledge changes; refuses (exit 1, with
|
|
1037
|
+
* the exact --resolve syntax) while any binding drift, modified core artifact, or
|
|
1038
|
+
* consent-requiring bundled-knowledge doc lacks a resolution.
|
|
1039
|
+
*
|
|
1040
|
+
* `--resolve` is shared across the finding classes: a key naming a bundled
|
|
1041
|
+
* knowledge doc routes to `knowledge_resolutions` (apply|keep|archive|recreate|
|
|
1042
|
+
* unbind), everything else to core `resolutions`; `--bind-to` consents an
|
|
1043
|
+
* added-knowledge-doc name collision; `--apply-all` accepts the package's version
|
|
1044
|
+
* for every consent-requiring knowledge doc (overwriting local edits).
|
|
680
1045
|
*/
|
|
681
1046
|
export async function packageUpgrade(client, args) {
|
|
682
|
-
const preview = await client.
|
|
1047
|
+
const preview = await client.previewPackageUpgrade(args.app_id, {
|
|
683
1048
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
684
1049
|
});
|
|
685
1050
|
console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
|
|
@@ -694,6 +1059,12 @@ export async function packageUpgrade(client, args) {
|
|
|
694
1059
|
console.error(` Adds: ${added}`);
|
|
695
1060
|
if (removed)
|
|
696
1061
|
console.error(` Unbinds (workspace data kept): ${removed}`);
|
|
1062
|
+
if (preview.knowledge.length > 0) {
|
|
1063
|
+
console.error(` Knowledge docs (${preview.knowledge.length}):`);
|
|
1064
|
+
for (const entry of preview.knowledge) {
|
|
1065
|
+
console.error(` ${formatKnowledgeEntryLine(entry)}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
697
1068
|
// Breaking contract changes are NOT resolvable via --resolve — scaffold would
|
|
698
1069
|
// refuse them on a bound alias, and there is no in-flow remediation. Report
|
|
699
1070
|
// every entry and the only two real remedies, then hard-stop.
|
|
@@ -707,9 +1078,31 @@ export async function packageUpgrade(client, args) {
|
|
|
707
1078
|
` before making the change: lotics package eject ${args.app_id}`);
|
|
708
1079
|
process.exit(1);
|
|
709
1080
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1081
|
+
// Route the shared --resolve flags: a knowledge-doc alias resolves the doc, a
|
|
1082
|
+
// core artifact key resolves the artifact; a key that is BOTH throws.
|
|
1083
|
+
const coreKeys = new Set([
|
|
1084
|
+
...preview.drift.map((d) => `${d.namespace}.${d.alias}`),
|
|
1085
|
+
...preview.modified.map((m) => `${m.kind}.${m.alias}`),
|
|
1086
|
+
]);
|
|
1087
|
+
const { coreResolve, knowledgeResolutions } = routeAppUpgradeResolve(args.resolve, preview.knowledge, coreKeys);
|
|
1088
|
+
const resolutions = parseResolveFlags(coreResolve);
|
|
1089
|
+
if (args.applyAll) {
|
|
1090
|
+
for (const entry of preview.knowledge) {
|
|
1091
|
+
if (knowledgeEntryNeedsConsent(entry) && knowledgeResolutions[entry.alias] === undefined) {
|
|
1092
|
+
knowledgeResolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
const knowledge_resolutions = {
|
|
1097
|
+
resolutions: knowledgeResolutions,
|
|
1098
|
+
bind_to: parseBindToFlags(args.bindTo),
|
|
1099
|
+
};
|
|
1100
|
+
const unresolvedDrift = preview.drift.filter((d) => resolutions[`${d.namespace}.${d.alias}`] === undefined);
|
|
1101
|
+
const unresolvedModified = preview.modified.filter((m) => resolutions[`${m.kind}.${m.alias}`] === undefined);
|
|
1102
|
+
const unresolvedKnowledge = preview.knowledge.filter((e) => knowledgeEntryNeedsConsent(e) && knowledgeResolutions[e.alias] === undefined);
|
|
1103
|
+
if (unresolvedDrift.length > 0 ||
|
|
1104
|
+
unresolvedModified.length > 0 ||
|
|
1105
|
+
unresolvedKnowledge.length > 0) {
|
|
713
1106
|
if (unresolvedDrift.length > 0) {
|
|
714
1107
|
console.error(" Binding drift must be resolved before upgrading:");
|
|
715
1108
|
for (const d of unresolvedDrift) {
|
|
@@ -722,39 +1115,505 @@ export async function packageUpgrade(client, args) {
|
|
|
722
1115
|
console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
|
|
723
1116
|
}
|
|
724
1117
|
}
|
|
1118
|
+
if (unresolvedKnowledge.length > 0) {
|
|
1119
|
+
console.error(" Bundled knowledge docs need consent before upgrading (or --apply-all to accept the package's version for all):");
|
|
1120
|
+
for (const entry of unresolvedKnowledge) {
|
|
1121
|
+
console.error(formatKnowledgeResolveHint(entry));
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
725
1124
|
process.exit(1);
|
|
726
1125
|
}
|
|
727
|
-
const
|
|
1126
|
+
const hasKnowledgeResolutions = Object.keys(knowledge_resolutions.resolutions).length > 0 ||
|
|
1127
|
+
Object.keys(knowledge_resolutions.bind_to).length > 0;
|
|
1128
|
+
const app = await client.upgradePackage(args.app_id, {
|
|
728
1129
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
729
|
-
...(Object.keys(
|
|
1130
|
+
...(Object.keys(resolutions).length > 0 ? { resolutions } : {}),
|
|
1131
|
+
...(hasKnowledgeResolutions ? { knowledge_resolutions } : {}),
|
|
730
1132
|
});
|
|
731
1133
|
console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
|
|
732
1134
|
}
|
|
1135
|
+
/** `--apply-all`'s accept-the-package resolution for a consent-requiring entry. */
|
|
1136
|
+
function knowledgeAcceptResolution(change) {
|
|
1137
|
+
switch (change) {
|
|
1138
|
+
case "changed":
|
|
1139
|
+
return "apply";
|
|
1140
|
+
case "removed":
|
|
1141
|
+
return "archive";
|
|
1142
|
+
case "drifted":
|
|
1143
|
+
return "recreate";
|
|
1144
|
+
case "added":
|
|
1145
|
+
return "apply"; // unreachable (added never needs consent), kept total.
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/** The install-consent trust line: official > your own org > third-party. */
|
|
1149
|
+
function trustBadge(pkg) {
|
|
1150
|
+
if (pkg.is_official)
|
|
1151
|
+
return "official Lotics package";
|
|
1152
|
+
if (pkg.owned_by_caller === true)
|
|
1153
|
+
return "your organization's package";
|
|
1154
|
+
return "third-party package (runs under your authority once installed)";
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* `lotics package show <package_id>` — registry metadata + version history
|
|
1158
|
+
* (trust badge, retirement, per-version channel/yank/changelog). The read
|
|
1159
|
+
* surface for "what is this package and what shipped when". Kind-agnostic — a
|
|
1160
|
+
* content package shows here the same way.
|
|
1161
|
+
*/
|
|
1162
|
+
export async function packageShow(client, args) {
|
|
1163
|
+
const pkg = await client.getPackage(args.package_id);
|
|
1164
|
+
const { versions } = await client.listPackageVersions(args.package_id);
|
|
1165
|
+
console.error(`${pkg.name} (${pkg.id}) — ${trustBadge(pkg)}`);
|
|
1166
|
+
if (pkg.description)
|
|
1167
|
+
console.error(` ${pkg.description}`);
|
|
1168
|
+
if (pkg.retired_at)
|
|
1169
|
+
console.error(` RETIRED ${pkg.retired_at}`);
|
|
1170
|
+
console.error(` latest installable: ${pkg.latest_version > 0 ? `v${pkg.latest_version}` : "none"}`);
|
|
1171
|
+
console.error("");
|
|
1172
|
+
for (const v of versions) {
|
|
1173
|
+
const marks = [
|
|
1174
|
+
v.version === pkg.latest_version ? "*" : " ",
|
|
1175
|
+
v.channel === "dev" ? "dev" : " ",
|
|
1176
|
+
v.yanked_at ? "YANKED" : " ",
|
|
1177
|
+
].join(" ");
|
|
1178
|
+
console.log(`${marks} v${v.version} ${v.created_at} ${v.changelog ?? ""}`.trimEnd());
|
|
1179
|
+
}
|
|
1180
|
+
if (versions.length === 0)
|
|
1181
|
+
console.error(" (no published versions)");
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* One preview line for a knowledge upgrade entry — `[change] alias "name"` with
|
|
1185
|
+
* `modified` / `needs consent` marks. Shared verbatim by the standalone (`pci_`)
|
|
1186
|
+
* and the app-install upgrade previews so both speak one vocabulary; the caller
|
|
1187
|
+
* owns the leading indent.
|
|
1188
|
+
*/
|
|
1189
|
+
function formatKnowledgeEntryLine(entry) {
|
|
1190
|
+
const marks = [
|
|
1191
|
+
entry.modified ? "modified" : null,
|
|
1192
|
+
knowledgeEntryNeedsConsent(entry) ? "needs consent" : null,
|
|
1193
|
+
].filter((m) => m !== null);
|
|
1194
|
+
return `[${entry.change}] ${entry.alias} "${entry.name}"${marks.length ? ` (${marks.join(", ")})` : ""}`;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* The exact `--resolve <alias>=<valid options>` remediation line for a
|
|
1198
|
+
* consent-requiring knowledge entry (options from the shared
|
|
1199
|
+
* `validKnowledgeResolutions`). Shared by both upgrade paths' gate output.
|
|
1200
|
+
*/
|
|
1201
|
+
function formatKnowledgeResolveHint(entry) {
|
|
1202
|
+
const note = entry.change === "changed" || entry.change === "removed"
|
|
1203
|
+
? " (a local edit — apply/archive overwrites it; keep retains it)"
|
|
1204
|
+
: " (bound doc is gone; recreate from the package, or unbind)";
|
|
1205
|
+
return ` --resolve ${entry.alias}=${validKnowledgeResolutions(entry.change).join("|")}${note}`;
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Route an app-install upgrade's `--resolve` entries into the core-artifact
|
|
1209
|
+
* bucket vs the bundled-knowledge bucket. `--resolve` is shared between the two
|
|
1210
|
+
* finding classes, so each key is matched against the preview: a key that names a
|
|
1211
|
+
* bundled knowledge doc resolves that doc (its value validated against the doc's
|
|
1212
|
+
* change class here — the app path has no separate knowledge-only parser);
|
|
1213
|
+
* anything else stays a core resolution (a `<namespace>.<alias>` drift, a
|
|
1214
|
+
* `<kind>.<alias>` modified core, or a key the server validates). A key that is
|
|
1215
|
+
* BOTH a knowledge alias and a core-artifact key is ambiguous — throw rather than
|
|
1216
|
+
* guess which the operator meant.
|
|
1217
|
+
*/
|
|
1218
|
+
export function routeAppUpgradeResolve(resolve, knowledgeEntries, coreKeys) {
|
|
1219
|
+
const byAlias = new Map(knowledgeEntries.map((e) => [e.alias, e]));
|
|
1220
|
+
const coreResolve = [];
|
|
1221
|
+
const knowledgeResolutions = {};
|
|
1222
|
+
for (const entry of resolve) {
|
|
1223
|
+
const eq = entry.indexOf("=");
|
|
1224
|
+
if (eq <= 0 || eq === entry.length - 1) {
|
|
1225
|
+
throw new Error(`Invalid --resolve "${entry}" — expected <key>=<value>.`);
|
|
1226
|
+
}
|
|
1227
|
+
const key = entry.slice(0, eq);
|
|
1228
|
+
const value = entry.slice(eq + 1);
|
|
1229
|
+
const known = byAlias.get(key);
|
|
1230
|
+
if (known && coreKeys.has(key)) {
|
|
1231
|
+
throw new Error(`--resolve "${key}" is ambiguous — it names both a bundled knowledge doc and a core artifact in this upgrade. ` +
|
|
1232
|
+
`Rename one alias so it is unambiguous; refusing to guess.`);
|
|
1233
|
+
}
|
|
1234
|
+
if (known) {
|
|
1235
|
+
const valid = validKnowledgeResolutions(known.change);
|
|
1236
|
+
const match = valid.find((v) => v === value);
|
|
1237
|
+
if (match === undefined) {
|
|
1238
|
+
throw new Error(`Invalid --resolve value "${value}" for knowledge doc "${key}" (${known.change}) — expected ${valid.join("|")}.`);
|
|
1239
|
+
}
|
|
1240
|
+
knowledgeResolutions[key] = match;
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
coreResolve.push(entry);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return { coreResolve, knowledgeResolutions };
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Route a STANDALONE content upgrade's shared `--resolve <alias>=<value>` flags
|
|
1250
|
+
* into the knowledge vs templates buckets by matching each alias against the two
|
|
1251
|
+
* preview namespaces. A doc alias takes apply|keep|archive|recreate|unbind; a
|
|
1252
|
+
* template alias takes revert|keep. An alias present in BOTH namespaces is
|
|
1253
|
+
* ambiguous → loud error (no guess). An alias in NEITHER → error. The value is
|
|
1254
|
+
* validated against the namespace it routes to. Exported for testing.
|
|
1255
|
+
*/
|
|
1256
|
+
export function routeContentResolveFlags(resolve, knowledgeAliases, templateAliases) {
|
|
1257
|
+
const knowledge = {};
|
|
1258
|
+
const templates = {};
|
|
1259
|
+
const knowledgeValid = new Set(["apply", "keep", "archive", "recreate", "unbind"]);
|
|
1260
|
+
const templateValid = new Set(["revert", "keep"]);
|
|
1261
|
+
for (const entry of resolve) {
|
|
1262
|
+
const eq = entry.indexOf("=");
|
|
1263
|
+
if (eq <= 0 || eq === entry.length - 1) {
|
|
1264
|
+
throw new Error(`Invalid --resolve "${entry}" — expected <alias>=<value>.`);
|
|
1265
|
+
}
|
|
1266
|
+
const key = entry.slice(0, eq);
|
|
1267
|
+
const value = entry.slice(eq + 1);
|
|
1268
|
+
const isKnowledge = knowledgeAliases.has(key);
|
|
1269
|
+
const isTemplate = templateAliases.has(key);
|
|
1270
|
+
if (isKnowledge && isTemplate) {
|
|
1271
|
+
throw new Error(`--resolve "${key}" is ambiguous — it names both a knowledge doc and a template in this upgrade. ` +
|
|
1272
|
+
`Rename one alias in the package so the two content namespaces are disjoint.`);
|
|
1273
|
+
}
|
|
1274
|
+
if (isTemplate) {
|
|
1275
|
+
if (!templateValid.has(value)) {
|
|
1276
|
+
throw new Error(`Invalid --resolve value "${value}" for template "${key}" — expected revert|keep.`);
|
|
1277
|
+
}
|
|
1278
|
+
templates[key] = value;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (isKnowledge) {
|
|
1282
|
+
if (!knowledgeValid.has(value)) {
|
|
1283
|
+
throw new Error(`Invalid --resolve value "${value}" for knowledge alias "${key}" — expected apply|keep|archive|recreate|unbind.`);
|
|
1284
|
+
}
|
|
1285
|
+
knowledge[key] = value;
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
throw new Error(`--resolve "${key}" does not name a knowledge doc or template in this upgrade.`);
|
|
1289
|
+
}
|
|
1290
|
+
return { knowledge, templates };
|
|
1291
|
+
}
|
|
1292
|
+
/** The `--resolve <alias>=revert|keep` remediation line for a consent-requiring template. */
|
|
1293
|
+
function formatTemplateResolveHint(entry) {
|
|
1294
|
+
const note = entry.baseline_unknown
|
|
1295
|
+
? " (can't verify the local edit — older package version; revert overwrites, keep retains)"
|
|
1296
|
+
: " (a local edit — revert overwrites it with the package's version; keep retains it)";
|
|
1297
|
+
return ` --resolve ${entry.alias}=revert|keep${note}`;
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Preview-then-apply a STANDALONE content installation upgrade — knowledge docs
|
|
1301
|
+
* AND document templates behind one review gate. Prints the per-alias plan;
|
|
1302
|
+
* refuses (exit 1, with the exact `--resolve` syntax) while any consent-requiring
|
|
1303
|
+
* entry lacks a resolution — a modified knowledge change/removal or drift, or a
|
|
1304
|
+
* locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
|
|
1305
|
+
* by accepting the package's version (knowledge changed→apply, removed→archive,
|
|
1306
|
+
* drifted→recreate; template→revert) — an explicit bulk "take upstream" that
|
|
1307
|
+
* discards local edits. `--resolve <alias>=<value>` and `--bind-to <alias>=<kdc_id>`
|
|
1308
|
+
* resolve entries individually (`--resolve` is routed to the right namespace by
|
|
1309
|
+
* matching the alias against the preview).
|
|
1310
|
+
*/
|
|
1311
|
+
export async function packageUpgradeKnowledge(client, args) {
|
|
1312
|
+
const preview = await client.previewContentInstallationUpgrade(args.installation_id, {
|
|
1313
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
1314
|
+
});
|
|
1315
|
+
console.error(`Content upgrade v${preview.from_version} → v${preview.to_version}` +
|
|
1316
|
+
(preview.changelog ? ` — ${preview.changelog}` : ""));
|
|
1317
|
+
const apply = async (resolutions) => {
|
|
1318
|
+
const updated = await client.applyContentInstallationUpgrade(args.installation_id, {
|
|
1319
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
1320
|
+
resolutions,
|
|
1321
|
+
});
|
|
1322
|
+
console.error(`Upgraded ${args.installation_id} → v${updated.package_version}.`);
|
|
1323
|
+
};
|
|
1324
|
+
if (preview.entries.length === 0 && preview.templates.length === 0) {
|
|
1325
|
+
if (preview.to_version === preview.from_version) {
|
|
1326
|
+
console.error(" Already up to date — no content changes.");
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
console.error(" No changes needing consent — advancing the version pin; clean template updates apply automatically.");
|
|
1330
|
+
await apply({ knowledge: { resolutions: {}, bind_to: {} }, templates: {} });
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
for (const entry of preview.entries) {
|
|
1334
|
+
console.error(` ${formatKnowledgeEntryLine(entry)}`);
|
|
1335
|
+
}
|
|
1336
|
+
for (const entry of preview.templates) {
|
|
1337
|
+
console.error(` [template] ${entry.alias} (modified — needs consent)`);
|
|
1338
|
+
}
|
|
1339
|
+
// Route the shared --resolve flags across the two namespaces (loud on an alias
|
|
1340
|
+
// in both), then optionally bulk-accept every remaining consent entry.
|
|
1341
|
+
const routed = routeContentResolveFlags(args.resolve, new Set(preview.entries.map((e) => e.alias)), new Set(preview.templates.map((t) => t.alias)));
|
|
1342
|
+
const resolutions = {
|
|
1343
|
+
knowledge: { resolutions: { ...routed.knowledge }, bind_to: { ...args.bind_to } },
|
|
1344
|
+
templates: { ...routed.templates },
|
|
1345
|
+
};
|
|
1346
|
+
if (args.applyAll) {
|
|
1347
|
+
for (const entry of preview.entries) {
|
|
1348
|
+
if (knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined) {
|
|
1349
|
+
resolutions.knowledge.resolutions[entry.alias] = knowledgeAcceptResolution(entry.change);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
for (const entry of preview.templates) {
|
|
1353
|
+
if (resolutions.templates[entry.alias] === undefined)
|
|
1354
|
+
resolutions.templates[entry.alias] = "revert";
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const unresolvedKnowledge = preview.entries.filter((entry) => knowledgeEntryNeedsConsent(entry) && resolutions.knowledge.resolutions[entry.alias] === undefined);
|
|
1358
|
+
const unresolvedTemplates = preview.templates.filter((entry) => resolutions.templates[entry.alias] === undefined);
|
|
1359
|
+
if (unresolvedKnowledge.length > 0 || unresolvedTemplates.length > 0) {
|
|
1360
|
+
console.error(" These need a resolution before upgrading (or pass --apply-all to accept the package's version for all):");
|
|
1361
|
+
for (const entry of unresolvedKnowledge)
|
|
1362
|
+
console.error(formatKnowledgeResolveHint(entry));
|
|
1363
|
+
for (const entry of unresolvedTemplates)
|
|
1364
|
+
console.error(formatTemplateResolveHint(entry));
|
|
1365
|
+
process.exit(1);
|
|
1366
|
+
}
|
|
1367
|
+
await apply(resolutions);
|
|
1368
|
+
}
|
|
1369
|
+
/** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
|
|
1370
|
+
export function parseBindToFlags(bindTo) {
|
|
1371
|
+
const map = {};
|
|
1372
|
+
for (const entry of bindTo) {
|
|
1373
|
+
const eq = entry.indexOf("=");
|
|
1374
|
+
if (eq <= 0 || eq === entry.length - 1) {
|
|
1375
|
+
throw new Error(`Invalid --bind-to "${entry}" — expected <alias>=<kdc_id>.`);
|
|
1376
|
+
}
|
|
1377
|
+
map[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
1378
|
+
}
|
|
1379
|
+
return map;
|
|
1380
|
+
}
|
|
1381
|
+
/** Print advisory `knowledge_expects` misses loudly (never blocks the install). */
|
|
1382
|
+
function warnMissingExpectedDocs(missing) {
|
|
1383
|
+
if (missing.length === 0)
|
|
1384
|
+
return;
|
|
1385
|
+
console.error(` ⚠ ${missing.length} expected knowledge doc(s) the package's agents route to are missing from this workspace:`);
|
|
1386
|
+
for (const name of missing)
|
|
1387
|
+
console.error(` - "${name}"`);
|
|
1388
|
+
console.error(" The package installed, but those agents will degrade until a doc with each name exists.");
|
|
1389
|
+
}
|
|
733
1390
|
export async function packageInstall(client, args) {
|
|
734
1391
|
// Surface the trust badge at the consent point: installing materializes the
|
|
735
|
-
// package's workflows/agents
|
|
736
|
-
const pkg = await client.
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const app = await client.installAppPackage(args.package_id, {
|
|
1392
|
+
// package's workflows/agents (or its doc corpus) under YOUR authority.
|
|
1393
|
+
const pkg = await client.getPackage(args.package_id);
|
|
1394
|
+
const kindLabel = pkg.kind === "content" ? "content package" : "package";
|
|
1395
|
+
console.error(`Installing ${pkg.name} — ${trustBadge(pkg)} (${kindLabel})...`);
|
|
1396
|
+
const result = await client.installPackage(args.package_id, {
|
|
741
1397
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
1398
|
+
...(args.bind_to && Object.keys(args.bind_to).length > 0 ? { bind_to: args.bind_to } : {}),
|
|
1399
|
+
...(args.config && Object.keys(args.config).length > 0 ? { config: args.config } : {}),
|
|
742
1400
|
});
|
|
1401
|
+
if (result.kind === "content") {
|
|
1402
|
+
const { installation, warnings } = result;
|
|
1403
|
+
const docs = installation.binding.knowledge;
|
|
1404
|
+
const templates = installation.binding.templates;
|
|
1405
|
+
console.error(`Installed ${pkg.name} v${installation.package_version} → content installation ${installation.id} ` +
|
|
1406
|
+
`(workspace ${installation.workspace_id}).`);
|
|
1407
|
+
const docAliases = Object.keys(docs);
|
|
1408
|
+
if (docAliases.length > 0) {
|
|
1409
|
+
console.error(` ${docAliases.length} doc(s) live: ${docAliases.map((a) => `${a}→${docs[a]}`).join(", ")}`);
|
|
1410
|
+
}
|
|
1411
|
+
const templateAliases = Object.keys(templates);
|
|
1412
|
+
if (templateAliases.length > 0) {
|
|
1413
|
+
console.error(` ${templateAliases.length} template(s) live: ${templateAliases.map((a) => `${a}→${templates[a]}`).join(", ")}`);
|
|
1414
|
+
}
|
|
1415
|
+
warnMissingExpectedDocs(warnings.missing_expected_docs);
|
|
1416
|
+
console.error(` Upgrade later: lotics package upgrade ${installation.id}`);
|
|
1417
|
+
console.error(` Uninstall: lotics package uninstall ${installation.id} [--keep-content]`);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const { app, knowledge_warnings } = result;
|
|
743
1421
|
const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
|
|
744
1422
|
console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
|
|
745
1423
|
console.error(" The data model, queries, workflows, and agents are live.");
|
|
1424
|
+
warnMissingExpectedDocs(knowledge_warnings.missing_expected_docs);
|
|
746
1425
|
console.error(" Pull it for local editing: lotics app pull " + app.id);
|
|
747
1426
|
}
|
|
1427
|
+
/**
|
|
1428
|
+
* `lotics package uninstall <app_id|pci_id>` — ONE command over both installation
|
|
1429
|
+
* kinds, dispatched by the id form (mirrors `package upgrade`):
|
|
1430
|
+
* - a `pci_` id → a STANDALONE CONTENT installation: deletes the row and (unless
|
|
1431
|
+
* `--keep-content`) archives its package-bound docs AND templates, listing each
|
|
1432
|
+
* archived id.
|
|
1433
|
+
* - anything else (an `app_id`) → an APP installation: archives its workflow
|
|
1434
|
+
* artifacts and — with `--archive-tables` — the scaffolded entity tables
|
|
1435
|
+
* (provenance- + reference-gated server-side).
|
|
1436
|
+
* A flag used on the wrong path is a loud error, never silently ignored.
|
|
1437
|
+
*/
|
|
1438
|
+
export async function packageUninstall(client, args) {
|
|
1439
|
+
if (args.id.startsWith("pci_")) {
|
|
1440
|
+
if (args.archive_tables) {
|
|
1441
|
+
throw new Error("--archive-tables applies only to an app installation (<app_id>). A content installation " +
|
|
1442
|
+
"(pci_) has no scaffolded tables — use --keep-content to retain its docs/templates.");
|
|
1443
|
+
}
|
|
1444
|
+
const result = await client.uninstallContentPackage(args.id, {
|
|
1445
|
+
keep_content: args.keep_content,
|
|
1446
|
+
});
|
|
1447
|
+
if (args.keep_content) {
|
|
1448
|
+
console.error(`Uninstalled content installation ${result.installation_id} — its docs and templates were kept as ordinary workspace content.`);
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
console.error(`Uninstalled content installation ${result.installation_id} — archived ` +
|
|
1452
|
+
`${result.archived_doc_ids.length} doc(s) and ${result.archived_template_ids.length} template(s).`);
|
|
1453
|
+
for (const docId of result.archived_doc_ids)
|
|
1454
|
+
console.error(` ${docId}`);
|
|
1455
|
+
for (const templateId of result.archived_template_ids)
|
|
1456
|
+
console.error(` ${templateId}`);
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (args.keep_content) {
|
|
1461
|
+
throw new Error("--keep-content applies only to a standalone content installation (pci_). An app installation " +
|
|
1462
|
+
"(<app_id>) uses --archive-tables to also archive its scaffolded tables.");
|
|
1463
|
+
}
|
|
1464
|
+
const app = await client.getApp(args.id);
|
|
1465
|
+
if (!app.package_id) {
|
|
1466
|
+
throw new Error(`App ${args.id} is not a package installation — use the app delete flow for a bespoke app.`);
|
|
1467
|
+
}
|
|
1468
|
+
const lifecycleCount = Object.keys(app.binding?.workflows ?? {}).length;
|
|
1469
|
+
const boundCount = Object.keys(app.workflows ?? {}).length;
|
|
1470
|
+
const tableCount = Object.keys(app.binding?.entities ?? {}).length;
|
|
1471
|
+
console.error(`Uninstalling ${app.name} (${app.id}):`);
|
|
1472
|
+
console.error(` Archives ${boundCount + lifecycleCount} workflow(s) (${boundCount} app, ${lifecycleCount} lifecycle).`);
|
|
1473
|
+
console.error(args.archive_tables
|
|
1474
|
+
? ` Archives ${tableCount} scaffolded table(s) — refused if they were adopted or are still referenced.`
|
|
1475
|
+
: ` Leaves the data model (${tableCount} table(s)) intact. Pass --archive-tables to also archive them.`);
|
|
1476
|
+
const result = await client.uninstallAppPackage(args.id, {
|
|
1477
|
+
archive_tables: args.archive_tables,
|
|
1478
|
+
});
|
|
1479
|
+
console.error(`Uninstalled ${app.name} (${app.id}).`);
|
|
1480
|
+
if (result.archived_table_ids.length > 0) {
|
|
1481
|
+
console.error(` Archived tables: ${result.archived_table_ids.join(", ")}`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* `lotics package list-content` — list the selected workspace's package-managed
|
|
1486
|
+
* content installations (standalone `pci_` corpora + app-bundled ones), each
|
|
1487
|
+
* with its registry status. The read surface that surfaces a standalone `pci_` id
|
|
1488
|
+
* for `upgrade` / `uninstall` (install prints it once; nothing else did before).
|
|
1489
|
+
*/
|
|
1490
|
+
export async function packageListContent(client) {
|
|
1491
|
+
const workspaceId = client.getWorkspaceId();
|
|
1492
|
+
if (!workspaceId) {
|
|
1493
|
+
throw new Error("No workspace selected. Pass --workspace <ws> (or select one) to list its content installations.");
|
|
1494
|
+
}
|
|
1495
|
+
const installations = await client.listContentInstallations(workspaceId);
|
|
1496
|
+
if (installations.length === 0) {
|
|
1497
|
+
console.error(`No package-managed content installations in workspace ${workspaceId}.`);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
console.error(`Content installations in workspace ${workspaceId} (${installations.length}):`);
|
|
1501
|
+
for (const inst of installations) {
|
|
1502
|
+
const name = inst.package_registry?.name ?? "(unknown package)";
|
|
1503
|
+
const latest = inst.package_registry?.latest_version;
|
|
1504
|
+
const updateAvailable = inst.package_registry?.update_available ?? false;
|
|
1505
|
+
const source = inst.app_id === null ? "standalone" : `app-bundled (${inst.app_id})`;
|
|
1506
|
+
const versionLabel = latest !== undefined && latest !== inst.package_version
|
|
1507
|
+
? `v${inst.package_version} → latest v${latest}`
|
|
1508
|
+
: `v${inst.package_version}`;
|
|
1509
|
+
console.error(` ${inst.id} ${name} ${versionLabel} ${source}` +
|
|
1510
|
+
(updateAvailable ? " → update available" : ""));
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
748
1513
|
export async function packageEject(client, args) {
|
|
749
|
-
const app = await client.
|
|
1514
|
+
const app = await client.ejectPackage(args.app_id);
|
|
750
1515
|
console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
|
|
751
1516
|
console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
|
|
752
1517
|
console.error(" Its data model, queries, workflows, and templates are unchanged.");
|
|
753
1518
|
console.error(" Pull the pinned source for local editing: lotics app pull " + app.id);
|
|
754
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Parse one `key=value` config assignment. When the knob's type is known (from
|
|
1522
|
+
* the stored value at `package config --set`) the value is parsed to that type
|
|
1523
|
+
* loudly; otherwise (`package install --config`) it is inferred (true/false →
|
|
1524
|
+
* boolean, numeric → number, else string) and the server validates it against
|
|
1525
|
+
* the contract.
|
|
1526
|
+
*/
|
|
1527
|
+
function parseConfigAssignment(entry, flag, knownType) {
|
|
1528
|
+
const eq = entry.indexOf("=");
|
|
1529
|
+
if (eq <= 0 || eq === entry.length - 1) {
|
|
1530
|
+
throw new Error(`Invalid ${flag} "${entry}" — expected key=value.`);
|
|
1531
|
+
}
|
|
1532
|
+
const key = entry.slice(0, eq);
|
|
1533
|
+
const raw = entry.slice(eq + 1);
|
|
1534
|
+
if (knownType === "number") {
|
|
1535
|
+
const n = Number(raw);
|
|
1536
|
+
if (!Number.isFinite(n))
|
|
1537
|
+
throw new Error(`Config key "${key}" is a number knob — "${raw}" is not numeric.`);
|
|
1538
|
+
return { key, value: n };
|
|
1539
|
+
}
|
|
1540
|
+
if (knownType === "boolean") {
|
|
1541
|
+
if (raw !== "true" && raw !== "false") {
|
|
1542
|
+
throw new Error(`Config key "${key}" is a boolean knob — expected true or false, got "${raw}".`);
|
|
1543
|
+
}
|
|
1544
|
+
return { key, value: raw === "true" };
|
|
1545
|
+
}
|
|
1546
|
+
if (knownType === "string")
|
|
1547
|
+
return { key, value: raw };
|
|
1548
|
+
// Inferred (install override): the server re-validates against the contract.
|
|
1549
|
+
if (raw === "true" || raw === "false")
|
|
1550
|
+
return { key, value: raw === "true" };
|
|
1551
|
+
if (/^-?\d+(\.\d+)?$/.test(raw))
|
|
1552
|
+
return { key, value: Number(raw) };
|
|
1553
|
+
return { key, value: raw };
|
|
1554
|
+
}
|
|
1555
|
+
/** `--config key=value` (install): inferred types, server-validated. */
|
|
1556
|
+
export function parseInstallConfigFlags(config) {
|
|
1557
|
+
const out = {};
|
|
1558
|
+
for (const entry of config) {
|
|
1559
|
+
const { key, value } = parseConfigAssignment(entry, "--config");
|
|
1560
|
+
out[key] = value;
|
|
1561
|
+
}
|
|
1562
|
+
return out;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* `lotics package config <app_id>` — show the installation's effective config;
|
|
1566
|
+
* with `--set key=value` (repeatable) partial-merge edits, each value parsed by
|
|
1567
|
+
* the knob's current type. No `--set` prints the values.
|
|
1568
|
+
*/
|
|
1569
|
+
export async function packageConfig(client, args) {
|
|
1570
|
+
const app = await client.getApp(args.app_id);
|
|
1571
|
+
if (!app.package_id) {
|
|
1572
|
+
throw new Error(`App ${args.app_id} is not a package installation — it has no config.`);
|
|
1573
|
+
}
|
|
1574
|
+
const current = app.config ?? {};
|
|
1575
|
+
if (args.sets.length === 0) {
|
|
1576
|
+
const keys = Object.keys(current).sort();
|
|
1577
|
+
if (keys.length === 0) {
|
|
1578
|
+
console.error(`${app.name} (${app.id}) — no config knobs.`);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
console.error(`${app.name} (${app.id}) config:`);
|
|
1582
|
+
for (const key of keys)
|
|
1583
|
+
console.log(` ${key} = ${JSON.stringify(current[key])}`);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
const overrides = {};
|
|
1587
|
+
for (const entry of args.sets) {
|
|
1588
|
+
const key = entry.slice(0, Math.max(0, entry.indexOf("=")));
|
|
1589
|
+
const knownType = typeof current[key];
|
|
1590
|
+
const { value } = parseConfigAssignment(entry, "--set", knownType === "number" || knownType === "boolean" || knownType === "string" ? knownType : undefined);
|
|
1591
|
+
overrides[key] = value;
|
|
1592
|
+
}
|
|
1593
|
+
const { config } = await client.updateAppPackageConfig(args.app_id, { config: overrides });
|
|
1594
|
+
console.error(`Updated config for ${app.name} (${app.id}):`);
|
|
1595
|
+
for (const key of Object.keys(config).sort()) {
|
|
1596
|
+
const marker = key in overrides ? " *" : "";
|
|
1597
|
+
console.log(` ${key} = ${JSON.stringify(config[key])}${marker}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* `lotics package retire <package_id> [--undo]` — retire (or un-retire) a
|
|
1602
|
+
* registry package. Owner-org admin-only.
|
|
1603
|
+
*/
|
|
1604
|
+
export async function packageRetire(client, args) {
|
|
1605
|
+
const pkg = await client.retirePackage(args.package_id, { undo: args.undo });
|
|
1606
|
+
if (pkg.retired_at !== null) {
|
|
1607
|
+
console.error(`Retired ${pkg.name} (${pkg.id}). New installs refuse it and it is hidden from other orgs; ` +
|
|
1608
|
+
`existing installations keep working and may still upgrade.`);
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
console.error(`Un-retired ${pkg.name} (${pkg.id}) — installable again.`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
755
1614
|
/**
|
|
756
1615
|
* `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
|
|
757
|
-
* package project (docs/
|
|
1616
|
+
* package project (docs/packages.md § Promotion). Calls the extract read,
|
|
758
1617
|
* prints the findings report grouped by severity, then ALWAYS emits the draft
|
|
759
1618
|
* project (a broken contract is still the reviewable starting point): the app's
|
|
760
1619
|
* current source archive (same mechanics as `lotics app pull`) with the app
|
|
@@ -764,28 +1623,57 @@ export async function packageEject(client, args) {
|
|
|
764
1623
|
* Exits non-zero when any `error` finding exists — the draft is written, but
|
|
765
1624
|
* publish re-validates and nothing should ship unreviewed.
|
|
766
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).
|
|
1631
|
+
*/
|
|
1632
|
+
function readAppKnowledgeDeclaration(appPkgJson) {
|
|
1633
|
+
const lotics = isPlainObject(appPkgJson.lotics) ? appPkgJson.lotics : {};
|
|
1634
|
+
const knowledge = [];
|
|
1635
|
+
if (Array.isArray(lotics.knowledge)) {
|
|
1636
|
+
for (const entry of lotics.knowledge) {
|
|
1637
|
+
if (isPlainObject(entry) && typeof entry.alias === "string" && typeof entry.doc_id === "string") {
|
|
1638
|
+
knowledge.push({ alias: entry.alias, doc_id: entry.doc_id });
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
const knowledge_expects = Array.isArray(lotics.knowledge_expects)
|
|
1643
|
+
? lotics.knowledge_expects.filter((v) => typeof v === "string")
|
|
1644
|
+
: [];
|
|
1645
|
+
return { knowledge, knowledge_expects };
|
|
1646
|
+
}
|
|
1647
|
+
/** Derive the package project's `lotics.package.knowledge` metadata from the extracted contract's knowledge namespace. */
|
|
1648
|
+
function knowledgeManifestFromContract(contract) {
|
|
1649
|
+
const out = {};
|
|
1650
|
+
if (!isPlainObject(contract) || !isPlainObject(contract.knowledge))
|
|
1651
|
+
return out;
|
|
1652
|
+
for (const [alias, entry] of Object.entries(contract.knowledge)) {
|
|
1653
|
+
if (!isPlainObject(entry) || typeof entry.name !== "string")
|
|
1654
|
+
continue;
|
|
1655
|
+
out[alias] = {
|
|
1656
|
+
name: entry.name,
|
|
1657
|
+
description: typeof entry.description === "string" ? entry.description : null,
|
|
1658
|
+
active_by_default: typeof entry.active_by_default === "boolean" ? entry.active_by_default : true,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
return out;
|
|
1662
|
+
}
|
|
767
1663
|
export async function packageExtract(client, args) {
|
|
768
1664
|
const app = await client.getApp(args.app_id);
|
|
769
1665
|
if (!app.current_version_id) {
|
|
770
1666
|
throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
|
|
771
1667
|
}
|
|
772
|
-
const extracted = await client.extractAppPackage(args.app_id);
|
|
773
|
-
const { lines, hasError } = formatExtractReport(extracted.report);
|
|
774
|
-
if (lines.length > 0) {
|
|
775
|
-
console.error(`Extraction report (${extracted.report.length}):`);
|
|
776
|
-
for (const line of lines)
|
|
777
|
-
console.error(line);
|
|
778
|
-
}
|
|
779
|
-
else {
|
|
780
|
-
console.error("Extraction report: no findings.");
|
|
781
|
-
}
|
|
782
1668
|
const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
|
|
783
1669
|
if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
|
|
784
1670
|
throw new Error(`Target directory ${targetPath} is not empty.`);
|
|
785
1671
|
}
|
|
786
1672
|
fs.mkdirSync(targetPath, { recursive: true });
|
|
787
|
-
// Pull the app's current source archive — same mechanics as `lotics app
|
|
788
|
-
// (getAppVersion → presigned source URL → download → untar
|
|
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.
|
|
789
1677
|
const version = await client.getAppVersion(app.id, app.current_version_id);
|
|
790
1678
|
const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
|
|
791
1679
|
const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
|
|
@@ -799,10 +1687,31 @@ export async function packageExtract(client, args) {
|
|
|
799
1687
|
if (fs.existsSync(tmpFile))
|
|
800
1688
|
fs.unlinkSync(tmpFile);
|
|
801
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
|
+
if (lines.length > 0) {
|
|
1700
|
+
console.error(`Extraction report (${extracted.report.length}):`);
|
|
1701
|
+
for (const line of lines)
|
|
1702
|
+
console.error(line);
|
|
1703
|
+
}
|
|
1704
|
+
else {
|
|
1705
|
+
console.error("Extraction report: no findings.");
|
|
1706
|
+
}
|
|
802
1707
|
// Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
|
|
803
1708
|
// package manifest — atomic write via the existing manifest writer.
|
|
804
|
-
const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
|
|
805
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;
|
|
806
1715
|
// The origin app's package.json rides in the source archive with ITS OWN
|
|
807
1716
|
// @lotics/app-sdk range — raise it to the getAppBinding floor the generated
|
|
808
1717
|
// .lotics/app_fields.ts needs (never lower an already-newer range).
|
|
@@ -865,11 +1774,26 @@ export async function packageExtract(client, args) {
|
|
|
865
1774
|
fs.renameSync(downloaded, dest);
|
|
866
1775
|
console.error(`Wrote ${tf.bytes_ref}`);
|
|
867
1776
|
}
|
|
868
|
-
//
|
|
869
|
-
//
|
|
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.
|
|
870
1789
|
const dotLotics = path.join(targetPath, ".lotics");
|
|
871
1790
|
fs.mkdirSync(dotLotics, { recursive: true });
|
|
872
|
-
fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({
|
|
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");
|
|
873
1797
|
console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
|
|
874
1798
|
console.error("Installing npm dependencies...");
|
|
875
1799
|
await runNpm(["install"], targetPath);
|
|
@@ -887,7 +1811,7 @@ export async function packageExtract(client, args) {
|
|
|
887
1811
|
}
|
|
888
1812
|
/**
|
|
889
1813
|
* `lotics package adopt <app_id> [path]` — bind the published package project
|
|
890
|
-
* onto the origin app (docs/
|
|
1814
|
+
* onto the origin app (docs/packages.md § Promotion). Reads the project
|
|
891
1815
|
* manifest (must be published — refuses otherwise), resolves the version
|
|
892
1816
|
* (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
|
|
893
1817
|
* REFUSING a pin recorded for a different app. On success the app becomes
|
|
@@ -910,10 +1834,15 @@ export async function packageAdopt(client, args) {
|
|
|
910
1834
|
`"lotics package extract" recorded — run extract to produce this project.`);
|
|
911
1835
|
}
|
|
912
1836
|
const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
|
|
913
|
-
const app = await client.
|
|
1837
|
+
const app = await client.adoptPackage(args.app_id, {
|
|
914
1838
|
package_id: manifest.id,
|
|
915
1839
|
version,
|
|
916
1840
|
binding: pin.binding,
|
|
1841
|
+
// The origin knowledge binding (empty when the app owns no package-managed
|
|
1842
|
+
// docs) — the server verifies it maps the contract's knowledge aliases faithfully.
|
|
1843
|
+
...(Object.keys(pin.knowledge_binding).length > 0
|
|
1844
|
+
? { knowledge_binding: pin.knowledge_binding }
|
|
1845
|
+
: {}),
|
|
917
1846
|
});
|
|
918
1847
|
console.error(`Adopted ${app.name} → installation of ${app.package_id} v${app.package_version}.`);
|
|
919
1848
|
console.error(` Verify the installation: lotics package doctor ${app.id}`);
|
|
@@ -932,7 +1861,7 @@ export async function packageAdopt(client, args) {
|
|
|
932
1861
|
* running. Owner-org admin-only.
|
|
933
1862
|
*/
|
|
934
1863
|
export async function packageYank(client, args) {
|
|
935
|
-
const result = await client.
|
|
1864
|
+
const result = await client.yankPackageVersion(args.package_id, args.version, !args.undo);
|
|
936
1865
|
if (result.yanked_at !== null) {
|
|
937
1866
|
console.error(`Yanked ${result.package_id} v${result.version} (${result.yanked_at}). ` +
|
|
938
1867
|
`New installs/upgrades refuse it; pinned installations keep running.`);
|
|
@@ -943,7 +1872,7 @@ export async function packageYank(client, args) {
|
|
|
943
1872
|
console.error(` Latest installable version: ${result.latest_version === 0 ? "none" : `v${result.latest_version}`}`);
|
|
944
1873
|
}
|
|
945
1874
|
export async function packageFleetUpgrade(client, args) {
|
|
946
|
-
const result = await client.
|
|
1875
|
+
const result = await client.fleetUpgradePackage(args.package_id, {
|
|
947
1876
|
...(args.version !== undefined ? { version: args.version } : {}),
|
|
948
1877
|
});
|
|
949
1878
|
console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);
|