@lotics/cli 0.83.0 → 0.86.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.
@@ -5,11 +5,29 @@
5
5
  * 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
6
6
  * stdio inherited so Vite's own logging surfaces to the developer.
7
7
  * 2. node:http server on <port> serving:
8
- * GET / → wrapper HTML (cached: no)
9
- * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
- * * 404
8
+ * GET / → wrapper HTML (cached: no)
9
+ * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
+ * POST /_agent_run SSE, piped from the run
11
+ * PUT /_upload/:id → file bytes in (relayed to storage)
12
+ * GET /_file/:token → file bytes out (relayed from storage)
13
+ * * → 404
11
14
  *
12
15
  * SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
16
+ *
17
+ * Why the byte relays exist: dev runs against the PROD bucket (there is no dev
18
+ * bucket), whose CORS allowlist holds the real app origins (`https://*.lotics.app`),
19
+ * not `http://localhost:<port>`. So a browser transfer straight to/from the presigned
20
+ * URL is blocked before it leaves the page — no upload could complete, and no preview
21
+ * engine (PDF/Word/Excel all FETCH the bytes) could read a file. Relaying through THIS
22
+ * server fixes both: Node has no same-origin policy, and the one cross-origin hop that
23
+ * remains (the app iframe reading from us) is OUR response to allow.
24
+ *
25
+ * Neither relay ever takes a destination from the client: the page sends a `file_id`
26
+ * or an opaque token, and the server transfers only to/from a URL IT minted or observed
27
+ * for that id. No client-controlled target ⇒ no SSRF surface, nothing to allowlist.
28
+ *
29
+ * Production is untouched — it transfers direct-to-storage, keeping every byte off the
30
+ * API server.
13
31
  */
14
32
  import http from "node:http";
15
33
  import net from "node:net";
@@ -17,6 +35,8 @@ import { spawn } from "node:child_process";
17
35
  import { ipv4ChildEnv } from "../child_env.js";
18
36
  import { dispatchRpc } from "./rpc_handler.js";
19
37
  import { buildWrapperPage } from "./wrapper_page.js";
38
+ import { createUploadRelay } from "./upload_relay.js";
39
+ import { createFileRelay } from "./file_relay.js";
20
40
  const DEFAULT_PORT = 5174;
21
41
  const DEFAULT_VITE_PORT = 5173;
