@gethmy/mcp 2.9.4 → 2.9.6

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/tui/setup.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  setActiveProject,
23
23
  setActiveWorkspace,
24
24
  } from "../config.js";
25
+ import { loginWithBrowser, type OAuthTokens } from "../oauth-login.js";
25
26
  import { onboardNewUser } from "../onboard.js";
26
27
  import { buildSkillFile, HARMONY_WORKFLOW_PROMPT } from "../skills.js";
27
28
  import { type AgentId, detectAgents } from "./agents.js";
@@ -708,7 +709,10 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
708
709
  }
709
710
 
710
711
  // Step 1: API Key (or create account)
711
- let apiKey = options.apiKey || existingConfig.apiKey;
712
+ // Prefer an existing OAuth access token, then a legacy key. A `hmy_at_` OAuth
713
+ // token also starts with `hmy_`, so the reuse check below accepts both.
714
+ let apiKey =
715
+ options.apiKey || existingConfig.oauthAccessToken || existingConfig.apiKey;
712
716
  let userEmail: string | undefined =
713
717
  options.userEmail || existingConfig.userEmail || undefined;
714
718
  let selectedWorkspaceIdFromSignup: string | undefined;
@@ -716,28 +720,53 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
716
720
  let selectedWorkspaceNameFromSignup: string | undefined;
717
721
  let selectedProjectNameFromSignup: string | undefined;
718
722
  let createdNewAccount = false;
723
+ // Set when the browser OAuth flow ran — persisted to config in Step 4.
724
+ let oauthTokens: OAuthTokens | undefined;
725
+
726
+ if (options.apiKey) {
727
+ // --- Deprecated: key passed via --api-key argv (card #364) ---
728
+ // A long-lived, unscoped account key in argv leaks via shell history,
729
+ // scrollback, and the process list. Kept as a CI/scripting escape hatch,
730
+ // but the browser OAuth flow is the supported onboarding path and the
731
+ // SetupWizard no longer emits this.
732
+ p.log.warn(
733
+ colors.warning(
734
+ "--api-key is deprecated and insecure: the key is exposed in your shell\n" +
735
+ "history, terminal scrollback, and the process list. Prefer the browser\n" +
736
+ "sign-in (run `npx @gethmy/mcp setup` with no --api-key). Use --api-key\n" +
737
+ "only for unattended CI where you accept that risk.",
738
+ ),
739
+ );
740
+ }
719
741
 
