@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.
- package/README.md +1 -1
- package/dist/{chunk-X6IS5LX7.js → chunk-NESQZ3BS.js} +7 -7
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +1316 -165
- package/package.json +1 -1
- package/src/apps/mcp/local-client.ts +86 -17
- package/src/apps/mcp/local-server.ts +280 -86
- package/src/apps/mcp/stdio-main.ts +11 -2
- package/src/apps/product-cli/cli.ts +337 -92
- package/src/apps/product-cli/command.ts +111 -6
- package/src/apps/product-cli/composition.ts +748 -120
- package/src/apps/product-cli/credential-store.ts +5 -3
- package/src/apps/product-cli/index.ts +2 -0
- package/src/apps/product-cli/product-api.ts +4 -4
- package/src/apps/product-cli/profile-store.ts +609 -0
- package/src/apps/product-cli/project-link-store.ts +169 -20
- package/src/apps/product-cli/runtime-contract.ts +15 -4
- package/src/apps/product-cli/workspace.ts +75 -13
- package/src/packages/agent-skills/generated-skill-resources.ts +6 -6
- package/src/packages/sdk-ts/generated/sdk.gen.ts +1 -1
|
@@ -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
|
-
|
|
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:
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
|
297
|
-
"organization use":
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
632
|
-
|
|
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
|
|
709
|
+
const account = await api.getAccountProfile();
|
|
659
710
|
return success("organization list", {
|
|
660
711
|
status: "listed",
|
|
661
|
-
organizations: organizations.map(publicWorkspace),
|
|
662
|
-
selected:
|
|
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 =
|
|
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
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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 (
|
|
1217
|
+
if (organization !== null)
|
|
1066
1218
|
return success("login", {
|
|
1067
1219
|
status: "authenticated",
|
|
1068
|
-
organization: publicWorkspace(
|
|
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
|
-
() =>
|
|
1228
|
+
async () => credential.accessToken,
|
|
1076
1229
|
selectedOrganizationId,
|
|
1077
1230
|
),
|
|
1078
1231
|
},
|
|
1079
|
-
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
/**
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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<
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
1317
|
+
const store = this.store(credentialStore);
|
|
1318
|
+
const removed = this.#profile === undefined ? {} : { profile: publicProfile(this.#profile) };
|
|
1138
1319
|
if (!revoke) {
|
|
1139
|
-
await
|
|
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
|
|
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:
|
|
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.
|
|
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,
|
|
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
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
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
|
|