@lotics/cli 0.45.1 → 0.46.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.
@@ -102,6 +102,12 @@ export declare function appPull(client: LoticsClient, args: {
102
102
  app_id: string;
103
103
  targetPath?: string;
104
104
  }): Promise<void>;
105
+ /**
106
+ * Capabilities the source CALLS but the manifest does not DECLARE — each one
107
+ * silently 403s at runtime (GAP-29). Pure over the concatenated source text so
108
+ * it's unit-testable; the deploy warns (non-blocking) on a non-empty result.
109
+ */
110
+ export declare function undeclaredCapabilities(sourceText: string, declared: Record<string, boolean | undefined> | undefined): string[];
105
111
  /**
106
112
  * `lotics app deploy [-m <message>]`
107
113
  *
@@ -289,6 +289,48 @@ export async function appPull(client, args) {
289
289
  console.error(` # edit src/App.tsx`);
290
290
  console.error(` lotics app deploy`);
291
291
  }
292
+ /**
293
+ * SDK calls that only work when the matching capability is declared in
294
+ * `package.json#lotics.capabilities`. Used by the deploy pre-flight (GAP-29):
295
+ * code that calls one of these but omits the capability silently 403s at
296
+ * runtime, with no build or deploy error. Extend this map when a new capability
297
+ * gate ships in `@lotics/app-sdk`.
298
+ */
299
+ const CAPABILITY_GATED_CALLS = {
300
+ comments: ["useComments", "createComment", "updateComment", "deleteComment"],
301
+ };
302
+ /**
303
+ * Capabilities the source CALLS but the manifest does not DECLARE — each one
304
+ * silently 403s at runtime (GAP-29). Pure over the concatenated source text so
305
+ * it's unit-testable; the deploy warns (non-blocking) on a non-empty result.
306
+ */
307
+ export function undeclaredCapabilities(sourceText, declared) {
308
+ const used = [];
309
+ for (const [capability, calls] of Object.entries(CAPABILITY_GATED_CALLS)) {
310
+ const isCalled = calls.some((call) => new RegExp(`\\b${call}\\b`).test(sourceText));
311
+ if (isCalled && declared?.[capability] !== true)
312
+ used.push(capability);
313
+ }
314
+ return used;
315
+ }
316
+ /** Concatenated text of the app's `src/` files — the input to the capability pre-flight. */
317
+ function readAppSourceText(projectDir) {
318
+ const srcDir = path.join(projectDir, "src");
319
+ if (!fs.existsSync(srcDir))
320
+ return "";
321
+ const parts = [];
322
+ const walk = (dir) => {
323
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
324
+ const full = path.join(dir, entry.name);
325
+ if (entry.isDirectory())
326
+ walk(full);
327
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry.name))
328
+ parts.push(fs.readFileSync(full, "utf8"));
329
+ }
330
+ };
331
+ walk(srcDir);
332
+ return parts.join("\n");
333
+ }
292
334
  /**
293
335
  * `lotics app deploy [-m <message>]`
294
336
  *
@@ -300,6 +342,16 @@ export async function appPull(client, args) {
300
342
  export async function appDeploy(client, args) {
301
343
  const projectDir = path.resolve(args.projectDir ?? process.cwd());
302
344
  const meta = readAppMeta(projectDir);
345
+ // Pre-flight (GAP-29): a capability the code calls but the manifest doesn't
346
+ // declare silently 403s at runtime with no other signal — warn before shipping
347
+ // it. Non-blocking, like the unbranded nudge below.
348
+ const undeclared = undeclaredCapabilities(readAppSourceText(projectDir), meta.capabilities);
349
+ if (undeclared.length > 0) {
350
+ const block = JSON.stringify(Object.fromEntries(undeclared.map((c) => [c, true])));
351
+ console.error(`\n⚠ This app calls capability-gated SDK functions for ${undeclared.join(", ")} but the ` +
352
+ `manifest doesn't declare ${undeclared.length > 1 ? "them" : "it"} — those calls will 403 at runtime.\n` +
353
+ ` Add to package.json#lotics.capabilities: ${block}`);
354
+ }
303
355
  // Regenerate AppWorkflows typing before the build picks up source. Keeps
304
356
  // .lotics/app_workflows.d.ts in sync with the manifest's workflows map
305
357
  // every time the developer ships.
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { stampPulledManifest } from "./app_commands.js";
5
+ import { stampPulledManifest, undeclaredCapabilities } from "./app_commands.js";
6
6
  /**
7
7
  * `appPull` reads workflows from the live App row (server response), NOT from
8
8
  * the manifest embedded in the extracted source archive. The frozen archive
@@ -104,3 +104,31 @@ describe("stampPulledManifest", () => {
104
104
  expect(dts).toContain("only_live");
105
105
  });
106
106
  });
107
+ /**
108
+ * The deploy pre-flight that catches GAP-29: source that CALLS a capability-gated
109
+ * SDK function while the manifest omits that capability would silently 403 at
110
+ * runtime. `undeclaredCapabilities` is the pure decision the warning is built on.
111
+ */
112
+ describe("undeclaredCapabilities", () => {
113
+ const commentsSource = `import { useComments } from "@lotics/app-sdk";\nconst { available } = useComments({ record_id });`;
114
+ it("flags a capability that's called but not declared", () => {
115
+ expect(undeclaredCapabilities(commentsSource, undefined)).toEqual(["comments"]);
116
+ expect(undeclaredCapabilities(commentsSource, {})).toEqual(["comments"]);
117
+ expect(undeclaredCapabilities(commentsSource, { comments: false })).toEqual(["comments"]);
118
+ });
119
+ it("stays quiet when the called capability is declared", () => {
120
+ expect(undeclaredCapabilities(commentsSource, { comments: true })).toEqual([]);
121
+ });
122
+ it("stays quiet when the capability isn't used at all", () => {
123
+ expect(undeclaredCapabilities(`const x = createInvoice();`, undefined)).toEqual([]);
124
+ expect(undeclaredCapabilities("", { comments: true })).toEqual([]);
125
+ });
126
+ it("matches on whole identifiers, not substrings", () => {
127
+ // `myUseCommentsHelper` is not a call to the gated `useComments`.
128
+ expect(undeclaredCapabilities(`const x = myUseCommentsHelper();`, undefined)).toEqual([]);
129
+ });
130
+ it("detects any of a capability's gated calls", () => {
131
+ expect(undeclaredCapabilities(`await createComment({ body });`, {})).toEqual(["comments"]);
132
+ expect(undeclaredCapabilities(`await deleteComment(id);`, {})).toEqual(["comments"]);
133
+ });
134
+ });
package/dist/client.js CHANGED
@@ -154,7 +154,7 @@ export class LoticsClient {
154
154
  return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}`);
155
155
  }
156
156
  async createApp(body) {
157
- return this.request("POST", "/v1/apps", { ...body, ui: { type: "custom_code" } });
157
+ return this.request("POST", "/v1/apps", body);
158
158
  }
159
159
  /**
160
160
  * Rename an app's public subdomain — its `<slug>.lotics.app` address.