@lotics/cli 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,10 +92,6 @@ function readAppMeta(projectDir) {
92
92
  version_number: pkg.lotics.version_number ?? null,
93
93
  workflows: pkg.lotics.workflows ?? {},
94
94
  queries: pkg.lotics.queries ?? {},
95
- // Left as `undefined` when absent so the deploy omits them and the
96
- // App row's icon/theme is preserved.
97
- icon: pkg.lotics.icon,
98
- theme: pkg.lotics.theme,
99
95
  capabilities: pkg.lotics.capabilities,
100
96
  };
101
97
  }
@@ -346,15 +342,10 @@ export async function appDeploy(client, args) {
346
342
  // Sync apps.queries from the manifest. Server validates each query
347
343
  // template (parseQueryNode, table access, param coverage).
348
344
  queries: meta.queries ?? {},
349
- // Optional icon/theme overrides only sent when the manifest
350
- // declares them (undefined omitted App row left untouched).
351
- icon: meta.icon,
352
- theme: meta.theme,
353
- // Capabilities are manifest-authoritative (like workflows/queries, not
354
- // like icon/theme): always send, defaulting to `{}` when the manifest
355
- // declares none — so deleting the `capabilities` block turns every
356
- // capability OFF on the next deploy (fail-safe; the declaration is the
357
- // grant). The manifest is the only writer of capabilities.
345
+ // Capabilities are manifest-authoritative (like workflows/queries):
346
+ // always send, defaulting to `{}` when the manifest declares none — so
347
+ // deleting the `capabilities` block turns every capability OFF on the
348
+ // next deploy (fail-safe; the declaration is the grant).
358
349
  capabilities: meta.capabilities ?? {},
359
350
  // Opt into destructive workflow removal. Default false — the server
360
351
  // rejects deploys whose manifest is missing aliases the App row has.
@@ -367,6 +358,7 @@ export async function appDeploy(client, args) {
367
358
  });
368
359
  console.error(`Deployed v${result.version_number} (${result.version_id})`);
369
360
  console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
361
+ await warnIfUnbranded(client, meta.app_id);
370
362
  }
371
363
  catch (err) {
372
364
  const e = err;
@@ -385,6 +377,30 @@ export async function appDeploy(client, args) {
385
377
  fs.unlinkSync(tmpDist);
386
378
  }
387
379
  }