22
42
  /**
@@ -84,6 +104,28 @@ export async function startDevServer(args) {
84
104
  }
85
105
  };
86
106
  process.once("exit", killViteOnExit);
107
+ const uploads = createUploadRelay();
108
+ // The app iframe is served from Vite's origin, so the file URLs it receives must be
109
+ // absolute against THIS server, and its byte reads are cross-origin to us — which is
110
+ // ours to allow (unlike the storage bucket's policy, which is not).
111
+ const wrapperOrigin = `http://localhost:${wrapperPort}`;
112
+ const viteOrigin = `http://localhost:${vitePort}`;
113
+ const files = createFileRelay(wrapperOrigin);
114
+ const fileCors = {
115
+ "Access-Control-Allow-Origin": viteOrigin,
116
+ "Access-Control-Allow-Headers": "range, content-type",
117
+ "Access-Control-Expose-Headers": "content-length, content-range, accept-ranges, content-type, content-disposition, etag",
118
+ };
119
+ // Headers a byte-reader actually needs: the type to decode, the length/range to seek.
120
+ const PASS_THROUGH = [
121
+ "content-type",
122
+ "content-length",
123
+ "content-range",
124
+ "accept-ranges",
125
+ "etag",
126
+ "last-modified",
127
+ "content-disposition",
128
+ ];
87
129
  // ── HTTP server ────────────────────────────────────────────────────────
88
130
  const server = http.createServer(async (req, res) => {
89
131
  const url = req.url ?? "/";
@@ -110,7 +152,9 @@ export async function startDevServer(args) {
110
152
  const ms = Date.now() - startedAt;
111
153
  process.stderr.write(`[rpc] ${body.op} ${ms}ms\n`);
112
154
  res.writeHead(200, { "Content-Type": "application/json" });
113
- res.end(serializeRpcResult(result));
155
+ // `upload_url` mints a PUT destination (upload relay); every other op may carry
156
+ // presigned file URLs the app will read bytes from (file relay).
157
+ res.end(serializeRpcResult(body.op === "upload_url" ? uploads.rewriteMint(result) : files.rewrite(result)));
114
158
  }
115
159
  catch (err) {
116
160
  const message = err instanceof Error ? err.message : String(err);
@@ -171,12 +215,138 @@ export async function startDevServer(args) {
171
215
  }
172
216
  return;
173
217
  }
218
+ // File relay — the app reads file bytes from here instead of straight from storage,
219
+ // whose CORS doesn't admit a localhost origin. The app is on Vite's origin, so this
220
+ // IS cross-origin — but it's our response, so we allow it. Serving from here also
221
+ // means <img>/<video> and openExternal keep working unchanged.
222
+ if (pathname.startsWith("/_file/")) {
223
+ if (req.method === "OPTIONS") {
224
+ res.writeHead(204, fileCors);
225
+ res.end();
226
+ return;
227
+ }
228
+ if (req.method !== "GET" && req.method !== "HEAD") {
229
+ res.writeHead(405, { ...fileCors, Allow: "GET, HEAD, OPTIONS" });
230
+ res.end();
231
+ return;
232
+ }
233
+ const token = decodeURIComponent(pathname.slice("/_file/".length));
234
+ const destination = files.destinationFor(token);
235
+ if (!destination) {
236
+ // Not a URL this server handed out (or its presign has aged out). Reading from
237
+ // anywhere else is the thing this design refuses to do.
238
+ process.stderr.write(`[file] ERROR unknown or expired token ${token}\n`);
239
+ res.writeHead(404, { ...fileCors, "Content-Type": "application/json" });
240
+ res.end(JSON.stringify({ message: "No file for this token" }));
241
+ return;
242
+ }
243
+ try {
244
+ // Forward Range verbatim: a PDF reader seeks rather than reading the whole file,
245
+ // and media scrubbing depends on 206s coming back intact.
246
+ const range = req.headers.range;
247
+ const upstream = await fetch(destination, {
248
+ method: req.method,
249
+ headers: range ? { Range: range } : undefined,
250
+ });
251
+ const headers = { ...fileCors };
252
+ for (const name of PASS_THROUGH) {
253
+ const value = upstream.headers.get(name);
254
+ if (value)
255
+ headers[name] = value;
256
+ }
257
+ // Same one-line-per-transfer visibility as [rpc] and [upload] — a preview that
258
+ // silently serves nothing is exactly the thing this relay exists to make legible.
259
+ const size = headers["content-length"] ?? "?";
260
+ process.stderr.write(`[file] ${token} ${upstream.status} ${size}B\n`);
261
+ res.writeHead(upstream.status, headers);
262
+ if (req.method === "HEAD" || !upstream.body) {
263
+ res.end();
264
+ return;
265
+ }
266
+ const reader = upstream.body.getReader();
267
+ for (;;) {
268
+ const { value, done } = await reader.read();
269
+ if (done)
270
+ break;
271
+ res.write(Buffer.from(value));
272
+ }
273
+ res.end();
274
+ }
275
+ catch (err) {
276
+ const message = err instanceof Error ? err.message : String(err);
277
+ process.stderr.write(`[file] ERROR ${token} ${message}\n`);
278
+ if (!res.headersSent) {
279
+ res.writeHead(502, { ...fileCors, "Content-Type": "application/json" });
280
+ res.end(JSON.stringify({ message }));
281
+ }
282
+ else {
283
+ res.end();
284
+ }
285
+ }
286
+ return;
287
+ }
288
+ // Upload relay — the wrapper page PUTs the bytes here (same-origin, so the
289
+ // browser never applies CORS), and Node forwards them to the presigned URL.
290
+ if (req.method === "PUT" && pathname.startsWith("/_upload/")) {
291
+ const fileId = decodeURIComponent(pathname.slice("/_upload/".length));
292
+ const destination = uploads.destinationFor(fileId);
293
+ if (!destination) {
294
+ // Not a URL this server minted (or its presign has aged out). Relaying
295
+ // anywhere else is exactly the thing this design refuses to do.
296
+ process.stderr.write(`[upload] ERROR unknown or expired file_id ${fileId}\n`);
297
+ res.writeHead(404, { "Content-Type": "application/json" });
298
+ res.end(JSON.stringify({ message: `No presigned upload pending for ${fileId}` }));
299
+ return;
300
+ }
301
+ try {
302
+ // Buffer rather than stream: the presign signs `content-length`, and a
303
+ // streamed body would go out chunked and fail the signature. Dev-only,
304
+ // and the size ceiling is the API's own upload limit.
305
+ const chunks = [];
306
+ for await (const chunk of req)
307
+ chunks.push(chunk);
308
+ const bytes = Buffer.concat(chunks);
309
+ const startedAt = Date.now();
310
+ const upstream = await fetch(destination, {
311
+ method: "PUT",
312
+ body: bytes,
313
+ headers: { "Content-Type": req.headers["content-type"] ?? "application/octet-stream" },
314
+ });
315
+ const ms = Date.now() - startedAt;
316
+ if (upstream.ok) {
317
+ uploads.settle(fileId);
318
+ process.stderr.write(`[upload] ${fileId} ${bytes.length}B ${ms}ms\n`);
319
+ }
320
+ else {
321
+ // Keep the mint: the wrapper retries 5xx, and the presign is still valid.
322
+ process.stderr.write(`[upload] ERROR ${fileId} storage returned ${upstream.status}\n`);
323
+ }
324
+ res.writeHead(upstream.status);
325
+ res.end();
326
+ }
327
+ catch (err) {
328
+ const message = err instanceof Error ? err.message : String(err);
329
+ process.stderr.write(`[upload] ERROR ${fileId} ${message}\n`);
330
+ if (!res.headersSent) {
331
+ res.writeHead(502, { "Content-Type": "application/json" });
332
+ res.end(JSON.stringify({ message }));
333
+ }
334
+ else {
335
+ res.end();
336
+ }
337
+ }
338
+ return;
339
+ }
174
340
  res.writeHead(404, { "Content-Type": "text/plain" });
175
341
  res.end("Not Found");
176
342
  });
177
343
  await new Promise((resolve, reject) => {
178
344
  server.once("error", reject);
179
- server.listen(wrapperPort, () => {
345
+ // Loopback ONLY — never 0.0.0.0. `/_rpc` dispatches with the developer's API key, so a
346
+ // server bound to every interface hands anyone on the same network full read/write on the
347
+ // workspace (and, since the relays, the file bytes too). Vite already binds loopback; this
348
+ // socket is strictly more sensitive than that one.
349
+ server.listen(wrapperPort, "127.0.0.1", () => {
180
350
  server.off("error", reject);
181
351
  resolve();
182
352
  });
@@ -0,0 +1,36 @@
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
+ export interface UploadRelay {
22
+ /**
23
+ * Record the presigned PUT from an `upload_url` mint and return the result the
24
+ * page should see — identical but for a same-origin `upload_url`. A result that
25
+ * carries no mint passes through untouched: nothing is recorded, nothing is
26
+ * rewritten, and the page's PUT fails loudly rather than uploading into a void.
27
+ */
28
+ rewriteMint(result: unknown): unknown;
29
+ /** The presigned URL minted for this id — `null` if unknown or expired. */
30
+ destinationFor(fileId: string): string | null;
31
+ /** Drop a mint once its bytes have landed. One presign, one object. */
32
+ settle(fileId: string): void;
33
+ /** Outstanding mints — for tests. */
34
+ size(): number;
35
+ }
36
+ export declare function createUploadRelay(now?: () => number): UploadRelay;
@@ -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
+ });
@@ -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 a presigned URL, PUT the bytes to storage, then finalize.
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
@@ -127,7 +127,12 @@ function inputDeclToTsType(decl) {
127
127
  return null;
128
128
  })
