/** * `dench apps` — the App Store surface (create / list / gallery / install / * publish / uninstall / enable / pin). Distinct from `dench integrations` * (Composio connected apps). Backed by convex/functions/appStore.ts — the same * functions the workspace App Store panel and the API v1 `/apps/*` routes call. * * Thin dispatcher; forwards the caller's bearer through `sessionToken`, which * the server's requireCrmAccess accepts (and appStore forwards to the file * functions as an apiKey for publish/install/create). */ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative, resolve } from "node:path"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { type AppFile, formatValidation, validateApp, } from "./lib/apps-validate"; import { closestMatch } from "./lib/levenshtein"; const api = { listInstalled: makeFunctionReference<"query">( "functions/appStore:listInstalled", ), listListings: makeFunctionReference<"query">( "functions/appStore:listListings", ), listGallery: makeFunctionReference<"query">("functions/appStore:listGallery"), install: makeFunctionReference<"action">("functions/appStore:install"), publish: makeFunctionReference<"action">("functions/appStore:publish"), createApp: makeFunctionReference<"action">("functions/appStore:createApp"), uninstall: makeFunctionReference<"mutation">("functions/appStore:uninstall"), setEnabled: makeFunctionReference<"mutation">( "functions/appStore:setEnabled", ), setPinned: makeFunctionReference<"mutation">("functions/appStore:setPinned"), listTree: makeFunctionReference<"query">("functions/files:listTree"), getFileContent: makeFunctionReference<"query">( "functions/files:getFileContent", ), webhooksRegister: makeFunctionReference<"mutation">( "functions/appWebhooks:register", ), webhooksList: makeFunctionReference<"query">("functions/appWebhooks:list"), webhooksPoll: makeFunctionReference<"query">("functions/appWebhooks:poll"), webhooksDelete: makeFunctionReference<"mutation">( "functions/appWebhooks:remove", ), }; class AppsCliError extends Error {} type Ctx = { convex: ConvexHttpClient; args: string[]; sessionToken?: string; jsonOutput: boolean; }; function shift(args: string[], label: string): string { const value = args.shift(); if (!value) throw new AppsCliError(`Missing ${label}`); return value; } function takeFlag(args: string[], name: string): string | undefined { const i = args.indexOf(name); if (i === -1) return undefined; const next = args[i + 1]; if (next !== undefined && !next.startsWith("--")) { args.splice(i, 2); return next; } args.splice(i, 1); return undefined; } function firstPositional(args: string[]): string | undefined { return args.find((a) => !a.startsWith("--")); } function out(ctx: Ctx, value: unknown) { console.log(JSON.stringify(value, null, ctx.jsonOutput ? 0 : 2)); } function withAuth(ctx: Ctx, args: Record) { return { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never; } function appsHelp(): void { console.log(appsHelpText()); } export function appsHelpText(): string { return `Usage: dench apps (App Store — not connected apps; see 'dench integrations') dench apps list [--json] Installed apps in this workspace dench apps gallery [--json] Discover listings — Official (Dench) + Community dench apps info [--json] One installed app's record dench apps create --name "My App" [--icon ] [--description "…"] Scaffold .dench.yaml + index.html and register it dench apps install [--json] Install a gallery listing into this workspace dench apps publish --slug --name "My App" \\ [--description "…"] [--icon ] [--visibility org|public|official|unlisted] [--version 1.0.0] Bundle an app's files into a version + listing dench apps uninstall Remove an installed app dench apps enable | disable Toggle whether the app loads dench apps pin | unpin Toggle the rail pin dench apps validate [--json] Check the manifest, permissions and every window.dench call (local folder, or a workspace path like /apps/x.dench.app) dench apps webhooks register [--rotate] Inbound webhook URL + signing secret (secret shown once) dench apps webhooks list dench apps webhooks poll [--since ] [--limit ] dench apps webhooks delete `; } const TEXT_FILE = /\.(html?|js|mjs|cjs|ts|jsx|tsx|css|json|ya?ml|md|txt|svg)$/i; function readLocalAppFolder(root: string): AppFile[] { const files: AppFile[] = []; const walk = (dir: string) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const abs = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== ".git") walk(abs); continue; } if (!entry.isFile()) continue; const path = relative(root, abs).replace(/\\/g, "/"); if (path !== ".dench.yaml" && !TEXT_FILE.test(path)) continue; files.push({ path, content: readFileSync(abs, "utf8") }); } }; walk(root); return files; } /** Local folder → disk; otherwise read the workspace tree from Convex. */ async function loadAppFolder( ctx: Ctx, appPath: string, ): Promise<{ files: AppFile[]; source: "disk" | "workspace" }> { const workspaceRoot = process.env.DENCH_WORKSPACE_ROOT?.trim() || "/workspace"; const candidates = [ resolve(appPath), join(workspaceRoot, appPath), join(process.cwd(), appPath.replace(/^\/+/, "")), ]; for (const candidate of candidates) { if (existsSync(candidate) && statSync(candidate).isDirectory()) { return { files: readLocalAppFolder(candidate), source: "disk" }; } } const prefix = appPath.startsWith("/") ? appPath : `/${appPath}`; const rows: AppFile[] = []; const walk = async (dir: string) => { const entries = (await ctx.convex.query( api.listTree, withAuth(ctx, { prefix: dir }), )) as Array<{ path: string; isDir: boolean }>; for (const entry of entries) { if (entry.isDir) { await walk(entry.path); continue; } const rel = entry.path.slice(prefix.length).replace(/^\/+/, ""); if (rel !== ".dench.yaml" && !TEXT_FILE.test(rel)) continue; const content = (await ctx.convex.query( api.getFileContent, withAuth(ctx, { path: entry.path }), )) as { content?: string | null } | null; if (typeof content?.content === "string") { rows.push({ path: rel, content: content.content }); } } }; await walk(prefix); if (rows.length === 0) { throw new AppsCliError( `No app folder at ${appPath} (looked on disk and in the workspace).`, ); } return { files: rows, source: "workspace" }; } async function runValidate(ctx: Ctx) { const appPath = firstPositional(ctx.args); if (!appPath) { throw new AppsCliError("Usage: dench apps validate [--json]"); } const { files, source } = await loadAppFolder(ctx, appPath); const manifest = files.find((f) => f.path === ".dench.yaml"); const result = validateApp({ manifestYaml: manifest?.content ?? null, files: files.filter((f) => f.path !== ".dench.yaml"), }); if (ctx.jsonOutput) { out(ctx, { appPath, source, ...result }); } else { console.log(formatValidation(result, appPath)); } if (!result.ok) process.exitCode = 1; } async function runWebhooks(ctx: Ctx) { const verb = ctx.args.shift(); const usage = "Usage: dench apps webhooks register|list|poll|delete [hookName]"; switch (verb) { case "register": { const rotateIndex = ctx.args.indexOf("--rotate"); const rotate = rotateIndex !== -1; if (rotate) ctx.args.splice(rotateIndex, 1); const slug = shift(ctx.args, "app slug"); const hookName = shift(ctx.args, "hook name"); const result = (await ctx.convex.mutation( api.webhooksRegister, withAuth(ctx, { slug, hookName, rotateSecret: rotate }), )) as { url: string | null; path: string; secret: string | null; created: boolean; }; const base = process.env.DENCH_API_URL?.trim().replace(/\/+$/, ""); const url = result.url ?? (base ? `${base}${result.path}` : result.path); if (ctx.jsonOutput) { out(ctx, { ...result, url }); return; } console.log( `${result.created ? "Registered" : "Existing"} webhook "${hookName}" for ${slug}`, ); console.log(` URL: ${url}`); if (result.secret) { console.log( ` Secret: ${result.secret} (shown once — store it with the sender)`, ); } else { console.log(" Secret: (unchanged; pass --rotate to mint a new one)"); } return; } case "list": { const slug = shift(ctx.args, "app slug"); out( ctx, await ctx.convex.query(api.webhooksList, withAuth(ctx, { slug })), ); return; } case "poll": { const since = takeFlag(ctx.args, "--since"); const limit = takeFlag(ctx.args, "--limit"); const slug = shift(ctx.args, "app slug"); const hookName = shift(ctx.args, "hook name"); out( ctx, await ctx.convex.query( api.webhooksPoll, withAuth(ctx, { slug, hookName, ...(since ? { since: Number(since) } : {}), ...(limit ? { limit: Number(limit) } : {}), }), ), ); return; } case "delete": { const slug = shift(ctx.args, "app slug"); const hookName = shift(ctx.args, "hook name"); out( ctx, await ctx.convex.mutation( api.webhooksDelete, withAuth(ctx, { slug, hookName }), ), ); return; } default: throw new AppsCliError(usage); } } export async function runAppsCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; }): Promise { const jsonOutput = opts.args.includes("--json"); const args = opts.args.filter((a) => a !== "--json"); const ctx: Ctx = { convex: opts.convex, args, sessionToken: opts.sessionToken, jsonOutput, }; const sub = ctx.args.shift(); switch (sub) { case undefined: if (jsonOutput) { out(ctx, await ctx.convex.query(api.listInstalled, withAuth(ctx, {}))); return; } appsHelp(); return; case "help": case "--help": appsHelp(); return; case "list": out(ctx, await ctx.convex.query(api.listInstalled, withAuth(ctx, {}))); return; case "gallery": out(ctx, await ctx.convex.query(api.listGallery, withAuth(ctx, {}))); return; case "info": { const slug = shift(ctx.args, "app slug"); const installed = (await ctx.convex.query( api.listInstalled, withAuth(ctx, {}), )) as Array<{ slug: string }>; out(ctx, installed.find((a) => a.slug === slug) ?? null); return; } case "create": { const name = takeFlag(ctx.args, "--name"); const icon = takeFlag(ctx.args, "--icon"); const description = takeFlag(ctx.args, "--description"); const slug = firstPositional(ctx.args) ?? name; if (!slug) { throw new AppsCliError( 'Usage: dench apps create --name "My App" [--icon ]', ); } out( ctx, await ctx.convex.action( api.createApp, withAuth(ctx, { slug, name: name ?? slug, ...(icon ? { icon } : {}), ...(description ? { description } : {}), }), ), ); return; } case "install": { const listingId = shift(ctx.args, "listing id"); out( ctx, await ctx.convex.action(api.install, withAuth(ctx, { listingId })), ); return; } case "publish": { const slug = takeFlag(ctx.args, "--slug"); const name = takeFlag(ctx.args, "--name"); const description = takeFlag(ctx.args, "--description"); const icon = takeFlag(ctx.args, "--icon"); const visibility = takeFlag(ctx.args, "--visibility"); const version = takeFlag(ctx.args, "--version"); const appPath = firstPositional(ctx.args); if (!appPath || !slug || !name) { throw new AppsCliError( 'Usage: dench apps publish --slug --name "My App"', ); } out( ctx, await ctx.convex.action( api.publish, withAuth(ctx, { appPath, slug, name, ...(description ? { description } : {}), ...(icon ? { icon } : {}), ...(visibility ? { visibility } : {}), ...(version ? { version } : {}), }), ), ); return; } case "uninstall": { const slug = shift(ctx.args, "app slug"); out( ctx, await ctx.convex.mutation(api.uninstall, withAuth(ctx, { slug })), ); return; } case "enable": case "disable": { const slug = shift(ctx.args, "app slug"); await ctx.convex.mutation( api.setEnabled, withAuth(ctx, { slug, enabled: sub === "enable" }), ); out(ctx, { ok: true, slug, enabled: sub === "enable" }); return; } case "validate": await runValidate(ctx); return; case "webhooks": await runWebhooks(ctx); return; case "pin": case "unpin": { const slug = ctx.args.shift(); if (!slug) { throw new AppsCliError( `Missing app slug. Usage: dench apps ${sub} `, ); } await ctx.convex.mutation( api.setPinned, withAuth(ctx, { slug, pinned: sub === "pin" }), ); out(ctx, { ok: true, slug, pinned: sub === "pin" }); return; } default: { const suggestion = closestMatch(sub, APPS_SUBCOMMANDS); throw new AppsCliError( `Unknown apps subcommand: ${sub}` + `${suggestion ? `. Did you mean "${suggestion}"?` : ""}` + " Run 'dench apps help' for usage.", ); } } } /** Every verb the dispatcher accepts (for "did you mean" suggestions). */ const APPS_SUBCOMMANDS = [ "list", "gallery", "info", "create", "install", "publish", "uninstall", "enable", "disable", "pin", "unpin", "validate", "webhooks", "help", ] as const;