720
742
  if (needsApiKey || !apiKey || !apiKey.startsWith("hmy_")) {
721
- // Determine path: create account or enter API key
743
+ // Determine path: browser OAuth (default), create account, or paste key.
722
744
  let useNewAccount = options.newAccount === true;
745
+ let useBrowserAuth = false;
723
746
 
724
747
  if (!useNewAccount && options.apiKey) {
725
748
  // API key passed via flag — skip the choice prompt
726
749
  useNewAccount = false;
727
750
  } else if (!useNewAccount && !options.apiKey) {
728
751
  const getStarted = await p.select({
729
- message: "How would you like to get started?",
752
+ message: "How would you like to connect?",
730
753
  options: [
754
+ {
755
+ value: "browser",
756
+ label: "Sign in with your browser",
757
+ hint: "recommended — secure, no key handling",
758
+ },
731
759
  {
732
760
  value: "create",
733
761
  label: "Create a free account",
734
- hint: "recommended for new users",
735
762
  },
736
763
  {
737
764
  value: "apikey",
738
- label: "I already have an API key",
765
+ label: "Paste an API key",
766
+ hint: "advanced / CI",
739
767
  },
740
768
  ],
769
+ initialValue: "browser",
741
770
  });
742
771
 
743
772
  if (p.isCancel(getStarted)) {
@@ -746,9 +775,47 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
746
775
  }
747
776
 
748
777
  useNewAccount = getStarted === "create";
778
+ useBrowserAuth = getStarted === "browser";
749
779
  }
750
780
 
751
- if (useNewAccount) {
781
+ if (useBrowserAuth) {
782
+ // --- Browser OAuth flow (loopback + PKCE), card #364 ---
783
+ const spinner = p.spinner();
784
+ spinner.start("Opening your browser to authorize…");
785
+ try {
786
+ oauthTokens = await loginWithBrowser({
787
+ apiUrl: API_URL,
788
+ workspaceId: options.workspaceId,
789
+ onUrl: (url) => {
790
+ spinner.message(
791
+ `Authorize in your browser. If it didn't open:\n${colors.dim(url)}`,
792
+ );
793
+ },
794
+ });
795
+ apiKey = oauthTokens.accessToken;
796
+ needsApiKey = true;
797
+ // Persist immediately — the token is already minted, so saving now
798
+ // survives an abort later in the wizard. Null the legacy key: OAuth is
799
+ // now the active credential.
800
+ saveConfig({
801
+ apiKey: null,
802
+ oauthAccessToken: oauthTokens.accessToken,
803
+ oauthRefreshToken: oauthTokens.refreshToken,
804
+ oauthExpiresAt: oauthTokens.expiresAt,
805
+ oauthClientId: oauthTokens.clientId,
806
+ apiUrl: API_URL,
807
+ });
808
+ spinner.stop(colors.success("Authorized"));
809
+ } catch (error) {
810
+ spinner.stop(colors.error("Browser authorization failed"));
811
+ const msg = error instanceof Error ? error.message : "Unknown error";
812
+ p.log.error(msg);
813
+ p.log.info(
814
+ "You can retry, or run with --api-key for unattended setup.",
815
+ );
816
+ process.exit(1);
817
+ }
818
+ } else if (useNewAccount) {
752
819
  // --- Create account flow ---
753
820
  const fullName =
754
821
  options.name ||
@@ -843,6 +910,12 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
843
910
  }
844
911
  process.exit(1);
845
912
  }
913
+ } else if (options.apiKey) {
914
+ // --- Key provided via --api-key: honor it non-interactively ---
915
+ // (the onboarding wizard's copied command depends on this path;
916
+ // validation below still runs against it)
917
+ apiKey = options.apiKey;
918
+ needsApiKey = true;
846
919
  } else {
847
920
  // --- Existing API key flow ---
848
921
  const keyInput = await p.text({
@@ -1102,21 +1175,32 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1102
1175
  const allFiles: FileToWrite[] = [];
1103
1176
  const allSymlinks: SymlinkToCreate[] = [];
1104
1177
 
1105
- // Always save global config if we have an API key change
1178
+ // Always save global config if we have an API key change. This write is
1179
+ // type:"text" (no merge), so it must carry the OAuth fields explicitly or the
1180
+ // browser-auth credential would be clobbered.
1106
1181
  if (needsApiKey || !alreadyConfigured) {
1107
- allFiles.push({
1108
- path: getConfigPath(),
1109
- content: JSON.stringify(
1110
- {
1182
+ const configToWrite = oauthTokens
1183
+ ? {
1184
+ apiKey: null,
1185
+ apiUrl: API_URL,
1186
+ userEmail: userEmail || null,
1187
+ activeWorkspaceId: null,
1188
+ activeProjectId: null,
1189
+ oauthAccessToken: oauthTokens.accessToken,
1190
+ oauthRefreshToken: oauthTokens.refreshToken,
1191
+ oauthExpiresAt: oauthTokens.expiresAt,
1192
+ oauthClientId: oauthTokens.clientId,
1193
+ }
1194
+ : {
1111
1195
  apiKey,
1112
1196
  apiUrl: API_URL,
1113
1197
  userEmail: userEmail || null,
1114
1198
  activeWorkspaceId: null,
1115
1199
  activeProjectId: null,
1116
- },
1117
- null,
1118
- 2,
1119
- ),
1200
+ };
1201
+ allFiles.push({
1202
+ path: getConfigPath(),
1203
+ content: JSON.stringify(configToWrite, null, 2),
1120
1204
  type: "text", // Use text to avoid merging
1121
1205
  });
1122
1206
  }
@@ -1151,7 +1235,13 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1151
1235
  p.log.step("Summary");
1152
1236
  console.log("");
1153
1237
 
1154
- console.log(` ${colors.bold("API Key:")} ${apiKey.slice(0, 8)}...`);
1238
+ if (oauthTokens) {
1239
+ console.log(
1240
+ ` ${colors.bold("Credential:")} Browser sign-in (OAuth, workspace-scoped)`,
1241
+ );
1242
+ } else {
1243
+ console.log(` ${colors.bold("API Key:")} ${apiKey.slice(0, 8)}...`);
1244
+ }
1155
1245
  if (userEmail) {
1156
1246
  console.log(` ${colors.bold("Email:")} ${userEmail}`);
1157
1247
  }