129
129
  .filter((v) => v !== null);
130
- const inner = literals.length > 0 ? literals.join(" | ") : "string";
130
+ // The literal union keeps autocomplete for hand-typed ids, but a package
131
+ // app addresses options through the runtime-resolved OPT map (typed
132
+ // string — consumer workspaces bind DIFFERENT concrete ids), so the
133
+ // input must also accept string. `(string & {})` widens without
134
+ // collapsing the union in intellisense.
135
+ const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
131
136
  return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
132
137
  }
133
138
  case "date_range":
@@ -193,7 +198,11 @@ function outputDeclToTsType(decl) {
193
198
  ? JSON.stringify(o.value)
194
199
  : null)
195
200
  .filter((v) => v !== null);
196
- const inner = literals.length > 0 ? literals.join(" | ") : "string";
201
+ // Same widening on the READ side: a package app's runtime output value is
202
+ // the CONSUMER workspace's concrete opt_ id, not the origin's literal —
203
+ // exhaustive narrowing over origin literals would be unsound. `(string & {})`
204
+ // keeps autocomplete without lying about the value space.
205
+ const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
197
206
  return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
198
207
  }
199
208
  case "object": {
@@ -77,8 +77,8 @@ describe("generateAppWorkflowsDts", () => {
77
77
  });
78
78
  expect(dts).toContain("record_id: string;"); // required → NOT nullable
79
79
  expect(dts).toContain("due?: string | null;"); // optional scalar → clearable
80
- expect(dts).toContain('status?: "opt_done" | null;');
81
- expect(dts).toContain('tags?: ReadonlyArray<"opt_a"> | null;'); // optional multi
80
+ expect(dts).toContain('status?: "opt_done" | (string & {}) | null;');
81
+ expect(dts).toContain('tags?: ReadonlyArray<"opt_a" | (string & {})> | null;'); // optional multi
82
82
  // The object itself is a top-level optional → nullable; its NESTED optional
83
83
  // field mirrors the backend's plain `.optional()` (no `| null`).
84
84
  expect(dts).toContain("note?: string;");
@@ -94,7 +94,7 @@ describe("generateAppWorkflowsDts", () => {
94
94
  },
95
95
  },
96
96
  });
