@andocorp/cli 0.1.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.
@@ -0,0 +1,149 @@
1
+ import * as Shared from "@ando/shared";
2
+ import { DiscoveredWorkspace, Membership } from "./types.js";
3
+
4
+ export function isChannel(membership: Membership) {
5
+ return membership.conversation.type === Shared.Models.ConversationType.CHANNEL;
6
+ }
7
+
8
+ function normalizeForMatch(value: string) {
9
+ return value.trim().toLowerCase();
10
+ }
11
+
12
+ export function compareByRecentActivity(left: Membership, right: Membership) {
13
+ const leftDate = new Date(
14
+ left.conversation.last_message_at ?? left.conversation.updated_at
15
+ ).getTime();
16
+ const rightDate = new Date(
17
+ right.conversation.last_message_at ?? right.conversation.updated_at
18
+ ).getTime();
19
+
20
+ return rightDate - leftDate;
21
+ }
22
+
23
+ export function getWorkspaceLabel(workspace: DiscoveredWorkspace) {
24
+ return `${workspace.name} (${workspace.slug})`;
25
+ }
26
+
27
+ export function resolveWorkspace(
28
+ workspaces: DiscoveredWorkspace[],
29
+ query: string | null
30
+ ) {
31
+ if (workspaces.length === 0) {
32
+ throw new Error("No workspaces were returned for this account.");
33
+ }
34
+
35
+ if (query == null || query.trim() === "") {
36
+ if (workspaces.length === 1) {
37
+ return workspaces[0] ?? null;
38
+ }
39
+
40
+ throw new Error("Multiple workspaces are available. Pass --workspace or choose one interactively.");
41
+ }
42
+
43
+ const normalizedQuery = normalizeForMatch(query);
44
+ const exactMatch =
45
+ workspaces.find((workspace) => {
46
+ return (
47
+ workspace.id === query ||
48
+ normalizeForMatch(workspace.slug) === normalizedQuery ||
49
+ normalizeForMatch(workspace.name) === normalizedQuery
50
+ );
51
+ }) ?? null;
52
+
53
+ if (exactMatch != null) {
54
+ return exactMatch;
55
+ }
56
+
57
+ const partialMatches = workspaces.filter((workspace) => {
58
+ return [workspace.id, workspace.slug, workspace.name].some((value) =>
59
+ normalizeForMatch(value).includes(normalizedQuery)
60
+ );
61
+ });
62
+
63
+ if (partialMatches.length === 1) {
64
+ return partialMatches[0] ?? null;
65
+ }
66
+
67
+ if (partialMatches.length === 0) {
68
+ throw new Error(`No workspace matched "${query}".`);
69
+ }
70
+
71
+ throw new Error(
72
+ `Workspace "${query}" is ambiguous: ${partialMatches
73
+ .slice(0, 8)
74
+ .map(getWorkspaceLabel)
75
+ .join(", ")}`
76
+ );
77
+ }
78
+
79
+ export function resolveConversation(
80
+ memberships: Membership[],
81
+ options: {
82
+ allowDms: boolean;
83
+ allowChannels: boolean;
84
+ query: string;
85
+ }
86
+ ) {
87
+ const filtered = memberships
88
+ .filter((membership) => {
89
+ if (!options.allowChannels && isChannel(membership)) {
90
+ return false;
91
+ }
92
+
93
+ if (!options.allowDms && !isChannel(membership)) {
94
+ return false;
95
+ }
96
+
97
+ return true;
98
+ })
99
+ .sort(compareByRecentActivity);
100
+
101
+ const normalizedQuery = normalizeForMatch(options.query);
102
+ const exactMatch =
103
+ filtered.find((membership) => {
104
+ return (
105
+ membership.conversation.id === options.query ||
106
+ normalizeForMatch(membership.conversation.name ?? "") === normalizedQuery
107
+ );
108
+ }) ?? null;
109
+
110
+ if (exactMatch != null) {
111
+ return exactMatch;
112
+ }
113
+
114
+ const partialMatches = filtered.filter((membership) => {
115
+ const fields = [
116
+ membership.conversation.id,
117
+ membership.conversation.name ?? "",
118
+ getConversationLabel(membership),
119
+ ];
120
+
121
+ return fields.some((value) =>
122
+ normalizeForMatch(value).includes(normalizedQuery)
123
+ );
124
+ });
125
+
126
+ if (partialMatches.length === 1) {
127
+ return partialMatches[0] ?? null;
128
+ }
129
+
130
+ if (partialMatches.length === 0) {
131
+ throw new Error(`No conversation matched "${options.query}".`);
132
+ }
133
+
134
+ throw new Error(
135
+ `Conversation "${options.query}" is ambiguous: ${partialMatches
136
+ .slice(0, 8)
137
+ .map((membership) => getConversationLabel(membership))
138
+ .join(", ")}`
139
+ );
140
+ }
141
+
142
+ export function getConversationLabel(membership: Membership) {
143
+ const prefix =
144
+ membership.conversation.type === Shared.Models.ConversationType.CHANNEL
145
+ ? "#"
146
+ : "@";
147
+
148
+ return `${prefix}${membership.conversation.name || membership.conversation.id}`;
149
+ }
@@ -0,0 +1,235 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createMe, createMessage } from "./test-helpers.js";
3
+
4
+ const clientMocks = vi.hoisted(() => {
5
+ return {
6
+ action: vi.fn(),
7
+ constructor: vi.fn(),
8
+ mutation: vi.fn(),
9
+ query: vi.fn(),
10
+ setAuth: vi.fn(),
11
+ };
12
+ });
13
+
14
+ vi.mock("convex/browser", () => {
15
+ return {
16
+ ConvexHttpClient: class {
17
+ constructor(...args: unknown[]) {
18
+ clientMocks.constructor(...args);
19
+ }
20
+
21
+ action = clientMocks.action;
22
+ mutation = clientMocks.mutation;
23
+ query = clientMocks.query;
24
+ setAuth = clientMocks.setAuth;
25
+ },
26
+ };
27
+ });
28
+
29
+ vi.mock("../../convex/convex/_generated/api.js", () => {
30
+ return {
31
+ api: {
32
+ app: {
33
+ bootstrap: "bootstrap",
34
+ createMessage: "createMessage",
35
+ createReaction: "createReaction",
36
+ getMessage: "getMessage",
37
+ listConversationMessagesPaginated: "listConversationMessagesPaginated",
38
+ listThreadReplies: "listThreadReplies",
39
+ },
40
+ auth: {
41
+ discoverWorkspaces: "discoverWorkspaces",
42
+ refreshSessionJwt: "refreshSessionJwt",
43
+ selectWorkspace: "selectWorkspace",
44
+ sendEmailOtp: "sendEmailOtp",
45
+ },
46
+ },
47
+ };
48
+ });
49
+
50
+ describe("createAndoCliClient", () => {
51
+ beforeEach(() => {
52
+ clientMocks.action.mockReset();
53
+ clientMocks.constructor.mockReset();
54
+ clientMocks.mutation.mockReset();
55
+ clientMocks.query.mockReset();
56
+ clientMocks.setAuth.mockReset();
57
+ });
58
+
59
+ it("loads me from bootstrap", async () => {
60
+ const { createAndoCliClient } = await import("./client.js");
61
+ clientMocks.query.mockResolvedValue({
62
+ me: createMe({
63
+ created_at: 1_700_000_000_000 as unknown as Date,
64
+ }),
65
+ memberships: [],
66
+ });
67
+
68
+ const client = createAndoCliClient({
69
+ convexUrl: "https://convex.example.com",
70
+ sessionToken: "session-token",
71
+ });
72
+ const me = await client.getMe();
73
+
74
+ expect(clientMocks.setAuth).toHaveBeenCalledWith("session-token");
75
+ expect(clientMocks.query).toHaveBeenCalledWith("bootstrap", {});
76
+ expect(me?.workspace.name).toBe("Ando");
77
+ expect(me?.created_at).toBeInstanceOf(Date);
78
+ });
79
+
80
+ it("reuses the bootstrap result across me and memberships", async () => {
81
+ const { createAndoCliClient } = await import("./client.js");
82
+ clientMocks.query.mockResolvedValue({
83
+ me: createMe(),
84
+ memberships: [],
85
+ });
86
+
87
+ const client = createAndoCliClient({
88
+ convexUrl: "https://convex.example.com",
89
+ sessionToken: "session-token",
90
+ });
91
+
92
+ await client.getMe();
93
+ await client.getAllMemberships();
94
+
95
+ expect(clientMocks.query).toHaveBeenCalledTimes(1);
96
+ expect(clientMocks.query).toHaveBeenCalledWith("bootstrap", {});
97
+ });
98
+
99
+ it("constructs the authenticated Convex client once per CLI client", async () => {
100
+ const { createAndoCliClient } = await import("./client.js");
101
+ clientMocks.query
102
+ .mockResolvedValueOnce({
103
+ me: createMe(),
104
+ memberships: [],
105
+ })
106
+ .mockResolvedValueOnce({
107
+ page: [],
108
+ isDone: true,
109
+ continueCursor: "",
110
+ });
111
+
112
+ const client = createAndoCliClient({
113
+ convexUrl: "https://convex.example.com",
114
+ sessionToken: "session-token",
115
+ });
116
+
117
+ await client.getMe();
118
+ await client.getConversationMessages("conversation-1", 10);
119
+
120
+ expect(clientMocks.constructor).toHaveBeenCalledTimes(2);
121
+ expect(clientMocks.constructor).toHaveBeenNthCalledWith(
122
+ 1,
123
+ "https://convex.example.com"
124
+ );
125
+ expect(clientMocks.constructor).toHaveBeenNthCalledWith(
126
+ 2,
127
+ "https://convex.example.com"
128
+ );
129
+ expect(clientMocks.setAuth).toHaveBeenCalledTimes(1);
130
+ expect(clientMocks.setAuth).toHaveBeenCalledWith("session-token");
131
+ });
132
+
133
+ it("reverses paginated message results", async () => {
134
+ const { createAndoCliClient } = await import("./client.js");
135
+ clientMocks.query.mockResolvedValue({
136
+ page: [
137
+ createMessage({ id: "message-3", created_at: 3 as unknown as Date }),
138
+ createMessage({ id: "message-2", created_at: 2 as unknown as Date }),
139
+ createMessage({ id: "message-1", created_at: 1 as unknown as Date }),
140
+ ],
141
+ isDone: false,
142
+ continueCursor: "cursor-2",
143
+ });
144
+
145
+ const client = createAndoCliClient({
146
+ convexUrl: "https://convex.example.com",
147
+ sessionToken: "session-token",
148
+ });
149
+ const page = await client.getConversationMessages("conversation-1", 10);
150
+
151
+ expect(clientMocks.query).toHaveBeenCalledWith(
152
+ "listConversationMessagesPaginated",
153
+ {
154
+ conversationId: "conversation-1",
155
+ paginationOpts: {
156
+ cursor: null,
157
+ numItems: 10,
158
+ },
159
+ }
160
+ );
161
+ expect(page.items.map((item) => item.id)).toEqual([
162
+ "message-1",
163
+ "message-2",
164
+ "message-3",
165
+ ]);
166
+ expect(page.cursor).toBe("cursor-2");
167
+ });
168
+
169
+ it("posts a message with the Convex mutation payload the web app expects", async () => {
170
+ const { createAndoCliClient } = await import("./client.js");
171
+ clientMocks.mutation.mockResolvedValue(createMessage());
172
+
173
+ const client = createAndoCliClient({
174
+ convexUrl: "https://convex.example.com",
175
+ sessionToken: "session-token",
176
+ });
177
+
178
+ await client.postMessage({
179
+ conversationId: "conversation-1",
180
+ markdownContent: "hello",
181
+ threadRootId: "message-root",
182
+ });
183
+
184
+ expect(clientMocks.mutation).toHaveBeenCalledWith("createMessage", {
185
+ conversationId: "conversation-1",
186
+ clientRequestId: null,
187
+ markdownContent: "hello",
188
+ explicitContextMessageIds: [],
189
+ imageUrls: [],
190
+ fileIds: [],
191
+ initiatorId: null,
192
+ suppressedLinkPreviewUrls: [],
193
+ callRootId: null,
194
+ threadRootMessageId: "message-root",
195
+ });
196
+ });
197
+
198
+ it("adapts discovered workspaces returned by auth discovery", async () => {
199
+ const { createAndoCliClient } = await import("./client.js");
200
+ clientMocks.action.mockResolvedValue({
201
+ success: true,
202
+ workspaces: [
203
+ {
204
+ id: "workspace-1",
205
+ stytch_organization_id: "org-1",
206
+ name: "Ando",
207
+ slug: "ando",
208
+ image_url: null,
209
+ icon_image_file_id: null,
210
+ description: null,
211
+ invite_link_id: "invite-1",
212
+ created_at: 1_700_000_000_000,
213
+ updated_at: 1_700_000_000_000,
214
+ member_count: 3,
215
+ },
216
+ ],
217
+ intermediate_session_token: "intermediate-token",
218
+ requires_workspace_creation: false,
219
+ });
220
+
221
+ const client = createAndoCliClient({
222
+ convexUrl: "https://convex.example.com",
223
+ });
224
+ const discovery = await client.discoverWorkspaces({
225
+ email: "alex@ando.so",
226
+ code: "123456",
227
+ });
228
+
229
+ expect(clientMocks.action).toHaveBeenCalledWith("discoverWorkspaces", {
230
+ email: "alex@ando.so",
231
+ code: "123456",
232
+ });
233
+ expect(discovery.workspaces[0]?.created_at).toBeInstanceOf(Date);
234
+ });
235
+ });