/** * `dench members ` — workspace member roster for CRM * `user`-type fields (and anyone else who needs to look up who is in * the org from the command line). * * Why this exists: * * CRM `user`-typed cells store a Convex `users` doc id. The UI * resolves that id to a real avatar + name against the org's member * roster, which the browser SPA fetches via * `api.functions.organizations.listMembers`. That query is wired to * `getAuthUserId(ctx)` (Convex Auth cookies), so it's invisible to * the dench-cli running in a sandbox under `DENCH_API_KEY` or a * `dch_agent_*` session token from `dench login`. * * Before this command existed, the AI agent had no way to discover * userIds and would write display names as strings into user-typed * cells, which the UI then rendered as unlinked badges. This module * pairs with the new `crm/members.list` Convex query (which * authenticates via `requireCrmAccess` and accepts every CLI bearer) * to give the AI a way to look up `{ userId, name, email, role }` for * every member of the org and assign cells correctly. * * Surfaces: * dench members list (top-level, this module) * dench crm members list (alias dispatched from cli/crm.ts so the * command is also discoverable from * inside the `dench crm` namespace, which * is what the seed skill points the AI * at most of the time) * * Both surfaces share this implementation so the CLI auth + payload * shape stays in lockstep. */ import { readFile } from "node:fs/promises"; import { extname } from "node:path"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { CliArgError, getFlag, hasFlag, shift } from "./lib/cli-args"; class MembersCliError extends Error {} type MembersCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; /** * Bearer forwarded with every Convex call so the server-side * `requireCrmAccess` helper can resolve the caller's organization * without a Convex Auth cookie. Two accepted formats: * • `dch_agent_*` agent session token minted by `dench login`. * • Unified Dench API key (`dench_pk_*`, `dench_live_*`, etc.) * baked into sandbox envVars as `DENCH_API_KEY`. */ sessionToken?: string; }; type MemberRow = { userId: string; name: string | null; email: string | null; image: string | null; tagline?: string | null; timezone?: string | null; presence?: "online" | "away" | "offline"; lastActiveAt?: number | null; role: "ADMIN" | "MEMBER"; joinedAt: number; }; type ListMembersResult = { members: MemberRow[]; organizationId: string; organizationSlug: string | null; organizationName: string; }; const api = { members: { list: makeFunctionReference<"query">("functions/crm/members:list"), }, profiles: { get: makeFunctionReference<"query">( "functions/memberProfiles:getMemberProfile", ), me: makeFunctionReference<"query">("functions/memberProfiles:getMyProfile"), activity: makeFunctionReference<"query">( "functions/memberProfiles:listMemberActivity", ), presence: makeFunctionReference<"query">( "functions/memberProfiles:getPresence", ), tasks: makeFunctionReference<"query">( "functions/crm/tasks:listOpenTasksForMember", ), updateMe: makeFunctionReference<"mutation">( "functions/memberProfiles:updateMyProfile", ), avatarUploadUrl: makeFunctionReference<"mutation">( "functions/memberProfiles:generateAvatarUploadUrl", ), setAvatar: makeFunctionReference<"mutation">( "functions/memberProfiles:setMyAvatar", ), clearAvatar: makeFunctionReference<"mutation">( "functions/memberProfiles:clearMyAvatar", ), }, }; const AVATAR_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", }; export async function runMembersCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: MembersCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, }; const subcommand = args.shift(); if (!subcommand || subcommand === "help" || subcommand === "--help") { membersHelp(); return; } switch (subcommand) { case "list": return await runListMembers(ctx); case "get": return await runGetMember(ctx); case "activity": return await runMemberActivity(ctx); case "tasks": return await runMemberTasks(ctx); case "presence": return await runPresence(ctx); case "me": return await runMe(ctx); default: throw new MembersCliError( `Unknown members subcommand: ${subcommand}. Run \`dench members help\`.`, ); } } async function runListMembers(ctx: MembersCliContext): Promise { if (ctx.args.some((arg) => arg.startsWith("--"))) { // No subflags supported beyond --json (which hasFlag already drained). // Surface a typo with a useful hint so the AI doesn't silently get a // full list when it asked for a filter that doesn't exist. throw new MembersCliError( `Unknown flag: ${ctx.args.find((arg) => arg.startsWith("--"))}. \`dench members list\` only accepts --json.`, ); } const result = (await callQuery(ctx, api.members.list, {})) as | ListMembersResult | null | undefined; if (ctx.jsonOutput) { console.log(JSON.stringify(result ?? { members: [] }, null, 2)); return; } const members = result?.members ?? []; if (members.length === 0) { console.log("(no members)"); return; } // Plain table for human consumption — userIds are first column so the // operator can copy/paste directly into a `dench crm cells set ... // --field Owner --value ` follow-up. const headers = ["userId", "role", "name", "email", "presence", "timezone"]; const rows = members.map((m) => [ m.userId, m.role, m.name ?? "", m.email ?? "", m.presence ?? "", m.timezone ?? "", ]); const widths = headers.map((header, idx) => Math.max(header.length, ...rows.map((row) => row[idx].length)), ); const formatRow = (row: string[]) => row.map((cell, idx) => cell.padEnd(widths[idx])).join(" "); console.log(formatRow(headers)); console.log(widths.map((w) => "-".repeat(w)).join(" ")); for (const row of rows) { console.log(formatRow(row)); } } /** * `` positional. Emails are resolved against the roster so a * script can say `dench members get jane@acme.com`. */ async function resolveMemberId( ctx: MembersCliContext, raw: string, ): Promise { const value = raw.trim(); if (!value.includes("@")) return value; const result = (await callQuery(ctx, api.members.list, {})) as | ListMembersResult | null | undefined; const match = (result?.members ?? []).find( (member) => member.email?.toLowerCase() === value.toLowerCase(), ); if (!match) { throw new MembersCliError( `No member with email ${value}. Run \`dench members list\` to see the roster.`, ); } return match.userId; } function out(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } function assertNoFlags(ctx: MembersCliContext, command: string): void { const stray = ctx.args.find((arg) => arg.startsWith("--")); if (stray) { throw new MembersCliError(`Unknown flag for ${command}: ${stray}`); } } async function runGetMember(ctx: MembersCliContext): Promise { const target = shiftArg(ctx, "member id or email"); assertNoFlags(ctx, "dench members get"); const userId = await resolveMemberId(ctx, target); const profile = await callQuery(ctx, api.profiles.get, { userId }); if (!profile) { throw new MembersCliError(`No member ${target} in this workspace.`); } out(profile); } async function runMemberActivity(ctx: MembersCliContext): Promise { const target = shiftArg(ctx, "member id or email"); const rawLimit = getFlag(ctx.args, "--limit"); const rawKinds = getFlag(ctx.args, "--kinds"); assertNoFlags(ctx, "dench members activity"); const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10); if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) { throw new MembersCliError("--limit must be a positive integer"); } const kinds = rawKinds ?.split(",") .map((kind) => kind.trim()) .filter(Boolean); for (const kind of kinds ?? []) { if (kind !== "comments" && kind !== "changes") { throw new MembersCliError("--kinds accepts comments,changes"); } } const userId = await resolveMemberId(ctx, target); out( await callQuery(ctx, api.profiles.activity, { userId, ...(limit !== undefined ? { limit } : {}), ...(kinds && kinds.length > 0 ? { kinds } : {}), }), ); } async function runMemberTasks(ctx: MembersCliContext): Promise { const target = shiftArg(ctx, "member id or email"); const rawLimit = getFlag(ctx.args, "--limit"); const cursor = getFlag(ctx.args, "--cursor"); const paged = hasFlag(ctx.args, "--page") || cursor !== undefined; const done = hasFlag(ctx.args, "--done"); assertNoFlags(ctx, "dench members tasks"); const userId = await resolveMemberId(ctx, target); const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); out( await callQuery( ctx, paged || done ? makeFunctionReference<"query">( "functions/crm/tasks:pageAssignedTasks", ) : api.profiles.tasks, { userId, todayStartMs: todayStart.getTime(), ...(paged || done ? { done, paginationOpts: { cursor: cursor ?? null, numItems: Number.parseInt(rawLimit ?? "25", 10), }, } : rawLimit ? { limit: Number.parseInt(rawLimit, 10) } : {}), }, ), ); } async function runPresence(ctx: MembersCliContext): Promise { const targets = ctx.args.filter((arg) => !arg.startsWith("--")); ctx.args.splice( 0, ctx.args.length, ...ctx.args.filter((arg) => arg.startsWith("--")), ); assertNoFlags(ctx, "dench members presence"); const userIds = await Promise.all( targets.map((target) => resolveMemberId(ctx, target)), ); out( await callQuery(ctx, api.profiles.presence, { ...(userIds.length > 0 ? { userIds } : {}), }), ); } /** `dench members me [--name --tagline --timezone --image-url --avatar --clear-avatar]` */ async function runMe(ctx: MembersCliContext): Promise { const name = getFlag(ctx.args, "--name"); const tagline = getFlag(ctx.args, "--tagline"); const timezone = getFlag(ctx.args, "--timezone"); const imageUrl = getFlag(ctx.args, "--image-url"); const avatarPath = getFlag(ctx.args, "--avatar"); const clearAvatar = hasFlag(ctx.args, "--clear-avatar"); const autoTimezone = hasFlag(ctx.args, "--auto-timezone"); assertNoFlags(ctx, "dench members me"); if (avatarPath && (clearAvatar || imageUrl !== undefined)) { throw new MembersCliError( "Pass one of --avatar , --image-url , or --clear-avatar.", ); } const patch: Record = {}; if (name !== undefined) patch.name = name; if (tagline !== undefined) patch.tagline = tagline; if (imageUrl !== undefined) patch.imageUrl = imageUrl; if (timezone !== undefined) patch.timezone = timezone; if (autoTimezone) patch.timezone = null; if (Object.keys(patch).length > 0) { await callMutation(ctx, api.profiles.updateMe, patch); } if (clearAvatar) await callMutation(ctx, api.profiles.clearAvatar, {}); if (avatarPath) await uploadAvatar(ctx, avatarPath); out(await callQuery(ctx, api.profiles.me, {})); } async function uploadAvatar( ctx: MembersCliContext, filePath: string, ): Promise { const contentType = AVATAR_TYPES[extname(filePath).toLowerCase()]; if (!contentType) { throw new MembersCliError( "--avatar must be a .png, .jpg, .jpeg, .webp, .gif, or .avif file", ); } const bytes = await readFile(filePath); if (bytes.byteLength > 2 * 1024 * 1024) { throw new MembersCliError("--avatar must be under 2 MB"); } const uploadUrl = (await callMutation( ctx, api.profiles.avatarUploadUrl, {}, )) as string; const response = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": contentType }, body: new Uint8Array(bytes), }); if (!response.ok) { throw new MembersCliError(`Avatar upload failed (${response.status})`); } const { storageId } = (await response.json()) as { storageId: string }; await callMutation(ctx, api.profiles.setAvatar, { storageId }); } function shiftArg(ctx: MembersCliContext, expected: string): string { try { return shift(ctx.args, expected); } catch (error) { if (error instanceof CliArgError) throw new MembersCliError(error.message); throw error; } } async function callMutation( ctx: MembersCliContext, fn: Parameters[0], args: Record = {}, ): Promise { try { return await ctx.convex.mutation(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { if (error instanceof CliArgError) throw new MembersCliError(error.message); const data = (error as { data?: unknown } | null)?.data; if (typeof data === "string" && data.trim()) { throw new MembersCliError(data.trim()); } throw error; } } async function callQuery( ctx: MembersCliContext, fn: Parameters[0], args: Record = {}, ): Promise { try { return await ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { if (error instanceof CliArgError) throw new MembersCliError(error.message); throw error; } } function membersHelp(): void { console.log(membersHelpText()); } export function membersHelpText(): string { return `Usage: dench members Workspace members for the active organization. Use these userIds when assigning CRM \`user\`-type cells (e.g. \`Owner\`, \`Assignee\`) so the UI shows real linked avatars instead of unlinked literal strings. Use one id for one assignee or a JSON id array for multiple assignees; preserve every owner from the source data. Subcommands: dench members list [--json] Every member as \`userId / role / name / email / presence / timezone\`. --json adds tagline, lastActiveAt, organizationId / Slug / Name. dench members get One member's profile: name, email, tagline, role, joined, timezone, presence (online / away / offline + lastActiveAt), open task count. dench members activity [--limit N] [--kinds comments,changes] What they did across the CRM, newest first: their comments and the field changes they made (Dench / automations acting for them excluded). dench members tasks [--limit N] [--page] [--cursor TOKEN] [--done] Assigned-task preview, or a cursor page for complete traversal. dench members presence [...] Live presence for some or all members. dench members me [--name "…"] [--tagline "…"] [--timezone |--auto-timezone] [--avatar ./photo.png | --image-url | --clear-avatar] Read (no flags) or update your own profile. Agent sessions act as their owner; an org API key has no person behind it and is refused. Alias: dench crm members list [--json] Same surface, dispatched from inside the \`dench crm\` namespace since the canonical use case is assigning CRM \`user\` fields. After picking a userId (confirm field names with \`dench crm fields list \` — the default \`task\` uses \`Assignee\`, not \`Owner\`, and some workspaces rename \`Name\` to \`Title\`): dench crm cells set task Assignee '""' dench crm cells set task Assignee '["",""]' dench crm entries create task --data '{"Name":"Ship CRM","Assignee":""}' The server best-effort-resolves a name / email passed to a \`user\` field when the match against the roster is unique, but the explicit lookup via \`dench members list\` is the correct pattern. Unknown field names are rejected on write (pass --allow-unknown to bypass). `; }