97
- expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b">');
97
+ expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b" | (string & {})>');
98
98
  });
99
99
  it("types a multi file input as ReadonlyArray<string>, single file as string", () => {
100
100
  const dts = generateAppWorkflowsDts({
@@ -79,24 +79,28 @@ export declare function packageShow(client: LoticsClient, args: {
79
79
  package_id: string;
80
80
  }): Promise<void>;
81
81
  /**
82
- * Preview-then-apply a STANDALONE content installation upgrade knowledge docs
83
- * AND document templates behind one review gate. Prints the per-alias plan;
84
- * refuses (exit 1, with the exact `--resolve` syntax) while any consent-requiring
85
- * entry lacks a resolution a modified knowledge change/removal or drift, or a
86
- * locally-edited changed template. `--apply-all` auto-resolves EVERY consent entry
87
- * by accepting the package's version (knowledge changed→apply, removed→archive,
88
- * drifted→recreate; template→revert) — an explicit bulk "take upstream" that
89
- * discards local edits. `--resolve knowledge.<alias>=<value>` /
90
- * `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
91
- * resolve entries individually — the ONE namespaced grammar, passed to the
92
- * server verbatim.
82
+ * CLEAN BREAK for an explicit `pci_` argument on `upgrade` / `uninstall`: the
83
+ * `pci_` resource id is retired from human sight content installs are addressed
84
+ * by their PACKAGE id. Prints a loud redirect, best-effort resolving the `pci_`
85
+ * back to its package id (via list-content) so the exact command is spelled out.
86
+ * The header prints synchronously first, so the redirect is observable even when
87
+ * the best-effort resolution can't reach the registry.
93
88
  */
94
- export declare function packageUpgradeKnowledge(client: LoticsClient, args: {
95
- installation_id: string;
89
+ export declare function redirectContentPciForm(client: LoticsClient, pci_id: string, verb: "upgrade" | "uninstall"): Promise<never>;
90
+ /**
91
+ * `lotics upgrade <apg_>` — the package-id upgrade path, kind-branched. `kind` is a
92
+ * DERIVED display hint (`contractHasAppSurface`): `'content'` means no app surface.
93
+ * - a CONTENT package upgrades THIS workspace's standalone content installation
94
+ * (resolved from the package id — the anchor is unique per workspace, so the
95
+ * `pci_` never surfaces), through the same content review gate.
96
+ * - an APP-surface package FLEET-upgrades every installation across the org
97
+ * (unchanged) — the resolve/bind/apply-all flags don't apply to a fleet run.
98
+ */
99
+ export declare function packageUpgradeByPackageId(client: LoticsClient, args: {
100
+ package_id: string;
96
101
  version?: number;
97
- /** Raw `--resolve <alias>=<value>` flags, routed to knowledge/templates after the preview. */
98
102
  resolve: string[];
99
- bind_to: Record<string, string>;
103
+ bindTo: string[];
100
104
  applyAll: boolean;
101
105
  }): Promise<void>;
102
106
  /** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
@@ -110,9 +114,11 @@ export declare function packageInstall(client: LoticsClient, args: {
110
114
  config?: Record<string, string | number | boolean>;
111
115
  }): Promise<void>;
112
116
  /**
113
- * `lotics uninstall <app_id|pci_id>` — ONE top-level command over both installation
114
- * kinds, dispatched by the id form (mirrors `lotics upgrade`):
115
- * - a `pci_` id a STANDALONE CONTENT installation: deletes the row and (unless
117
+ * `lotics uninstall <app_id|package_id>` — ONE top-level command over both
118
+ * installation kinds, dispatched by the id form (mirrors `lotics upgrade`):
119
+ * - a package id (`apg_`)THIS workspace's STANDALONE CONTENT installation
120
+ * (content installs are addressed by package id, UNIQUE per workspace, so the
121
+ * `pci_` resource id never surfaces): deletes the row and (unless
116
122
  * `--keep-content`) archives its package-bound docs AND templates, listing each
117
123
  * archived id.
118
124
  * - anything else (an `app_id`) → an APP installation: archives its workflow
@@ -129,8 +135,9 @@ export declare function packageUninstall(client: LoticsClient, args: {
129
135
  * `lotics package list-content` — list the selected workspace's STANDALONE
130
136
  * content installations (an app-bundled corpus rides its app's
131
137
  * `binding.knowledge` and shows on the Apps surface instead), each with its
132
- * registry status. The read surface that surfaces a `pci_` id for
133
- * `upgrade` / `uninstall` (install prints it once; nothing else did before).
138
+ * registry status. The what-is-installed listing: each row leads with the PACKAGE
139
+ * id the address for `lotics upgrade <package_id>` / `lotics uninstall
140
+ * <package_id>` (the `pci_` resource id stays hidden).
134
141
  */
135
142
  export declare function packageListContent(client: LoticsClient): Promise<void>;
136
143
  export declare function packageEject(client: LoticsClient, args: {
@@ -185,9 +192,15 @@ export declare function appPublish(client: LoticsClient, args: {
185
192
  * § Promotion). The origin is the permanent working copy; a release binding-aware-
186
193
  * extracts it (stable aliases), repackages its DEPLOYED source + dist as the
187
194
  * bundle, publishes the next version, and re-pins the origin. Prints the preview
188
- * first (next version, new + changed aliases, findings); applies only with
189
- * `--yes`, else exits 1 so a review step can't be skipped. An `error` finding
190
- * blocks the apply.
195
+ * first (next version, new + changed aliases, the bundled-knowledge delta,
196
+ * findings); applies only with `--yes`, else exits 1 so a review step can't be
197
+ * skipped. An `error` finding blocks the apply.
198
+ *
199
+ * Run from the pulled app project, the manifest's `lotics.knowledge` (alias →
200
+ * doc_id) is the bundle DECLARATION — it re-declares which docs the package owns
201
+ * (add/drop/re-snapshot). Forwarded only when non-empty; empty (or a bare id from
202
+ * elsewhere) sends nothing, so the current corpus is reconstructed from the pin
203
+ * (never silently dropped).
191
204
  */
192
205
  export declare function appRelease(client: LoticsClient, args: {
193
206
  app_id?: string;
@@ -206,14 +219,3 @@ export declare function packageYank(client: LoticsClient, args: {
206
219
  version: number;
207
220
  undo: boolean;
208
221
  }): Promise<void>;
209
- /**
210
- * `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
211
- * installation of the package across the caller's org to the target version.
212
- * Hands-off applies only where the preview is clean; skipped/failed
213
- * installations are reported per line and the process exits 1 so a release
214
- * script can gate on "fleet fully current".
215
- */
216
- export declare function packageFleetUpgrade(client: LoticsClient, args: {
217
- package_id: string;
218
- version?: number;
219
- }): Promise<void>;