380
+ /**
381
+ * Non-blocking nudge after a successful deploy: an app with no icon/color shows a
382
+ * generic tile in the launcher. Branding is set via `update_app` (the single
383
+ * setter) — this only reminds; it never fails the deploy.
384
+ */
385
+ async function warnIfUnbranded(client, appId) {
386
+ try {
387
+ const app = await client.getApp(appId);
388
+ const missing = [];
389
+ if (!app.icon)
390
+ missing.push("icon");
391
+ if (!app.theme?.color)
392
+ missing.push("color");
393
+ if (missing.length === 0)
394
+ return;
395
+ console.error(`\n⚠ This app has no ${missing.join(" or ")} set — it shows a generic tile in the launcher.\n` +
396
+ ` Set it: lotics run update_app '{"app_id":"${appId}","icon":"<lucide-name>","theme":{"color":"blue"}}'\n` +
397
+ ` Find an icon: lotics run search_app_icons '{"query":"<word>"}'`);
398
+ }
399
+ catch (err) {
400
+ // Deploy already succeeded; a failed branding check must not mask that.
401
+ console.error(`(skipped branding check: ${err.message})`);
402
+ }
403
+ }
388
404
  /**
389
405
  * `lotics app dev [path] [--port=5174] [--vite-port=5173]`
390
406
  *
@@ -418,12 +434,16 @@ export async function appDev(client, args) {
418
434
  port: args.port,
419
435
  vitePort: args.vitePort,
420
436
  client,
437
+ commentsEnabled: meta.capabilities?.comments,
421
438
  });
422
439
  await handle.ready;
423
440
  const url = `http://localhost:${handle.port}`;
424
441
  console.error(`\n lotics app dev`);
425
442
  console.error(` app: ${app.name} (${meta.app_id})`);
426
443
  console.error(` workspace: ${meta.workspace_id}`);
444
+ if (client.viewAsMemberId) {
445
+ console.error(` view as: ${client.viewAsMemberId} (is_current_member resolves to this member)`);
446
+ }
427
447
  console.error(` vite: http://localhost:${handle.vitePort}/`);
428
448
  console.error(` open: ${url}`);
429
449
  console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
package/dist/args.d.ts CHANGED
@@ -22,6 +22,7 @@ export declare function parseArgs(argv: string[]): {
22
22
  as?: string;
23
23
  apiKey?: string;
24
24
  workspace?: string;
25
+ viewAs?: string;
25
26
  name?: string;
26
27
  timezone?: string;
27
28
  message?: string;
package/dist/args.js CHANGED
@@ -18,6 +18,7 @@ export function parseArgs(argv) {
18
18
  as: undefined,
19
19
  apiKey: undefined,
20
20
  workspace: undefined,
21
+ viewAs: undefined,
21
22
  name: undefined,
22
23
  timezone: undefined,
23
24
  message: undefined,
@@ -55,6 +56,9 @@ export function parseArgs(argv) {
55
56
  case "-w":
56
57
  flags.workspace = argv[++i];
57
58
  break;
59
+ case "--view-as":
60
+ flags.viewAs = argv[++i];
61
+ break;
58
62
  case "--name":
59
63
  flags.name = argv[++i];
60
64
  break;
package/dist/args.test.js CHANGED
@@ -45,6 +45,12 @@ describe("parseArgs", () => {
45
45
  const r = parseArgs(["run", "query_tables", "-w", "wsp_123"]);
46
46
  expect(r.flags.workspace).toBe("wsp_123");
47
47
  });
48
+ it("parses --view-as as a value flag", () => {
49
+ const r = parseArgs(["app", "dev", "--view-as", "mbr_abc"]);
50
+ expect(r.flags.viewAs).toBe("mbr_abc");
51
+ expect(r.command).toBe("app");
52
+ expect(r.subcommand).toBe("dev");
53
+ });
48
54
  it("parses --all as a boolean flag", () => {
49
55
  const r = parseArgs(["auth", "logout", "--all"]);
50
56
  expect(r.flags.all).toBe(true);
package/dist/cli.js CHANGED
@@ -81,6 +81,9 @@ FLAGS
81
81
  --as <name> Override upload filename
82
82
  --api-key <key> One-off API key (overrides saved config + env)
83
83
  --workspace <id> One-off workspace override (alias: -w)
84
+ --view-as <id> Admin "View as": run every request as this member, so
85
+ is_current_member / row-scoping resolve to them (also
86
+ LOTICS_VIEW_AS env; admin key only; writes stay yours)
84
87
  --local Pin the current directory (lotics org use / auth api-key)
85
88
  --all (lotics auth logout) Remove every saved credential
86
89
  --version Show version
@@ -279,7 +282,13 @@ function requireClient(flags) {
279
282
  console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
280
283
  process.exit(1);
281
284
  }
282
- return { client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId }), ctx };
285
+ // Admin "View as": flag wins over env. The server enforces admin-only; a
286
+ // non-admin key gets a clear 403 on the first request.
287
+ const viewAsMemberId = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
288
+ return {
289
+ client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId, viewAsMemberId }),
290
+ ctx,
291
+ };
283
292
  }
284
293
  const SOURCE_LABELS = {
285
294
  flag: "--api-key flag",
@@ -378,8 +387,9 @@ async function main() {
378
387
  const info = await client.whoami();
379
388
  const existing = loadGlobalConfig() ?? {};
380
389
  saveGlobalConfig({ ...existing, email: info.email });
390
+ const viewAs = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
381
391
  if (flags.json) {
382
- console.log(JSON.stringify({ ...info, workspace_id: ctx.workspaceId ?? null, source: ctx.source }, null, 2));
392
+ console.log(JSON.stringify({ ...info, workspace_id: ctx.workspaceId ?? null, source: ctx.source, view_as: viewAs ?? null }, null, 2));
383
393
  }
384
394
  else {
385
395
  console.log(`Name: ${info.name}`);
@@ -387,6 +397,8 @@ async function main() {
387
397
  console.log(`Org: ${info.organization_name} (${info.organization_id})`);
388
398
  console.log(`Workspace: ${ctx.workspaceId ?? "(none selected)"}`);
389
399
  console.log(`Source: ${SOURCE_LABELS[ctx.source]}`);
400
+ if (viewAs)
401
+ console.log(`View as: ${viewAs} (admin preview; requires an admin key)`);
390
402
  }
391
403
  return;
392
404
  }
package/dist/client.d.ts CHANGED
@@ -22,6 +22,11 @@ export type AppQueryFilter = AppQueryFilterCondition | AppQueryFilterGroup;
22
22
  export interface LoticsClientOptions {
23
23
  apiKey: string;
24
24
  workspaceId?: string;
25
+ /** Admin "View as": when set, every request carries `x-view-as-member-id`, so
26
+ * the backend evaluates IAM scoping (and `is_current_member`) as this member.
27
+ * Admin-only — the server rejects a non-admin key. Writes stay attributed to
28
+ * the key's owner. */
29
+ viewAsMemberId?: string;
25
30
  }
