@amerged/ohmyhost-mcp 0.1.18 → 0.1.20

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.
@@ -43,17 +43,28 @@ import {
43
43
  type StoredCredential,
44
44
  } from "./credential-store.js";
45
45
  import type { ProductApi } from "./product-api.js";
46
+ import {
47
+ defaultProfileName,
48
+ isProfileUserId,
49
+ ProfileLimitError,
50
+ profileLimitAction,
51
+ ProfileNameConflictError,
52
+ publicProfile,
53
+ type LocalProfile,
54
+ type ProfileRegistry,
55
+ } from "./profile-store.js";
46
56
  import type { ProjectLinkStore } from "./project-link-store.js";
47
57
  import { DeviceTokenExpiryError, resolveDeviceTokenExpiresAt } from "./device-token-expiry.js";
48
58
  import {
59
+ AccountVerificationError,
60
+ assertVerifiedLogin,
49
61
  chooseWorkspace,
50
62
  InteractiveSessionRequiredError,
63
+ ProfileOrganizationFixedError,
51
64
  publicWorkspace,
52
65
  selectWorkspace,
53
- selectedWorkspaceId,
54
66
  workspaceOf,
55
67
  type WorkspaceSelection,
56
- type WorkspaceSession,
57
68
  } from "./workspace.js";
