@lotics/cli 0.86.0 → 0.86.2
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/dist/cli.js +2 -2
- package/dist/client.d.ts +22 -1
- package/dist/client.js +6 -0
- package/dist/dev/file_relay.d.ts +39 -0
- package/dist/dev/file_relay.js +87 -0
- package/dist/dev/file_relay.test.d.ts +1 -0
- package/dist/dev/file_relay.test.js +87 -0
- package/dist/dev/server.d.ts +21 -3
- package/dist/dev/server.js +175 -5
- package/dist/dev/upload_relay.d.ts +36 -0
- package/dist/dev/upload_relay.js +61 -0
- package/dist/dev/upload_relay.test.d.ts +1 -0
- package/dist/dev/upload_relay.test.js +76 -0
- package/dist/dev/wrapper_page.js +7 -1
- package/dist/package_commands.d.ts +15 -8
- package/dist/package_commands.js +111 -22
- package/dist/package_commands.test.js +131 -25
- package/dist/src/cli.js +304 -12
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
|
|
3
|
+
*
|
|
4
|
+
* Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
|
|
5
|
+
* holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
|
|
6
|
+
* A browser PUT straight to the presigned URL is blocked before it leaves the page,
|
|
7
|
+
* so without a relay no file-touching app can be exercised locally at all.
|
|
8
|
+
*
|
|
9
|
+
* The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
|
|
10
|
+
* preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
|
|
11
|
+
*
|
|
12
|
+
* The security property, and the reason the page never names its destination: a
|
|
13
|
+
* relay that forwarded to a client-supplied URL would be an open proxy. So the page
|
|
14
|
+
* sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
|
|
15
|
+
* itself for that id, moments earlier, via its own authenticated API call. There is
|
|
16
|
+
* no client-controlled target — hence nothing to allowlist, and no SSRF surface.
|
|
17
|
+
*
|
|
18
|
+
* Production is untouched: it PUTs direct-to-storage, keeping every byte off the
|
|
19
|
+
* API server.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* How long a mint is worth keeping. This is a pruning window, not a correctness gate —
|
|
23
|
+
* storage is the authority on whether a presign is still valid (an expired one is refused
|
|
24
|
+
* there, and the wrapper surfaces that). It only needs to outlive the presign (~10 min) so
|
|
25
|
+
* the relay never 404s a PUT that storage would still have accepted.
|
|
26
|
+
*/
|
|
27
|
+
const PRESIGN_TTL_MS = 15 * 60 * 1000;
|
|
28
|
+
export function createUploadRelay(now = Date.now) {
|
|
29
|
+
const minted = new Map();
|
|
30
|
+
const prune = (at) => {
|
|
31
|
+
for (const [id, entry] of minted) {
|
|
32
|
+
if (at - entry.mintedAt > PRESIGN_TTL_MS)
|
|
33
|
+
minted.delete(id);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
rewriteMint(result) {
|
|
38
|
+
const mint = result;
|
|
39
|
+
if (typeof mint?.file_id !== "string" || typeof mint?.upload_url !== "string")
|
|
40
|
+
return result;
|
|
41
|
+
const at = now();
|
|
42
|
+
prune(at);
|
|
43
|
+
minted.set(mint.file_id, { url: mint.upload_url, mintedAt: at });
|
|
44
|
+
return { ...mint, upload_url: `/_upload/${encodeURIComponent(mint.file_id)}` };
|
|
45
|
+
},
|
|
46
|
+
destinationFor(fileId) {
|
|
47
|
+
const entry = minted.get(fileId);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return null;
|
|
50
|
+
if (now() - entry.mintedAt > PRESIGN_TTL_MS) {
|
|
51
|
+
minted.delete(fileId);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return entry.url;
|
|
55
|
+
},
|
|
56
|
+
settle(fileId) {
|
|
57
|
+
minted.delete(fileId);
|
|
58
|
+
},
|
|
59
|
+
size: () => minted.size,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createUploadRelay } from "./upload_relay.js";
|
|
3
|
+
const MINT = {
|
|
4
|
+
file_id: "fil_abc",
|
|
5
|
+
file_storage_key: "org/files/fil_abc/photo.jpg",
|
|
6
|
+
upload_url: "https://production.r2.cloudflarestorage.com/org/files/fil_abc/photo.jpg?X-Amz-Signature=deadbeef",
|
|
7
|
+
};
|
|
8
|
+
describe("upload relay — the page PUTs same-origin", () => {
|
|
9
|
+
it("hands the page a same-origin URL and keeps everything else it needs to finalize", () => {
|
|
10
|
+
const relay = createUploadRelay();
|
|
11
|
+
const seen = relay.rewriteMint(MINT);
|
|
12
|
+
expect(seen.upload_url).toBe("/_upload/fil_abc");
|
|
13
|
+
// file_storage_key + file_id still reach the page — `upload_complete` needs both.
|
|
14
|
+
expect(seen.file_id).toBe("fil_abc");
|
|
15
|
+
expect(seen.file_storage_key).toBe(MINT.file_storage_key);
|
|
16
|
+
});
|
|
17
|
+
it("does not mutate the mint it was handed", () => {
|
|
18
|
+
const relay = createUploadRelay();
|
|
19
|
+
relay.rewriteMint(MINT);
|
|
20
|
+
expect(MINT.upload_url).toContain("r2.cloudflarestorage.com");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe("upload relay — it writes ONLY where it was told to by itself", () => {
|
|
24
|
+
it("relays to the presigned URL it minted for that id", () => {
|
|
25
|
+
const relay = createUploadRelay();
|
|
26
|
+
relay.rewriteMint(MINT);
|
|
27
|
+
expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
|
|
28
|
+
});
|
|
29
|
+
it("refuses an id it never minted — there is no client-supplied destination to honour", () => {
|
|
30
|
+
const relay = createUploadRelay();
|
|
31
|
+
expect(relay.destinationFor("fil_never_seen")).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
it("refuses a mint that has aged past its presign", () => {
|
|
34
|
+
let clock = 1_000_000;
|
|
35
|
+
const relay = createUploadRelay(() => clock);
|
|
36
|
+
relay.rewriteMint(MINT);
|
|
37
|
+
clock += 16 * 60 * 1000; // presigns die at ~10 min; the relay holds 15
|
|
38
|
+
expect(relay.destinationFor("fil_abc")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("upload relay — lifecycle", () => {
|
|
42
|
+
it("forgets a mint once its bytes have landed", () => {
|
|
43
|
+
const relay = createUploadRelay();
|
|
44
|
+
relay.rewriteMint(MINT);
|
|
45
|
+
relay.settle("fil_abc");
|
|
46
|
+
expect(relay.destinationFor("fil_abc")).toBeNull();
|
|
47
|
+
expect(relay.size()).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it("keeps the mint on a failed PUT so the page's retry can reuse it", () => {
|
|
50
|
+
const relay = createUploadRelay();
|
|
51
|
+
relay.rewriteMint(MINT);
|
|
52
|
+
// No settle() — the storage PUT 5xx'd and the wrapper page retries.
|
|
53
|
+
expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
|
|
54
|
+
});
|
|
55
|
+
it("prunes dead mints instead of growing forever across a long dev session", () => {
|
|
56
|
+
let clock = 0;
|
|
57
|
+
const relay = createUploadRelay(() => clock);
|
|
58
|
+
relay.rewriteMint({ ...MINT, file_id: "fil_old" });
|
|
59
|
+
clock += 16 * 60 * 1000;
|
|
60
|
+
relay.rewriteMint({ ...MINT, file_id: "fil_new" });
|
|
61
|
+
expect(relay.size()).toBe(1); // the stale one was swept on the next mint
|
|
62
|
+
expect(relay.destinationFor("fil_new")).toBe(MINT.upload_url);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("upload relay — a response that carries no mint", () => {
|
|
66
|
+
it("passes through untouched rather than inventing a relay for it", () => {
|
|
67
|
+
const relay = createUploadRelay();
|
|
68
|
+
const odd = { message: "upstream changed shape" };
|
|
69
|
+
expect(relay.rewriteMint(odd)).toBe(odd);
|
|
70
|
+
expect(relay.size()).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
it("tolerates null without throwing", () => {
|
|
73
|
+
const relay = createUploadRelay();
|
|
74
|
+
expect(relay.rewriteMint(null)).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -120,7 +120,13 @@ export function buildWrapperPage(args) {
|
|
|
120
120
|
|
|
121
121
|
// The iframe SDK sends one "upload" op carrying a File. A File can't
|
|
122
122
|
// cross the JSON /_rpc hop, so the upload runs here in the browser:
|
|
123
|
-
// mint
|
|
123
|
+
// mint an upload URL, PUT the bytes, then finalize.
|
|
124
|
+
//
|
|
125
|
+
// In dev the minted URL is same-origin (/_upload/<file_id>) — the dev
|
|
126
|
+
// server relays the bytes to storage on our behalf, because the prod
|
|
127
|
+
// bucket's CORS does not admit a localhost origin. Production PUTs the
|
|
128
|
+
// presigned storage URL directly. Same three lines either way: the page
|
|
129
|
+
// PUTs wherever the mint points.
|
|
124
130
|
//
|
|
125
131
|
// Retry policy parity with the production SDK
|
|
126
132
|
// (packages/app-sdk/src/upload/transport.ts): 3 PUT attempts with
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { LoticsClient, type ExtractFinding } from "./client.js";
|
|
2
|
-
import { type UpgradeResolutions } from "@lotics/shared/schemas/packages";
|
|
2
|
+
import { type ContractConfigEntry, type UpgradeResolutions } from "@lotics/shared/schemas/packages";
|
|
3
3
|
/**
|
|
4
4
|
* Read the local APP project's manifest (`package.json#lotics.app_id` +
|
|
5
|
-
* `lotics.knowledge`) — what `lotics app pull` writes.
|
|
6
|
-
* `release` run from a pulled app project resolve the app
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* `lotics.knowledge` + `lotics.config`) — what `lotics app pull` writes.
|
|
6
|
+
* `lotics app publish` / `release` run from a pulled app project resolve the app
|
|
7
|
+
* id from it and forward the package-managed knowledge declaration AND the config
|
|
8
|
+
* knob declarations to the server — both are declarations extract cannot invert
|
|
9
|
+
* from artifacts (agents reference docs by free text; a config knob has no concrete
|
|
10
|
+
* artifact), so the author declares them. Each `lotics.config` entry is validated
|
|
11
|
+
* against the shared `contractConfigEntrySchema` so a malformed knob fails LOUDLY
|
|
12
|
+
* client-side naming the alias, never silently dropped. Returns null when the dir
|
|
13
|
+
* has no package.json. `app_id` is null when the manifest is a package/non-app
|
|
14
|
+
* project.
|
|
11
15
|
*/
|
|
12
16
|
export declare function readLocalAppManifest(projectDir: string): {
|
|
13
17
|
app_id: string | null;
|
|
@@ -15,6 +19,7 @@ export declare function readLocalAppManifest(projectDir: string): {
|
|
|
15
19
|
alias: string;
|
|
16
20
|
doc_id: string;
|
|
17
21
|
}>;
|
|
22
|
+
config: ContractConfigEntry[];
|
|
18
23
|
} | null;
|
|
19
24
|
/** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
|
|
20
25
|
export declare function parseRenameFlags(renames: string[]): Array<{
|
|
@@ -43,7 +48,9 @@ export declare function formatExtractReport(report: ExtractFinding[]): {
|
|
|
43
48
|
export declare function parseResolveFlags(resolve: string[]): UpgradeResolutions;
|
|
44
49
|
/**
|
|
45
50
|
* Health check: version pin vs. registry latest + binding drift. Exits
|
|
46
|
-
* non-zero when drift is found so scripts can gate on it.
|
|
51
|
+
* non-zero when drift is found so scripts can gate on it. An `apg_` PACKAGE id
|
|
52
|
+
* resolves THIS workspace's standalone content installation — the same
|
|
53
|
+
* package-id addressing `upgrade`/`uninstall` speak.
|
|
47
54
|
*/
|
|
48
55
|
export declare function packageDoctor(client: LoticsClient, args: {
|
|
49
56
|
app_id?: string;
|
package/dist/package_commands.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import "./client.js";
|
|
24
|
-
import { knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
|
|
24
|
+
import { contractConfigEntrySchema, knowledgeEntryNeedsConsent, validKnowledgeResolutions, } from "@lotics/shared/schemas/packages";
|
|
25
25
|
function packageJsonPath(projectDir) {
|
|
26
26
|
return path.join(projectDir, "package.json");
|
|
27
27
|
}
|
|
@@ -30,12 +30,16 @@ function isPlainObject(value) {
|
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* Read the local APP project's manifest (`package.json#lotics.app_id` +
|
|
33
|
-
* `lotics.knowledge`) — what `lotics app pull` writes.
|
|
34
|
-
* `release` run from a pulled app project resolve the app
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* `lotics.knowledge` + `lotics.config`) — what `lotics app pull` writes.
|
|
34
|
+
* `lotics app publish` / `release` run from a pulled app project resolve the app
|
|
35
|
+
* id from it and forward the package-managed knowledge declaration AND the config
|
|
36
|
+
* knob declarations to the server — both are declarations extract cannot invert
|
|
37
|
+
* from artifacts (agents reference docs by free text; a config knob has no concrete
|
|
38
|
+
* artifact), so the author declares them. Each `lotics.config` entry is validated
|
|
39
|
+
* against the shared `contractConfigEntrySchema` so a malformed knob fails LOUDLY
|
|
40
|
+
* client-side naming the alias, never silently dropped. Returns null when the dir
|
|
41
|
+
* has no package.json. `app_id` is null when the manifest is a package/non-app
|
|
42
|
+
* project.
|
|
39
43
|
*/
|
|
40
44
|
export function readLocalAppManifest(projectDir) {
|
|
41
45
|
const pkgPath = packageJsonPath(projectDir);
|
|
@@ -54,7 +58,19 @@ export function readLocalAppManifest(projectDir) {
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
|
-
|
|
61
|
+
const config = [];
|
|
62
|
+
if (Array.isArray(lotics.config)) {
|
|
63
|
+
for (const entry of lotics.config) {
|
|
64
|
+
const result = contractConfigEntrySchema.safeParse(entry);
|
|
65
|
+
if (!result.success) {
|
|
66
|
+
const alias = isPlainObject(entry) && typeof entry.alias === "string" ? entry.alias : "?";
|
|
67
|
+
throw new Error(`Invalid lotics.config knob "${alias}" in package.json: ` +
|
|
68
|
+
result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; "));
|
|
69
|
+
}
|
|
70
|
+
config.push(result.data);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { app_id, knowledge, config };
|
|
58
74
|
}
|
|
59
75
|
/** Parse repeated `--rename old=new` flags into `{ from, to }[]` (first-publish alias fixes). */
|
|
60
76
|
export function parseRenameFlags(renames) {
|
|
@@ -115,9 +131,15 @@ export function parseResolveFlags(resolve) {
|
|
|
115
131
|
}
|
|
116
132
|
/**
|
|
117
133
|
* Health check: version pin vs. registry latest + binding drift. Exits
|
|
118
|
-
* non-zero when drift is found so scripts can gate on it.
|
|
134
|
+
* non-zero when drift is found so scripts can gate on it. An `apg_` PACKAGE id
|
|
135
|
+
* resolves THIS workspace's standalone content installation — the same
|
|
136
|
+
* package-id addressing `upgrade`/`uninstall` speak.
|
|
119
137
|
*/
|
|
120
138
|
export async function packageDoctor(client, args) {
|
|
139
|
+
if (args.app_id !== undefined && args.app_id.startsWith("apg_")) {
|
|
140
|
+
await contentDoctor(client, args.app_id);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
121
143
|
const app_id = resolveInstallationAppId(args.app_id);
|
|
122
144
|
const health = await client.getPackageHealth(app_id);
|
|
123
145
|
console.error(`${health.package_name} — installation ${health.app_id}` +
|
|
@@ -376,6 +398,47 @@ function formatTemplateResolveHint(entry) {
|
|
|
376
398
|
: " (a local edit — revert overwrites it with the package's version; keep retains it)";
|
|
377
399
|
return ` --resolve template.${entry.alias}=revert|keep${note}`;
|
|
378
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Health report for a STANDALONE content installation, addressed by PACKAGE id.
|
|
403
|
+
* Composed client-side from the surfaces the consumer verbs already speak: the
|
|
404
|
+
* list row (pin vs registry latest) and the upgrade preview against latest
|
|
405
|
+
* (which carries per-alias drift + local-edit detection even when the pin is
|
|
406
|
+
* current — the repair view). Exits non-zero on drift, mirroring the app
|
|
407
|
+
* doctor's gate semantics.
|
|
408
|
+
*/
|
|
409
|
+
async function contentDoctor(client, package_id) {
|
|
410
|
+
const installation = await resolveWorkspaceContentInstallation(client, package_id);
|
|
411
|
+
const preview = await client.previewContentInstallationUpgrade(installation.id, {});
|
|
412
|
+
const name = installation.package_registry?.name ?? package_id;
|
|
413
|
+
console.error(`${name} — content installation of ${package_id} (this workspace)`);
|
|
414
|
+
console.error(` Installed: v${preview.from_version} Latest: v${preview.to_version}` +
|
|
415
|
+
(preview.to_version > preview.from_version ? " → update available" : ""));
|
|
416
|
+
const drifted = preview.entries.filter((e) => e.change === "drifted");
|
|
417
|
+
if (drifted.length === 0) {
|
|
418
|
+
console.error(" Binding: healthy — every bound doc resolves.");
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
console.error(` Binding drift (${drifted.length}):`);
|
|
422
|
+
for (const entry of drifted) {
|
|
423
|
+
console.error(` - knowledge.${entry.alias} "${entry.name}" (bound doc is gone)`);
|
|
424
|
+
}
|
|
425
|
+
console.error(` Resolve while upgrading:\n lotics upgrade ${package_id}` +
|
|
426
|
+
` --resolve knowledge.<alias>=recreate|unbind (or --bind-to <alias>=<kdc_id> to re-point)`);
|
|
427
|
+
process.exitCode = 1;
|
|
428
|
+
}
|
|
429
|
+
const editedDocs = preview.entries.filter((e) => e.modified);
|
|
430
|
+
const editedTemplates = preview.templates;
|
|
431
|
+
if (editedDocs.length === 0 && editedTemplates.length === 0) {
|
|
432
|
+
console.error(" Content: pristine — no local edits an upgrade would ask about.");
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
console.error(` Local edits (${editedDocs.length + editedTemplates.length}) — each takes a keep/overwrite consent at upgrade:`);
|
|
436
|
+
for (const entry of editedDocs)
|
|
437
|
+
console.error(` - knowledge.${entry.alias} "${entry.name}"`);
|
|
438
|
+
for (const entry of editedTemplates)
|
|
439
|
+
console.error(` - template.${entry.alias}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
379
442
|
/**
|
|
380
443
|
* Resolve a STANDALONE content package's installation in the CURRENT workspace
|
|
381
444
|
* from its PACKAGE id. `UNIQUE (workspace_id, package_id)` means the package id
|
|
@@ -816,16 +879,17 @@ export async function appPublish(client, args) {
|
|
|
816
879
|
throw new Error("No app id. Run `lotics app publish` from a pulled app project (lotics app pull <app_id>), " +
|
|
817
880
|
"or pass one: lotics app publish <app_id>.");
|
|
818
881
|
}
|
|
819
|
-
// Forward the app's package-managed knowledge
|
|
820
|
-
// manifest is this app's own project (
|
|
821
|
-
//
|
|
822
|
-
//
|
|
882
|
+
// Forward the app's package-managed knowledge + config declarations ONLY when the
|
|
883
|
+
// local manifest is this app's own project (both are declarations extract can't
|
|
884
|
+
// invert, so the author declares them). A bare id published from elsewhere ships
|
|
885
|
+
// without them — publish from the app dir to include them.
|
|
823
886
|
const knowledge = local && local.app_id === appId ? local.knowledge : [];
|
|
887
|
+
const config = local && local.app_id === appId ? local.config : [];
|
|
824
888
|
const renames = parseRenameFlags(args.renames);
|
|
825
889
|
// Preview first (GET, no writes) — the same preview→--yes flow as `app release`,
|
|
826
890
|
// so the aliases v1 freezes forever are never a blind publish and `--rename`
|
|
827
891
|
// targets are inspectable before committing.
|
|
828
|
-
const preview = await client.previewPublishAppPackage(appId, { renames, knowledge });
|
|
892
|
+
const preview = await client.previewPublishAppPackage(appId, { renames, knowledge, config });
|
|
829
893
|
const { lines, hasError } = formatExtractReport(preview.findings);
|
|
830
894
|
console.error(`Publish preview — ${appId} as new package "${preview.package_name}" (v1):`);
|
|
831
895
|
const groups = [
|
|
@@ -847,6 +911,9 @@ export async function appPublish(client, args) {
|
|
|
847
911
|
else {
|
|
848
912
|
console.error(" No renamable aliases.");
|
|
849
913
|
}
|
|
914
|
+
if (config.length > 0) {
|
|
915
|
+
console.error(` Config knobs (${config.length}): ${config.map((c) => `${c.alias} (${c.type})`).join(", ")}`);
|
|
916
|
+
}
|
|
850
917
|
if (lines.length > 0) {
|
|
851
918
|
console.error(` Findings (${preview.findings.length}):`);
|
|
852
919
|
for (const line of lines)
|
|
@@ -870,6 +937,7 @@ export async function appPublish(client, args) {
|
|
|
870
937
|
renames,
|
|
871
938
|
changelog: args.changelog ?? null,
|
|
872
939
|
knowledge,
|
|
940
|
+
config,
|
|
873
941
|
});
|
|
874
942
|
console.error(`Published ${result.package_id} v${result.version} from app ${appId}.`);
|
|
875
943
|
console.error(` The app is now installation #1 — develop it in place, then release the next version:`);
|
|
@@ -901,11 +969,12 @@ export async function appRelease(client, args) {
|
|
|
901
969
|
if (appId === null) {
|
|
902
970
|
throw new Error("No app id. Run this from a pulled app project (lotics app pull <app_id>), or pass an app id explicitly.");
|
|
903
971
|
}
|
|
904
|
-
// Forward the manifest's knowledge
|
|
905
|
-
// AND only when
|
|
906
|
-
// (reconstruct from the pin), never "drop
|
|
972
|
+
// Forward the manifest's knowledge + config DECLARATIONS only from this app's own
|
|
973
|
+
// project AND only when non-empty — an empty/absent declaration is "no change"
|
|
974
|
+
// (reconstruct knowledge from the pin / carry config forward), never "drop them".
|
|
907
975
|
const knowledge = local && local.app_id === appId && local.knowledge.length > 0 ? local.knowledge : undefined;
|
|
908
|
-
const
|
|
976
|
+
const config = local && local.app_id === appId && local.config.length > 0 ? local.config : undefined;
|
|
977
|
+
const preview = await client.previewPackageRelease(appId, { knowledge, config });
|
|
909
978
|
const { lines, hasError } = formatExtractReport(preview.findings);
|
|
910
979
|
console.error(`Release preview — ${appId} → ${preview.package_id} v${preview.version}:`);
|
|
911
980
|
if (preview.added_aliases.length > 0) {
|
|
@@ -914,7 +983,15 @@ export async function appRelease(client, args) {
|
|
|
914
983
|
if (preview.changed_artifacts.length > 0) {
|
|
915
984
|
console.error(` Changed (${preview.changed_artifacts.length}): ${preview.changed_artifacts.join(", ")}`);
|
|
916
985
|
}
|
|
917
|
-
// Deploy-skew boundary: a pre-
|
|
986
|
+
// Deploy-skew boundary: a pre-declaration server omits the echo field. Silent
|
|
987
|
+
// skew is dangerous exactly when a declaration was SENT — the server ignored it
|
|
988
|
+
// and the preview would misread as "no changes"; say so loudly, per surface.
|
|
989
|
+
if (preview.knowledge === undefined && knowledge !== undefined) {
|
|
990
|
+
console.error(" WARNING: the server ignored the knowledge declaration (it predates bundled-knowledge releases) — the bundle will NOT change. Retry after the platform deploy completes.");
|
|
991
|
+
}
|
|
992
|
+
if (preview.config === undefined && config !== undefined) {
|
|
993
|
+
console.error(" WARNING: the server ignored the config declaration (it predates manifest config declarations) — the knobs will NOT change. Retry after the platform deploy completes.");
|
|
994
|
+
}
|
|
918
995
|
const k = preview.knowledge ?? { added: [], removed: [], changed: [] };
|
|
919
996
|
if (k.added.length > 0 || k.removed.length > 0 || k.changed.length > 0) {
|
|
920
997
|
const parts = [
|
|
@@ -924,11 +1001,23 @@ export async function appRelease(client, args) {
|
|
|
924
1001
|
].filter((p) => p !== null);
|
|
925
1002
|
console.error(` Knowledge: ${parts.join("; ")}`);
|
|
926
1003
|
}
|
|
1004
|
+
const cfg = preview.config ?? { added: [], removed: [], changed: [] };
|
|
1005
|
+
if (cfg.added.length > 0 || cfg.removed.length > 0 || cfg.changed.length > 0) {
|
|
1006
|
+
const parts = [
|
|
1007
|
+
cfg.added.length > 0 ? `+${cfg.added.join(", ")}` : null,
|
|
1008
|
+
cfg.removed.length > 0 ? `dropped ${cfg.removed.join(", ")}` : null,
|
|
1009
|
+
cfg.changed.length > 0 ? `changed ${cfg.changed.join(", ")}` : null,
|
|
1010
|
+
].filter((p) => p !== null);
|
|
1011
|
+
console.error(` Config: ${parts.join("; ")}`);
|
|
1012
|
+
}
|
|
927
1013
|
if (preview.added_aliases.length === 0 &&
|
|
928
1014
|
preview.changed_artifacts.length === 0 &&
|
|
929
1015
|
k.added.length === 0 &&
|
|
930
1016
|
k.removed.length === 0 &&
|
|
931
|
-
k.changed.length === 0
|
|
1017
|
+
k.changed.length === 0 &&
|
|
1018
|
+
cfg.added.length === 0 &&
|
|
1019
|
+
cfg.removed.length === 0 &&
|
|
1020
|
+
cfg.changed.length === 0) {
|
|
932
1021
|
console.error(" No contract changes since the current version (a fresh code/dist snapshot still ships).");
|
|
933
1022
|
}
|
|
934
1023
|
if (lines.length > 0) {
|
|
@@ -947,7 +1036,7 @@ export async function appRelease(client, args) {
|
|
|
947
1036
|
process.exitCode = 1;
|
|
948
1037
|
return;
|
|
949
1038
|
}
|
|
950
|
-
const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge });
|
|
1039
|
+
const result = await client.releasePackage(appId, { changelog: args.changelog, knowledge, config });
|
|
951
1040
|
console.error(`Released ${result.package_id} v${result.version}.`);
|
|
952
1041
|
console.error(` The origin was re-pinned to v${result.version} — verify: lotics package doctor ${appId}`);
|
|
953
1042
|
}
|
|
@@ -990,7 +1079,7 @@ async function packageFleetUpgrade(client, args) {
|
|
|
990
1079
|
const from = inst.from_version === null ? "?" : `v${inst.from_version}`;
|
|
991
1080
|
const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
|
|
992
1081
|
if (inst.outcome === "skipped" && inst.blockers) {
|
|
993
|
-
console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
|
|
1082
|
+
console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified} knowledge=${inst.blockers.knowledge}`);
|
|
994
1083
|
console.error(` resolve via: lotics upgrade ${inst.app_id} --version ${result.target_version} ...`);
|
|
995
1084
|
}
|
|
996
1085
|
else if (inst.message) {
|