26
31
  export interface WorkspaceInfo {
27
32
  id: string;
@@ -56,6 +61,9 @@ export declare const API_BASE_URL: string;
56
61
  export declare class LoticsClient {
57
62
  private apiKey;
58
63
  private workspaceId;
64
+ /** The active "View as" target member id, if any. Read-only after
65
+ * construction — surfaced so `lotics app dev` can show it in the banner. */
66
+ readonly viewAsMemberId: string | undefined;
59
67
  /** API URL the client is configured against. Read-only after construction.
60
68
  * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
61
69
  * shows it in the banner). */
@@ -65,6 +73,7 @@ export declare class LoticsClient {
65
73
  private buildHeaders;
66
74
  private request;
67
75
  whoami(): Promise<{
76
+ member_id: string;
68
77
  email: string;
69
78
  name: string;
70
79
  organization_id: string;
@@ -96,6 +105,12 @@ export declare class LoticsClient {
96
105
  name: string;
97
106
  workspace_id: string;
98
107
  current_version_id: string | null;
108
+ /** Launcher icon — a Lucide name, or an image ref. Null when unset. */
109
+ icon?: string | null;
110
+ /** Launcher theme — `{ color }` is a palette token. Null when unset. */
111
+ theme?: {
112
+ color?: string | null;
113
+ } | null;
99
114
  /**
100
115
  * Live alias → workflow declaration map from `apps.workflows`. Source of
101
116
  * truth for `lotics app pull` — supersedes the manifest embedded in the
@@ -232,14 +247,6 @@ export declare class LoticsClient {
232
247
  ast: unknown;
233
248
  params?: Record<string, unknown>;
234
249
  }>;
235
- /**
236
- * App icon (Lucide name) + theme `{ color }` from `package.json#lotics`.
237
- * `undefined` ⇒ not sent ⇒ the deploy leaves the App row's icon/theme
238
- * as-is. A present value is applied authoritatively. `icon` rides as a
239
- * raw form field; `theme` is an object, so it's JSON-encoded.
240
- */
241
- icon?: string;
242
- theme?: Record<string, unknown>;
243
250
  /**
244
251
  * Opt-in app capabilities from `package.json#lotics.capabilities`. The CLI
245
252
  * sends this on every deploy (defaulting to `{}`): the manifest is
package/dist/client.js CHANGED
@@ -49,6 +49,9 @@ export const API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai
49
49
  export class LoticsClient {
50
50
  apiKey;
51
51
  workspaceId;
52
+ /** The active "View as" target member id, if any. Read-only after
53
+ * construction — surfaced so `lotics app dev` can show it in the banner. */
54
+ viewAsMemberId;
52
55
  /** API URL the client is configured against. Read-only after construction.
53
56
  * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
54
57
  * shows it in the banner). */
@@ -56,6 +59,7 @@ export class LoticsClient {
56
59
  constructor(options) {
57
60
  this.apiKey = options.apiKey;
58
61
  this.workspaceId = options.workspaceId;
62
+ this.viewAsMemberId = options.viewAsMemberId;
59
63
  this.baseUrl = API_BASE_URL;
60
64
  }
61
65
  async throwResponseError(response) {
@@ -77,6 +81,9 @@ export class LoticsClient {
77
81
  if (this.workspaceId) {
78
82
  headers["x-workspace-id"] = this.workspaceId;
79
83
  }
84
+ if (this.viewAsMemberId) {
85
+ headers["x-view-as-member-id"] = this.viewAsMemberId;
86
+ }
80
87
  return headers;
81
88
  }
82
89
  async request(method, path, body) {
@@ -220,16 +227,6 @@ export class LoticsClient {
220
227
  // Always send queries — empty object clears any previously-declared
221
228
  // named queries. Server validates each template.
222
229
  formData.append("queries", JSON.stringify(args.queries ?? {}));
223
- // icon/theme are optional overrides — only sent when the manifest
224
- // declares them, so a deploy never clobbers a value set in the UI.
225
- // `icon` is a plain string → raw field, like `message`. `theme` is an
226
- // object → JSON-encoded, like `workflows`/`queries`.
227
- if (args.icon !== undefined) {
228
- formData.append("icon", args.icon);
229
- }
230
- if (args.theme !== undefined) {
231
- formData.append("theme", JSON.stringify(args.theme));
232
- }
233
230
  // capabilities is manifest-authoritative — the caller always passes it
234
231
  // (`{}` when none declared), so a deploy turns off any capability the
235
232
  // manifest no longer declares. JSON-encoded.
@@ -6,10 +6,16 @@
6
6
  * { message }. Same shape as the production iframe-host's error path.
7
7
  */
8
8
  import { LoticsClient } from "../client.js";
9
- export type RpcOp = "query" | "workflow" | "members" | "upload_url" | "upload_complete";
9
+ export type RpcOp = "query" | "workflow" | "members" | "context" | "upload_url" | "upload_complete";
10
10
  export interface RpcRequest {
11
11
  app_id: string;
12
12
  op: RpcOp;
13
13
  payload: unknown;
14
14
  }
15
- export declare function dispatchRpc(client: LoticsClient, body: RpcRequest): Promise<unknown>;
15
+ /** Per-app facts the dev server knows from the local manifest that the
16
+ * production iframe-host supplies from the embedding context. */
17
+ export interface DispatchOptions {
18
+ /** From `package.json#lotics.capabilities.comments`. */
19
+ commentsEnabled?: boolean;
20
+ }
21
+ export declare function dispatchRpc(client: LoticsClient, body: RpcRequest, opts?: DispatchOptions): Promise<unknown>;
@@ -9,10 +9,11 @@ const SUPPORTED_OPS = new Set([
9
9
  "query",
10
10
  "workflow",
11
11
  "members",
12
+ "context",
12
13
  "upload_url",
13
14
  "upload_complete",
14
15
  ]);
15
- export async function dispatchRpc(client, body) {
16
+ export async function dispatchRpc(client, body, opts) {
16
17
  if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
17
18
  throw new Error("RPC envelope must include app_id and op");
18
19
  }
@@ -20,6 +21,17 @@ export async function dispatchRpc(client, body) {
20
21
  throw new Error(`Unknown RPC op: ${body.op}`);
21
22
  }
22
23
  switch (body.op) {
24
+ case "context": {
25
+ // In production the iframe host supplies the context; here we resolve the
26
+ // effective viewer from the CLI key (the view-as target when the client
27
+ // carries `x-view-as-member-id`, else the key's owner), so `useViewer`
28
+ // and `is_current_member` agree in the dev loop.
29
+ const who = await client.whoami();
30
+ return {
31
+ member_id: who.member_id,
32
+ comments_enabled: opts?.commentsEnabled ?? false,
33
+ };
34
+ }
23
35
  case "query": {
24
36
  const p = body.payload;
25
37
  if (!p || typeof p.alias !== "string") {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { dispatchRpc } from "./rpc_handler.js";
3
+ function mockClient(over) {
4
+ return over;
5
+ }
6
+ const WHOAMI = {
7
+ member_id: "mbr_viewer",
8
+ email: "v@test.local",
9
+ name: "Viewer",
10
+ organization_id: "org_1",
11
+ organization_name: "Org",
12
+ };
13
+ describe("dispatchRpc — context op", () => {
14
+ it("resolves the viewer member_id from whoami + manifest comments_enabled", async () => {
15
+ const client = mockClient({ whoami: async () => WHOAMI });
16
+ const result = await dispatchRpc(client, { app_id: "app_x", op: "context", payload: {} }, { commentsEnabled: true });
17
+ expect(result).toEqual({ member_id: "mbr_viewer", comments_enabled: true });
18
+ });
19
+ it("defaults comments_enabled to false when the manifest declares none", async () => {
20
+ const client = mockClient({ whoami: async () => WHOAMI });
21
+ const result = await dispatchRpc(client, { app_id: "app_x", op: "context", payload: {} });
22
+ expect(result).toEqual({ member_id: "mbr_viewer", comments_enabled: false });
23
+ });
24
+ it("rejects an op that isn't supported", async () => {
25
+ const client = mockClient({});
26
+ await expect(dispatchRpc(client, { app_id: "app_x", op: "bogus", payload: {} })).rejects.toThrow(/Unknown RPC op/);
27
+ });
28
+ });
@@ -25,6 +25,9 @@ export interface DevServerArgs {
25
25
  /** Preferred Vite port. If taken, auto-picks a free one. */
26
26
  vitePort?: number;
27
27
  client: LoticsClient;
28
+ /** From `package.json#lotics.capabilities.comments` — fed to the `context` op
29
+ * so `useComments` reflects the manifest in the dev loop. */
30
+ commentsEnabled?: boolean;
28
31
  }
29
32
  export interface DevServerHandle {
30
33
  port: number;
@@ -91,7 +91,7 @@ export async function startDevServer(args) {
91
91
  app_id: body.app_id,
92
92
  op: body.op,
93
93
  payload: body.payload,
94
- });
94
+ }, { commentsEnabled: args.commentsEnabled });
95
95
  const ms = Date.now() - startedAt;
96
96
  process.stderr.write(`[rpc] ${body.op} ${ms}ms\n`);
97
97
  res.writeHead(200, { "Content-Type": "application/json" });
package/dist/src/cli.js CHANGED
@@ -29625,6 +29625,9 @@ var API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai";
29625
29625
  var LoticsClient = class {
29626
29626
  apiKey;
29627
29627
  workspaceId;
29628
+ /** The active "View as" target member id, if any. Read-only after
29629
+ * construction — surfaced so `lotics app dev` can show it in the banner. */
29630
+ viewAsMemberId;
29628
29631
  /** API URL the client is configured against. Read-only after construction.
29629
29632
  * Surfaced for callers that need to display or log it (e.g., `lotics app dev`
29630
29633
  * shows it in the banner). */
@@ -29632,6 +29635,7 @@ var LoticsClient = class {
29632
29635
  constructor(options) {
29633
29636
  this.apiKey = options.apiKey;
29634
29637
  this.workspaceId = options.workspaceId;
29638
+ this.viewAsMemberId = options.viewAsMemberId;
29635
29639
  this.baseUrl = API_BASE_URL;
29636
29640
  }
29637
29641
  async throwResponseError(response) {
@@ -29652,6 +29656,9 @@ var LoticsClient = class {
29652
29656
  if (this.workspaceId) {
29653
29657
  headers["x-workspace-id"] = this.workspaceId;
29654
29658
  }
29659
+ if (this.viewAsMemberId) {
29660
+ headers["x-view-as-member-id"] = this.viewAsMemberId;
29661
+ }
29655
29662
  return headers;
29656
29663
  }
29657
29664
  async request(method, path7, body) {
@@ -29814,12 +29821,6 @@ var LoticsClient = class {
29814
29821
  }
29815
29822
  formData.append("workflows", JSON.stringify(args.workflows ?? {}));
29816
29823
  formData.append("queries", JSON.stringify(args.queries ?? {}));
29817
- if (args.icon !== void 0) {
29818
- formData.append("icon", args.icon);
29819
- }
29820
- if (args.theme !== void 0) {
29821
- formData.append("theme", JSON.stringify(args.theme));
29822
- }
29823
29824
  if (args.capabilities !== void 0) {
29824
29825
  formData.append("capabilities", JSON.stringify(args.capabilities));
29825
29826
  }
@@ -30648,10 +30649,11 @@ var SUPPORTED_OPS = /* @__PURE__ */ new Set([
30648
30649
  "query",
30649
30650
  "workflow",
30650
30651
  "members",
30652
+ "context",
30651
30653
  "upload_url",
30652
30654
  "upload_complete"
30653
30655
  ]);
30654
- async function dispatchRpc(client, body) {
30656
+ async function dispatchRpc(client, body, opts) {
30655
30657
  if (!body || typeof body.app_id !== "string" || typeof body.op !== "string") {
30656
30658
  throw new Error("RPC envelope must include app_id and op");
30657
30659
  }
@@ -30659,6 +30661,13 @@ async function dispatchRpc(client, body) {
30659
30661
  throw new Error(`Unknown RPC op: ${body.op}`);
30660
30662
  }
30661
30663
  switch (body.op) {
30664
+ case "context": {
30665
+ const who = await client.whoami();
30666
+ return {
30667
+ member_id: who.member_id,
30668
+ comments_enabled: opts?.commentsEnabled ?? false
30669
+ };
30670
+ }
30662
30671
  case "query": {
30663
30672
  const p = body.payload;
30664
30673
  if (!p || typeof p.alias !== "string") {
@@ -30949,11 +30958,15 @@ async function startDevServer(args) {
30949
30958
  try {
30950
30959
  const body = await readJson(req);
30951
30960
  const startedAt = Date.now();
30952
- const result = await dispatchRpc(args.client, {
30953
- app_id: body.app_id,
30954
- op: body.op,
30955
- payload: body.payload
30956
- });
30961
+ const result = await dispatchRpc(
30962
+ args.client,
30963
+ {
30964
+ app_id: body.app_id,
30965
+ op: body.op,
30966
+ payload: body.payload
30967
+ },
30968
+ { commentsEnabled: args.commentsEnabled }
30969
+ );
30957
30970
  const ms = Date.now() - startedAt;
30958
30971
  process.stderr.write(`[rpc] ${body.op} ${ms}ms
30959
30972
  `);
@@ -31239,10 +31252,6 @@ function readAppMeta(projectDir) {
31239
31252
  version_number: pkg2.lotics.version_number ?? null,
31240
31253
  workflows: pkg2.lotics.workflows ?? {},
31241
31254
  queries: pkg2.lotics.queries ?? {},
31242
- // Left as `undefined` when absent so the deploy omits them and the
31243
- // App row's icon/theme is preserved.
31244
- icon: pkg2.lotics.icon,
31245
- theme: pkg2.lotics.theme,
31246
31255
  capabilities: pkg2.lotics.capabilities
31247
31256
  };
31248
31257
  }
@@ -31417,15 +31426,10 @@ async function appDeploy(client, args) {
31417
31426
  // Sync apps.queries from the manifest. Server validates each query
31418
31427
  // template (parseQueryNode, table access, param coverage).
31419
31428
  queries: meta.queries ?? {},
31420
- // Optional icon/theme overrides only sent when the manifest
31421
- // declares them (undefined omitted App row left untouched).
31422
- icon: meta.icon,
31423
- theme: meta.theme,
31424
- // Capabilities are manifest-authoritative (like workflows/queries, not
31425
- // like icon/theme): always send, defaulting to `{}` when the manifest
31426
- // declares none — so deleting the `capabilities` block turns every
31427
- // capability OFF on the next deploy (fail-safe; the declaration is the
31428
- // grant). The manifest is the only writer of capabilities.
31429
+ // Capabilities are manifest-authoritative (like workflows/queries):
31430
+ // always send, defaulting to `{}` when the manifest declares none — so
31431
+ // deleting the `capabilities` block turns every capability OFF on the
31432
+ // next deploy (fail-safe; the declaration is the grant).
31429
31433
  capabilities: meta.capabilities ?? {},
31430
31434
  // Opt into destructive workflow removal. Default false — the server
31431
31435
  // rejects deploys whose manifest is missing aliases the App row has.
@@ -31438,6 +31442,7 @@ async function appDeploy(client, args) {
31438
31442
  });
31439
31443
  console.error(`Deployed v${result.version_number} (${result.version_id})`);
31440
31444
  console.error(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
31445
+ await warnIfUnbranded(client, meta.app_id);
31441
31446
  } catch (err2) {
31442
31447
  const e = err2;
31443
31448
  if (e.code === "VERSION_CONFLICT") {
@@ -31453,6 +31458,23 @@ async function appDeploy(client, args) {
31453
31458
  if (fs3.existsSync(tmpDist)) fs3.unlinkSync(tmpDist);
31454
31459
  }
31455
31460
  }
31461
+ async function warnIfUnbranded(client, appId) {
31462
+ try {
31463
+ const app = await client.getApp(appId);
31464
+ const missing = [];
31465
+ if (!app.icon) missing.push("icon");
31466
+ if (!app.theme?.color) missing.push("color");
31467
+ if (missing.length === 0) return;
31468
+ console.error(
31469
+ `
31470
+ \u26A0 This app has no ${missing.join(" or ")} set \u2014 it shows a generic tile in the launcher.
31471
+ Set it: lotics run update_app '{"app_id":"${appId}","icon":"<lucide-name>","theme":{"color":"blue"}}'
31472
+ Find an icon: lotics run search_app_icons '{"query":"<word>"}'`
31473
+ );
31474
+ } catch (err2) {
31475
+ console.error(`(skipped branding check: ${err2.message})`);
31476
+ }
31477
+ }
31456
31478
  async function appDev(client, args) {
31457
31479
  const projectDir = path4.resolve(args.projectDir ?? process.cwd());
31458
31480
  const meta = readAppMeta(projectDir);
@@ -31466,7 +31488,8 @@ async function appDev(client, args) {
31466
31488
  api_url: client.baseUrl,
31467
31489
  port: args.port,
31468
31490
  vitePort: args.vitePort,
31469
- client
31491
+ client,
31492
+ commentsEnabled: meta.capabilities?.comments
31470
31493
  });
31471
31494
  await handle.ready;
31472
31495
  const url = `http://localhost:${handle.port}`;
@@ -31474,6 +31497,9 @@ async function appDev(client, args) {
31474
31497
  lotics app dev`);
31475
31498
  console.error(` app: ${app.name} (${meta.app_id})`);
31476
31499
  console.error(` workspace: ${meta.workspace_id}`);
31500
+ if (client.viewAsMemberId) {
31501
+ console.error(` view as: ${client.viewAsMemberId} (is_current_member resolves to this member)`);
31502
+ }
31477
31503
  console.error(` vite: http://localhost:${handle.vitePort}/`);
31478
31504
  console.error(` open: ${url}`);
31479
31505
  console.error(` rpc: ${client.baseUrl} (via Bearer API key)
@@ -31503,6 +31529,7 @@ function parseArgs(argv) {
31503
31529
  as: void 0,
31504
31530
  apiKey: void 0,
31505
31531
  workspace: void 0,
31532
+ viewAs: void 0,
31506
31533
  name: void 0,
31507
31534
  timezone: void 0,
31508
31535
  message: void 0,
@@ -31540,6 +31567,9 @@ function parseArgs(argv) {
31540
31567
  case "-w":
31541
31568
  flags.workspace = argv[++i];
31542
31569
  break;
31570
+ case "--view-as":
31571
+ flags.viewAs = argv[++i];
31572
+ break;
31543
31573
  case "--name":
31544
31574
  flags.name = argv[++i];
31545
31575
  break;
@@ -47360,6 +47390,9 @@ FLAGS
47360
47390
  --as <name> Override upload filename
47361
47391
  --api-key <key> One-off API key (overrides saved config + env)
47362
47392
  --workspace <id> One-off workspace override (alias: -w)
47393
+ --view-as <id> Admin "View as": run every request as this member, so
47394
+ is_current_member / row-scoping resolve to them (also
47395
+ LOTICS_VIEW_AS env; admin key only; writes stay yours)
47363
47396
  --local Pin the current directory (lotics org use / auth api-key)
47364
47397
  --all (lotics auth logout) Remove every saved credential
47365
47398
  --version Show version
@@ -47542,7 +47575,11 @@ function requireClient(flags) {
47542
47575
  console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
47543
47576
  process.exit(1);
47544
47577
  }
47545
- return { client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId }), ctx };
47578
+ const viewAsMemberId = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
47579
+ return {
47580
+ client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId, viewAsMemberId }),
47581
+ ctx
47582
+ };
47546
47583
  }
47547
47584
  var SOURCE_LABELS = {
47548
47585
  flag: "--api-key flag",
@@ -47632,14 +47669,16 @@ async function main() {
47632
47669
  const info = await client2.whoami();
47633
47670
  const existing = loadGlobalConfig() ?? {};
47634
47671
  saveGlobalConfig({ ...existing, email: info.email });
47672
+ const viewAs = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
47635
47673
  if (flags.json) {
47636
- console.log(JSON.stringify({ ...info, workspace_id: ctx2.workspaceId ?? null, source: ctx2.source }, null, 2));
47674
+ console.log(JSON.stringify({ ...info, workspace_id: ctx2.workspaceId ?? null, source: ctx2.source, view_as: viewAs ?? null }, null, 2));
47637
47675
  } else {
47638
47676
  console.log(`Name: ${info.name}`);
47639
47677
  console.log(`Email: ${info.email}`);
47640
47678
  console.log(`Org: ${info.organization_name} (${info.organization_id})`);
47641
47679
  console.log(`Workspace: ${ctx2.workspaceId ?? "(none selected)"}`);
47642
47680
  console.log(`Source: ${SOURCE_LABELS[ctx2.source]}`);
47681
+ if (viewAs) console.log(`View as: ${viewAs} (admin preview; requires an admin key)`);
47643
47682
  }
47644
47683
  return;
47645
47684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {