@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.
- package/README.md +136 -0
- package/package.json +42 -0
- package/src/adapters.test.ts +50 -0
- package/src/adapters.ts +215 -0
- package/src/args.test.ts +28 -0
- package/src/args.ts +98 -0
- package/src/cli-helpers.test.ts +82 -0
- package/src/cli-helpers.ts +149 -0
- package/src/client.test.ts +235 -0
- package/src/client.ts +378 -0
- package/src/components/prompt-line.ts +179 -0
- package/src/components/transcript-pane.test.ts +26 -0
- package/src/components/transcript-pane.ts +457 -0
- package/src/config.ts +53 -0
- package/src/emoji-suggestions.ts +152 -0
- package/src/format.test.ts +54 -0
- package/src/format.ts +611 -0
- package/src/help.test.ts +13 -0
- package/src/help.ts +48 -0
- package/src/index.ts +466 -0
- package/src/interactive.ts +832 -0
- package/src/test-helpers.ts +207 -0
- package/src/types.ts +24 -0
|
@@ -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
|
+
});
|