@mintmcp/hosted-cli 0.0.16 → 0.0.17

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/src/index.ts CHANGED
@@ -6,7 +6,6 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
6
6
  import archiver from "archiver";
7
7
  import * as fs from "fs";
8
8
  import { readFileSync } from "fs";
9
- import keytar from "keytar";
10
9
  import * as path from "path";
11
10
  import { dirname, join } from "path";
12
11
  import superjson from "superjson";
@@ -18,6 +17,23 @@ import {
18
17
  type HostedTransport,
19
18
  UserConfigUpdateSchema,
20
19
  } from "./api.js";
20
+ import {
21
+ type Auth,
22
+ clearAllAuth,
23
+ clearSelectedAuth,
24
+ type Env,
25
+ EnvSchema,
26
+ formatAuthSummary,
27
+ getAuth,
28
+ getCurrentAuthAccount,
29
+ getSelectedAuth,
30
+ getTokenClaims,
31
+ listAuthRecords,
32
+ requestAndStoreAuth,
33
+ type StoredAuthAccount,
34
+ sameStoredAuthAccount,
35
+ useAuth,
36
+ } from "./auth.js";
21
37
  import {
22
38
  buildPartialUpdate,
23
39
  type DeployOptions,
@@ -35,9 +51,6 @@ const version = packageJson.version;
35
51
  export type CliClientAppRouterInputs = inferRouterInputs<AppRouter>;
36
52
  export type CliClientAppRouterOutputs = inferRouterOutputs<AppRouter>;
37
53
 
38
- const EnvSchema = z.enum(["staging", "production"]);
39
- type Env = z.infer<typeof EnvSchema>;
40
-
41
54
  const TransportSchema = z.enum(["http", "stdio"]);
42
55
 
43
56
  const TRPC_API_URL: Record<Env, string> = {
@@ -50,11 +63,6 @@ const WEB_CLIENT_URL: Record<Env, string> = {
50
63
  production: "https://app.mintmcp.com",
51
64
  };
52
65
 
53
- const CLIENT_IDS: Record<Env, string> = {
54
- staging: "client_01K0WB8ABW72JSBZ62V98AZMPS",
55
- production: "client_01K0WB8AHKS2J39SFKAPTHTR90",
56
- };
57
-
58
66
  // Where to look for the hosted config, relative to "--base".
59
67
  const HOSTED_CONFIG_PATHS: Record<Env, string> = {
60
68
  staging: ".mintmcp/hosted-staging.json",
@@ -66,131 +74,27 @@ const HostedConfigSchema = z.object({
66
74
 
67
75
  // Directory containing data to send to hosted server, relative to "--base".
68
76
  mntDataDir: z.string(),
69
- });
70
- type HostedConfig = z.infer<typeof HostedConfigSchema>;
71
77
 
72
- const AuthSchema = z.object({
73
- accessToken: z.string(),
74
- email: z.string(),
75
- organizationId: z.string(),
78
+ // Organization that owns this hosted server. Older config files may omit it.
79
+ organizationId: z.string().optional(),
76
80
  });
81
+ type HostedConfig = z.infer<typeof HostedConfigSchema>;
77
82
 
78
- type Auth = z.infer<typeof AuthSchema>;
79
-
80
- async function requestAuth(env: Env): Promise<Auth> {
81
- const clientId = CLIENT_IDS[env];
82
-
83
- const deviceCodeResponse = await fetch(
84
- "https://api.workos.com/user_management/authorize/device",
85
- {
86
- method: "POST",
87
- headers: {
88
- "Content-Type": "application/x-www-form-urlencoded",
89
- },
90
- body: new URLSearchParams({
91
- client_id: clientId,
92
- }),
93
- },
83
+ function isTRPCUnauthorized(error: unknown): boolean {
84
+ return (
85
+ error instanceof Error &&
86
+ "data" in error &&
87
+ typeof (error as any).data === "object" &&
88
+ (error as any).data?.code === "UNAUTHORIZED"
94
89
  );
95
-
96
- if (!deviceCodeResponse.ok) {
97
- throw new Error(
98
- `Failed to get device code: ${await deviceCodeResponse.text()}`,
99
- );
100
- }
101
-
102
- const deviceData = (await deviceCodeResponse.json()) as any;
103
- const {
104
- device_code: deviceCode,
105
- verification_uri_complete: verificationUriComplete,
106
- interval = 5,
107
- } = deviceData;
108
-
109
- console.log(`To authenticate, visit ${verificationUriComplete}`);
110
-
111
- // Poll for access token.
112
- const maxAttempts = 60;
113
- for (let attempts = 0; attempts < maxAttempts; attempts += 1) {
114
- await new Promise<void>((resolve) => {
115
- setTimeout(resolve, interval * 1000);
116
- });
117
-
118
- const tokenResponse = await fetch(
119
- "https://api.workos.com/user_management/authenticate",
120
- {
121
- method: "POST",
122
- headers: {
123
- "Content-Type": "application/x-www-form-urlencoded",
124
- },
125
- body: new URLSearchParams({
126
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
127
- device_code: deviceCode,
128
- client_id: clientId,
129
- }),
130
- },
131
- );
132
-
133
- if (!tokenResponse.ok) {
134
- continue;
135
- }
136
-
137
- const tokenData = (await tokenResponse.json()) as any;
138
-
139
- if (tokenData.error) {
140
- if (tokenData.error === "authorization_pending") {
141
- continue;
142
- } else if (tokenData.error === "slow_down") {
143
- // Increase interval as requested
144
- await new Promise<void>((resolve) => {
145
- setTimeout(resolve, 5000);
146
- });
147
- continue;
148
- } else {
149
- throw new Error(`Authentication failed: ${tokenData.error}`);
150
- }
151
- }
152
-
153
- if (tokenData.access_token) {
154
- if (!tokenData.user?.email) {
155
- throw new Error("Authentication response missing user email");
156
- }
157
- if (!tokenData.organization_id) {
158
- throw new Error("Authentication response missing organization ID");
159
- }
160
- return {
161
- accessToken: tokenData.access_token,
162
- email: tokenData.user.email,
163
- organizationId: tokenData.organization_id,
164
- };
165
- }
166
- }
167
-
168
- throw new Error("Authentication timeout");
169
90
  }
170
91
 
171
- async function getAuth(env: Env): Promise<Auth> {
172
- const keychainServiceName = "mintmcp-credentials";
173
- const stored = await keytar.getPassword(keychainServiceName, env);
174
- if (stored) {
175
- try {
176
- const parsedData = JSON.parse(stored);
177
- const validatedAuth = AuthSchema.parse(parsedData);
178
- return validatedAuth;
179
- } catch (error) {
180
- console.warn("Invalid stored credentials, re-authenticating:", error);
181
- // If validation fails, delete the invalid stored credentials and re-authenticate
182
- await keytar.deletePassword(keychainServiceName, env);
183
- }
184
- }
185
- const auth = await requestAuth(env);
186
- await keytar.setPassword(keychainServiceName, env, JSON.stringify(auth));
187
- return auth;
188
- }
92
+ async function makeAuthenticatedClient(
93
+ env: Env,
94
+ options?: { forceRefresh?: boolean },
95
+ ) {
96
+ const auth = await getAuth(env, options);
189
97
 
190
- async function makeAuthenticatedClient(env: Env) {
191
- const auth = await getAuth(env);
192
-
193
- // TODO: Get the organization name because that is more human readable.
194
98
  console.log(`Authenticated as ${auth.email} in ${auth.organizationId}.`);
195
99
 
196
100
  return createTRPCClient<AppRouter>({
@@ -206,6 +110,29 @@ async function makeAuthenticatedClient(env: Env) {
206
110
  });
207
111
  }
208
112
 
113
+ type AuthenticatedClient = Awaited<ReturnType<typeof makeAuthenticatedClient>>;
114
+
115
+ async function withAuthRetry<T>(
116
+ env: Env,
117
+ fn: (client: AuthenticatedClient) => Promise<T>,
118
+ ): Promise<T> {
119
+ const client = await makeAuthenticatedClient(env);
120
+ try {
121
+ return await fn(client);
122
+ } catch (error) {
123
+ if (isTRPCUnauthorized(error)) {
124
+ console.warn(
125
+ "Session expired or invalid. Refreshing credentials and retrying...",
126
+ );
127
+ const newClient = await makeAuthenticatedClient(env, {
128
+ forceRefresh: true,
129
+ });
130
+ return await fn(newClient);
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
209
136
  function argParser<T>(schema: ZodSchema<T>) {
210
137
  return (value: string): T => {
211
138
  const result = schema.safeParse(value);
@@ -292,6 +219,7 @@ const DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS = {
292
219
  async function createNew(
293
220
  client: Awaited<ReturnType<typeof makeAuthenticatedClient>>,
294
221
  base: string,
222
+ organizationId: string,
295
223
  options: DeployOptions,
296
224
  ): Promise<HostedConfig> {
297
225
  const {
@@ -324,6 +252,7 @@ async function createNew(
324
252
  return {
325
253
  hostedId: createdServer.hostedId,
326
254
  mntDataDir,
255
+ organizationId,
327
256
  };
328
257
  }
329
258
 
@@ -331,12 +260,22 @@ async function updateExisting(
331
260
  client: Awaited<ReturnType<typeof makeAuthenticatedClient>>,
332
261
  base: string,
333
262
  config: HostedConfig,
263
+ organizationId: string,
334
264
  options: DeployOptions,
335
265
  ): Promise<HostedConfig> {
266
+ if (
267
+ config.organizationId != undefined &&
268
+ config.organizationId !== organizationId
269
+ ) {
270
+ throw new Error(
271
+ `Hosted config belongs to organization ${config.organizationId}, but the selected credentials are for ${organizationId}. Switch organizations with "hosted auth use --organization-id ${config.organizationId}" before deploying.`,
272
+ );
273
+ }
336
274
  const newConfig = { ...config };
337
275
  if (options.mntDataDir != undefined) {
338
276
  newConfig.mntDataDir = options.mntDataDir;
339
277
  }
278
+ newConfig.organizationId = organizationId;
340
279
  const upload = await uploadData(
341
280
  client,
342
281
  path.join(base, newConfig.mntDataDir),
@@ -366,6 +305,66 @@ function makeDefaultMessage(
366
305
  return `Defaults to '${DEPLOY_OPTIONS_NEW_SERVER_DEFAULTS[optionName]}' for new servers.`;
367
306
  }
368
307
 
308
+ const authCommand = program
309
+ .command("auth")
310
+ .description("Manage cached hosted-cli authentication credentials.");
311
+
312
+ authCommand
313
+ .command("login")
314
+ .description(
315
+ "Authenticate and cache credentials for the current environment.",
316
+ )
317
+ .action(async () => {
318
+ const { env } = program.opts();
319
+ const auth = await requestAndStoreAuth(env);
320
+ console.log(`Cached ${formatAuthSummary(auth)}.`);
321
+ });
322
+
323
+ authCommand
324
+ .command("list")
325
+ .description("List cached credentials for the current environment.")
326
+ .action(async () => {
327
+ const { env } = program.opts();
328
+ const currentAccount = await getCurrentAuthAccount(env);
329
+ const records = await listAuthRecords(env);
330
+ if (records.length === 0) {
331
+ console.log(`No cached credentials found for ${env}.`);
332
+ return;
333
+ }
334
+ for (const record of records) {
335
+ const marker =
336
+ currentAccount && sameStoredAuthAccount(record.account, currentAccount)
337
+ ? "*"
338
+ : " ";
339
+ console.log(`${marker} ${formatAuthSummary(record.auth)}`);
340
+ }
341
+ });
342
+
343
+ authCommand
344
+ .command("use")
345
+ .description("Select which cached credentials the CLI should use.")
346
+ .option("--user-id <id>", "WorkOS user ID to select.")
347
+ .option("--email <email>", "Email address to select.")
348
+ .option("--organization-id <id>", "Organization ID to select.")
349
+ .action(
350
+ async (options: {
351
+ userId?: string;
352
+ email?: string;
353
+ organizationId?: string;
354
+ }) => {
355
+ const { env } = program.opts();
356
+ if (!options.userId && !options.email && !options.organizationId) {
357
+ throw new Error(
358
+ 'Specify at least one filter, for example "hosted auth use --organization-id <id>".',
359
+ );
360
+ }
361
+ const result = await useAuth(env, options);
362
+ const message =
363
+ result.mode === "switched" ? "Cached and selected" : "Selected";
364
+ console.log(`${message} ${formatAuthSummary(result.auth)}.`);
365
+ },
366
+ );
367
+
369
368
  program
370
369
  .command("deploy")
371
370
  .description("Create or update a hosted server.")
@@ -388,32 +387,78 @@ program
388
387
  )
389
388
  .action(async (options) => {
390
389
  const { env, base } = program.opts();
391
- const client = await makeAuthenticatedClient(env);
392
-
393
- const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
394
-
395
- let newOrUpdatedConfig: HostedConfig;
396
- if (fs.existsSync(configPath)) {
397
- console.log("Updating existing server...");
398
- const configData = fs.readFileSync(configPath, "utf8");
399
- newOrUpdatedConfig = await updateExisting(
400
- client,
401
- base,
402
- HostedConfigSchema.parse(JSON.parse(configData)),
403
- options,
404
- );
405
- console.log(
406
- `Updated ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
407
- );
390
+ const auth = await getAuth(env);
391
+
392
+ await withAuthRetry(env, async (client) => {
393
+ const configPath = path.join(base, HOSTED_CONFIG_PATHS[env]);
394
+
395
+ let newOrUpdatedConfig: HostedConfig;
396
+ if (fs.existsSync(configPath)) {
397
+ console.log("Updating existing server...");
398
+ const configData = fs.readFileSync(configPath, "utf8");
399
+ newOrUpdatedConfig = await updateExisting(
400
+ client,
401
+ base,
402
+ HostedConfigSchema.parse(JSON.parse(configData)),
403
+ auth.organizationId,
404
+ options,
405
+ );
406
+ console.log(
407
+ `Updated ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
408
+ );
409
+ } else {
410
+ console.log("Creating new server...");
411
+ newOrUpdatedConfig = await createNew(
412
+ client,
413
+ base,
414
+ auth.organizationId,
415
+ options,
416
+ );
417
+ console.log(
418
+ `Created ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
419
+ );
420
+ }
421
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
422
+ fs.writeFileSync(configPath, JSON.stringify(newOrUpdatedConfig, null, 2));
423
+ });
424
+ });
425
+
426
+ program
427
+ .command("logout")
428
+ .description(
429
+ "Clear stored credentials and sign out of the WorkOS browser session.",
430
+ )
431
+ .option("--all", "Clear all cached credentials for the current environment.")
432
+ .action(async (options: { all?: boolean }) => {
433
+ const { env } = program.opts();
434
+
435
+ let logoutUrl: string | undefined;
436
+
437
+ const selectedAuth = await getSelectedAuth(env);
438
+ if (selectedAuth) {
439
+ const { sid } = getTokenClaims(selectedAuth.accessToken);
440
+ if (sid) {
441
+ logoutUrl = `https://api.workos.com/user_management/sessions/logout?session_id=${sid}`;
442
+ }
443
+ }
444
+
445
+ if (options.all) {
446
+ await clearAllAuth(env);
447
+ console.log(`All cached credentials cleared for ${env}.`);
408
448
  } else {
409
- console.log("Creating new server...");
410
- newOrUpdatedConfig = await createNew(client, base, options);
411
- console.log(
412
- `Created ${WEB_CLIENT_URL[env]}/hosted/${newOrUpdatedConfig.hostedId}`,
413
- );
449
+ const clearedAuth = await clearSelectedAuth(env);
450
+ if (clearedAuth) {
451
+ console.log(
452
+ `Cleared cached credentials for ${formatAuthSummary(clearedAuth)}.`,
453
+ );
454
+ } else {
455
+ console.log(`No cached credentials found for ${env}.`);
456
+ }
457
+ }
458
+ if (logoutUrl) {
459
+ console.log("To fully sign out of the WorkOS browser session, visit:");
460
+ console.log(logoutUrl);
414
461
  }
415
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
416
- fs.writeFileSync(configPath, JSON.stringify(newOrUpdatedConfig, null, 2));
417
462
  });
418
463
 
419
464
  program.parse();