@sequelsh/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 +61 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1068 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import open from "open";
|
|
6
|
+
import { homedir, platform } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
import * as p$4 from "@clack/prompts";
|
|
11
|
+
import * as p$3 from "@clack/prompts";
|
|
12
|
+
import * as p$2 from "@clack/prompts";
|
|
13
|
+
import * as p$1 from "@clack/prompts";
|
|
14
|
+
import * as p from "@clack/prompts";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import YAML from "yaml";
|
|
17
|
+
|
|
18
|
+
//#region src/lib/config.ts
|
|
19
|
+
/** Default production API base. Override with SEQUEL_API_URL for local/dev. */
|
|
20
|
+
const DEFAULT_API_URL = "https://api.sequel.sh";
|
|
21
|
+
/** Directory holding the CLI's state. Overridable for tests. */
|
|
22
|
+
function configDir() {
|
|
23
|
+
return process.env.SEQUEL_CONFIG_DIR || join(homedir(), ".sequel");
|
|
24
|
+
}
|
|
25
|
+
function configPath() {
|
|
26
|
+
return join(configDir(), "auth.json");
|
|
27
|
+
}
|
|
28
|
+
/** The API base URL: explicit env wins, then a saved value, then the default. */
|
|
29
|
+
function resolveApiUrl(config) {
|
|
30
|
+
return process.env.SEQUEL_API_URL || config?.apiUrl || DEFAULT_API_URL;
|
|
31
|
+
}
|
|
32
|
+
function emptyConfig() {
|
|
33
|
+
return {
|
|
34
|
+
version: 1,
|
|
35
|
+
apiUrl: resolveApiUrl(),
|
|
36
|
+
tokens: {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Read auth.json, returning a fresh empty config if missing or corrupt. */
|
|
40
|
+
function readConfig() {
|
|
41
|
+
const path = configPath();
|
|
42
|
+
if (!existsSync(path)) return emptyConfig();
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
45
|
+
if (!parsed || typeof parsed !== "object" || !parsed.tokens) return emptyConfig();
|
|
46
|
+
parsed.tokens ??= {};
|
|
47
|
+
return parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
return emptyConfig();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Persist auth.json with locked-down permissions (dir 0700, file 0600). */
|
|
53
|
+
function writeConfig(config) {
|
|
54
|
+
const dir = configDir();
|
|
55
|
+
mkdirSync(dir, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
mode: 448
|
|
58
|
+
});
|
|
59
|
+
const path = configPath();
|
|
60
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
61
|
+
chmodSync(path, 384);
|
|
62
|
+
}
|
|
63
|
+
/** Remove all stored credentials. Idempotent. */
|
|
64
|
+
function clearConfig() {
|
|
65
|
+
const path = configPath();
|
|
66
|
+
if (existsSync(path)) rmSync(path);
|
|
67
|
+
}
|
|
68
|
+
function isLoggedIn$1(config) {
|
|
69
|
+
return Boolean(config.activeOrgId && config.tokens[config.activeOrgId]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/lib/auth.ts
|
|
74
|
+
/** Refresh a token this many ms before it actually expires. */
|
|
75
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
76
|
+
function base64url(buf) {
|
|
77
|
+
return buf.toString("base64url");
|
|
78
|
+
}
|
|
79
|
+
/** PKCE pair: challenge = base64url(sha256(verifier)), matching the API's S256. */
|
|
80
|
+
function generatePkce() {
|
|
81
|
+
const verifier = base64url(randomBytes(32));
|
|
82
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
83
|
+
return {
|
|
84
|
+
verifier,
|
|
85
|
+
challenge
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** POST the OAuth token endpoint (form-encoded, per RFC 6749). */
|
|
89
|
+
async function postToken(apiUrl, params) {
|
|
90
|
+
const res = await fetch(`${apiUrl}/oauth/token`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
93
|
+
body: new URLSearchParams(params).toString()
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const body = await res.text().catch(() => "");
|
|
97
|
+
throw new Error(`Token request failed (${res.status}): ${body}`);
|
|
98
|
+
}
|
|
99
|
+
return await res.json();
|
|
100
|
+
}
|
|
101
|
+
/** Register a fresh dynamic OAuth client bound to this run's loopback URI. */
|
|
102
|
+
async function registerClient(apiUrl, redirectUri) {
|
|
103
|
+
const res = await fetch(`${apiUrl}/oauth/register`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
client_name: "Sequel CLI",
|
|
108
|
+
redirect_uris: [redirectUri]
|
|
109
|
+
})
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const body = await res.text().catch(() => "");
|
|
113
|
+
throw new Error(`Client registration failed (${res.status}): ${body}`);
|
|
114
|
+
}
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
return data.client_id;
|
|
117
|
+
}
|
|
118
|
+
async function fetchWhoAmI(apiUrl, accessToken) {
|
|
119
|
+
const res = await fetch(`${apiUrl}/users/me`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
120
|
+
if (!res.ok) throw new Error(`Failed to resolve identity (${res.status})`);
|
|
121
|
+
return await res.json();
|
|
122
|
+
}
|
|
123
|
+
async function fetchOrganizations(apiUrl, accessToken) {
|
|
124
|
+
const res = await fetch(`${apiUrl}/organizations`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
125
|
+
if (!res.ok) throw new Error(`Failed to list organizations (${res.status})`);
|
|
126
|
+
const rows = await res.json();
|
|
127
|
+
return rows.map((r) => ({
|
|
128
|
+
id: r.organization.id,
|
|
129
|
+
name: r.organization.name,
|
|
130
|
+
slug: r.organization.slug,
|
|
131
|
+
role: r.role
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Wait for the browser to hit the loopback redirect and hand back `?code&state`.
|
|
136
|
+
* Resolves with the authorization code once `state` is verified.
|
|
137
|
+
*/
|
|
138
|
+
function waitForCallback(expectedState) {
|
|
139
|
+
let resolveCode;
|
|
140
|
+
let rejectCode;
|
|
141
|
+
const code = new Promise((resolve, reject) => {
|
|
142
|
+
resolveCode = resolve;
|
|
143
|
+
rejectCode = reject;
|
|
144
|
+
});
|
|
145
|
+
const server = createServer((req, res) => {
|
|
146
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
147
|
+
if (url.pathname !== "/callback") {
|
|
148
|
+
res.writeHead(404).end("Not found");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const error = url.searchParams.get("error");
|
|
152
|
+
const returnedState = url.searchParams.get("state");
|
|
153
|
+
const returnedCode = url.searchParams.get("code");
|
|
154
|
+
const finish = (title, message) => {
|
|
155
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
156
|
+
res.end(`<!doctype html><html><head><title>${title}</title></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>${title}</h2><p>${message}</p><p>You can close this tab and return to your terminal.</p></body></html>`);
|
|
157
|
+
};
|
|
158
|
+
if (error) {
|
|
159
|
+
finish("Sign-in failed", `The authorization server returned: ${error}`);
|
|
160
|
+
rejectCode(new Error(`Authorization failed: ${error}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!returnedState || returnedState !== expectedState) {
|
|
164
|
+
finish("Sign-in failed", "State mismatch — possible CSRF. Please try again.");
|
|
165
|
+
rejectCode(new Error("State mismatch in OAuth callback"));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!returnedCode) {
|
|
169
|
+
finish("Sign-in failed", "No authorization code was returned.");
|
|
170
|
+
rejectCode(new Error("Missing authorization code"));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
finish("Signed in to Sequel", "Authentication complete.");
|
|
174
|
+
resolveCode(returnedCode);
|
|
175
|
+
});
|
|
176
|
+
const port = new Promise((resolve, reject) => {
|
|
177
|
+
server.on("error", reject);
|
|
178
|
+
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
server,
|
|
182
|
+
port,
|
|
183
|
+
code
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Run the full OAuth 2.0 + PKCE loopback flow: register a client, open the
|
|
188
|
+
* browser to the consent page (where the user picks an org), capture the code,
|
|
189
|
+
* exchange it for an org-bound token, then cache that token keyed by its org.
|
|
190
|
+
*
|
|
191
|
+
* Shared by `login` and `switch` (for orgs without a cached token).
|
|
192
|
+
*/
|
|
193
|
+
async function runLoginFlow(options) {
|
|
194
|
+
const config = readConfig();
|
|
195
|
+
const apiUrl = resolveApiUrl(config);
|
|
196
|
+
const { verifier, challenge } = generatePkce();
|
|
197
|
+
const state = base64url(randomBytes(16));
|
|
198
|
+
const { server, port: portPromise, code: codePromise } = waitForCallback(state);
|
|
199
|
+
const port = await portPromise;
|
|
200
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
201
|
+
try {
|
|
202
|
+
const clientId = await registerClient(apiUrl, redirectUri);
|
|
203
|
+
const authorizeUrl = new URL(`${apiUrl}/oauth/authorize`);
|
|
204
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
205
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
206
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
207
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
208
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
209
|
+
authorizeUrl.searchParams.set("state", state);
|
|
210
|
+
if (!options?.silent) open(authorizeUrl.toString()).catch(() => {});
|
|
211
|
+
process.stderr.write(`\nOpen this URL to continue:\n${authorizeUrl.toString()}\n\n`);
|
|
212
|
+
const code = await codePromise;
|
|
213
|
+
const token = await postToken(apiUrl, {
|
|
214
|
+
grant_type: "authorization_code",
|
|
215
|
+
code,
|
|
216
|
+
redirect_uri: redirectUri,
|
|
217
|
+
client_id: clientId,
|
|
218
|
+
code_verifier: verifier
|
|
219
|
+
});
|
|
220
|
+
const me = await fetchWhoAmI(apiUrl, token.access_token);
|
|
221
|
+
const orgs = await fetchOrganizations(apiUrl, token.access_token).catch(() => config.orgs ?? []);
|
|
222
|
+
const next = {
|
|
223
|
+
...config,
|
|
224
|
+
version: 1,
|
|
225
|
+
apiUrl,
|
|
226
|
+
clientId,
|
|
227
|
+
activeOrgId: me.organizationId,
|
|
228
|
+
tokens: {
|
|
229
|
+
...config.tokens,
|
|
230
|
+
[me.organizationId]: {
|
|
231
|
+
accessToken: token.access_token,
|
|
232
|
+
refreshToken: token.refresh_token,
|
|
233
|
+
expiresAt: Date.now() + token.expires_in * 1e3
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
user: {
|
|
237
|
+
id: me.id,
|
|
238
|
+
email: me.email,
|
|
239
|
+
name: me.name
|
|
240
|
+
},
|
|
241
|
+
orgs
|
|
242
|
+
};
|
|
243
|
+
writeConfig(next);
|
|
244
|
+
return {
|
|
245
|
+
user: next.user,
|
|
246
|
+
org: orgs.find((o) => o.id === me.organizationId),
|
|
247
|
+
organizationId: me.organizationId
|
|
248
|
+
};
|
|
249
|
+
} finally {
|
|
250
|
+
server.close();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** True when there's a cached token for the active org (cheap, non-throwing). */
|
|
254
|
+
function isLoggedIn() {
|
|
255
|
+
const config = readConfig();
|
|
256
|
+
const orgId = config.activeOrgId;
|
|
257
|
+
return Boolean(orgId && config.tokens[orgId]);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Return a valid access token for the active org, refreshing it in place when
|
|
261
|
+
* expired. Throws a friendly error if there is no token or the refresh fails.
|
|
262
|
+
*/
|
|
263
|
+
async function getValidAccessToken() {
|
|
264
|
+
const config = readConfig();
|
|
265
|
+
const orgId = config.activeOrgId;
|
|
266
|
+
if (!orgId || !config.tokens[orgId]) throw new Error("Not logged in. Run `sequel login` first.");
|
|
267
|
+
const entry = config.tokens[orgId];
|
|
268
|
+
if (entry.expiresAt - EXPIRY_SKEW_MS > Date.now()) return {
|
|
269
|
+
token: entry.accessToken,
|
|
270
|
+
config
|
|
271
|
+
};
|
|
272
|
+
const apiUrl = resolveApiUrl(config);
|
|
273
|
+
try {
|
|
274
|
+
const refreshed = await postToken(apiUrl, {
|
|
275
|
+
grant_type: "refresh_token",
|
|
276
|
+
refresh_token: entry.refreshToken,
|
|
277
|
+
client_id: config.clientId ?? ""
|
|
278
|
+
});
|
|
279
|
+
config.tokens[orgId] = {
|
|
280
|
+
accessToken: refreshed.access_token,
|
|
281
|
+
refreshToken: refreshed.refresh_token,
|
|
282
|
+
expiresAt: Date.now() + refreshed.expires_in * 1e3
|
|
283
|
+
};
|
|
284
|
+
writeConfig(config);
|
|
285
|
+
return {
|
|
286
|
+
token: refreshed.access_token,
|
|
287
|
+
config
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
throw new Error("Your session for this organization has expired. Run `sequel login` (or `sequel switch`) to sign in again.");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/lib/api.ts
|
|
296
|
+
var ApiError = class extends Error {
|
|
297
|
+
constructor(status, message) {
|
|
298
|
+
super(message);
|
|
299
|
+
this.status = status;
|
|
300
|
+
this.name = "ApiError";
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Authenticated request against the Sequel API using the active org's token.
|
|
305
|
+
* Refreshes the token transparently and scopes the call to that org.
|
|
306
|
+
*/
|
|
307
|
+
async function request(path, options = {}) {
|
|
308
|
+
const { token, config } = await getValidAccessToken();
|
|
309
|
+
const apiUrl = resolveApiUrl(config);
|
|
310
|
+
const orgId = config.activeOrgId;
|
|
311
|
+
const orgMode = options.org ?? "query";
|
|
312
|
+
const url = new URL(`${apiUrl}${path}`);
|
|
313
|
+
if (orgMode === "query" && orgId) url.searchParams.set("organizationId", orgId);
|
|
314
|
+
for (const [k, v] of Object.entries(options.query ?? {})) if (v !== void 0) url.searchParams.set(k, v);
|
|
315
|
+
let bodyObj = options.body;
|
|
316
|
+
if ((orgMode === "organizationId" || orgMode === "organization_id") && orgId) bodyObj = {
|
|
317
|
+
...bodyObj ?? {},
|
|
318
|
+
[orgMode]: orgId
|
|
319
|
+
};
|
|
320
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
321
|
+
let body;
|
|
322
|
+
if (bodyObj !== void 0) {
|
|
323
|
+
headers["Content-Type"] = "application/json";
|
|
324
|
+
body = JSON.stringify(bodyObj);
|
|
325
|
+
}
|
|
326
|
+
const res = await fetch(url.toString(), {
|
|
327
|
+
method: options.method ?? "GET",
|
|
328
|
+
headers,
|
|
329
|
+
body
|
|
330
|
+
});
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
let message = `Request failed (${res.status})`;
|
|
333
|
+
const text$1 = await res.text().catch(() => "");
|
|
334
|
+
try {
|
|
335
|
+
const json = JSON.parse(text$1);
|
|
336
|
+
if (json.error) message = json.error;
|
|
337
|
+
} catch {
|
|
338
|
+
if (text$1) message = text$1;
|
|
339
|
+
}
|
|
340
|
+
throw new ApiError(res.status, message);
|
|
341
|
+
}
|
|
342
|
+
if (res.status === 204) return void 0;
|
|
343
|
+
const text = await res.text();
|
|
344
|
+
return text ? JSON.parse(text) : void 0;
|
|
345
|
+
}
|
|
346
|
+
/** The active organization id, or throws if not logged in. */
|
|
347
|
+
async function activeOrgId() {
|
|
348
|
+
const { config } = await getValidAccessToken();
|
|
349
|
+
if (!config.activeOrgId) throw new Error("No active organization. Run `sequel login`.");
|
|
350
|
+
return config.activeOrgId;
|
|
351
|
+
}
|
|
352
|
+
function listConnectors() {
|
|
353
|
+
return request("/connectors", { org: "none" });
|
|
354
|
+
}
|
|
355
|
+
function listConnections() {
|
|
356
|
+
return request("/connections");
|
|
357
|
+
}
|
|
358
|
+
function getConnection(id) {
|
|
359
|
+
return request(`/connections/${id}`);
|
|
360
|
+
}
|
|
361
|
+
function deleteConnection(id) {
|
|
362
|
+
return request(`/connections/${id}`, { method: "DELETE" });
|
|
363
|
+
}
|
|
364
|
+
function testConnection(id) {
|
|
365
|
+
return request(`/connections/${id}/test`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
org: "organizationId"
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function getConnectionConfig(id) {
|
|
371
|
+
return request(`/connections/${id}/config`);
|
|
372
|
+
}
|
|
373
|
+
/** Mint a connect link / create a connection for the given connector. */
|
|
374
|
+
function createLink(appId, body) {
|
|
375
|
+
return request(`/link/${appId}`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
org: "none",
|
|
378
|
+
body
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Poll a connect link's status. Uses the authenticated route, which (unlike the
|
|
383
|
+
* public one) reports terminal states — `completed`/`failed` — so the CLI can
|
|
384
|
+
* tell when the browser flow has finished.
|
|
385
|
+
*/
|
|
386
|
+
function getLinkStatus(token) {
|
|
387
|
+
return request(`/link/${token}/status`, { org: "none" });
|
|
388
|
+
}
|
|
389
|
+
function execute(connectionId, tool, params) {
|
|
390
|
+
return request("/execute", {
|
|
391
|
+
method: "POST",
|
|
392
|
+
org: "organization_id",
|
|
393
|
+
body: {
|
|
394
|
+
connection_id: connectionId,
|
|
395
|
+
tool,
|
|
396
|
+
params
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function search(useCase, connectionIds) {
|
|
401
|
+
return request("/search", {
|
|
402
|
+
method: "POST",
|
|
403
|
+
org: "organization_id",
|
|
404
|
+
body: {
|
|
405
|
+
action_info: `CLI search: ${useCase}`,
|
|
406
|
+
use_case: useCase,
|
|
407
|
+
connection_ids: connectionIds
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async function createApiKey(name) {
|
|
412
|
+
const orgId = await activeOrgId();
|
|
413
|
+
return request(`/organizations/${orgId}/api-keys`, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
org: "none",
|
|
416
|
+
body: { name }
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/lib/output.ts
|
|
422
|
+
/** True when the user passed a global --json flag (set by index.ts). */
|
|
423
|
+
let jsonMode = false;
|
|
424
|
+
function setJsonMode(on) {
|
|
425
|
+
jsonMode = on;
|
|
426
|
+
}
|
|
427
|
+
function isJsonMode() {
|
|
428
|
+
return jsonMode;
|
|
429
|
+
}
|
|
430
|
+
function printJson(data) {
|
|
431
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
432
|
+
}
|
|
433
|
+
function cell(value) {
|
|
434
|
+
if (value === null || value === void 0) return "";
|
|
435
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
436
|
+
return String(value);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Render an array of records as an aligned text table. `columns` controls order
|
|
440
|
+
* and which keys are shown; defaults to the union of keys in the first row.
|
|
441
|
+
*/
|
|
442
|
+
function renderTable(rows, columns) {
|
|
443
|
+
if (rows.length === 0) return pc.dim("(no rows)");
|
|
444
|
+
const cols = columns ?? Object.keys(rows[0]);
|
|
445
|
+
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)));
|
|
446
|
+
const header = cols.map((c, i) => pc.bold(c.padEnd(widths[i]))).join(" ");
|
|
447
|
+
const sep = cols.map((_, i) => "─".repeat(widths[i])).join(" ");
|
|
448
|
+
const body = rows.map((r) => cols.map((c, i) => cell(r[c]).padEnd(widths[i])).join(" ")).join("\n");
|
|
449
|
+
return `${header}\n${pc.dim(sep)}\n${body}`;
|
|
450
|
+
}
|
|
451
|
+
function printTable(rows, columns) {
|
|
452
|
+
process.stdout.write(`${renderTable(rows, columns)}\n`);
|
|
453
|
+
}
|
|
454
|
+
function success(message) {
|
|
455
|
+
process.stdout.write(`${pc.green("✓")} ${message}\n`);
|
|
456
|
+
}
|
|
457
|
+
function info(message) {
|
|
458
|
+
process.stdout.write(`${message}\n`);
|
|
459
|
+
}
|
|
460
|
+
function fail(message) {
|
|
461
|
+
process.stderr.write(`${pc.red("✗")} ${message}\n`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
//#endregion
|
|
465
|
+
//#region src/commands/login.ts
|
|
466
|
+
async function loginCommand() {
|
|
467
|
+
p$4.intro(pc.cyan("Sign in to Sequel"));
|
|
468
|
+
p$4.log.info("Opening your browser to authorize…");
|
|
469
|
+
const res = await runLoginFlow();
|
|
470
|
+
success(`Logged in as ${pc.bold(res.user.email)}${res.org ? ` · ${pc.bold(res.org.name)}` : ""}`);
|
|
471
|
+
p$4.outro("You're all set. Try `sequel sources list` or `sequel connect`.");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/commands/logout.ts
|
|
476
|
+
async function logoutCommand() {
|
|
477
|
+
const config = readConfig();
|
|
478
|
+
if (!isLoggedIn$1(config)) {
|
|
479
|
+
info("You are not logged in.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
clearConfig();
|
|
483
|
+
success("Logged out. Cached credentials removed from ~/.sequel/auth.json.");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/commands/switch.ts
|
|
488
|
+
async function switchCommand() {
|
|
489
|
+
const config = readConfig();
|
|
490
|
+
if (!isLoggedIn$1(config)) throw new Error("Not logged in. Run `sequel login` first.");
|
|
491
|
+
let orgs = config.orgs ?? [];
|
|
492
|
+
try {
|
|
493
|
+
const { token } = await getValidAccessToken();
|
|
494
|
+
orgs = await fetchOrganizations(resolveApiUrl(config), token);
|
|
495
|
+
writeConfig({
|
|
496
|
+
...readConfig(),
|
|
497
|
+
orgs
|
|
498
|
+
});
|
|
499
|
+
} catch {}
|
|
500
|
+
if (orgs.length === 0) throw new Error("No organizations available for this account.");
|
|
501
|
+
const selection = await p$3.select({
|
|
502
|
+
message: "Switch to organization",
|
|
503
|
+
options: orgs.map((o) => ({
|
|
504
|
+
value: o.id,
|
|
505
|
+
label: o.id === config.activeOrgId ? `${o.name} ${pc.dim("(current)")}` : o.name,
|
|
506
|
+
hint: config.tokens[o.id] ? o.role : "sign-in required"
|
|
507
|
+
}))
|
|
508
|
+
});
|
|
509
|
+
if (p$3.isCancel(selection)) {
|
|
510
|
+
p$3.cancel("Cancelled.");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const orgId = selection;
|
|
514
|
+
const org = orgs.find((o) => o.id === orgId);
|
|
515
|
+
const fresh = readConfig();
|
|
516
|
+
if (fresh.tokens[orgId]) {
|
|
517
|
+
writeConfig({
|
|
518
|
+
...fresh,
|
|
519
|
+
activeOrgId: orgId
|
|
520
|
+
});
|
|
521
|
+
success(`Switched to ${pc.bold(org?.name ?? orgId)}.`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
p$3.log.info(`You haven't authorized ${pc.bold(org?.name ?? orgId)} yet — opening your browser…`);
|
|
525
|
+
const res = await runLoginFlow();
|
|
526
|
+
success(`Switched to ${pc.bold(res.org?.name ?? res.organizationId)}.`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/commands/connect.ts
|
|
531
|
+
const SECRET_RE = /pass|secret|token|key|connection|credential/i;
|
|
532
|
+
function fieldsFor(connector) {
|
|
533
|
+
if (connector.userInputFields?.length) return connector.userInputFields;
|
|
534
|
+
const props = connector.credentialsSchema?.properties;
|
|
535
|
+
return props ? Object.keys(props) : [];
|
|
536
|
+
}
|
|
537
|
+
function isOAuth(connector) {
|
|
538
|
+
return Boolean(connector.configuration) || /oauth/i.test(connector.authId);
|
|
539
|
+
}
|
|
540
|
+
/** Poll a pending connect link until it completes or fails (or times out). */
|
|
541
|
+
async function pollLink(token) {
|
|
542
|
+
const deadline = Date.now() + 5 * 6e4;
|
|
543
|
+
while (Date.now() < deadline) {
|
|
544
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
545
|
+
let status;
|
|
546
|
+
try {
|
|
547
|
+
status = await getLinkStatus(token);
|
|
548
|
+
} catch {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (status.status === "failed") return {
|
|
552
|
+
ok: false,
|
|
553
|
+
reason: status.reason
|
|
554
|
+
};
|
|
555
|
+
if (status.status === "success" || status.status === "completed") return { ok: true };
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
ok: false,
|
|
559
|
+
reason: "Timed out waiting for the browser to finish."
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async function connectCommand(source) {
|
|
563
|
+
const connectors = await listConnectors();
|
|
564
|
+
let connector;
|
|
565
|
+
if (source) {
|
|
566
|
+
connector = connectors.find((c) => c.id === source);
|
|
567
|
+
if (!connector) throw new Error(`Unknown connector "${source}". Available: ${connectors.map((c) => c.id).join(", ")}`);
|
|
568
|
+
} else {
|
|
569
|
+
const choice = await p$2.select({
|
|
570
|
+
message: "Which data source do you want to connect?",
|
|
571
|
+
options: connectors.slice().sort((a, b) => a.appType.localeCompare(b.appType) || a.name.localeCompare(b.name)).map((c) => ({
|
|
572
|
+
value: c.id,
|
|
573
|
+
label: c.name,
|
|
574
|
+
hint: c.appType
|
|
575
|
+
}))
|
|
576
|
+
});
|
|
577
|
+
if (p$2.isCancel(choice)) {
|
|
578
|
+
p$2.cancel("Cancelled.");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
connector = connectors.find((c) => c.id === choice);
|
|
582
|
+
}
|
|
583
|
+
p$2.intro(`Connect ${pc.bold(connector.name)}`);
|
|
584
|
+
if (!isOAuth(connector)) {
|
|
585
|
+
const fields = fieldsFor(connector);
|
|
586
|
+
const credentials = {};
|
|
587
|
+
for (const field of fields) {
|
|
588
|
+
const secret = SECRET_RE.test(field);
|
|
589
|
+
const value = await (secret ? p$2.password : p$2.text)({ message: field });
|
|
590
|
+
if (p$2.isCancel(value)) {
|
|
591
|
+
p$2.cancel("Cancelled.");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
credentials[field] = value;
|
|
595
|
+
}
|
|
596
|
+
const name = await p$2.text({
|
|
597
|
+
message: "Name for this source (optional)",
|
|
598
|
+
placeholder: connector.name
|
|
599
|
+
});
|
|
600
|
+
if (p$2.isCancel(name)) {
|
|
601
|
+
p$2.cancel("Cancelled.");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const s$1 = p$2.spinner();
|
|
605
|
+
s$1.start("Validating credentials…");
|
|
606
|
+
const res$1 = await createLink(connector.id, {
|
|
607
|
+
credentials,
|
|
608
|
+
...name ? { name } : {}
|
|
609
|
+
});
|
|
610
|
+
if (res$1.status === "success") {
|
|
611
|
+
s$1.stop("Connected.");
|
|
612
|
+
success(`Created source ${pc.bold(res$1.connection.name)} (${res$1.connection.shortId}).`);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
s$1.stop("Continue in your browser…");
|
|
616
|
+
open(res$1.redirect_url).catch(() => {});
|
|
617
|
+
info(`Open: ${res$1.redirect_url}`);
|
|
618
|
+
const result$1 = await pollLink(res$1.link_token);
|
|
619
|
+
if (result$1.ok) success("Connected.");
|
|
620
|
+
else throw new Error(result$1.reason ?? "Connection failed.");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const res = await createLink(connector.id, {});
|
|
624
|
+
if (res.status === "success") {
|
|
625
|
+
success(`Created source ${pc.bold(res.connection.name)} (${res.connection.shortId}).`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
open(res.redirect_url).catch(() => {});
|
|
629
|
+
info(`Complete authorization in your browser:\n${res.redirect_url}`);
|
|
630
|
+
const s = p$2.spinner();
|
|
631
|
+
s.start("Waiting for you to finish in the browser…");
|
|
632
|
+
const result = await pollLink(res.link_token);
|
|
633
|
+
if (result.ok) {
|
|
634
|
+
s.stop("Connected.");
|
|
635
|
+
success("Source connected.");
|
|
636
|
+
} else {
|
|
637
|
+
s.stop("Failed.");
|
|
638
|
+
throw new Error(result.reason ?? "Connection failed.");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/commands/sources.ts
|
|
644
|
+
/** Resolve a user-supplied id (uuid or shortId) to the connection's real id. */
|
|
645
|
+
async function resolveConnection(idOrShort) {
|
|
646
|
+
const all = await listConnections();
|
|
647
|
+
const match = all.find((c) => c.id === idOrShort || c.shortId === idOrShort);
|
|
648
|
+
if (!match) throw new Error(`No source found matching "${idOrShort}".`);
|
|
649
|
+
return match;
|
|
650
|
+
}
|
|
651
|
+
async function pickConnection(message) {
|
|
652
|
+
const all = await listConnections();
|
|
653
|
+
if (all.length === 0) {
|
|
654
|
+
info("No sources connected yet. Run `sequel connect`.");
|
|
655
|
+
return void 0;
|
|
656
|
+
}
|
|
657
|
+
const choice = await p$1.select({
|
|
658
|
+
message,
|
|
659
|
+
options: all.map((c) => ({
|
|
660
|
+
value: c.shortId,
|
|
661
|
+
label: `${c.name} (${c.shortId})`,
|
|
662
|
+
hint: c.type
|
|
663
|
+
}))
|
|
664
|
+
});
|
|
665
|
+
if (p$1.isCancel(choice)) return void 0;
|
|
666
|
+
return all.find((c) => c.shortId === choice);
|
|
667
|
+
}
|
|
668
|
+
function toRow(c) {
|
|
669
|
+
return {
|
|
670
|
+
id: c.shortId,
|
|
671
|
+
name: c.name,
|
|
672
|
+
type: c.type,
|
|
673
|
+
created: c.createdAt.slice(0, 10)
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
async function sourcesList() {
|
|
677
|
+
const connections = await listConnections();
|
|
678
|
+
if (isJsonMode()) return printJson(connections);
|
|
679
|
+
if (connections.length === 0) return info("No sources connected yet. Run `sequel connect`.");
|
|
680
|
+
printTable(connections.map(toRow), [
|
|
681
|
+
"id",
|
|
682
|
+
"name",
|
|
683
|
+
"type",
|
|
684
|
+
"created"
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
async function sourcesActive() {
|
|
688
|
+
const connections = await listConnections();
|
|
689
|
+
const rows = await Promise.all(connections.map(async (c) => {
|
|
690
|
+
let status = "unknown";
|
|
691
|
+
try {
|
|
692
|
+
const t = await testConnection(c.id);
|
|
693
|
+
status = t.success ? "healthy" : "error";
|
|
694
|
+
} catch {
|
|
695
|
+
status = "error";
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
...toRow(c),
|
|
699
|
+
status
|
|
700
|
+
};
|
|
701
|
+
}));
|
|
702
|
+
if (isJsonMode()) return printJson(rows);
|
|
703
|
+
if (rows.length === 0) return info("No sources connected yet. Run `sequel connect`.");
|
|
704
|
+
printTable(rows, [
|
|
705
|
+
"id",
|
|
706
|
+
"name",
|
|
707
|
+
"type",
|
|
708
|
+
"status",
|
|
709
|
+
"created"
|
|
710
|
+
]);
|
|
711
|
+
}
|
|
712
|
+
async function sourcesInfo(idOrShort) {
|
|
713
|
+
const conn = idOrShort ? await resolveConnection(idOrShort) : await pickConnection("Source to inspect");
|
|
714
|
+
if (!conn) return;
|
|
715
|
+
const full = await getConnection(conn.id);
|
|
716
|
+
let config = null;
|
|
717
|
+
try {
|
|
718
|
+
config = await getConnectionConfig(conn.id);
|
|
719
|
+
} catch {}
|
|
720
|
+
if (isJsonMode()) return printJson({
|
|
721
|
+
...full,
|
|
722
|
+
config
|
|
723
|
+
});
|
|
724
|
+
info(`${pc.bold(full.name)} ${pc.dim(full.shortId)}`);
|
|
725
|
+
info(`type: ${full.type}`);
|
|
726
|
+
info(`id: ${full.id}`);
|
|
727
|
+
info(`created: ${full.createdAt}`);
|
|
728
|
+
if (config?.schemaFilters?.length) info(`schemas: ${config.schemaFilters.join(", ")}`);
|
|
729
|
+
if (config?.instructions) info(`\n${pc.dim(config.instructions)}`);
|
|
730
|
+
}
|
|
731
|
+
async function sourcesDelete(idOrShort) {
|
|
732
|
+
const conn = idOrShort ? await resolveConnection(idOrShort) : await pickConnection("Source to delete");
|
|
733
|
+
if (!conn) return;
|
|
734
|
+
const confirmed = await p$1.confirm({ message: `Delete ${pc.bold(conn.name)} (${conn.shortId})? This cannot be undone.` });
|
|
735
|
+
if (p$1.isCancel(confirmed) || !confirmed) {
|
|
736
|
+
info("Cancelled.");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
await deleteConnection(conn.id);
|
|
740
|
+
success(`Deleted ${conn.name} (${conn.shortId}).`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/lib/mcp-clients.ts
|
|
745
|
+
const SERVER_NAME = "sequel";
|
|
746
|
+
const mcpUrl = (apiUrl) => `${apiUrl.replace(/\/$/, "")}/mcp`;
|
|
747
|
+
const bearer = (apiKey) => `Bearer ${apiKey}`;
|
|
748
|
+
function home(opts) {
|
|
749
|
+
return opts.home ?? homedir();
|
|
750
|
+
}
|
|
751
|
+
function cwd(opts) {
|
|
752
|
+
return opts.cwd ?? process.cwd();
|
|
753
|
+
}
|
|
754
|
+
/** OS-specific application-support dir for a vendor folder. */
|
|
755
|
+
function appSupport(opts, vendor) {
|
|
756
|
+
const h = home(opts);
|
|
757
|
+
switch (platform()) {
|
|
758
|
+
case "darwin": return join(h, "Library", "Application Support", vendor);
|
|
759
|
+
case "win32": return join(process.env.APPDATA ?? join(h, "AppData", "Roaming"), vendor);
|
|
760
|
+
default: return join(process.env.XDG_CONFIG_HOME ?? join(h, ".config"), vendor);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
/** Read+merge+write a JSON config, preserving every other key already present. */
|
|
764
|
+
function mergeJson(path, mutate) {
|
|
765
|
+
let cfg = {};
|
|
766
|
+
if (existsSync(path)) try {
|
|
767
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
768
|
+
} catch {
|
|
769
|
+
cfg = {};
|
|
770
|
+
}
|
|
771
|
+
mutate(cfg);
|
|
772
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
773
|
+
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
774
|
+
}
|
|
775
|
+
function mergeYaml(path, mutate) {
|
|
776
|
+
let cfg = {};
|
|
777
|
+
if (existsSync(path)) try {
|
|
778
|
+
cfg = YAML.parse(readFileSync(path, "utf8")) ?? {};
|
|
779
|
+
} catch {
|
|
780
|
+
cfg = {};
|
|
781
|
+
}
|
|
782
|
+
mutate(cfg);
|
|
783
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
784
|
+
writeFileSync(path, YAML.stringify(cfg));
|
|
785
|
+
}
|
|
786
|
+
function setNested(cfg, key, value) {
|
|
787
|
+
const obj = cfg[key] ?? {};
|
|
788
|
+
obj[SERVER_NAME] = value;
|
|
789
|
+
cfg[key] = obj;
|
|
790
|
+
}
|
|
791
|
+
const mcpClients = [
|
|
792
|
+
{
|
|
793
|
+
slug: "claude",
|
|
794
|
+
name: "Claude Desktop",
|
|
795
|
+
scopes: ["global"],
|
|
796
|
+
describe: (o) => join(appSupport(o, "Claude"), "claude_desktop_config.json"),
|
|
797
|
+
install(o) {
|
|
798
|
+
const path = join(appSupport(o, "Claude"), "claude_desktop_config.json");
|
|
799
|
+
mergeJson(path, (cfg) => setNested(cfg, "mcpServers", {
|
|
800
|
+
url: mcpUrl(o.apiUrl),
|
|
801
|
+
headers: { Authorization: bearer(o.apiKey) }
|
|
802
|
+
}));
|
|
803
|
+
return {
|
|
804
|
+
kind: "file",
|
|
805
|
+
path
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
slug: "claude-code",
|
|
811
|
+
name: "Claude Code",
|
|
812
|
+
scopes: ["global", "local"],
|
|
813
|
+
describe: (o) => `claude mcp add --scope ${o.scope === "global" ? "user" : "local"} --transport http ${SERVER_NAME} …`,
|
|
814
|
+
install(o) {
|
|
815
|
+
return {
|
|
816
|
+
kind: "command",
|
|
817
|
+
command: "claude",
|
|
818
|
+
args: [
|
|
819
|
+
"mcp",
|
|
820
|
+
"add",
|
|
821
|
+
"--scope",
|
|
822
|
+
o.scope === "global" ? "user" : "local",
|
|
823
|
+
"--transport",
|
|
824
|
+
"http",
|
|
825
|
+
SERVER_NAME,
|
|
826
|
+
mcpUrl(o.apiUrl),
|
|
827
|
+
"--header",
|
|
828
|
+
`Authorization: ${bearer(o.apiKey)}`
|
|
829
|
+
]
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
slug: "cursor",
|
|
835
|
+
name: "Cursor",
|
|
836
|
+
scopes: ["global", "local"],
|
|
837
|
+
describe: (o) => o.scope === "global" ? join(home(o), ".cursor", "mcp.json") : join(cwd(o), ".cursor", "mcp.json"),
|
|
838
|
+
install(o) {
|
|
839
|
+
const path = o.scope === "global" ? join(home(o), ".cursor", "mcp.json") : join(cwd(o), ".cursor", "mcp.json");
|
|
840
|
+
mergeJson(path, (cfg) => setNested(cfg, "mcpServers", {
|
|
841
|
+
url: mcpUrl(o.apiUrl),
|
|
842
|
+
headers: { Authorization: bearer(o.apiKey) }
|
|
843
|
+
}));
|
|
844
|
+
return {
|
|
845
|
+
kind: "file",
|
|
846
|
+
path
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
slug: "vscode",
|
|
852
|
+
name: "VS Code",
|
|
853
|
+
scopes: ["global", "local"],
|
|
854
|
+
describe: (o) => o.scope === "global" ? join(appSupport(o, "Code"), "User", "mcp.json") : join(cwd(o), ".vscode", "mcp.json"),
|
|
855
|
+
install(o) {
|
|
856
|
+
const path = o.scope === "global" ? join(appSupport(o, "Code"), "User", "mcp.json") : join(cwd(o), ".vscode", "mcp.json");
|
|
857
|
+
mergeJson(path, (cfg) => setNested(cfg, "servers", {
|
|
858
|
+
url: mcpUrl(o.apiUrl),
|
|
859
|
+
type: "http",
|
|
860
|
+
headers: { Authorization: bearer(o.apiKey) }
|
|
861
|
+
}));
|
|
862
|
+
return {
|
|
863
|
+
kind: "file",
|
|
864
|
+
path
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
slug: "windsurf",
|
|
870
|
+
name: "Windsurf",
|
|
871
|
+
scopes: ["global"],
|
|
872
|
+
describe: (o) => join(home(o), ".codeium", "windsurf", "mcp_config.json"),
|
|
873
|
+
install(o) {
|
|
874
|
+
const path = join(home(o), ".codeium", "windsurf", "mcp_config.json");
|
|
875
|
+
mergeJson(path, (cfg) => setNested(cfg, "mcpServers", {
|
|
876
|
+
serverUrl: mcpUrl(o.apiUrl),
|
|
877
|
+
headers: { Authorization: bearer(o.apiKey) }
|
|
878
|
+
}));
|
|
879
|
+
return {
|
|
880
|
+
kind: "file",
|
|
881
|
+
path
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
slug: "zed",
|
|
887
|
+
name: "Zed",
|
|
888
|
+
scopes: ["global", "local"],
|
|
889
|
+
describe: (o) => o.scope === "global" ? join(home(o), ".config", "zed", "settings.json") : join(cwd(o), ".zed", "settings.json"),
|
|
890
|
+
install(o) {
|
|
891
|
+
const path = o.scope === "global" ? join(home(o), ".config", "zed", "settings.json") : join(cwd(o), ".zed", "settings.json");
|
|
892
|
+
mergeJson(path, (cfg) => setNested(cfg, "context_servers", {
|
|
893
|
+
url: mcpUrl(o.apiUrl),
|
|
894
|
+
settings: { headers: { Authorization: bearer(o.apiKey) } }
|
|
895
|
+
}));
|
|
896
|
+
return {
|
|
897
|
+
kind: "file",
|
|
898
|
+
path
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
slug: "codex",
|
|
904
|
+
name: "Codex",
|
|
905
|
+
scopes: ["global"],
|
|
906
|
+
describe: (o) => join(home(o), ".codex", "config.yaml"),
|
|
907
|
+
install(o) {
|
|
908
|
+
const path = join(home(o), ".codex", "config.yaml");
|
|
909
|
+
mergeYaml(path, (cfg) => setNested(cfg, "mcp_servers", {
|
|
910
|
+
url: mcpUrl(o.apiUrl),
|
|
911
|
+
headers: { Authorization: bearer(o.apiKey) }
|
|
912
|
+
}));
|
|
913
|
+
return {
|
|
914
|
+
kind: "file",
|
|
915
|
+
path
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
];
|
|
920
|
+
function getMcpClient(slug) {
|
|
921
|
+
return mcpClients.find((c) => c.slug === slug);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/commands/install.ts
|
|
926
|
+
async function installCommand(slugArg) {
|
|
927
|
+
p.intro(pc.cyan("Install the Sequel MCP server"));
|
|
928
|
+
if (!isLoggedIn()) {
|
|
929
|
+
p.cancel("Not logged in. Run `sequel login` first.");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
let slug = slugArg;
|
|
933
|
+
if (!slug) {
|
|
934
|
+
const choice = await p.select({
|
|
935
|
+
message: "Which coding agent do you want to install Sequel into?",
|
|
936
|
+
options: mcpClients.map((c) => ({
|
|
937
|
+
value: c.slug,
|
|
938
|
+
label: c.name
|
|
939
|
+
}))
|
|
940
|
+
});
|
|
941
|
+
if (p.isCancel(choice)) return p.cancel("Cancelled.");
|
|
942
|
+
slug = choice;
|
|
943
|
+
}
|
|
944
|
+
const client = getMcpClient(slug);
|
|
945
|
+
if (!client) throw new Error(`Unknown agent "${slug}". Options: ${mcpClients.map((c) => c.slug).join(", ")}`);
|
|
946
|
+
let scope = client.scopes[0];
|
|
947
|
+
if (client.scopes.length > 1) {
|
|
948
|
+
const choice = await p.select({
|
|
949
|
+
message: "Install scope",
|
|
950
|
+
options: [{
|
|
951
|
+
value: "global",
|
|
952
|
+
label: "Global",
|
|
953
|
+
hint: "available everywhere on this machine"
|
|
954
|
+
}, {
|
|
955
|
+
value: "local",
|
|
956
|
+
label: "Local",
|
|
957
|
+
hint: "this project only"
|
|
958
|
+
}].filter((o) => client.scopes.includes(o.value))
|
|
959
|
+
});
|
|
960
|
+
if (p.isCancel(choice)) return p.cancel("Cancelled.");
|
|
961
|
+
scope = choice;
|
|
962
|
+
}
|
|
963
|
+
const config = readConfig();
|
|
964
|
+
const apiUrl = resolveApiUrl(config);
|
|
965
|
+
const s = p.spinner();
|
|
966
|
+
s.start("Creating a Sequel API key for this agent…");
|
|
967
|
+
let key;
|
|
968
|
+
try {
|
|
969
|
+
({key} = await createApiKey(`Sequel CLI (${client.name})`));
|
|
970
|
+
} catch (err) {
|
|
971
|
+
s.stop("Failed to create API key.");
|
|
972
|
+
throw err;
|
|
973
|
+
}
|
|
974
|
+
s.stop("API key created.");
|
|
975
|
+
const result = client.install({
|
|
976
|
+
apiUrl,
|
|
977
|
+
apiKey: key,
|
|
978
|
+
scope
|
|
979
|
+
});
|
|
980
|
+
if (result.kind === "command") {
|
|
981
|
+
try {
|
|
982
|
+
execFileSync(result.command, result.args, { stdio: "inherit" });
|
|
983
|
+
} catch {
|
|
984
|
+
throw new Error(`Failed to run \`${result.command}\`. Make sure it's installed, or run manually:\n${result.command} ${result.args.join(" ")}`);
|
|
985
|
+
}
|
|
986
|
+
success(`Installed Sequel into ${client.name} (${scope}).`);
|
|
987
|
+
} else {
|
|
988
|
+
success(`Installed Sequel into ${client.name}.`);
|
|
989
|
+
p.log.info(`Config written to ${pc.dim(result.path)}`);
|
|
990
|
+
}
|
|
991
|
+
p.outro(`Restart ${client.name} and ask it to "list my Sequel connections" to verify.`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/commands/search.ts
|
|
996
|
+
async function searchCommand(useCase, opts) {
|
|
997
|
+
const res = await search(useCase, opts.connection);
|
|
998
|
+
if (isJsonMode()) return printJson(res);
|
|
999
|
+
if (res.results.length === 0) {
|
|
1000
|
+
info("No matching tools found across your connected sources.");
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
for (const r of res.results) {
|
|
1004
|
+
info(`\n${pc.bold(r.connection_name)} ${pc.dim(`(${r.connection_id} · ${r.app_id})`)}`);
|
|
1005
|
+
if (r.instructions) info(pc.dim(r.instructions));
|
|
1006
|
+
for (const tool of r.tools) {
|
|
1007
|
+
info(` ${pc.cyan(tool.tool_id)}`);
|
|
1008
|
+
if (tool.relevant_tables?.length) info(` tables: ${tool.relevant_tables.join(", ")}`);
|
|
1009
|
+
}
|
|
1010
|
+
info(pc.dim(` → sequel execute ${r.connection_id} ${r.tools[0]?.tool_id ?? "<tool>"} -d '{…}'`));
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/commands/execute.ts
|
|
1016
|
+
async function executeCommand(connectionId, tool, opts) {
|
|
1017
|
+
let params = {};
|
|
1018
|
+
if (opts.data) try {
|
|
1019
|
+
params = JSON.parse(opts.data);
|
|
1020
|
+
} catch {
|
|
1021
|
+
throw new Error("Invalid JSON passed to -d/--data.");
|
|
1022
|
+
}
|
|
1023
|
+
const res = await execute(connectionId, tool, params);
|
|
1024
|
+
if (isJsonMode()) return printJson(res);
|
|
1025
|
+
if (res.data.length === 0) {
|
|
1026
|
+
info(pc.dim("(0 rows)"));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
printTable(res.data, res.columns.length ? res.columns : void 0);
|
|
1030
|
+
info(pc.dim(`\n${res.row_count} row(s)${res.truncated ? " (truncated)" : ""}`));
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region src/index.ts
|
|
1035
|
+
const program = new Command();
|
|
1036
|
+
program.name("sequel").description("Sequel CLI — connect data sources and run tools from your terminal").version("0.0.0").option("--json", "output machine-readable JSON").enablePositionalOptions();
|
|
1037
|
+
/** Wrap a command action with shared --json handling and error reporting. */
|
|
1038
|
+
function action(fn) {
|
|
1039
|
+
return async (...args) => {
|
|
1040
|
+
if (program.opts().json) setJsonMode(true);
|
|
1041
|
+
try {
|
|
1042
|
+
await fn(...args);
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
if (err instanceof ApiError && err.status === 401) fail("Unauthorized. Run `sequel login` to sign in again.");
|
|
1045
|
+
else fail(err instanceof Error ? err.message : String(err));
|
|
1046
|
+
process.exitCode = 1;
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
program.command("login").description("Sign in to Sequel").action(action(loginCommand));
|
|
1051
|
+
program.command("logout").description("Sign out and remove cached credentials").action(action(logoutCommand));
|
|
1052
|
+
program.command("switch").description("Switch the active organization").action(action(switchCommand));
|
|
1053
|
+
program.command("connect").argument("[source]", "connector id (e.g. postgres); omit to choose interactively").description("Connect a new data source").action(action(connectCommand));
|
|
1054
|
+
const sources = program.command("sources").description("Manage connected data sources");
|
|
1055
|
+
sources.command("list", { isDefault: true }).description("List connected sources").action(action(sourcesList));
|
|
1056
|
+
sources.command("active").description("List sources with live health status").action(action(sourcesActive));
|
|
1057
|
+
sources.command("info").argument("[id]", "source id or short id").description("Show details for a source").action(action(sourcesInfo));
|
|
1058
|
+
sources.command("delete").argument("[id]", "source id or short id").description("Delete a source").action(action(sourcesDelete));
|
|
1059
|
+
program.command("install").argument("[agent]", "coding agent slug (e.g. cursor, claude-code)").description("Install the Sequel MCP server into a coding agent").action(action(installCommand));
|
|
1060
|
+
program.command("search").argument("<usecase>", "what you want to do, e.g. \"monthly revenue\"").option("-c, --connection <id...>", "limit to specific connection short ids").description("Find the right tools for a use case across your sources").action(action((usecase, opts) => searchCommand(usecase, opts)));
|
|
1061
|
+
program.command("execute").argument("<connection_id>", "connection short id").argument("<tool>", "tool id, e.g. postgres.query").option("-d, --data <json>", "tool input as a JSON string").description("Execute a tool against a connection").action(action((connectionId, tool, opts) => executeCommand(connectionId, tool, opts)));
|
|
1062
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1063
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
//#endregion
|
|
1068
|
+
//# sourceMappingURL=index.js.map
|