58
69
  import {
59
70
  parseCloudflareDnsAuthorization,
@@ -63,6 +74,7 @@ import {
63
74
  parseDeploymentPlan,
64
75
  parseCurrentIdentity,
65
76
  parseOrganization,
77
+ type PublicAccount,
66
78
  type PublicWorkspace,
67
79
  parseOrganizationCredits,
68
80
  parseProjectCreditBudget,
@@ -198,7 +210,18 @@ export class PublicResponseTooLargeError extends Error {
198
210
  export interface ProductCliDependencies {
199
211
  readonly apiToken?: string;
200
212
  readonly commandPrefix: string;
201
- readonly credentialStore: (kind: "native") => CredentialStore;
213
+ /** One credential store for a run without saved logins; a chosen saved login uses its own. */
214
+ readonly credentialStore?: (kind: "native") => CredentialStore;
215
+ /** Saved interactive logins; login, logout, profile list and workspace binding need them. */
216
+ readonly profiles?: ProfileRegistry;
217
+ /** The saved login this run acts as, chosen before the command started and never by another run. */
218
+ readonly profile?: LocalProfile;
219
+ /** The alias `login` gives the login it adds. */
220
+ readonly profileName?: string;
221
+ /** The OHMYHOST_PROFILE binding of this process, reported by `profile list`. */
222
+ readonly profileBinding?: string;
223
+ /** Read-only `GET /v1/me`, which verifies a fresh login before anything is stored. */
224
+ readonly currentIdentity?: (accessToken: () => Promise<string>) => Promise<unknown>;
202
225
  readonly deviceFlow: WorkosDeviceFlowClient;
203
226
  readonly deviceAuthorizationPresenter: DeviceAuthorizationPresenter;
204
227
  readonly productApi: (accessToken: () => Promise<string>) => ProductApi;
@@ -206,9 +229,7 @@ export interface ProductCliDependencies {
206
229
  * Reads the signed-in customer's workspaces through a separate read-only dependency,
207
230
  * keeping product mutation capabilities out of login.
208
231
  */
209
- readonly accountProfile: (
210
- accessToken: () => Promise<string>,
211
- ) => Promise<readonly PublicWorkspace[]>;
232
+ readonly accountProfile: (accessToken: () => Promise<string>) => Promise<PublicAccount>;
212
233
  /** Optional onboarding guidance; production supplies only this one read-only SDK query. */
213
234
  readonly githubConnectionStatus?: (
214
235
  accessToken: () => Promise<string>,
@@ -256,6 +277,8 @@ interface CliErrorDocument {
256
277
  readonly retry_after_seconds?: number;
257
278
  readonly request_id?: string;
258
279
  readonly docs_url?: string;
280
+ /** The account the command ran as, for answers that depend on which account that is. */
281
+ readonly acting_as?: Readonly<Record<string, unknown>>;
259
282
  };
260
283
  }
261
284
 
@@ -265,9 +288,14 @@ type CustomerCommandLabel = Exclude<CommandLabel, "unknown" | "help" | "version"
265
288
  const CLI_VERSION = cliPackage.version;
266
289
  const HELP_USAGE = Object.freeze({
267
290
  init: "ohmyhost init [--directory PATH] [--root PATH] [--project SLUG] [--region us|eu] [--dry-run] --json (pass the project's hosting region so storage.jurisdiction matches it; us when omitted)",
268
- login: "ohmyhost login [--organization ULID] --json",
269
- logout: "ohmyhost logout [--revoke] --json",
270
- whoami: "ohmyhost whoami --json",
291
+ login:
292
+ "ohmyhost login [--organization ULID] [--user USER_ID] [--profile-name NAME] --json (adds one saved login; nothing is saved unless the browser signed in as that user and organization)",
293
+ logout:
294
+ "ohmyhost logout [--profile-name NAME] [--revoke] --json (removes only the selected saved login)",
295
+ whoami:
296
+ "ohmyhost whoami [--profile-name NAME] --json (the effective user, organization and saved login)",
297
+ "profile list":
298
+ "ohmyhost profile list --json (saved logins on this computer: name, user and organization, never a token; pass --profile-name NAME or set OHMYHOST_PROFILE to choose one)",
271
299
  "github connect":
272
300
  "ohmyhost github connect --organization ULID --idempotency-key KEY --json (connect once, then link covered repositories without another browser consent)",
273
301
  "github status": "ohmyhost github status --organization ULID --json",
@@ -291,10 +319,11 @@ const HELP_USAGE = Object.freeze({
291
319
  "budget set":
292
320
  "ohmyhost budget set --project ULID --credits NUMBER|none [--mode continue|stop] --idempotency-key KEY --json",
293
321
  "organization create":
294
- "ohmyhost organization create --name NAME --idempotency-key KEY [--source SOURCE] --json (SOURCE is optional attribution from a link's r value; the new workspace is selected immediately)",
322
+ "ohmyhost organization create --name NAME --idempotency-key KEY [--source SOURCE] --json (SOURCE is optional attribution from a link's r value; a login without organization is bound to the new workspace, another login keeps its own)",
295
323
  "organization list":
296
- "ohmyhost organization list --json (the workspaces you belong to and the selected one)",
297
- "organization use": "ohmyhost organization use --organization ULID --json",
324
+ "ohmyhost organization list [--profile-name NAME] --json (the workspaces of the chosen login's user and the one that login is scoped to)",
325
+ "organization use":
326
+ "ohmyhost organization use --organization ULID [--profile-name NAME] --json (binds a login that has no organization yet; another organization needs its own login)",
298
327
  "operation get": "ohmyhost operation get OPERATION_ULID --json",
299
328
  "operation reconcile":
300
329
  "ohmyhost operation reconcile OPERATION_ULID --idempotency-key KEY --yes --json",
@@ -377,7 +406,7 @@ const HELP_USAGE = Object.freeze({
377
406
  "function runs":
378
407
  "ohmyhost function runs --project ULID --environment ENVIRONMENT_ULID [--limit 1-100] --json",
379
408
  "secret set":
380
- "printf '%s' \"$SECRET_VALUE\" | ohmyhost secret set NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY --stdin [--wait] --json",
409
+ "printf '%s' \"$SECRET_VALUE\" | ohmyhost secret set NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--profile-user USER_ID --profile-organization ULID | --token-user USER_ID --token-organization ULID] --stdin [--wait] --json (with --profile-user and --profile-organization the saved login that runs it must belong to that user and organization; with --token-user and --token-organization it runs only with an OHMYHOST_TOKEN of that user and organization, never with a saved login)",
381
410
  "secret delete":
382
411
  "ohmyhost secret delete NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--wait] --json",
383
412
  "mail setup":
@@ -411,8 +440,9 @@ export const cliMetadataResult = (kind: "help" | "version", topic?: string): Cli
411
440
  ? success("help", {
412
441
  status: "succeeded",
413
442
  cli_version: CLI_VERSION,
414
- usage: "ohmyhost <command> [options] --json",
415
- environment: "OHMYHOST_ENVIRONMENT=development|production (default: production)",
443
+ usage: "ohmyhost <command> [options] [--profile-name NAME] --json",
444
+ environment:
445
+ "OHMYHOST_ENVIRONMENT=development|production (default: production); OHMYHOST_PROFILE=NAME binds this process to one saved login",
416
446
  commands: HELP_COMMANDS.filter((entry) => !topic || entry.command === topic),
417
447
  })
418
448
  : success("version", { status: "succeeded", cli_version: CLI_VERSION });
@@ -431,7 +461,12 @@ class ProductCliFailure extends Error {
431
461
  }
432
462
 
433
463
  export class ProductCli {
434
- public constructor(private readonly dependencies: ProductCliDependencies) {}
464
+ /** The saved login of this run; binding a login without organization replaces it. */
465
+ #profile: LocalProfile | undefined;
466
+
467
+ public constructor(private readonly dependencies: ProductCliDependencies) {
468
+ this.#profile = dependencies.profile;
469
+ }
435
470
 
436
471
  public async run(
437
472
  argv: readonly string[],
@@ -479,8 +514,9 @@ export class ProductCli {
479
514
  // A user API token is bound to one workspace and cannot read the account profile.
480
515
  throw new InteractiveSessionRequiredError();
481
516
  }
517
+ if (command.kind === "profile-list") return await this.listProfiles();
482
518
  if (command.kind === "login")
483
- return await this.login(command.credentialStore, command.organizationId, signal);
519
+ return await this.login(command.organizationId, command.userId, signal);
484
520
  if (command.kind === "logout") {
485
521
  return await this.logout(command.credentialStore, command.revoke, signal);
486
522
  }
@@ -628,12 +664,9 @@ export class ProductCli {
628
664
  idempotencyKey: command.idempotencyKey,
629
665
  }),
630
666
  );
631
- // A created workspace is only usable once it is in the access token, so select it now.
632
- const selection = await this.chooseAndStoreWorkspace(
633
- await api.getAccountProfile(),
667
+ const selection = chooseWorkspace(
668
+ (await api.getAccountProfile()).organizations,
634
669
  organization.id,
635
- command.credentialStore,
636
- signal,
637
670
  );
638
671
  if (selection.outcome !== "selected")
639
672
  throw new ProductCliFailure(
@@ -642,10 +675,28 @@ export class ProductCli {
642
675
  true,
643
676
  `The new workspace is not yet readable for this account. Run '${this.dependencies.commandPrefix} organization use --organization ${organization.id} --json' again.`,
644
677
  );
678
+ const current = this.#profile;
679
+ // A login in another organization keeps it: the new workspace gets its own login, so no
680
+ // other agent that uses this login is moved into the new workspace.
681
+ if (
682
+ current !== undefined &&
683
+ current.organizationId !== null &&
684
+ current.organizationId !== organization.id
685
+ )
686
+ return success("organization create", {
687
+ status: "succeeded",
688
+ organization,
689
+ selected: current.organizationId,
690
+ profile: publicProfile(current),
691
+ next_action: `Run '${this.dependencies.commandPrefix} login --organization ${organization.id} --user ${current.userId} --json' to add a login for the new workspace as the same user; profile ${current.name} stays in organization ${current.organizationId}.`,
692
+ });
693
+ // A created workspace is only usable once it is in the access token, so bind it now.
694
+ const profile = await this.bindOrganization(selection.organization, signal);
645
695
  return success("organization create", {
646
696
  status: "succeeded",
647
697
  organization,
648
698
  selected: organization.id,
699
+ profile: publicProfile(profile),
649
700
  next_action: `Run '${this.dependencies.commandPrefix} whoami --json' to confirm the selected workspace, then create a project.`,
650
701
  ...(await this.githubNextAction(
651
702
  await this.authenticatedApi(command.credentialStore, signal),
@@ -655,26 +706,26 @@ export class ProductCli {
655
706
  }
656
707
  if (command.kind === "organization-list") {
657
708
  const api = await this.authenticatedApi(command.credentialStore, signal);
658
- const organizations = await api.getAccountProfile();
709
+ const account = await api.getAccountProfile();
659
710
  return success("organization list", {
660
711
  status: "listed",
661
- organizations: organizations.map(publicWorkspace),
662
- selected: await selectedWorkspaceId(this.workspaceSession(command.credentialStore)),
712
+ organizations: account.organizations.map(publicWorkspace),
713
+ selected: this.#profile?.organizationId ?? null,
663
714
  });
664
715
  }
665
716
  if (command.kind === "organization-use") {
666
717
  const api = await this.authenticatedApi(command.credentialStore, signal);
667
- const selection = await this.chooseAndStoreWorkspace(
668
- await api.getAccountProfile(),
718
+ const selection = chooseWorkspace(
719
+ (await api.getAccountProfile()).organizations,
669
720
  command.organizationId,
670
- command.credentialStore,
671
- signal,
672
721
  );
673
722
  if (selection.outcome !== "selected") throw this.unknownWorkspace(selection);
723
+ const profile = await this.bindOrganization(selection.organization, signal);
674
724
  return success("organization use", {
675
725
  status: "selected",
676
726
  organization: publicWorkspace(selection.organization),
677
727
  selected: selection.organization.id,
728
+ profile: publicProfile(profile),
678
729
  ...(await this.githubNextAction(
679
730
  await this.authenticatedApi(command.credentialStore, signal),
680
731
  selection.organization.id,
@@ -887,8 +938,16 @@ export class ProductCli {
887
938
  false,
888
939
  `A user API token cannot create, list or select a workspace. Run '${this.dependencies.commandPrefix} login --json' in a process without OHMYHOST_TOKEN.`,
889
940
  );
941
+ if (error instanceof ProfileLimitError)
942
+ return failure(
943
+ name,
944
+ 2,
945
+ "profile_limit_reached",
946
+ false,
947
+ profileLimitAction(this.dependencies.commandPrefix),
948
+ );
890
949
  this.recordDiagnostic(diagnosticDescriptor(error, signal));
891
- return classifyFailure(name, error, signal);
950
+ return classifyFailure(name, error, signal, this.context());
892
951
  }
893
952
  }
894
953
 
@@ -929,33 +988,59 @@ export class ProductCli {
929
988
  return success("whoami", {
930
989
  status: "authenticated",
931
990
  identity,
991
+ context: this.context(),
932
992
  ...(identity.organization_ids.length === 1 && identity.organization_ids[0] !== undefined
933
993
  ? await this.githubNextAction(api, identity.organization_ids[0])
934
994
  : {}),
935
995
  });
936
- const selection = await this.chooseAndStoreWorkspace(
937
- await api.getAccountProfile(),
938
- undefined,
939
- command.credentialStore,
940
- signal,
941
- );
942
- if (selection.outcome === "selected")
996
+ const selection = chooseWorkspace((await api.getAccountProfile()).organizations, undefined);
997
+ if (selection.outcome === "selected") {
998
+ await this.bindOrganization(selection.organization, signal);
943
999
  return success("whoami", {
944
1000
  status: "authenticated",
945
1001
  identity: parseCurrentIdentity(await api.getCurrentIdentity()),
946
1002
  organization: publicWorkspace(selection.organization),
947
1003
  selected: selection.organization.id,
1004
+ context: this.context(),
948
1005
  ...(await this.githubNextAction(api, selection.organization.id)),
949
1006
  });
1007
+ }
950
1008
  return success("whoami", {
951
1009
  status: "authenticated",
952
1010
  identity,
1011
+ context: this.context(),
953
1012
  organizations:
954
1013
  selection.outcome === "choice_required" ? selection.organizations.map(publicWorkspace) : [],
955
- next_action:
956
- selection.outcome === "choice_required"
957
- ? `Ask the customer which workspace to use, then run '${this.dependencies.commandPrefix} organization use --organization ULID --json'.`
958
- : `Run '${this.dependencies.commandPrefix} organization create --name NAME --idempotency-key KEY --json' to create the first workspace.`,
1014
+ next_action: this.workspaceAction(selection),
1015
+ });
1016
+ }
1017
+
1018
+ /** Which account this run acts as, without any credential. */
1019
+ private context(): Readonly<Record<string, unknown>> {
1020
+ if (this.dependencies.apiToken !== undefined) return { credential: "environment_token" };
1021
+ return {
1022
+ credential: "profile",
1023
+ profile: this.#profile === undefined ? null : publicProfile(this.#profile),
1024
+ };
1025
+ }
1026
+
1027
+ /** The next step for a login that has no organization yet. */
1028
+ private workspaceAction(selection: WorkspaceSelection): string {
1029
+ const prefix = this.dependencies.commandPrefix;
1030
+ const named = this.#profile === undefined ? "" : ` --profile-name ${this.#profile.name}`;
1031
+ return selection.outcome === "choice_required"
1032
+ ? `Ask the customer which workspace to use, then run '${prefix} organization use --organization ULID${named} --json'.`
1033
+ : `Run '${prefix} organization create --name NAME --idempotency-key KEY${named} --json' to create the first workspace.`;
1034
+ }
1035
+
1036
+ private async listProfiles(): Promise<CliResult> {
1037
+ const profiles = this.dependencies.profiles;
1038
+ if (profiles === undefined) throw new ProviderCapabilityBlockedError();
1039
+ return success("profile list", {
1040
+ status: "listed",
1041
+ profiles: (await profiles.list()).map(publicProfile),
1042
+ bound: this.dependencies.profileBinding ?? null,
1043
+ environment_token: this.dependencies.apiToken !== undefined,
959
1044
  });
960
1045
  }
961
1046
 
@@ -974,10 +1059,14 @@ export class ProductCli {
974
1059
 
975
1060
  // The reviewed boundary proof parses this signature, so keep its parameters brace-free.
976
1061
  private async login(
977
- credentialStore: "native",
978
1062
  organizationId: string | undefined,
1063
+ expectedUserId: string | undefined,
979
1064
  signal: AbortSignal,
980
1065
  ): Promise<CliResult> {
1066
+ const profiles = this.dependencies.profiles;
1067
+ const readIdentity = this.dependencies.currentIdentity;
1068
+ if (profiles === undefined || readIdentity === undefined)
1069
+ throw new ProviderCapabilityBlockedError();
981
1070
  // A device code lives five minutes and the provider offers no way to lengthen it, so the
982
1071
  // sign-in outlives the code instead: while this window lasts, a lapsed code is replaced.
983
1072
  const signInDeadline = this.dependencies.now().getTime() + SIGN_IN_WINDOW_MS;
@@ -1046,78 +1135,169 @@ export class ProductCli {
1046
1135
  "Run login again; the authorization response was incomplete.",
1047
1136
  );
1048
1137
  }
1049
- await this.dependencies.credentialStore(credentialStore).replace({
1138
+ // Nothing is stored before the browser's account is verified, so a wrong, refused or
1139
+ // cancelled sign-in leaves every saved login, and every command using one, as it was.
1140
+ const signedIn: CurrentCredential = {
1050
1141
  accessToken: result.tokens.accessToken,
1051
1142
  refreshToken: result.tokens.refreshToken,
1052
1143
  expiresAt,
1053
- });
1054
- // The workspace lives in the access token, so signing in is only half of a usable login.
1055
- const profile = await this.dependencies.accountProfile(() =>
1056
- this.authenticatedToken(credentialStore, signal),
1057
- );
1058
- const selection = await this.chooseAndStoreWorkspace(
1059
- profile,
1060
- organizationId,
1061
- credentialStore,
1062
- signal,
1063
- );
1144
+ };
1145
+ const account = await this.dependencies.accountProfile(async () => signedIn.accessToken);
1146
+ if (!isProfileUserId(account.userId)) throw this.unverifiedLogin();
1147
+ if (expectedUserId !== undefined && account.userId !== expectedUserId)
1148
+ throw new ProductCliFailure(
1149
+ 4,
1150
+ "login_account_mismatch",
1151
+ false,
1152
+ `The browser signed in as user ${account.userId}, not ${expectedUserId}. Nothing was saved and every saved login is unchanged. Sign out of ohmyho.st in that browser or use the browser profile of ${expectedUserId}, then run '${this.dependencies.commandPrefix} login${organizationId === undefined ? "" : ` --organization ${organizationId}`} --user ${expectedUserId} --json' again.`,
1153
+ );
1154
+ const selection = chooseWorkspace(account.organizations, organizationId);
1155
+ if (selection.outcome === "not_a_member") throw this.unknownWorkspace(selection);
1156
+ // The workspace lives in the access token, so a chosen one is requested before storing.
1157
+ const credential =
1158
+ selection.outcome === "selected"
1159
+ ? await runBounded(
1160
+ (bounded) =>
1161
+ this.dependencies.tokenRefresher.refresh(
1162
+ signedIn.refreshToken,
1163
+ bounded,
1164
+ selection.organization.workos_id,
1165
+ ),
1166
+ signal,
1167
+ this.dependencies.lifecycleTimeoutMs,
1168
+ )
1169
+ : signedIn;
1170
+ const identity = await readIdentity(async () => credential.accessToken);
1171
+ const scopedId =
1172
+ selection.outcome === "selected"
1173
+ ? selection.organization.id
1174
+ : (parseCurrentIdentity(identity).organization_ids[0] ?? null);
1175
+ const organization =
1176
+ scopedId === null
1177
+ ? null
1178
+ : account.organizations.find((candidate) => candidate.id === scopedId);
1179
+ if (organization === undefined) throw this.unverifiedLogin();
1180
+ try {
1181
+ assertVerifiedLogin(identity, account.userId, scopedId);
1182
+ } catch (error) {
1183
+ if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1184
+ throw error;
1185
+ }
1186
+ const login = {
1187
+ userId: account.userId,
1188
+ organizationId: scopedId,
1189
+ organizationName: organization?.name ?? null,
1190
+ };
1191
+ const profile: LocalProfile = {
1192
+ name: this.dependencies.profileName ?? defaultProfileName(await profiles.list(), login),
1193
+ ...login,
1194
+ };
1195
+ try {
1196
+ await profiles.save(
1197
+ profile,
1198
+ organization === null
1199
+ ? credential
1200
+ : {
1201
+ ...credential,
1202
+ workosOrganizationId: organization.workos_id,
1203
+ organizationId: organization.id,
1204
+ },
1205
+ );
1206
+ } catch (error) {
1207
+ if (!(error instanceof ProfileNameConflictError)) throw error;
1208
+ throw new ProductCliFailure(
1209
+ 2,
1210
+ "profile_name_conflict",
1211
+ false,
1212
+ `The name ${profile.name} already belongs to the saved login of user ${error.profile.userId} in organization ${String(error.profile.organizationId ?? "none")}. Nothing was saved. Choose another --profile-name, or remove that login with '${this.dependencies.commandPrefix} logout --profile-name ${profile.name} --json' first.`,
1213
+ );
1214
+ }
1215
+ this.#profile = profile;
1064
1216
  const readGithubConnection = this.dependencies.githubConnectionStatus;
1065
- if (selection.outcome === "selected")
1217
+ if (organization !== null)
1066
1218
  return success("login", {
1067
1219
  status: "authenticated",
1068
- organization: publicWorkspace(selection.organization),
1220
+ organization: publicWorkspace(organization),
1221
+ profile: publicProfile(profile),
1069
1222
  ...(readGithubConnection === undefined
1070
1223
  ? {}
1071
1224
  : await this.githubNextAction(
1072
1225
  {
1073
1226
  getGithubConnection: (selectedOrganizationId) =>
1074
1227
  readGithubConnection(
1075
- () => this.authenticatedToken(credentialStore, signal),
1228
+ async () => credential.accessToken,
1076
1229
  selectedOrganizationId,
1077
1230
  ),
1078
1231
  },
1079
- selection.organization.id,
1232
+ organization.id,
1080
1233
  )),
1081
1234
  });
1082
- if (selection.outcome === "not_a_member") throw this.unknownWorkspace(selection);
1083
1235
  return success("login", {
1084
1236
  status: "authenticated",
1085
1237
  organization: null,
1238
+ profile: publicProfile(profile),
1086
1239
  organizations:
1087
1240
  selection.outcome === "choice_required"
1088
1241
  ? selection.organizations.map(publicWorkspace)
1089
1242
  : [],
1090
- next_action:
1091
- selection.outcome === "choice_required"
1092
- ? `Ask the customer which workspace to use, then run '${this.dependencies.commandPrefix} organization use --organization ULID --json'.`
1093
- : `Run '${this.dependencies.commandPrefix} organization create --name NAME --idempotency-key KEY --json' to create the first workspace.`,
1243
+ next_action: this.workspaceAction(selection),
1094
1244
  });
1095
1245
  }
1096
1246
  }
1097
1247
 
1098
- private workspaceSession(credentialStore: "native"): WorkspaceSession {
1099
- return {
1100
- store: this.dependencies.credentialStore(credentialStore),
1101
- refresher: this.dependencies.tokenRefresher,
1102
- };
1248
+ /** The provider answered with another account than the one this login requested. */
1249
+ private unverifiedLogin(): ProductCliFailure {
1250
+ return new ProductCliFailure(
1251
+ 4,
1252
+ "login_verification_failed",
1253
+ true,
1254
+ `The signed-in account could not be verified; nothing was saved. Run '${this.dependencies.commandPrefix} login --json' again.`,
1255
+ );
1103
1256
  }
1104
1257
 
1105
- /** Decide the workspace and, when the answer cannot be wrong, put it into the credential. */
1106
- private async chooseAndStoreWorkspace(
1107
- organizations: readonly PublicWorkspace[],
1108
- requested: string | undefined,
1109
- credentialStore: "native",
1258
+ /**
1259
+ * Bind the login of this run, which has no organization yet, to one of its user's organizations.
1260
+ * A login that already has one keeps it; another organization is its own login.
1261
+ */
1262
+ private async bindOrganization(
1263
+ organization: PublicWorkspace,
1110
1264
  signal: AbortSignal,
1111
- ): Promise<WorkspaceSelection> {
1112
- const selection = chooseWorkspace(organizations, requested);
1113
- if (selection.outcome === "selected")
1114
- await runBounded(
1265
+ ): Promise<LocalProfile> {
1266
+ const profiles = this.dependencies.profiles;
1267
+ const profile = this.#profile;
1268
+ if (profiles === undefined || profile === undefined)
1269
+ throw new InteractiveSessionRequiredError();
1270
+ let bound: LocalProfile;
1271
+ try {
1272
+ bound = await runBounded(
1115
1273
  (bounded) =>
1116
- selectWorkspace(this.workspaceSession(credentialStore), selection.organization, bounded),
1274
+ selectWorkspace(
1275
+ {
1276
+ store: profiles.credential(profile),
1277
+ refresher: this.dependencies.tokenRefresher,
1278
+ profiles,
1279
+ profile,
1280
+ },
1281
+ organization,
1282
+ bounded,
1283
+ (accessToken) =>
1284
+ this.dependencies.productApi(async () => accessToken).getCurrentIdentity(),
1285
+ ),
1117
1286
  signal,
1118
1287
  this.dependencies.lifecycleTimeoutMs,
1119
1288
  );
1120
- return selection;
1289
+ } catch (error) {
1290
+ if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1291
+ if (!(error instanceof ProfileOrganizationFixedError)) throw error;
1292
+ throw new ProductCliFailure(
1293
+ 2,
1294
+ "profile_organization_fixed",
1295
+ false,
1296
+ `Profile ${profile.name} stays signed in to organization ${String(profile.organizationId)}. Run '${this.dependencies.commandPrefix} login --organization ${organization.id} --user ${profile.userId} --json' to add a separate login for ${organization.id} as the same user; a saved login never switches organizations.`,
1297
+ );
1298
+ }
1299
+ this.#profile = bound;
1300
+ return bound;
1121
1301
  }
1122
1302
 
1123
1303
  private unknownWorkspace(selection: WorkspaceSelection): ProductCliFailure {
@@ -1134,10 +1314,11 @@ export class ProductCli {
1134
1314
  revoke: boolean,
1135
1315
  signal: AbortSignal,
1136
1316
  ): Promise<CliResult> {
1137
- const store = this.dependencies.credentialStore(credentialStore);
1317
+ const store = this.store(credentialStore);
1318
+ const removed = this.#profile === undefined ? {} : { profile: publicProfile(this.#profile) };
1138
1319
  if (!revoke) {
1139
- await store.clear();
1140
- return success("logout", { status: "logged_out" });
1320
+ await this.forgetLogin(store);
1321
+ return success("logout", { status: "logged_out", ...removed });
1141
1322
  }
1142
1323
  const credential = await store.read();
1143
1324
  let revocationError: unknown;
@@ -1152,7 +1333,7 @@ export class ProductCli {
1152
1333
  } catch (error) {
1153
1334
  revocationError = error;
1154
1335
  } finally {
1155
- await store.clear();
1336
+ await this.forgetLogin(store);
1156
1337
  }
1157
1338
  if (revocationError !== undefined) {
1158
1339
  if (isCancellation(revocationError) && signal.aborted) throw revocationError;
@@ -1182,7 +1363,34 @@ export class ProductCli {
1182
1363
  }
1183
1364
  throw revocationError;
1184
1365
  }
1185
- return success("logout", { status: "logged_out" });
1366
+ return success("logout", { status: "logged_out", ...removed });
1367
+ }
1368
+
1369
+ /** The credential store of this run's saved login; it follows a binding made during the run. */
1370
+ private store(kind: "native"): CredentialStore {
1371
+ const profiles = this.dependencies.profiles;
1372
+ if (profiles !== undefined && this.#profile !== undefined)
1373
+ return profiles.credential(this.#profile);
1374
+ if (this.dependencies.credentialStore === undefined)
1375
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1376
+ return this.dependencies.credentialStore(kind);
1377
+ }
1378
+
1379
+ /** Removes only this run's saved login; every other saved login stays untouched. */
1380
+ private async forgetLogin(store: CredentialStore): Promise<void> {
1381
+ const profiles = this.dependencies.profiles;
1382
+ if (profiles !== undefined && this.#profile !== undefined) await profiles.remove(this.#profile);
1383
+ else await store.clear();
1384
+ }
1385
+
1386
+ /** How to sign this run's login in again, naming its organization and alias when known. */
1387
+ private loginAction(): string {
1388
+ const profile = this.#profile;
1389
+ return `Run '${this.dependencies.commandPrefix} login${
1390
+ profile?.organizationId === undefined || profile.organizationId === null
1391
+ ? ""
1392
+ : ` --organization ${profile.organizationId}`
1393
+ }${profile === undefined ? "" : ` --profile-name ${profile.name}`} --json'.`;
1186
1394
  }
1187
1395
 
1188
1396
  private async getOperation(
@@ -1271,7 +1479,7 @@ export class ProductCli {
1271
1479
  ): Promise<CliResult> {
1272
1480
  const api = await this.authenticatedApi(command.credentialStore, signal);
1273
1481
  const project = parseProjectStatus(await api.getProjectStatus(command.projectId));
1274
- return success("project status", { status: "succeeded", project });
1482
+ return success("project status", { status: "succeeded", project, context: this.context() });
1275
1483
  }
1276
1484
 
1277
1485
  private async createDevAccessTicket(
@@ -1476,14 +1684,21 @@ export class ProductCli {
1476
1684
  `${command.repositoryOwner}/${command.repositoryName}`.toLowerCase()
1477
1685
  )
1478
1686
  throw new ResponseContractError();
1687
+ // The link names the organization the server authorized, so this checkout can be linked once
1688
+ // per organization and another account's link is never used by mistake.
1689
+ const [organizationId, ...others] = parseCurrentIdentity(
1690
+ await api.getCurrentIdentity(),
1691
+ ).organization_ids;
1692
+ if (organizationId === undefined || others.length > 0) throw new ResponseContractError();
1479
1693
  await this.dependencies.projectLinkStore.save({
1480
- version: 1,
1694
+ version: 2,
1481
1695
  api_origin: this.dependencies.productApiOrigin,
1696
+ organization_id: organizationId,
1482
1697
  project_id: command.projectId,
1483
1698
  installation_id: source.installation_id,
1484
1699
  repository_full_name: source.repository_full_name,
1485
1700
  });
1486
- return success("link", { status: "completed", operation });
1701
+ return success("link", { status: "completed", operation, organization_id: organizationId });
1487
1702
  }
1488
1703
 
1489
1704
  private async configureAutoDeploy(
@@ -1925,18 +2140,19 @@ export class ProductCli {
1925
2140
  signal: AbortSignal,
1926
2141
  ): Promise<string> {
1927
2142
  if (this.dependencies.apiToken !== undefined) return this.dependencies.apiToken;
1928
- const store = this.dependencies.credentialStore(credentialStore);
2143
+ const store = this.store(credentialStore);
1929
2144
  const stored = await store.read();
1930
2145
  if (stored === undefined) {
1931
- throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
2146
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1932
2147
  }
1933
2148
  let refresh: { readonly credential: CurrentCredential; readonly refreshed: boolean };
1934
2149
  try {
1935
2150
  refresh = await this.refreshIfExpired(stored, signal);
1936
2151
  } catch (error) {
1937
2152
  if (!(error instanceof TokenRefreshRejectedError)) throw error;
1938
- await store.clear();
1939
- throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
2153
+ // The provider ended this login; only this saved login is removed.
2154
+ await this.forgetLogin(store);
2155
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1940
2156
  }
1941
2157
  if (refresh.refreshed) await store.replace(refresh.credential);
1942
2158
  return refresh.credential.accessToken;
@@ -2049,7 +2265,32 @@ const redactVerificationUri = (value: string): string => {
2049
2265
  return url.toString();
2050
2266
  };
2051
2267
 
2052
- const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSignal): CliResult => {
2268
+ /**
2269
+ * Answers that mean "this account cannot see or use that": they name the account that ran, so a
2270
+ * wrong saved login is told apart from a missing organization or GitHub authorization.
2271
+ */
2272
+ export const ACCOUNT_SENSITIVE_PROBLEMS: ReadonlySet<string> = new Set([
2273
+ "resource_not_found",
2274
+ "forbidden",
2275
+ "organization_required",
2276
+ "github_connection_required",
2277
+ "github_connection_revoked",
2278
+ "repository_not_installed",
2279
+ ]);
2280
+
2281
+ /** The stable CLI answer for a failure outside a command run, such as choosing the account. */
2282
+ export const classifyCliFailure = (
2283
+ command: RecognizedCommandLabel,
2284
+ error: unknown,
2285
+ signal: AbortSignal,
2286
+ ): CliResult => classifyFailure(command, error, signal);
2287
+
2288
+ const classifyFailure = (
2289
+ command: CommandLabel,
2290
+ error: unknown,
2291
+ signal: AbortSignal,
2292
+ actingAs?: Readonly<Record<string, unknown>>,
2293
+ ): CliResult => {
2053
2294
  if (signal.aborted || isCancellation(error)) {
2054
2295
  return failure(command, 130, "command_cancelled", true, "Run the command again.");
2055
2296
  }
@@ -2124,6 +2365,7 @@ const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSig
2124
2365
  problem.retryAfterSeconds,
2125
2366
  problem.requestId,
2126
2367
  problem.docsUrl,
2368
+ ACCOUNT_SENSITIVE_PROBLEMS.has(problem.code) ? actingAs : undefined,
2127
2369
  );
2128
2370
  } catch {
2129
2371
  return failure(
@@ -2175,6 +2417,7 @@ const failure = (
2175
2417
  retryAfterSeconds?: number,
2176
2418
  requestId?: string,
2177
2419
  docsUrl?: string,
2420
+ actingAs?: Readonly<Record<string, unknown>>,
2178
2421
  ): CliResult => {
2179
2422
  const document: CliErrorDocument = {
2180
2423
  version: 1,
@@ -2189,6 +2432,7 @@ const failure = (
2189
2432
  ...(requestId === undefined ? {} : { request_id: requestId }),
2190
2433
  ...(docsUrl === undefined ? {} : { docs_url: docsUrl }),
2191
2434
  ...(operation?.error === undefined ? {} : { message: operation.error.message }),
2435
+ ...(actingAs === undefined ? {} : { acting_as: actingAs }),
2192
2436
  },
2193
2437
  };
2194
2438
  return { exitCode, stdout: `${JSON.stringify(document)}\n`, stderr: "" };
@@ -2258,6 +2502,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2258
2502
  if (command.kind === "secret-set") return "secret set";
2259
2503
  if (command.kind === "secret-delete") return "secret delete";
2260
2504
  if (command.kind === "managed-mail") return command.label;
2505
+ if (command.kind === "profile-list") return "profile list";
2261
2506
  return command.kind;
2262
2507
  };
2263
2508