@amerged/ohmyhost-mcp 0.1.19 → 0.1.21

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,11 +74,14 @@ import {
63
74
  parseDeploymentPlan,
64
75
  parseCurrentIdentity,
65
76
  parseOrganization,
77
+ type PublicAccount,
66
78
  type PublicWorkspace,
67
79
  parseOrganizationCredits,
68
80
  parseProjectCreditBudget,
69
81
  parseDevAccessTicket,
70
82
  parseDevAccessState,
83
+ parsePoweredByFlag,
84
+ parseOrganizationReferral,
71
85
  parseGuardedActionPlan,
72
86
  parseEnvironmentSecret,
73
87
  parseEnvironmentSecretDeletion,
@@ -198,7 +212,18 @@ export class PublicResponseTooLargeError extends Error {
198
212
  export interface ProductCliDependencies {
199
213
  readonly apiToken?: string;
200
214
  readonly commandPrefix: string;
201
- readonly credentialStore: (kind: "native") => CredentialStore;
215
+ /** One credential store for a run without saved logins; a chosen saved login uses its own. */
216
+ readonly credentialStore?: (kind: "native") => CredentialStore;
217
+ /** Saved interactive logins; login, logout, profile list and workspace binding need them. */
218
+ readonly profiles?: ProfileRegistry;
219
+ /** The saved login this run acts as, chosen before the command started and never by another run. */
220
+ readonly profile?: LocalProfile;
221
+ /** The alias `login` gives the login it adds. */
222
+ readonly profileName?: string;
223
+ /** The OHMYHOST_PROFILE binding of this process, reported by `profile list`. */
224
+ readonly profileBinding?: string;
225
+ /** Read-only `GET /v1/me`, which verifies a fresh login before anything is stored. */
226
+ readonly currentIdentity?: (accessToken: () => Promise<string>) => Promise<unknown>;
202
227
  readonly deviceFlow: WorkosDeviceFlowClient;
203
228
  readonly deviceAuthorizationPresenter: DeviceAuthorizationPresenter;
204
229
  readonly productApi: (accessToken: () => Promise<string>) => ProductApi;
@@ -206,9 +231,7 @@ export interface ProductCliDependencies {
206
231
  * Reads the signed-in customer's workspaces through a separate read-only dependency,
207
232
  * keeping product mutation capabilities out of login.
208
233
  */
209
- readonly accountProfile: (
210
- accessToken: () => Promise<string>,
211
- ) => Promise<readonly PublicWorkspace[]>;
234
+ readonly accountProfile: (accessToken: () => Promise<string>) => Promise<PublicAccount>;
212
235
  /** Optional onboarding guidance; production supplies only this one read-only SDK query. */
213
236
  readonly githubConnectionStatus?: (
214
237
  accessToken: () => Promise<string>,
@@ -256,6 +279,8 @@ interface CliErrorDocument {
256
279
  readonly retry_after_seconds?: number;
257
280
  readonly request_id?: string;
258
281
  readonly docs_url?: string;
282
+ /** The account the command ran as, for answers that depend on which account that is. */
283
+ readonly acting_as?: Readonly<Record<string, unknown>>;
259
284
  };
260
285
  }
261
286
 
@@ -265,9 +290,14 @@ type CustomerCommandLabel = Exclude<CommandLabel, "unknown" | "help" | "version"
265
290
  const CLI_VERSION = cliPackage.version;
266
291
  const HELP_USAGE = Object.freeze({
267
292
  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",
293
+ login:
294
+ "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)",
295
+ logout:
296
+ "ohmyhost logout [--profile-name NAME] [--revoke] --json (removes only the selected saved login)",
297
+ whoami:
298
+ "ohmyhost whoami [--profile-name NAME] --json (the effective user, organization and saved login)",
299
+ "profile list":
300
+ "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
301
  "github connect":
272
302
  "ohmyhost github connect --organization ULID --idempotency-key KEY --json (connect once, then link covered repositories without another browser consent)",
273
303
  "github status": "ohmyhost github status --organization ULID --json",
@@ -276,6 +306,8 @@ const HELP_USAGE = Object.freeze({
276
306
  "export get":
277
307
  "ohmyhost export get EXPORT_ULID --project ULID --json (poll the original job; signed ZIP download lasts 24 hours)",
278
308
  "credits account": "ohmyhost credits account --organization ULID --json",
309
+ "referral link":
310
+ "ohmyhost referral link --organization ULID --json (the workspace's link to share; a new user who signs up through it starts with a free Paid month and 1,000 credits, and their first payment gives this workspace the same)",
279
311
  "credits balance": "ohmyhost credits balance --organization ULID --json",
280
312
  "billing recharge get": "ohmyhost billing recharge get --organization ULID --json",
281
313
  "billing recharge set":
@@ -291,10 +323,11 @@ const HELP_USAGE = Object.freeze({
291
323
  "budget set":
292
324
  "ohmyhost budget set --project ULID --credits NUMBER|none [--mode continue|stop] --idempotency-key KEY --json",
293
325
  "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)",
326
+ "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
327
  "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",
328
+ "ohmyhost organization list [--profile-name NAME] --json (the workspaces of the chosen login's user and the one that login is scoped to)",
329
+ "organization use":
330
+ "ohmyhost organization use --organization ULID [--profile-name NAME] --json (binds a login that has no organization yet; another organization needs its own login)",
298
331
  "operation get": "ohmyhost operation get OPERATION_ULID --json",
299
332
  "operation reconcile":
300
333
  "ohmyhost operation reconcile OPERATION_ULID --idempotency-key KEY --yes --json",
@@ -321,6 +354,9 @@ const HELP_USAGE = Object.freeze({
321
354
  "ohmyhost project dev-share revoke --project ULID --idempotency-key KEY --yes --json",
322
355
  "project dev-access mode":
323
356
  "ohmyhost project dev-access mode --project ULID --mode protected|public --idempotency-key KEY --yes --json",
357
+ "project flag status": "ohmyhost project flag status --project ULID --json",
358
+ "project flag set":
359
+ "ohmyhost project flag set --project ULID --enabled true|false --idempotency-key KEY --json (shows the small Powered by ohmyho.st flag on the production site; while it shows, a Free workspace may connect its own domain without the domain fee and each Paid period adds 250 credits)",
324
360
  "project handle check":
325
361
  "ohmyhost project handle check --handle HANDLE --json (is this address free? answers with a reason and free alternatives; the address becomes HANDLE.check.omh.st)",
326
362
  "project handle set":
@@ -377,7 +413,7 @@ const HELP_USAGE = Object.freeze({
377
413
  "function runs":
378
414
  "ohmyhost function runs --project ULID --environment ENVIRONMENT_ULID [--limit 1-100] --json",
379
415
  "secret set":
380
- "printf '%s' \"$SECRET_VALUE\" | ohmyhost secret set NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY --stdin [--wait] --json",
416
+ "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
417
  "secret delete":
382
418
  "ohmyhost secret delete NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--wait] --json",
383
419
  "mail setup":
@@ -411,8 +447,9 @@ export const cliMetadataResult = (kind: "help" | "version", topic?: string): Cli
411
447
  ? success("help", {
412
448
  status: "succeeded",
413
449
  cli_version: CLI_VERSION,
414
- usage: "ohmyhost <command> [options] --json",
415
- environment: "OHMYHOST_ENVIRONMENT=development|production (default: production)",
450
+ usage: "ohmyhost <command> [options] [--profile-name NAME] --json",
451
+ environment:
452
+ "OHMYHOST_ENVIRONMENT=development|production (default: production); OHMYHOST_PROFILE=NAME binds this process to one saved login",
416
453
  commands: HELP_COMMANDS.filter((entry) => !topic || entry.command === topic),
417
454
  })
418
455
  : success("version", { status: "succeeded", cli_version: CLI_VERSION });
@@ -431,7 +468,12 @@ class ProductCliFailure extends Error {
431
468
  }
432
469
 
433
470
  export class ProductCli {
434
- public constructor(private readonly dependencies: ProductCliDependencies) {}
471
+ /** The saved login of this run; binding a login without organization replaces it. */
472
+ #profile: LocalProfile | undefined;
473
+
474
+ public constructor(private readonly dependencies: ProductCliDependencies) {
475
+ this.#profile = dependencies.profile;
476
+ }
435
477
 
436
478
  public async run(
437
479
  argv: readonly string[],
@@ -479,8 +521,9 @@ export class ProductCli {
479
521
  // A user API token is bound to one workspace and cannot read the account profile.
480
522
  throw new InteractiveSessionRequiredError();
481
523
  }
524
+ if (command.kind === "profile-list") return await this.listProfiles();
482
525
  if (command.kind === "login")
483
- return await this.login(command.credentialStore, command.organizationId, signal);
526
+ return await this.login(command.organizationId, command.userId, signal);
484
527
  if (command.kind === "logout") {
485
528
  return await this.logout(command.credentialStore, command.revoke, signal);
486
529
  }
@@ -583,6 +626,13 @@ export class ProductCli {
583
626
  }
584
627
  return success(name, { status: "succeeded", billing: result });
585
628
  }
629
+ if (command.kind === "referral-link") {
630
+ const api = await this.authenticatedApi(command.credentialStore, signal);
631
+ const referral = parseOrganizationReferral(
632
+ await api.getOrganizationReferral(command.organizationId),
633
+ );
634
+ return success("referral link", { status: "succeeded", referral });
635
+ }
586
636
  if (command.kind === "credits-account") {
587
637
  const api = await this.authenticatedApi(command.credentialStore, signal);
588
638
  const account = await api.getOrganizationAccount(command.organizationId);
@@ -628,12 +678,9 @@ export class ProductCli {
628
678
  idempotencyKey: command.idempotencyKey,
629
679
  }),
630
680
  );
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(),
681
+ const selection = chooseWorkspace(
682
+ (await api.getAccountProfile()).organizations,
634
683
  organization.id,
635
- command.credentialStore,
636
- signal,
637
684
  );
638
685
  if (selection.outcome !== "selected")
639
686
  throw new ProductCliFailure(
@@ -642,10 +689,28 @@ export class ProductCli {
642
689
  true,
643
690
  `The new workspace is not yet readable for this account. Run '${this.dependencies.commandPrefix} organization use --organization ${organization.id} --json' again.`,
644
691
  );
692
+ const current = this.#profile;
693
+ // A login in another organization keeps it: the new workspace gets its own login, so no
694
+ // other agent that uses this login is moved into the new workspace.
695
+ if (
696
+ current !== undefined &&
697
+ current.organizationId !== null &&
698
+ current.organizationId !== organization.id
699
+ )
700
+ return success("organization create", {
701
+ status: "succeeded",
702
+ organization,
703
+ selected: current.organizationId,
704
+ profile: publicProfile(current),
705
+ 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}.`,
706
+ });
707
+ // A created workspace is only usable once it is in the access token, so bind it now.
708
+ const profile = await this.bindOrganization(selection.organization, signal);
645
709
  return success("organization create", {
646
710
  status: "succeeded",
647
711
  organization,
648
712
  selected: organization.id,
713
+ profile: publicProfile(profile),
649
714
  next_action: `Run '${this.dependencies.commandPrefix} whoami --json' to confirm the selected workspace, then create a project.`,
650
715
  ...(await this.githubNextAction(
651
716
  await this.authenticatedApi(command.credentialStore, signal),
@@ -655,26 +720,26 @@ export class ProductCli {
655
720
  }
656
721
  if (command.kind === "organization-list") {
657
722
  const api = await this.authenticatedApi(command.credentialStore, signal);
658
- const organizations = await api.getAccountProfile();
723
+ const account = await api.getAccountProfile();
659
724
  return success("organization list", {
660
725
  status: "listed",
661
- organizations: organizations.map(publicWorkspace),
662
- selected: await selectedWorkspaceId(this.workspaceSession(command.credentialStore)),
726
+ organizations: account.organizations.map(publicWorkspace),
727
+ selected: this.#profile?.organizationId ?? null,
663
728
  });
664
729
  }
665
730
  if (command.kind === "organization-use") {
666
731
  const api = await this.authenticatedApi(command.credentialStore, signal);
667
- const selection = await this.chooseAndStoreWorkspace(
668
- await api.getAccountProfile(),
732
+ const selection = chooseWorkspace(
733
+ (await api.getAccountProfile()).organizations,
669
734
  command.organizationId,
670
- command.credentialStore,
671
- signal,
672
735
  );
673
736
  if (selection.outcome !== "selected") throw this.unknownWorkspace(selection);
737
+ const profile = await this.bindOrganization(selection.organization, signal);
674
738
  return success("organization use", {
675
739
  status: "selected",
676
740
  organization: publicWorkspace(selection.organization),
677
741
  selected: selection.organization.id,
742
+ profile: publicProfile(profile),
678
743
  ...(await this.githubNextAction(
679
744
  await this.authenticatedApi(command.credentialStore, signal),
680
745
  selection.organization.id,
@@ -758,6 +823,22 @@ export class ProductCli {
758
823
  if (command.kind === "project-status") return await this.getProjectStatus(command, signal);
759
824
  if (command.kind === "project-dev-access-create")
760
825
  return await this.createDevAccessTicket(command, signal);
826
+ if (command.kind === "project-flag-status" || command.kind === "project-flag-set") {
827
+ const api = await this.authenticatedApi(command.credentialStore, signal);
828
+ const flag = parsePoweredByFlag(
829
+ command.kind === "project-flag-status"
830
+ ? await api.getPoweredByFlag(command.projectId)
831
+ : await api.setPoweredByFlag(
832
+ command.projectId,
833
+ command.enabled,
834
+ command.idempotencyKey,
835
+ ),
836
+ );
837
+ return success(
838
+ command.kind === "project-flag-status" ? "project flag status" : "project flag set",
839
+ { status: "succeeded", flag },
840
+ );
841
+ }
761
842
  if (
762
843
  command.kind === "project-dev-share-link" ||
763
844
  command.kind === "project-dev-share-rotate" ||
@@ -887,8 +968,16 @@ export class ProductCli {
887
968
  false,
888
969
  `A user API token cannot create, list or select a workspace. Run '${this.dependencies.commandPrefix} login --json' in a process without OHMYHOST_TOKEN.`,
889
970
  );
971
+ if (error instanceof ProfileLimitError)
972
+ return failure(
973
+ name,
974
+ 2,
975
+ "profile_limit_reached",
976
+ false,
977
+ profileLimitAction(this.dependencies.commandPrefix),
978
+ );
890
979
  this.recordDiagnostic(diagnosticDescriptor(error, signal));
891
- return classifyFailure(name, error, signal);
980
+ return classifyFailure(name, error, signal, this.context());
892
981
  }
893
982
  }
894
983
 
@@ -929,33 +1018,59 @@ export class ProductCli {
929
1018
  return success("whoami", {
930
1019
  status: "authenticated",
931
1020
  identity,
1021
+ context: this.context(),
932
1022
  ...(identity.organization_ids.length === 1 && identity.organization_ids[0] !== undefined
933
1023
  ? await this.githubNextAction(api, identity.organization_ids[0])
934
1024
  : {}),
935
1025
  });
936
- const selection = await this.chooseAndStoreWorkspace(
937
- await api.getAccountProfile(),
938
- undefined,
939
- command.credentialStore,
940
- signal,
941
- );
942
- if (selection.outcome === "selected")
1026
+ const selection = chooseWorkspace((await api.getAccountProfile()).organizations, undefined);
1027
+ if (selection.outcome === "selected") {
1028
+ await this.bindOrganization(selection.organization, signal);
943
1029
  return success("whoami", {
944
1030
  status: "authenticated",
945
1031
  identity: parseCurrentIdentity(await api.getCurrentIdentity()),
946
1032
  organization: publicWorkspace(selection.organization),
947
1033
  selected: selection.organization.id,
1034
+ context: this.context(),
948
1035
  ...(await this.githubNextAction(api, selection.organization.id)),
949
1036
  });
1037
+ }
950
1038
  return success("whoami", {
951
1039
  status: "authenticated",
952
1040
  identity,
1041
+ context: this.context(),
953
1042
  organizations:
954
1043
  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.`,
1044
+ next_action: this.workspaceAction(selection),
1045
+ });
1046
+ }
1047
+
1048
+ /** Which account this run acts as, without any credential. */
1049
+ private context(): Readonly<Record<string, unknown>> {
1050
+ if (this.dependencies.apiToken !== undefined) return { credential: "environment_token" };
1051
+ return {
1052
+ credential: "profile",
1053
+ profile: this.#profile === undefined ? null : publicProfile(this.#profile),
1054
+ };
1055
+ }
1056
+
1057
+ /** The next step for a login that has no organization yet. */
1058
+ private workspaceAction(selection: WorkspaceSelection): string {
1059
+ const prefix = this.dependencies.commandPrefix;
1060
+ const named = this.#profile === undefined ? "" : ` --profile-name ${this.#profile.name}`;
1061
+ return selection.outcome === "choice_required"
1062
+ ? `Ask the customer which workspace to use, then run '${prefix} organization use --organization ULID${named} --json'.`
1063
+ : `Run '${prefix} organization create --name NAME --idempotency-key KEY${named} --json' to create the first workspace.`;
1064
+ }
1065
+
1066
+ private async listProfiles(): Promise<CliResult> {
1067
+ const profiles = this.dependencies.profiles;
1068
+ if (profiles === undefined) throw new ProviderCapabilityBlockedError();
1069
+ return success("profile list", {
1070
+ status: "listed",
1071
+ profiles: (await profiles.list()).map(publicProfile),
1072
+ bound: this.dependencies.profileBinding ?? null,
1073
+ environment_token: this.dependencies.apiToken !== undefined,
959
1074
  });
960
1075
  }
961
1076
 
@@ -974,10 +1089,14 @@ export class ProductCli {
974
1089
 
975
1090
  // The reviewed boundary proof parses this signature, so keep its parameters brace-free.
976
1091
  private async login(
977
- credentialStore: "native",
978
1092
  organizationId: string | undefined,
1093
+ expectedUserId: string | undefined,
979
1094
  signal: AbortSignal,
980
1095
  ): Promise<CliResult> {
1096
+ const profiles = this.dependencies.profiles;
1097
+ const readIdentity = this.dependencies.currentIdentity;
1098
+ if (profiles === undefined || readIdentity === undefined)
1099
+ throw new ProviderCapabilityBlockedError();
981
1100
  // A device code lives five minutes and the provider offers no way to lengthen it, so the
982
1101
  // sign-in outlives the code instead: while this window lasts, a lapsed code is replaced.
983
1102
  const signInDeadline = this.dependencies.now().getTime() + SIGN_IN_WINDOW_MS;
@@ -1046,78 +1165,169 @@ export class ProductCli {
1046
1165
  "Run login again; the authorization response was incomplete.",
1047
1166
  );
1048
1167
  }
1049
- await this.dependencies.credentialStore(credentialStore).replace({
1168
+ // Nothing is stored before the browser's account is verified, so a wrong, refused or
1169
+ // cancelled sign-in leaves every saved login, and every command using one, as it was.
1170
+ const signedIn: CurrentCredential = {
1050
1171
  accessToken: result.tokens.accessToken,
1051
1172
  refreshToken: result.tokens.refreshToken,
1052
1173
  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
- );
1174
+ };
1175
+ const account = await this.dependencies.accountProfile(async () => signedIn.accessToken);
1176
+ if (!isProfileUserId(account.userId)) throw this.unverifiedLogin();
1177
+ if (expectedUserId !== undefined && account.userId !== expectedUserId)
1178
+ throw new ProductCliFailure(
1179
+ 4,
1180
+ "login_account_mismatch",
1181
+ false,
1182
+ `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.`,
1183
+ );
1184
+ const selection = chooseWorkspace(account.organizations, organizationId);
1185
+ if (selection.outcome === "not_a_member") throw this.unknownWorkspace(selection);
1186
+ // The workspace lives in the access token, so a chosen one is requested before storing.
1187
+ const credential =
1188
+ selection.outcome === "selected"
1189
+ ? await runBounded(
1190
+ (bounded) =>
1191
+ this.dependencies.tokenRefresher.refresh(
1192
+ signedIn.refreshToken,
1193
+ bounded,
1194
+ selection.organization.workos_id,
1195
+ ),
1196
+ signal,
1197
+ this.dependencies.lifecycleTimeoutMs,
1198
+ )
1199
+ : signedIn;
1200
+ const identity = await readIdentity(async () => credential.accessToken);
1201
+ const scopedId =
1202
+ selection.outcome === "selected"
1203
+ ? selection.organization.id
1204
+ : (parseCurrentIdentity(identity).organization_ids[0] ?? null);
1205
+ const organization =
1206
+ scopedId === null
1207
+ ? null
1208
+ : account.organizations.find((candidate) => candidate.id === scopedId);
1209
+ if (organization === undefined) throw this.unverifiedLogin();
1210
+ try {
1211
+ assertVerifiedLogin(identity, account.userId, scopedId);
1212
+ } catch (error) {
1213
+ if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1214
+ throw error;
1215
+ }
1216
+ const login = {
1217
+ userId: account.userId,
1218
+ organizationId: scopedId,
1219
+ organizationName: organization?.name ?? null,
1220
+ };
1221
+ const profile: LocalProfile = {
1222
+ name: this.dependencies.profileName ?? defaultProfileName(await profiles.list(), login),
1223
+ ...login,
1224
+ };
1225
+ try {
1226
+ await profiles.save(
1227
+ profile,
1228
+ organization === null
1229
+ ? credential
1230
+ : {
1231
+ ...credential,
1232
+ workosOrganizationId: organization.workos_id,
1233
+ organizationId: organization.id,
1234
+ },
1235
+ );
1236
+ } catch (error) {
1237
+ if (!(error instanceof ProfileNameConflictError)) throw error;
1238
+ throw new ProductCliFailure(
1239
+ 2,
1240
+ "profile_name_conflict",
1241
+ false,
1242
+ `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.`,
1243
+ );
1244
+ }
1245
+ this.#profile = profile;
1064
1246
  const readGithubConnection = this.dependencies.githubConnectionStatus;
1065
- if (selection.outcome === "selected")
1247
+ if (organization !== null)
1066
1248
  return success("login", {
1067
1249
  status: "authenticated",
1068
- organization: publicWorkspace(selection.organization),
1250
+ organization: publicWorkspace(organization),
1251
+ profile: publicProfile(profile),
1069
1252
  ...(readGithubConnection === undefined
1070
1253
  ? {}
1071
1254
  : await this.githubNextAction(
1072
1255
  {
1073
1256
  getGithubConnection: (selectedOrganizationId) =>
1074
1257
  readGithubConnection(
1075
- () => this.authenticatedToken(credentialStore, signal),
1258
+ async () => credential.accessToken,
1076
1259
  selectedOrganizationId,
1077
1260
  ),
1078
1261
  },
1079
- selection.organization.id,
1262
+ organization.id,
1080
1263
  )),
1081
1264
  });
1082
- if (selection.outcome === "not_a_member") throw this.unknownWorkspace(selection);
1083
1265
  return success("login", {
1084
1266
  status: "authenticated",
1085
1267
  organization: null,
1268
+ profile: publicProfile(profile),
1086
1269
  organizations:
1087
1270
  selection.outcome === "choice_required"
1088
1271
  ? selection.organizations.map(publicWorkspace)
1089
1272
  : [],
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.`,
1273
+ next_action: this.workspaceAction(selection),
1094
1274
  });
1095
1275
  }
1096
1276
  }
1097
1277
 
1098
- private workspaceSession(credentialStore: "native"): WorkspaceSession {
1099
- return {
1100
- store: this.dependencies.credentialStore(credentialStore),
1101
- refresher: this.dependencies.tokenRefresher,
1102
- };
1278
+ /** The provider answered with another account than the one this login requested. */
1279
+ private unverifiedLogin(): ProductCliFailure {
1280
+ return new ProductCliFailure(
1281
+ 4,
1282
+ "login_verification_failed",
1283
+ true,
1284
+ `The signed-in account could not be verified; nothing was saved. Run '${this.dependencies.commandPrefix} login --json' again.`,
1285
+ );
1103
1286
  }
1104
1287
 
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",
1288
+ /**
1289
+ * Bind the login of this run, which has no organization yet, to one of its user's organizations.
1290
+ * A login that already has one keeps it; another organization is its own login.
1291
+ */
1292
+ private async bindOrganization(
1293
+ organization: PublicWorkspace,
1110
1294
  signal: AbortSignal,
1111
- ): Promise<WorkspaceSelection> {
1112
- const selection = chooseWorkspace(organizations, requested);
1113
- if (selection.outcome === "selected")
1114
- await runBounded(
1295
+ ): Promise<LocalProfile> {
1296
+ const profiles = this.dependencies.profiles;
1297
+ const profile = this.#profile;
1298
+ if (profiles === undefined || profile === undefined)
1299
+ throw new InteractiveSessionRequiredError();
1300
+ let bound: LocalProfile;
1301
+ try {
1302
+ bound = await runBounded(
1115
1303
  (bounded) =>
1116
- selectWorkspace(this.workspaceSession(credentialStore), selection.organization, bounded),
1304
+ selectWorkspace(
1305
+ {
1306
+ store: profiles.credential(profile),
1307
+ refresher: this.dependencies.tokenRefresher,
1308
+ profiles,
1309
+ profile,
1310
+ },
1311
+ organization,
1312
+ bounded,
1313
+ (accessToken) =>
1314
+ this.dependencies.productApi(async () => accessToken).getCurrentIdentity(),
1315
+ ),
1117
1316
  signal,
1118
1317
  this.dependencies.lifecycleTimeoutMs,
1119
1318
  );
1120
- return selection;
1319
+ } catch (error) {
1320
+ if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1321
+ if (!(error instanceof ProfileOrganizationFixedError)) throw error;
1322
+ throw new ProductCliFailure(
1323
+ 2,
1324
+ "profile_organization_fixed",
1325
+ false,
1326
+ `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.`,
1327
+ );
1328
+ }
1329
+ this.#profile = bound;
1330
+ return bound;
1121
1331
  }
1122
1332
 
1123
1333
  private unknownWorkspace(selection: WorkspaceSelection): ProductCliFailure {
@@ -1134,10 +1344,11 @@ export class ProductCli {
1134
1344
  revoke: boolean,
1135
1345
  signal: AbortSignal,
1136
1346
  ): Promise<CliResult> {
1137
- const store = this.dependencies.credentialStore(credentialStore);
1347
+ const store = this.store(credentialStore);
1348
+ const removed = this.#profile === undefined ? {} : { profile: publicProfile(this.#profile) };
1138
1349
  if (!revoke) {
1139
- await store.clear();
1140
- return success("logout", { status: "logged_out" });
1350
+ await this.forgetLogin(store);
1351
+ return success("logout", { status: "logged_out", ...removed });
1141
1352
  }
1142
1353
  const credential = await store.read();
1143
1354
  let revocationError: unknown;
@@ -1152,7 +1363,7 @@ export class ProductCli {
1152
1363
  } catch (error) {
1153
1364
  revocationError = error;
1154
1365
  } finally {
1155
- await store.clear();
1366
+ await this.forgetLogin(store);
1156
1367
  }
1157
1368
  if (revocationError !== undefined) {
1158
1369
  if (isCancellation(revocationError) && signal.aborted) throw revocationError;
@@ -1182,7 +1393,34 @@ export class ProductCli {
1182
1393
  }
1183
1394
  throw revocationError;
1184
1395
  }
1185
- return success("logout", { status: "logged_out" });
1396
+ return success("logout", { status: "logged_out", ...removed });
1397
+ }
1398
+
1399
+ /** The credential store of this run's saved login; it follows a binding made during the run. */
1400
+ private store(kind: "native"): CredentialStore {
1401
+ const profiles = this.dependencies.profiles;
1402
+ if (profiles !== undefined && this.#profile !== undefined)
1403
+ return profiles.credential(this.#profile);
1404
+ if (this.dependencies.credentialStore === undefined)
1405
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1406
+ return this.dependencies.credentialStore(kind);
1407
+ }
1408
+
1409
+ /** Removes only this run's saved login; every other saved login stays untouched. */
1410
+ private async forgetLogin(store: CredentialStore): Promise<void> {
1411
+ const profiles = this.dependencies.profiles;
1412
+ if (profiles !== undefined && this.#profile !== undefined) await profiles.remove(this.#profile);
1413
+ else await store.clear();
1414
+ }
1415
+
1416
+ /** How to sign this run's login in again, naming its organization and alias when known. */
1417
+ private loginAction(): string {
1418
+ const profile = this.#profile;
1419
+ return `Run '${this.dependencies.commandPrefix} login${
1420
+ profile?.organizationId === undefined || profile.organizationId === null
1421
+ ? ""
1422
+ : ` --organization ${profile.organizationId}`
1423
+ }${profile === undefined ? "" : ` --profile-name ${profile.name}`} --json'.`;
1186
1424
  }
1187
1425
 
1188
1426
  private async getOperation(
@@ -1271,7 +1509,7 @@ export class ProductCli {
1271
1509
  ): Promise<CliResult> {
1272
1510
  const api = await this.authenticatedApi(command.credentialStore, signal);
1273
1511
  const project = parseProjectStatus(await api.getProjectStatus(command.projectId));
1274
- return success("project status", { status: "succeeded", project });
1512
+ return success("project status", { status: "succeeded", project, context: this.context() });
1275
1513
  }
1276
1514
 
1277
1515
  private async createDevAccessTicket(
@@ -1476,14 +1714,21 @@ export class ProductCli {
1476
1714
  `${command.repositoryOwner}/${command.repositoryName}`.toLowerCase()
1477
1715
  )
1478
1716
  throw new ResponseContractError();
1717
+ // The link names the organization the server authorized, so this checkout can be linked once
1718
+ // per organization and another account's link is never used by mistake.
1719
+ const [organizationId, ...others] = parseCurrentIdentity(
1720
+ await api.getCurrentIdentity(),
1721
+ ).organization_ids;
1722
+ if (organizationId === undefined || others.length > 0) throw new ResponseContractError();
1479
1723
  await this.dependencies.projectLinkStore.save({
1480
- version: 1,
1724
+ version: 2,
1481
1725
  api_origin: this.dependencies.productApiOrigin,
1726
+ organization_id: organizationId,
1482
1727
  project_id: command.projectId,
1483
1728
  installation_id: source.installation_id,
1484
1729
  repository_full_name: source.repository_full_name,
1485
1730
  });
1486
- return success("link", { status: "completed", operation });
1731
+ return success("link", { status: "completed", operation, organization_id: organizationId });
1487
1732
  }
1488
1733
 
1489
1734
  private async configureAutoDeploy(
@@ -1925,18 +2170,19 @@ export class ProductCli {
1925
2170
  signal: AbortSignal,
1926
2171
  ): Promise<string> {
1927
2172
  if (this.dependencies.apiToken !== undefined) return this.dependencies.apiToken;
1928
- const store = this.dependencies.credentialStore(credentialStore);
2173
+ const store = this.store(credentialStore);
1929
2174
  const stored = await store.read();
1930
2175
  if (stored === undefined) {
1931
- throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
2176
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1932
2177
  }
1933
2178
  let refresh: { readonly credential: CurrentCredential; readonly refreshed: boolean };
1934
2179
  try {
1935
2180
  refresh = await this.refreshIfExpired(stored, signal);
1936
2181
  } catch (error) {
1937
2182
  if (!(error instanceof TokenRefreshRejectedError)) throw error;
1938
- await store.clear();
1939
- throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
2183
+ // The provider ended this login; only this saved login is removed.
2184
+ await this.forgetLogin(store);
2185
+ throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
1940
2186
  }
1941
2187
  if (refresh.refreshed) await store.replace(refresh.credential);
1942
2188
  return refresh.credential.accessToken;
@@ -2049,7 +2295,32 @@ const redactVerificationUri = (value: string): string => {
2049
2295
  return url.toString();
2050
2296
  };
2051
2297
 
2052
- const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSignal): CliResult => {
2298
+ /**
2299
+ * Answers that mean "this account cannot see or use that": they name the account that ran, so a
2300
+ * wrong saved login is told apart from a missing organization or GitHub authorization.
2301
+ */
2302
+ export const ACCOUNT_SENSITIVE_PROBLEMS: ReadonlySet<string> = new Set([
2303
+ "resource_not_found",
2304
+ "forbidden",
2305
+ "organization_required",
2306
+ "github_connection_required",
2307
+ "github_connection_revoked",
2308
+ "repository_not_installed",
2309
+ ]);
2310
+
2311
+ /** The stable CLI answer for a failure outside a command run, such as choosing the account. */
2312
+ export const classifyCliFailure = (
2313
+ command: RecognizedCommandLabel,
2314
+ error: unknown,
2315
+ signal: AbortSignal,
2316
+ ): CliResult => classifyFailure(command, error, signal);
2317
+
2318
+ const classifyFailure = (
2319
+ command: CommandLabel,
2320
+ error: unknown,
2321
+ signal: AbortSignal,
2322
+ actingAs?: Readonly<Record<string, unknown>>,
2323
+ ): CliResult => {
2053
2324
  if (signal.aborted || isCancellation(error)) {
2054
2325
  return failure(command, 130, "command_cancelled", true, "Run the command again.");
2055
2326
  }
@@ -2124,6 +2395,7 @@ const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSig
2124
2395
  problem.retryAfterSeconds,
2125
2396
  problem.requestId,
2126
2397
  problem.docsUrl,
2398
+ ACCOUNT_SENSITIVE_PROBLEMS.has(problem.code) ? actingAs : undefined,
2127
2399
  );
2128
2400
  } catch {
2129
2401
  return failure(
@@ -2175,6 +2447,7 @@ const failure = (
2175
2447
  retryAfterSeconds?: number,
2176
2448
  requestId?: string,
2177
2449
  docsUrl?: string,
2450
+ actingAs?: Readonly<Record<string, unknown>>,
2178
2451
  ): CliResult => {
2179
2452
  const document: CliErrorDocument = {
2180
2453
  version: 1,
@@ -2189,6 +2462,7 @@ const failure = (
2189
2462
  ...(requestId === undefined ? {} : { request_id: requestId }),
2190
2463
  ...(docsUrl === undefined ? {} : { docs_url: docsUrl }),
2191
2464
  ...(operation?.error === undefined ? {} : { message: operation.error.message }),
2465
+ ...(actingAs === undefined ? {} : { acting_as: actingAs }),
2192
2466
  },
2193
2467
  };
2194
2468
  return { exitCode, stdout: `${JSON.stringify(document)}\n`, stderr: "" };
@@ -2206,6 +2480,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2206
2480
  if (command.kind === "billing-status") return "billing status";
2207
2481
  if (command.kind === "billing-portal") return "billing portal";
2208
2482
  if (command.kind === "credits-account") return "credits account";
2483
+ if (command.kind === "referral-link") return "referral link";
2209
2484
  if (command.kind === "credits-balance") return "credits balance";
2210
2485
  if (command.kind === "credits-usage") return "credits usage";
2211
2486
  if (command.kind === "budget-get") return "budget get";
@@ -2231,6 +2506,8 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2231
2506
  if (command.kind === "project-dev-share-rotate") return "project dev-share rotate";
2232
2507
  if (command.kind === "project-dev-share-revoke") return "project dev-share revoke";
2233
2508
  if (command.kind === "project-dev-access-mode-set") return "project dev-access mode";
2509
+ if (command.kind === "project-flag-status") return "project flag status";
2510
+ if (command.kind === "project-flag-set") return "project flag set";
2234
2511
  if (command.kind === "project-handle-check") return "project handle check";
2235
2512
  if (command.kind === "project-handle-set") return "project handle set";
2236
2513
  if (command.kind === "database-write") return "database write";
@@ -2258,6 +2535,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2258
2535
  if (command.kind === "secret-set") return "secret set";
2259
2536
  if (command.kind === "secret-delete") return "secret delete";
2260
2537
  if (command.kind === "managed-mail") return command.label;
2538
+ if (command.kind === "profile-list") return "profile list";
2261
2539
  return command.kind;
2262
2540
  };
2263
2541