@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.
- package/README.md +1 -1
- package/dist/{chunk-PXGLL6SR.js → chunk-4WY7AAPR.js} +11 -11
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +1575 -310
- package/package.json +1 -1
- package/src/apps/mcp/local-client.ts +119 -17
- package/src/apps/mcp/local-server.ts +321 -88
- package/src/apps/mcp/stdio-main.ts +11 -2
- package/src/apps/product-cli/cli.ts +370 -92
- package/src/apps/product-cli/command.ts +163 -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 +4 -0
- package/src/apps/product-cli/product-api.ts +32 -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 +63 -7
- package/src/apps/product-cli/workspace.ts +75 -13
- package/src/packages/agent-skills/generated-skill-resources.ts +10 -10
- package/src/packages/contracts/operation-failure.ts +3 -3
- package/src/packages/sdk-ts/generated/index.ts +21 -0
- package/src/packages/sdk-ts/generated/sdk.gen.ts +142 -0
- package/src/packages/sdk-ts/generated/types.gen.ts +200 -2
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
createClient,
|
|
5
5
|
getGithubOrganizationConnection,
|
|
6
|
+
getProject,
|
|
6
7
|
revokeCurrentSession,
|
|
7
8
|
} from "@ohmyhost/sdk-ts";
|
|
8
9
|
import { WORKOS_USER_API_KEY_PATTERN } from "@ohmyhost/workos-auth-contracts/user-api-keys";
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
ProductCli,
|
|
22
|
+
classifyCliFailure,
|
|
21
23
|
cliMetadataResult,
|
|
22
24
|
NetworkTransportError,
|
|
23
25
|
LifecycleTransportError,
|
|
@@ -31,20 +33,46 @@ import {
|
|
|
31
33
|
type TokenRevoker,
|
|
32
34
|
} from "./cli.js";
|
|
33
35
|
import {
|
|
36
|
+
extractProfileName,
|
|
34
37
|
InvalidCommandError,
|
|
35
38
|
parseProductCliCommand,
|
|
36
39
|
recognizedCommandLabel,
|
|
37
40
|
type ProductCliCommand,
|
|
38
41
|
} from "./command.js";
|
|
39
42
|
import {
|
|
40
|
-
|
|
43
|
+
CredentialStoreUnavailableError,
|
|
41
44
|
type CredentialStore,
|
|
42
45
|
type CurrentCredential,
|
|
43
46
|
type StoredCredential,
|
|
44
47
|
} from "./credential-store.js";
|
|
45
48
|
import { GeneratedSdkProductApi, type ProductApi } from "./product-api.js";
|
|
46
|
-
import {
|
|
47
|
-
|
|
49
|
+
import {
|
|
50
|
+
defaultProfileName,
|
|
51
|
+
describeProfile,
|
|
52
|
+
isProfileName,
|
|
53
|
+
NativeKeyringProfileRegistry,
|
|
54
|
+
ProfileLimitError,
|
|
55
|
+
profileLimitAction,
|
|
56
|
+
ProfileSelectionError,
|
|
57
|
+
profileSelectionError,
|
|
58
|
+
publicProfile,
|
|
59
|
+
selectProfile,
|
|
60
|
+
selectProfileForOrganization,
|
|
61
|
+
type LocalProfile,
|
|
62
|
+
type ProfileRegistry,
|
|
63
|
+
type ProfileRequest,
|
|
64
|
+
type ProfileSelector,
|
|
65
|
+
} from "./profile-store.js";
|
|
66
|
+
import { verifiedUserId, workspaceOf, type WorkspaceSession } from "./workspace.js";
|
|
67
|
+
import {
|
|
68
|
+
chooseLinkedProject,
|
|
69
|
+
linkedOrganization,
|
|
70
|
+
OwnerOnlyProjectLinkStore,
|
|
71
|
+
readProjectLinks,
|
|
72
|
+
removeLegacyProjectLink,
|
|
73
|
+
type DirectoryLinks,
|
|
74
|
+
type LinkedProject,
|
|
75
|
+
} from "./project-link-store.js";
|
|
48
76
|
import {
|
|
49
77
|
initializeRepository,
|
|
50
78
|
RepositoryInitBlockedError,
|
|
@@ -52,12 +80,14 @@ import {
|
|
|
52
80
|
RepositoryInitError,
|
|
53
81
|
} from "./repository-init.js";
|
|
54
82
|
import { runLocalPsql } from "./psql-session.js";
|
|
55
|
-
import { ResponseContractError } from "./runtime-contract.js";
|
|
83
|
+
import { parseCurrentIdentity, ResponseContractError } from "./runtime-contract.js";
|
|
56
84
|
import { resolveDeviceTokenExpiresAt } from "./device-token-expiry.js";
|
|
57
85
|
|
|
58
86
|
export type ProductCliEnvironment = Readonly<{
|
|
59
87
|
OHMYHOST_ENVIRONMENT?: string;
|
|
60
88
|
OHMYHOST_TOKEN?: string;
|
|
89
|
+
/** Binds this process to one saved login by name; a request for another login is refused. */
|
|
90
|
+
OHMYHOST_PROFILE?: string;
|
|
61
91
|
}>;
|
|
62
92
|
|
|
63
93
|
export interface ProductCliIo {
|
|
@@ -66,15 +96,29 @@ export interface ProductCliIo {
|
|
|
66
96
|
}
|
|
67
97
|
|
|
68
98
|
export interface ProductCliCompositionOverrides {
|
|
69
|
-
|
|
99
|
+
/** Saved logins; the native credential store of the selected platform environment by default. */
|
|
100
|
+
readonly profiles?: ProfileRegistry;
|
|
70
101
|
/** Directory whose project link supplies a missing --project; defaults to the process directory. */
|
|
71
102
|
readonly workingDirectory?: string;
|
|
72
103
|
readonly readSecretValue?: (signal: AbortSignal) => Promise<string>;
|
|
73
104
|
}
|
|
74
105
|
|
|
106
|
+
/** What one command or tool call asked for. Identifiers are context, never credentials. */
|
|
107
|
+
export interface ProfileRequestInput {
|
|
108
|
+
readonly name?: string;
|
|
109
|
+
readonly organizationId?: string;
|
|
110
|
+
/** The user this call must run as; a login or OHMYHOST_TOKEN of another user is refused. */
|
|
111
|
+
readonly userId?: string;
|
|
112
|
+
/** `organization use`: the login to bind is the one in that organization or the one without. */
|
|
113
|
+
readonly bindOrganization?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
export interface AuthenticatedProductApiOverrides {
|
|
76
117
|
readonly environment?: ProductCliEnvironment;
|
|
77
|
-
readonly
|
|
118
|
+
readonly profiles?: ProfileRegistry;
|
|
119
|
+
readonly request?: ProfileRequestInput;
|
|
120
|
+
/** How the caller names a login in its error guidance: a CLI flag or an MCP argument. */
|
|
121
|
+
readonly selector?: ProfileSelector;
|
|
78
122
|
readonly fetch?: typeof globalThis.fetch;
|
|
79
123
|
readonly now?: () => Date;
|
|
80
124
|
readonly productApiOrigin?: string;
|
|
@@ -83,13 +127,23 @@ export interface AuthenticatedProductApiOverrides {
|
|
|
83
127
|
|
|
84
128
|
export class ProductApiAuthenticationError extends Error {
|
|
85
129
|
public constructor(
|
|
86
|
-
readonly code:
|
|
130
|
+
readonly code:
|
|
131
|
+
| "authentication_required"
|
|
132
|
+
| "token_refresh_failed"
|
|
133
|
+
| "invalid_environment_token"
|
|
134
|
+
| "invalid_profile_binding",
|
|
87
135
|
) {
|
|
88
136
|
super(code);
|
|
89
137
|
this.name = "ProductApiAuthenticationError";
|
|
90
138
|
}
|
|
91
139
|
}
|
|
92
140
|
|
|
141
|
+
/** Which account a transport acts as. It names the login, never a credential. */
|
|
142
|
+
export interface EffectiveContext {
|
|
143
|
+
readonly credential: "profile" | "environment_token";
|
|
144
|
+
readonly profile: LocalProfile | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
93
147
|
type OutputCommand = ReturnType<typeof recognizedCommandLabel>;
|
|
94
148
|
|
|
95
149
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -126,6 +180,24 @@ function environmentToken(environment: ProductCliEnvironment = {}): string | und
|
|
|
126
180
|
return value;
|
|
127
181
|
}
|
|
128
182
|
|
|
183
|
+
/** The saved login this process is bound to; an empty value means no binding. */
|
|
184
|
+
function environmentProfile(environment: ProductCliEnvironment = {}): string | undefined {
|
|
185
|
+
const value = environment.OHMYHOST_PROFILE;
|
|
186
|
+
if (value === undefined || value === "") return undefined;
|
|
187
|
+
if (!isProfileName(value)) throw new ProductApiAuthenticationError("invalid_profile_binding");
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const profileRequest = (
|
|
192
|
+
input: ProfileRequestInput | undefined,
|
|
193
|
+
bound: string | undefined,
|
|
194
|
+
): ProfileRequest => ({
|
|
195
|
+
...(input?.name === undefined ? {} : { name: input.name }),
|
|
196
|
+
...(bound === undefined ? {} : { bound }),
|
|
197
|
+
...(input?.organizationId === undefined ? {} : { organizationId: input.organizationId }),
|
|
198
|
+
...(input?.userId === undefined ? {} : { userId: input.userId }),
|
|
199
|
+
});
|
|
200
|
+
|
|
129
201
|
export const createProductionAuthenticatedProductApi = async (
|
|
130
202
|
signal: AbortSignal,
|
|
131
203
|
overrides: AuthenticatedProductApiOverrides = {},
|
|
@@ -134,51 +206,142 @@ export const createProductionAuthenticatedProductApi = async (
|
|
|
134
206
|
return new GeneratedSdkProductApi(transport.baseUrl, transport.accessToken, transport.fetch);
|
|
135
207
|
};
|
|
136
208
|
|
|
137
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Private client composition data, never a public API or MCP response. Each call chooses its own
|
|
211
|
+
* login from its request and the process binding; nothing another process chose can change it.
|
|
212
|
+
*/
|
|
138
213
|
export const createAuthenticatedProductTransport = async (
|
|
139
214
|
signal: AbortSignal,
|
|
140
215
|
overrides: AuthenticatedProductApiOverrides = {},
|
|
141
216
|
) => {
|
|
142
|
-
const
|
|
217
|
+
const platform = resolveProductCliEnvironment(overrides.environment);
|
|
143
218
|
const apiToken = environmentToken(overrides.environment);
|
|
219
|
+
const bound = environmentProfile(overrides.environment);
|
|
220
|
+
const baseUrl = overrides.productApiOrigin ?? platform.apiOrigin;
|
|
144
221
|
const boundedFetch = createBoundedFetch(
|
|
145
222
|
overrides.fetch ?? fetch,
|
|
146
223
|
signal,
|
|
147
224
|
REQUEST_TIMEOUT_MS,
|
|
148
225
|
MAX_PUBLIC_RESPONSE_BYTES,
|
|
149
226
|
);
|
|
150
|
-
|
|
227
|
+
const registry =
|
|
228
|
+
overrides.profiles ?? new NativeKeyringProfileRegistry(undefined, platform.credentialService);
|
|
229
|
+
const binding = overrides.request?.bindOrganization === true;
|
|
230
|
+
const request = profileRequest(overrides.request, bound);
|
|
231
|
+
if (apiToken !== undefined) {
|
|
232
|
+
// A user API token carries a fixed scope; it must match whatever this call asked for.
|
|
233
|
+
await verifyEnvironmentTokenContext({
|
|
234
|
+
token: apiToken,
|
|
235
|
+
request: binding ? profileRequest({}, bound) : request,
|
|
236
|
+
registry,
|
|
237
|
+
baseUrl,
|
|
238
|
+
fetch: boundedFetch,
|
|
239
|
+
});
|
|
151
240
|
return {
|
|
152
|
-
baseUrl
|
|
241
|
+
baseUrl,
|
|
153
242
|
accessToken: apiToken,
|
|
154
243
|
fetch: boundedFetch,
|
|
155
|
-
// A user API token carries a fixed scope; there is no session that could select a workspace.
|
|
156
244
|
workspace: undefined,
|
|
245
|
+
context: { credential: "environment_token", profile: null } as EffectiveContext,
|
|
157
246
|
};
|
|
158
|
-
|
|
159
|
-
overrides.credentialStore ??
|
|
160
|
-
new NativeNapiKeyringCredentialStore(undefined, profile.credentialService);
|
|
161
|
-
const stored = await store.read();
|
|
162
|
-
if (stored === undefined) throw new ProductApiAuthenticationError("authentication_required");
|
|
247
|
+
}
|
|
163
248
|
const now = overrides.now ?? (() => new Date());
|
|
164
249
|
const refresher =
|
|
165
250
|
overrides.tokenRefresher ??
|
|
166
251
|
new PublicWorkosTokenRefresher(overrides.environment, overrides.fetch, now);
|
|
167
|
-
const
|
|
252
|
+
const profiles = await loadProfiles(registry, {
|
|
253
|
+
baseUrl,
|
|
254
|
+
fetch: boundedFetch,
|
|
255
|
+
signal,
|
|
256
|
+
now,
|
|
257
|
+
refresher,
|
|
258
|
+
});
|
|
259
|
+
const selection =
|
|
260
|
+
binding && request.organizationId !== undefined
|
|
261
|
+
? selectProfileForOrganization(profiles, {
|
|
262
|
+
...request,
|
|
263
|
+
organizationId: request.organizationId,
|
|
264
|
+
})
|
|
265
|
+
: selectProfile(profiles, request);
|
|
266
|
+
if (selection.outcome !== "selected")
|
|
267
|
+
throw profileSelectionError(
|
|
268
|
+
selection,
|
|
269
|
+
request,
|
|
270
|
+
platform.commandPrefix,
|
|
271
|
+
overrides.selector ?? "cli",
|
|
272
|
+
);
|
|
273
|
+
const profile = selection.profile;
|
|
274
|
+
const store = registry.credential(profile);
|
|
275
|
+
const stored = await store.read();
|
|
276
|
+
if (stored === undefined) throw missingLogin(profile, platform.commandPrefix);
|
|
277
|
+
const credential = await currentCredential(stored, store, signal, now, refresher, () =>
|
|
278
|
+
registry.remove(profile),
|
|
279
|
+
);
|
|
168
280
|
return {
|
|
169
|
-
baseUrl
|
|
281
|
+
baseUrl,
|
|
170
282
|
accessToken: credential.accessToken,
|
|
171
283
|
fetch: boundedFetch,
|
|
172
|
-
workspace: { store, refresher } satisfies WorkspaceSession,
|
|
284
|
+
workspace: { store, refresher, profiles: registry, profile } satisfies WorkspaceSession,
|
|
285
|
+
context: { credential: "profile", profile } as EffectiveContext,
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** The saved logins and this process's binding; the MCP `profile_list` answer. */
|
|
290
|
+
export const listSavedProfiles = async (
|
|
291
|
+
signal: AbortSignal,
|
|
292
|
+
overrides: AuthenticatedProductApiOverrides = {},
|
|
293
|
+
): Promise<{
|
|
294
|
+
readonly profiles: readonly ReturnType<typeof publicProfile>[];
|
|
295
|
+
readonly bound: string | null;
|
|
296
|
+
readonly environment_token: boolean;
|
|
297
|
+
}> => {
|
|
298
|
+
const platform = resolveProductCliEnvironment(overrides.environment);
|
|
299
|
+
const apiToken = environmentToken(overrides.environment);
|
|
300
|
+
const bound = environmentProfile(overrides.environment);
|
|
301
|
+
const registry =
|
|
302
|
+
overrides.profiles ?? new NativeKeyringProfileRegistry(undefined, platform.credentialService);
|
|
303
|
+
const now = overrides.now ?? (() => new Date());
|
|
304
|
+
const profiles =
|
|
305
|
+
apiToken === undefined
|
|
306
|
+
? await loadProfiles(registry, {
|
|
307
|
+
baseUrl: overrides.productApiOrigin ?? platform.apiOrigin,
|
|
308
|
+
fetch: createBoundedFetch(
|
|
309
|
+
overrides.fetch ?? fetch,
|
|
310
|
+
signal,
|
|
311
|
+
REQUEST_TIMEOUT_MS,
|
|
312
|
+
MAX_PUBLIC_RESPONSE_BYTES,
|
|
313
|
+
),
|
|
314
|
+
signal,
|
|
315
|
+
now,
|
|
316
|
+
refresher:
|
|
317
|
+
overrides.tokenRefresher ??
|
|
318
|
+
new PublicWorkosTokenRefresher(overrides.environment, overrides.fetch, now),
|
|
319
|
+
})
|
|
320
|
+
: await registry.list();
|
|
321
|
+
return {
|
|
322
|
+
profiles: profiles.map(publicProfile),
|
|
323
|
+
bound: bound ?? null,
|
|
324
|
+
environment_token: apiToken !== undefined,
|
|
173
325
|
};
|
|
174
326
|
};
|
|
175
327
|
|
|
328
|
+
/** An index entry whose credential is gone, for example after it was removed by hand. */
|
|
329
|
+
const missingLogin = (profile: LocalProfile, commandPrefix: string): ProfileSelectionError =>
|
|
330
|
+
new ProfileSelectionError(
|
|
331
|
+
"authentication_required",
|
|
332
|
+
5,
|
|
333
|
+
`The saved login ${profile.name} has no credential on this computer. Run '${commandPrefix} login${
|
|
334
|
+
profile.organizationId === null ? "" : ` --organization ${profile.organizationId}`
|
|
335
|
+
} --profile-name ${profile.name} --json'.`,
|
|
336
|
+
);
|
|
337
|
+
|
|
176
338
|
const currentCredential = async (
|
|
177
339
|
stored: StoredCredential,
|
|
178
340
|
store: CredentialStore,
|
|
179
341
|
signal: AbortSignal,
|
|
180
342
|
now: () => Date,
|
|
181
343
|
tokenRefresher: TokenRefresher,
|
|
344
|
+
forget: () => Promise<void>,
|
|
182
345
|
): Promise<CurrentCredential> => {
|
|
183
346
|
const expiresAt = stored.expiresAt === undefined ? NaN : Date.parse(stored.expiresAt);
|
|
184
347
|
if (Number.isFinite(expiresAt) && expiresAt > now().getTime() + REQUEST_TIMEOUT_MS) {
|
|
@@ -200,19 +363,173 @@ const currentCredential = async (
|
|
|
200
363
|
throw new Error("invalid refreshed credential");
|
|
201
364
|
}
|
|
202
365
|
// WorkOS returns a bare credential; the selection only survives if we carry it forward.
|
|
203
|
-
// WorkOS returns a bare credential; the selection only survives if we carry it forward.
|
|
204
366
|
const carried = { ...refreshed, ...workspaceOf(stored) };
|
|
205
367
|
await store.replace(carried);
|
|
206
368
|
return carried;
|
|
207
369
|
} catch (error) {
|
|
208
370
|
if (error instanceof TokenRefreshRejectedError) {
|
|
209
|
-
|
|
371
|
+
// The provider ended this login; only this saved login is removed.
|
|
372
|
+
await forget();
|
|
210
373
|
throw new ProductApiAuthenticationError("authentication_required");
|
|
211
374
|
}
|
|
212
375
|
throw new ProductApiAuthenticationError("token_refresh_failed");
|
|
213
376
|
}
|
|
214
377
|
};
|
|
215
378
|
|
|
379
|
+
interface ProfileLoadContext {
|
|
380
|
+
readonly baseUrl: string;
|
|
381
|
+
readonly fetch: typeof globalThis.fetch;
|
|
382
|
+
readonly signal: AbortSignal;
|
|
383
|
+
readonly now: () => Date;
|
|
384
|
+
readonly refresher: TokenRefresher;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The saved logins of this platform environment. A login saved by a client before named profiles
|
|
389
|
+
* becomes one named login, verified with the API; it is removed only once that login is written.
|
|
390
|
+
*/
|
|
391
|
+
export const loadProfiles = async (
|
|
392
|
+
registry: ProfileRegistry,
|
|
393
|
+
context: ProfileLoadContext,
|
|
394
|
+
): Promise<readonly LocalProfile[]> => {
|
|
395
|
+
const legacy = registry.legacy();
|
|
396
|
+
const stored = await legacy.read();
|
|
397
|
+
if (stored !== undefined) await migrateLegacyLogin(registry, legacy, stored, context);
|
|
398
|
+
return registry.list();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const migrateLegacyLogin = async (
|
|
402
|
+
registry: ProfileRegistry,
|
|
403
|
+
legacy: CredentialStore,
|
|
404
|
+
stored: StoredCredential,
|
|
405
|
+
context: ProfileLoadContext,
|
|
406
|
+
): Promise<void> => {
|
|
407
|
+
let credential: CurrentCredential;
|
|
408
|
+
try {
|
|
409
|
+
credential = await currentCredential(
|
|
410
|
+
stored,
|
|
411
|
+
legacy,
|
|
412
|
+
context.signal,
|
|
413
|
+
context.now,
|
|
414
|
+
context.refresher,
|
|
415
|
+
() => legacy.clear(),
|
|
416
|
+
);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// The provider had already ended that login, so it was removed and nothing is left to move.
|
|
419
|
+
if (error instanceof ProductApiAuthenticationError && error.code === "authentication_required")
|
|
420
|
+
return;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
const api = new GeneratedSdkProductApi(context.baseUrl, credential.accessToken, context.fetch);
|
|
424
|
+
let identity: unknown;
|
|
425
|
+
try {
|
|
426
|
+
identity = await api.getCurrentIdentity();
|
|
427
|
+
} catch (error) {
|
|
428
|
+
// The API refused it, so it cannot be verified or moved. It stays: only the provider ends a
|
|
429
|
+
// login for good, which the next refresh then detects and removes.
|
|
430
|
+
if (!isProblem(error, "unauthenticated")) throw error;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const userId = verifiedUserId(identity);
|
|
434
|
+
const organizationId = parseCurrentIdentity(identity).organization_ids[0] ?? null;
|
|
435
|
+
const organizationName =
|
|
436
|
+
organizationId === null
|
|
437
|
+
? null
|
|
438
|
+
: ((await api.getAccountProfile()).organizations.find(
|
|
439
|
+
(organization) => organization.id === organizationId,
|
|
440
|
+
)?.name ?? null);
|
|
441
|
+
// A saved login of the same account may hold a stale or missing credential, for example after an
|
|
442
|
+
// interrupted earlier move, so it receives the one just verified under its own name. The old entry
|
|
443
|
+
// goes only after that write: until then it keeps the refreshed, usable credential.
|
|
444
|
+
const login = { userId, organizationId, organizationName };
|
|
445
|
+
await registry.save(
|
|
446
|
+
{ name: defaultProfileName(await registry.list(), login), ...login },
|
|
447
|
+
credential,
|
|
448
|
+
);
|
|
449
|
+
await legacy.clear();
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const isProblem = (error: unknown, code: string): boolean =>
|
|
453
|
+
typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* OHMYHOST_TOKEN overrides saved logins, but it cannot silently stand in for another account: a
|
|
457
|
+
* requested login or organization must be the token's own, or nothing is sent.
|
|
458
|
+
*/
|
|
459
|
+
const verifyEnvironmentTokenContext = async (input: {
|
|
460
|
+
readonly token: string;
|
|
461
|
+
readonly request: ProfileRequest;
|
|
462
|
+
readonly registry: ProfileRegistry;
|
|
463
|
+
readonly baseUrl: string;
|
|
464
|
+
readonly fetch: typeof globalThis.fetch;
|
|
465
|
+
}): Promise<{ readonly userId: string; readonly organizationId: string | null } | null> => {
|
|
466
|
+
const { request } = input;
|
|
467
|
+
const named = request.name ?? request.bound;
|
|
468
|
+
if (named === undefined && request.organizationId === undefined && request.userId === undefined)
|
|
469
|
+
return null;
|
|
470
|
+
const refuse = (target: string): ProfileSelectionError =>
|
|
471
|
+
new ProfileSelectionError(
|
|
472
|
+
"environment_token_context_mismatch",
|
|
473
|
+
2,
|
|
474
|
+
`${target} Unset OHMYHOST_TOKEN to use the saved login, or load the key of that account from its private env file. Nothing was sent.`,
|
|
475
|
+
);
|
|
476
|
+
if (request.name !== undefined && request.bound !== undefined && request.name !== request.bound)
|
|
477
|
+
throw refuse(
|
|
478
|
+
`This call names profile ${request.name}, but the process is bound to OHMYHOST_PROFILE=${request.bound}.`,
|
|
479
|
+
);
|
|
480
|
+
const profile =
|
|
481
|
+
named === undefined
|
|
482
|
+
? undefined
|
|
483
|
+
: (await input.registry.list()).find((saved) => saved.name === named);
|
|
484
|
+
if (named !== undefined && profile === undefined)
|
|
485
|
+
throw refuse(
|
|
486
|
+
`OHMYHOST_TOKEN overrides saved logins in this process, and no saved login is named ${named}.`,
|
|
487
|
+
);
|
|
488
|
+
const { userId, organizationId } = await readTokenIdentity(input);
|
|
489
|
+
const owner = `OHMYHOST_TOKEN belongs to user ${userId} in organization ${organizationId ?? "none"},`;
|
|
490
|
+
if (
|
|
491
|
+
profile !== undefined &&
|
|
492
|
+
(profile.userId !== userId || profile.organizationId !== organizationId)
|
|
493
|
+
)
|
|
494
|
+
throw refuse(`${owner} but this call names ${describeProfile(profile)}.`);
|
|
495
|
+
if (request.organizationId !== undefined && request.organizationId !== organizationId)
|
|
496
|
+
throw refuse(`${owner} but this call targets organization ${request.organizationId}.`);
|
|
497
|
+
if (request.userId !== undefined && request.userId !== userId)
|
|
498
|
+
throw refuse(`${owner} but this call targets user ${request.userId}.`);
|
|
499
|
+
return { userId, organizationId };
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/** Whose key OHMYHOST_TOKEN is, as the API reports it. */
|
|
503
|
+
const readTokenIdentity = async (input: {
|
|
504
|
+
readonly token: string;
|
|
505
|
+
readonly baseUrl: string;
|
|
506
|
+
readonly fetch: typeof globalThis.fetch;
|
|
507
|
+
}): Promise<{ readonly userId: string; readonly organizationId: string | null }> => {
|
|
508
|
+
const identity = parseCurrentIdentity(
|
|
509
|
+
await new GeneratedSdkProductApi(input.baseUrl, input.token, input.fetch).getCurrentIdentity(),
|
|
510
|
+
);
|
|
511
|
+
return {
|
|
512
|
+
userId: identity.actor_id.startsWith("user:") ? identity.actor_id.slice(5) : identity.actor_id,
|
|
513
|
+
organizationId: identity.organization_ids[0] ?? null,
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** The organization a command names explicitly; `organization use` names its target instead. */
|
|
518
|
+
const commandOrganizationId = (command: ProductCliCommand): string | undefined => {
|
|
519
|
+
if (command.kind === "organization-use" || command.kind === "login") return undefined;
|
|
520
|
+
if (command.kind === "feedback-submit") return command.report.organization_id;
|
|
521
|
+
return "organizationId" in command && typeof command.organizationId === "string"
|
|
522
|
+
? command.organizationId
|
|
523
|
+
: undefined;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const commandProjectId = (command: ProductCliCommand): string | undefined => {
|
|
527
|
+
if (command.kind === "managed-mail") return command.request.projectId;
|
|
528
|
+
return "projectId" in command && typeof command.projectId === "string"
|
|
529
|
+
? command.projectId
|
|
530
|
+
: undefined;
|
|
531
|
+
};
|
|
532
|
+
|
|
216
533
|
export const runProductionCli = async (
|
|
217
534
|
argv: readonly string[],
|
|
218
535
|
environment: ProductCliEnvironment,
|
|
@@ -221,22 +538,82 @@ export const runProductionCli = async (
|
|
|
221
538
|
overrides: ProductCliCompositionOverrides = {},
|
|
222
539
|
): Promise<number> => {
|
|
223
540
|
const diagnostics = new StderrDiagnosticSink(io);
|
|
541
|
+
const written = (result: CliResult): number => {
|
|
542
|
+
io.writeStdout(result.stdout);
|
|
543
|
+
if (result.stderr.length > 0) io.writeStderr(result.stderr);
|
|
544
|
+
return result.exitCode;
|
|
545
|
+
};
|
|
546
|
+
let args: readonly string[];
|
|
547
|
+
let profileName: string | undefined;
|
|
548
|
+
try {
|
|
549
|
+
const extracted = extractProfileName(argv);
|
|
550
|
+
args = extracted.argv;
|
|
551
|
+
profileName = extracted.profileName;
|
|
552
|
+
} catch {
|
|
553
|
+
return written(invalidCommandFailure(recognizedCommandLabel(argv)));
|
|
554
|
+
}
|
|
555
|
+
const workingDirectory = overrides.workingDirectory ?? process.cwd();
|
|
224
556
|
let command: ProductCliCommand;
|
|
557
|
+
let linked: Extract<LinkedProject, { outcome: "linked" }> | null;
|
|
558
|
+
let links: DirectoryLinks = { links: [], legacy: null };
|
|
225
559
|
try {
|
|
226
|
-
const parsed = await parseWithLinkedProject(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
560
|
+
const parsed = await parseWithLinkedProject(args, async () => {
|
|
561
|
+
let platform: ReturnType<typeof resolveProductCliEnvironment>;
|
|
562
|
+
try {
|
|
563
|
+
platform = resolveProductCliEnvironment(environment);
|
|
564
|
+
} catch {
|
|
565
|
+
return { outcome: "none" };
|
|
566
|
+
}
|
|
567
|
+
links = await readProjectLinks(
|
|
568
|
+
join(workingDirectory, platform.linkDirectory),
|
|
569
|
+
platform.apiOrigin,
|
|
570
|
+
);
|
|
571
|
+
let named = profileName;
|
|
572
|
+
try {
|
|
573
|
+
named ??= environmentProfile(environment);
|
|
574
|
+
} catch {
|
|
575
|
+
// An invalid binding is reported once the command is known.
|
|
576
|
+
}
|
|
577
|
+
const profiles =
|
|
578
|
+
named === undefined
|
|
579
|
+
? []
|
|
580
|
+
: await (
|
|
581
|
+
overrides.profiles ??
|
|
582
|
+
new NativeKeyringProfileRegistry(undefined, platform.credentialService)
|
|
583
|
+
).list();
|
|
584
|
+
// The login a command names picks that organization's link of this checkout.
|
|
585
|
+
return chooseLinkedProject(
|
|
586
|
+
links,
|
|
587
|
+
profiles.find((profile) => profile.name === named)?.organizationId ?? undefined,
|
|
231
588
|
);
|
|
232
589
|
});
|
|
233
590
|
command = parsed.command;
|
|
234
|
-
|
|
235
|
-
|
|
591
|
+
linked = parsed.linked;
|
|
592
|
+
if (linked !== null) io.writeStderr(`ohmyhost: using linked project ${linked.projectId}\n`);
|
|
236
593
|
} catch (error) {
|
|
594
|
+
if (error instanceof LinkedProjectAmbiguousError)
|
|
595
|
+
return written(
|
|
596
|
+
errorDocument(
|
|
597
|
+
recognizedCommandLabel(args),
|
|
598
|
+
2,
|
|
599
|
+
"linked_project_selection_required",
|
|
600
|
+
false,
|
|
601
|
+
`This directory is linked to projects of several organizations (${error.organizationIds.join(", ")}). Pass --project ULID, or choose the login with --profile-name NAME; '${resolveProductCliEnvironment(environment).commandPrefix} profile list --json' shows the saved logins.`,
|
|
602
|
+
),
|
|
603
|
+
);
|
|
604
|
+
if (error instanceof CredentialStoreUnavailableError)
|
|
605
|
+
return written(
|
|
606
|
+
errorDocument(
|
|
607
|
+
recognizedCommandLabel(args),
|
|
608
|
+
3,
|
|
609
|
+
"credential_store_unavailable",
|
|
610
|
+
true,
|
|
611
|
+
"Unlock or repair the selected credential store and retry.",
|
|
612
|
+
),
|
|
613
|
+
);
|
|
237
614
|
const result =
|
|
238
615
|
error instanceof InvalidCommandError
|
|
239
|
-
? invalidCommandFailure(recognizedCommandLabel(
|
|
616
|
+
? invalidCommandFailure(recognizedCommandLabel(args))
|
|
240
617
|
: errorDocument("unknown", 1, "internal_error", false, "Retry the command.");
|
|
241
618
|
if (!(error instanceof InvalidCommandError)) {
|
|
242
619
|
diagnostics.record({
|
|
@@ -246,102 +623,230 @@ export const runProductionCli = async (
|
|
|
246
623
|
errorKind: "unexpected",
|
|
247
624
|
});
|
|
248
625
|
}
|
|
249
|
-
|
|
250
|
-
return result.exitCode;
|
|
626
|
+
return written(result);
|
|
251
627
|
}
|
|
252
628
|
if (command.kind === "init") {
|
|
629
|
+
if (profileName !== undefined) return written(invalidCommandFailure("init"));
|
|
253
630
|
return runOfflineInit(command, io, diagnostics);
|
|
254
631
|
}
|
|
255
632
|
if (command.kind === "help" || command.kind === "version") {
|
|
256
|
-
|
|
257
|
-
command.kind,
|
|
258
|
-
command.kind === "help" ? command.topic : undefined,
|
|
633
|
+
return written(
|
|
634
|
+
cliMetadataResult(command.kind, command.kind === "help" ? command.topic : undefined),
|
|
259
635
|
);
|
|
260
|
-
io.writeStdout(result.stdout);
|
|
261
|
-
return result.exitCode;
|
|
262
636
|
}
|
|
263
|
-
let
|
|
637
|
+
let platform: ReturnType<typeof resolveProductCliEnvironment>;
|
|
264
638
|
try {
|
|
265
|
-
|
|
639
|
+
platform = resolveProductCliEnvironment(environment);
|
|
266
640
|
} catch {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
641
|
+
return written(
|
|
642
|
+
errorDocument(
|
|
643
|
+
recognizedCommandLabel(args),
|
|
644
|
+
2,
|
|
645
|
+
"invalid_environment",
|
|
646
|
+
false,
|
|
647
|
+
"Set OHMYHOST_ENVIRONMENT to development or production.",
|
|
648
|
+
),
|
|
273
649
|
);
|
|
274
|
-
io.writeStdout(result.stdout);
|
|
275
|
-
return result.exitCode;
|
|
276
650
|
}
|
|
277
651
|
let apiToken: string | undefined;
|
|
278
652
|
try {
|
|
279
653
|
apiToken = environmentToken(environment);
|
|
280
654
|
} catch {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
655
|
+
return written(
|
|
656
|
+
errorDocument(
|
|
657
|
+
recognizedCommandLabel(args),
|
|
658
|
+
2,
|
|
659
|
+
"invalid_environment_token",
|
|
660
|
+
false,
|
|
661
|
+
"Set OHMYHOST_TOKEN to a valid user API key, or unset it to use interactive login.",
|
|
662
|
+
),
|
|
287
663
|
);
|
|
288
|
-
io.writeStdout(result.stdout);
|
|
289
|
-
return result.exitCode;
|
|
290
664
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
665
|
+
let bound: string | undefined;
|
|
666
|
+
try {
|
|
667
|
+
bound = environmentProfile(environment);
|
|
668
|
+
} catch {
|
|
669
|
+
return written(
|
|
670
|
+
errorDocument(
|
|
671
|
+
recognizedCommandLabel(args),
|
|
672
|
+
2,
|
|
673
|
+
"invalid_profile_binding",
|
|
674
|
+
false,
|
|
675
|
+
`Set OHMYHOST_PROFILE to the name of a saved login ('${platform.commandPrefix} profile list --json'), or unset it.`,
|
|
676
|
+
),
|
|
298
677
|
);
|
|
299
|
-
io.writeStdout(result.stdout);
|
|
300
|
-
return result.exitCode;
|
|
301
678
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
679
|
+
if (apiToken !== undefined && (command.kind === "login" || command.kind === "logout")) {
|
|
680
|
+
return written(
|
|
681
|
+
errorDocument(
|
|
682
|
+
recognizedCommandLabel(args),
|
|
683
|
+
2,
|
|
684
|
+
"environment_token_active",
|
|
685
|
+
false,
|
|
686
|
+
"Unset OHMYHOST_TOKEN before changing interactive login credentials. Logout does not revoke a user API key.",
|
|
687
|
+
),
|
|
688
|
+
);
|
|
310
689
|
}
|
|
311
|
-
//
|
|
312
|
-
|
|
690
|
+
// A command handed over from an OHMYHOST_TOKEN process runs only with a key of that account,
|
|
691
|
+
// never with a saved login, even one of the same user.
|
|
692
|
+
const requiredToken = command.kind === "secret-set" ? command.environmentToken : undefined;
|
|
693
|
+
// Either handed form names its account; the login or key that would run it must be exactly that
|
|
694
|
+
// account, so a login of the same name on another computer is refused before anything is read.
|
|
695
|
+
const handedAccount =
|
|
696
|
+
command.kind === "secret-set" ? (requiredToken ?? command.profileAccount) : undefined;
|
|
697
|
+
if (requiredToken !== undefined && apiToken === undefined)
|
|
698
|
+
return written(
|
|
699
|
+
errorDocument(
|
|
700
|
+
recognizedCommandLabel(args),
|
|
701
|
+
2,
|
|
702
|
+
"environment_token_required",
|
|
703
|
+
false,
|
|
704
|
+
`This command runs only with OHMYHOST_TOKEN holding a key of user ${requiredToken.userId} in organization ${requiredToken.organizationId}; a saved login cannot run it. Load that key from its private env file into this process and retry. Nothing was sent.`,
|
|
705
|
+
),
|
|
706
|
+
);
|
|
707
|
+
const registry =
|
|
708
|
+
overrides.profiles ?? new NativeKeyringProfileRegistry(undefined, platform.credentialService);
|
|
709
|
+
const linkDirectory = join(workingDirectory, platform.linkDirectory);
|
|
710
|
+
const projectId = commandProjectId(command);
|
|
711
|
+
// The organization this command is about: named on the command, or recorded by this checkout's
|
|
712
|
+
// link of its project. It narrows the saved logins and is checked against OHMYHOST_TOKEN.
|
|
713
|
+
const organizationId =
|
|
714
|
+
handedAccount?.organizationId ??
|
|
715
|
+
commandOrganizationId(command) ??
|
|
716
|
+
linked?.organizationId ??
|
|
717
|
+
(projectId === undefined ? undefined : linkedOrganization(links, projectId));
|
|
718
|
+
const request = profileRequest(
|
|
719
|
+
{
|
|
720
|
+
...(profileName === undefined ? {} : { name: profileName }),
|
|
721
|
+
...(organizationId === undefined || organizationId === null ? {} : { organizationId }),
|
|
722
|
+
...(handedAccount === undefined ? {} : { userId: handedAccount.userId }),
|
|
723
|
+
},
|
|
724
|
+
bound,
|
|
725
|
+
);
|
|
313
726
|
const transport = new FetchWorkosPublicTransport(signal, REQUEST_TIMEOUT_MS);
|
|
727
|
+
const now = (): Date => new Date();
|
|
728
|
+
const refresher = new PublicWorkosTokenRefresher(environment);
|
|
729
|
+
const boundedFetch = (): typeof globalThis.fetch =>
|
|
730
|
+
createBoundedFetch(fetch, signal, REQUEST_TIMEOUT_MS, MAX_PUBLIC_RESPONSE_BYTES);
|
|
731
|
+
let profile: LocalProfile | undefined;
|
|
732
|
+
try {
|
|
733
|
+
if (apiToken !== undefined) {
|
|
734
|
+
if (command.kind !== "profile-list")
|
|
735
|
+
await verifyEnvironmentTokenContext({
|
|
736
|
+
token: apiToken,
|
|
737
|
+
request,
|
|
738
|
+
registry,
|
|
739
|
+
baseUrl: platform.apiOrigin,
|
|
740
|
+
fetch: boundedFetch(),
|
|
741
|
+
});
|
|
742
|
+
if (linked !== null && linked.organizationId === null)
|
|
743
|
+
await adoptLegacyLink({
|
|
744
|
+
links,
|
|
745
|
+
directory: linkDirectory,
|
|
746
|
+
repositoryRoot: workingDirectory,
|
|
747
|
+
apiOrigin: platform.apiOrigin,
|
|
748
|
+
organizationId: (
|
|
749
|
+
await readTokenIdentity({
|
|
750
|
+
token: apiToken,
|
|
751
|
+
baseUrl: platform.apiOrigin,
|
|
752
|
+
fetch: boundedFetch(),
|
|
753
|
+
})
|
|
754
|
+
).organizationId,
|
|
755
|
+
accessToken: apiToken,
|
|
756
|
+
fetch: boundedFetch(),
|
|
757
|
+
actor: "OHMYHOST_TOKEN",
|
|
758
|
+
commandPrefix: platform.commandPrefix,
|
|
759
|
+
});
|
|
760
|
+
} else if (command.kind === "logout" && !command.revoke) {
|
|
761
|
+
// Removing a saved login needs no network: it is chosen from the local index alone.
|
|
762
|
+
return await runOfflineLogout(registry, request, platform.commandPrefix, io, diagnostics);
|
|
763
|
+
} else if (command.kind !== "login") {
|
|
764
|
+
// A login adds its own entry and needs nothing else; every other command first moves a
|
|
765
|
+
// login saved before named profiles into one, then chooses among the saved logins.
|
|
766
|
+
const profiles = await loadProfiles(registry, {
|
|
767
|
+
baseUrl: platform.apiOrigin,
|
|
768
|
+
fetch: boundedFetch(),
|
|
769
|
+
signal,
|
|
770
|
+
now,
|
|
771
|
+
refresher,
|
|
772
|
+
});
|
|
773
|
+
if (command.kind !== "profile-list") {
|
|
774
|
+
const selection =
|
|
775
|
+
command.kind === "organization-use"
|
|
776
|
+
? selectProfileForOrganization(profiles, {
|
|
777
|
+
...request,
|
|
778
|
+
organizationId: command.organizationId,
|
|
779
|
+
})
|
|
780
|
+
: selectProfile(profiles, request);
|
|
781
|
+
if (selection.outcome !== "selected")
|
|
782
|
+
throw profileSelectionError(selection, request, platform.commandPrefix, "cli");
|
|
783
|
+
profile = selection.profile;
|
|
784
|
+
io.writeStderr(`ohmyhost: acting as ${describeProfile(profile)}\n`);
|
|
785
|
+
if (linked !== null && linked.organizationId === null) {
|
|
786
|
+
const selected = await createAuthenticatedProductTransport(signal, {
|
|
787
|
+
environment,
|
|
788
|
+
profiles: registry,
|
|
789
|
+
request: { name: profile.name },
|
|
790
|
+
});
|
|
791
|
+
await adoptLegacyLink({
|
|
792
|
+
links,
|
|
793
|
+
directory: linkDirectory,
|
|
794
|
+
repositoryRoot: workingDirectory,
|
|
795
|
+
apiOrigin: platform.apiOrigin,
|
|
796
|
+
organizationId: profile.organizationId,
|
|
797
|
+
accessToken: selected.accessToken,
|
|
798
|
+
fetch: selected.fetch,
|
|
799
|
+
actor: `profile ${profile.name}`,
|
|
800
|
+
commandPrefix: platform.commandPrefix,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
} catch (error) {
|
|
806
|
+
return written(
|
|
807
|
+
compositionFailure(recognizedCommandLabel(args), error, signal, platform.commandPrefix),
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
const selected = profile;
|
|
811
|
+
const productApiOrigin = platform.apiOrigin;
|
|
314
812
|
const lifecycle = new GeneratedSdkSessionRevoker({
|
|
315
813
|
environment,
|
|
316
|
-
|
|
814
|
+
profiles: registry,
|
|
815
|
+
...(selected === undefined ? {} : { request: { name: selected.name } }),
|
|
317
816
|
});
|
|
318
817
|
const cli = new ProductCli({
|
|
319
818
|
...(apiToken === undefined ? {} : { apiToken }),
|
|
320
|
-
commandPrefix:
|
|
321
|
-
|
|
819
|
+
commandPrefix: platform.commandPrefix,
|
|
820
|
+
profiles: registry,
|
|
821
|
+
...(selected === undefined ? {} : { profile: selected }),
|
|
822
|
+
// A login in a process bound to one saved login renews exactly that one.
|
|
823
|
+
...((profileName ?? bound) === undefined ? {} : { profileName: profileName ?? bound }),
|
|
824
|
+
...(bound === undefined ? {} : { profileBinding: bound }),
|
|
322
825
|
deviceFlow: new WorkosDeviceFlowClient(
|
|
323
826
|
{
|
|
324
|
-
clientId:
|
|
827
|
+
clientId: platform.clientId,
|
|
325
828
|
scope: "openid offline_access",
|
|
326
|
-
verificationUri:
|
|
829
|
+
verificationUri: platform.verificationUri,
|
|
327
830
|
},
|
|
328
831
|
transport,
|
|
329
|
-
{ now
|
|
832
|
+
{ now },
|
|
330
833
|
),
|
|
331
834
|
deviceAuthorizationPresenter: new StderrDeviceAuthorizationPresenter(io),
|
|
332
835
|
productApi: (accessToken) =>
|
|
333
|
-
new GeneratedSdkProductApi(
|
|
334
|
-
|
|
335
|
-
accessToken,
|
|
336
|
-
createBoundedFetch(fetch, signal, REQUEST_TIMEOUT_MS, MAX_PUBLIC_RESPONSE_BYTES),
|
|
337
|
-
),
|
|
338
|
-
// Login receives read-only workspace and GitHub queries instead of the product surface.
|
|
836
|
+
new GeneratedSdkProductApi(productApiOrigin, accessToken, boundedFetch()),
|
|
837
|
+
// Login receives read-only identity, workspace and GitHub queries instead of the product surface.
|
|
339
838
|
accountProfile: async (accessToken) =>
|
|
340
839
|
await new GeneratedSdkProductApi(
|
|
341
840
|
productApiOrigin,
|
|
342
841
|
accessToken,
|
|
343
|
-
|
|
842
|
+
boundedFetch(),
|
|
344
843
|
).getAccountProfile(),
|
|
844
|
+
currentIdentity: async (accessToken) =>
|
|
845
|
+
await new GeneratedSdkProductApi(
|
|
846
|
+
productApiOrigin,
|
|
847
|
+
accessToken,
|
|
848
|
+
boundedFetch(),
|
|
849
|
+
).getCurrentIdentity(),
|
|
345
850
|
githubConnectionStatus: (accessToken, organizationId) =>
|
|
346
851
|
getGithubOrganizationConnection(
|
|
347
852
|
{ organization_id: organizationId },
|
|
@@ -350,19 +855,16 @@ export const runProductionCli = async (
|
|
|
350
855
|
baseUrl: productApiOrigin,
|
|
351
856
|
auth: accessToken,
|
|
352
857
|
throwOnError: true,
|
|
353
|
-
fetch:
|
|
858
|
+
fetch: boundedFetch(),
|
|
354
859
|
}),
|
|
355
860
|
signal,
|
|
356
861
|
},
|
|
357
862
|
),
|
|
358
|
-
projectLinkStore: new OwnerOnlyProjectLinkStore(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
),
|
|
362
|
-
productApiOrigin: profile.apiOrigin,
|
|
363
|
-
tokenRefresher: new PublicWorkosTokenRefresher(environment),
|
|
863
|
+
projectLinkStore: new OwnerOnlyProjectLinkStore(linkDirectory, workingDirectory),
|
|
864
|
+
productApiOrigin,
|
|
865
|
+
tokenRefresher: refresher,
|
|
364
866
|
tokenRevoker: lifecycle,
|
|
365
|
-
now
|
|
867
|
+
now,
|
|
366
868
|
waitUntil,
|
|
367
869
|
lifecycleTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
368
870
|
createCorrelationId: randomUUID,
|
|
@@ -373,10 +875,98 @@ export const runProductionCli = async (
|
|
|
373
875
|
((readSignal) => readSecretValueFromStandardInput(process.stdin, readSignal)),
|
|
374
876
|
spawnPsql: runLocalPsql,
|
|
375
877
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
878
|
+
return written(await cli.runCommand(command, signal));
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* A link written before links named their organization is used only after the API confirms that
|
|
883
|
+
* the acting account can see its project; it is then rewritten for that organization.
|
|
884
|
+
*/
|
|
885
|
+
const adoptLegacyLink = async (input: {
|
|
886
|
+
readonly links: DirectoryLinks;
|
|
887
|
+
readonly directory: string;
|
|
888
|
+
readonly repositoryRoot: string;
|
|
889
|
+
readonly apiOrigin: string;
|
|
890
|
+
readonly organizationId: string | null;
|
|
891
|
+
readonly accessToken: string;
|
|
892
|
+
readonly fetch: typeof globalThis.fetch;
|
|
893
|
+
readonly actor: string;
|
|
894
|
+
readonly commandPrefix: string;
|
|
895
|
+
}): Promise<void> => {
|
|
896
|
+
const legacy = input.links.legacy;
|
|
897
|
+
if (legacy === null) return;
|
|
898
|
+
const refuse = (): ProfileSelectionError =>
|
|
899
|
+
new ProfileSelectionError(
|
|
900
|
+
"linked_project_organization_mismatch",
|
|
901
|
+
2,
|
|
902
|
+
`This directory's link names project ${legacy.project_id}, which ${input.actor} cannot see${
|
|
903
|
+
input.organizationId === null ? "" : ` in organization ${input.organizationId}`
|
|
904
|
+
}. Nothing was sent to it. Pass --project ULID or --profile-name NAME for the account that owns it, or run '${input.commandPrefix} link' again for this account.`,
|
|
905
|
+
);
|
|
906
|
+
let organizationId: string;
|
|
907
|
+
try {
|
|
908
|
+
const project = await getProject(
|
|
909
|
+
{ project_id: legacy.project_id },
|
|
910
|
+
{
|
|
911
|
+
client: createClient({
|
|
912
|
+
baseUrl: input.apiOrigin,
|
|
913
|
+
auth: input.accessToken,
|
|
914
|
+
fetch: input.fetch,
|
|
915
|
+
throwOnError: true,
|
|
916
|
+
}),
|
|
917
|
+
},
|
|
918
|
+
);
|
|
919
|
+
organizationId = project.organization_id;
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (isProblem(error, "resource_not_found") || isProblem(error, "forbidden")) throw refuse();
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
if (organizationId !== input.organizationId) throw refuse();
|
|
925
|
+
await new OwnerOnlyProjectLinkStore(input.directory, input.repositoryRoot).save({
|
|
926
|
+
version: 2,
|
|
927
|
+
api_origin: input.apiOrigin,
|
|
928
|
+
organization_id: organizationId,
|
|
929
|
+
project_id: legacy.project_id,
|
|
930
|
+
installation_id: legacy.installation_id,
|
|
931
|
+
repository_full_name: legacy.repository_full_name,
|
|
932
|
+
});
|
|
933
|
+
await removeLegacyProjectLink(input.directory);
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/** Failures while choosing the account, before the command itself runs. */
|
|
937
|
+
const compositionFailure = (
|
|
938
|
+
command: OutputCommand,
|
|
939
|
+
error: unknown,
|
|
940
|
+
signal: AbortSignal,
|
|
941
|
+
commandPrefix: string,
|
|
942
|
+
): CliResult => {
|
|
943
|
+
if (error instanceof ProfileSelectionError)
|
|
944
|
+
return errorDocument(command, error.exitCode, error.code, false, error.suggestedAction);
|
|
945
|
+
if (error instanceof ProfileLimitError)
|
|
946
|
+
return errorDocument(
|
|
947
|
+
command,
|
|
948
|
+
2,
|
|
949
|
+
"profile_limit_reached",
|
|
950
|
+
false,
|
|
951
|
+
profileLimitAction(commandPrefix),
|
|
952
|
+
);
|
|
953
|
+
if (error instanceof ProductApiAuthenticationError)
|
|
954
|
+
return error.code === "authentication_required"
|
|
955
|
+
? errorDocument(
|
|
956
|
+
command,
|
|
957
|
+
5,
|
|
958
|
+
"authentication_required",
|
|
959
|
+
false,
|
|
960
|
+
`Run '${commandPrefix} login --json'.`,
|
|
961
|
+
)
|
|
962
|
+
: errorDocument(
|
|
963
|
+
command,
|
|
964
|
+
5,
|
|
965
|
+
"token_refresh_failed",
|
|
966
|
+
true,
|
|
967
|
+
`Run '${commandPrefix} login --json' if retrying does not succeed.`,
|
|
968
|
+
);
|
|
969
|
+
return classifyCliFailure(command, error, signal);
|
|
380
970
|
};
|
|
381
971
|
|
|
382
972
|
const runOfflineInit = async (
|
|
@@ -602,30 +1192,35 @@ export class GeneratedSdkSessionRevoker implements TokenRevoker {
|
|
|
602
1192
|
}
|
|
603
1193
|
}
|
|
604
1194
|
|
|
1195
|
+
/** This checkout is linked for several organizations and nothing chose one of them. */
|
|
1196
|
+
export class LinkedProjectAmbiguousError extends Error {
|
|
1197
|
+
public constructor(readonly organizationIds: readonly string[]) {
|
|
1198
|
+
super("Several organizations' links apply");
|
|
1199
|
+
this.name = "LinkedProjectAmbiguousError";
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
605
1203
|
/**
|
|
606
|
-
* A command run inside a linked directory may omit --project; the link supplies it. The
|
|
607
|
-
*
|
|
1204
|
+
* A command run inside a linked directory may omit --project; the link supplies it. The link is
|
|
1205
|
+
* read only when the command needs a project it did not name.
|
|
608
1206
|
*/
|
|
609
1207
|
export async function parseWithLinkedProject(
|
|
610
1208
|
argv: readonly string[],
|
|
611
|
-
|
|
612
|
-
): Promise<{
|
|
1209
|
+
linkedProject: () => Promise<LinkedProject>,
|
|
1210
|
+
): Promise<{
|
|
1211
|
+
readonly command: ProductCliCommand;
|
|
1212
|
+
readonly linked: Extract<LinkedProject, { outcome: "linked" }> | null;
|
|
1213
|
+
}> {
|
|
613
1214
|
try {
|
|
614
|
-
return { command: parseProductCliCommand(argv),
|
|
1215
|
+
return { command: parseProductCliCommand(argv), linked: null };
|
|
615
1216
|
} catch (error) {
|
|
616
1217
|
if (!(error instanceof InvalidCommandError) || argv.includes("--project")) throw error;
|
|
617
|
-
|
|
1218
|
+
const linked = await linkedProject();
|
|
1219
|
+
if (linked.outcome === "ambiguous")
|
|
1220
|
+
throw new LinkedProjectAmbiguousError(linked.organizationIds);
|
|
1221
|
+
if (linked.outcome === "none") throw error;
|
|
618
1222
|
try {
|
|
619
|
-
|
|
620
|
-
} catch {
|
|
621
|
-
projectId = null;
|
|
622
|
-
}
|
|
623
|
-
if (projectId === null) throw error;
|
|
624
|
-
try {
|
|
625
|
-
return {
|
|
626
|
-
command: parseProductCliCommand([...argv, "--project", projectId]),
|
|
627
|
-
linkedProjectId: projectId,
|
|
628
|
-
};
|
|
1223
|
+
return { command: parseProductCliCommand([...argv, "--project", linked.projectId]), linked };
|
|
629
1224
|
} catch {
|
|
630
1225
|
throw error;
|
|
631
1226
|
}
|
|
@@ -780,15 +1375,43 @@ const boundedSignal = (parent: AbortSignal, timeoutMs: number): AbortSignal =>
|
|
|
780
1375
|
const isAbortOrTimeout = (error: unknown): boolean =>
|
|
781
1376
|
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
782
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* Remove one saved login without network access. Only the chosen login is removed; a login saved
|
|
1380
|
+
* before named profiles is removed only when no named login exists.
|
|
1381
|
+
*/
|
|
783
1382
|
const runOfflineLogout = async (
|
|
1383
|
+
registry: ProfileRegistry,
|
|
1384
|
+
request: ProfileRequest,
|
|
1385
|
+
commandPrefix: string,
|
|
784
1386
|
io: ProductCliIo,
|
|
785
1387
|
diagnostics: StderrDiagnosticSink,
|
|
786
|
-
overrides: ProductCliCompositionOverrides,
|
|
787
1388
|
): Promise<number> => {
|
|
788
1389
|
let result: CliResult;
|
|
789
1390
|
try {
|
|
790
|
-
|
|
791
|
-
|
|
1391
|
+
const selection = selectProfile(await registry.list(), request);
|
|
1392
|
+
if (selection.outcome === "selected") {
|
|
1393
|
+
await registry.remove(selection.profile);
|
|
1394
|
+
result = resultDocument("logout", 0, "logged_out", {
|
|
1395
|
+
profile: publicProfile(selection.profile),
|
|
1396
|
+
});
|
|
1397
|
+
} else if (
|
|
1398
|
+
selection.outcome === "none" &&
|
|
1399
|
+
request.name === undefined &&
|
|
1400
|
+
request.bound === undefined
|
|
1401
|
+
) {
|
|
1402
|
+
// Nothing is saved under a name: a login of an earlier client goes, and none is logged out.
|
|
1403
|
+
await registry.legacy().clear();
|
|
1404
|
+
result = resultDocument("logout", 0, "logged_out");
|
|
1405
|
+
} else {
|
|
1406
|
+
const failure = profileSelectionError(selection, request, commandPrefix, "cli");
|
|
1407
|
+
result = errorDocument(
|
|
1408
|
+
"logout",
|
|
1409
|
+
failure.exitCode,
|
|
1410
|
+
failure.code,
|
|
1411
|
+
false,
|
|
1412
|
+
failure.suggestedAction,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
792
1415
|
} catch {
|
|
793
1416
|
diagnostics.record({
|
|
794
1417
|
correlationId: randomUUID(),
|
|
@@ -841,9 +1464,14 @@ export const readSecretValueFromStandardInput = async (
|
|
|
841
1464
|
const invalidCommandFailure = (command: OutputCommand): CliResult =>
|
|
842
1465
|
errorDocument(command, 2, "invalid_command", false, "Check 'ohmyhost --help'.");
|
|
843
1466
|
|
|
844
|
-
const resultDocument = (
|
|
1467
|
+
const resultDocument = (
|
|
1468
|
+
command: OutputCommand,
|
|
1469
|
+
exitCode: number,
|
|
1470
|
+
status: string,
|
|
1471
|
+
body: Readonly<Record<string, unknown>> = {},
|
|
1472
|
+
): CliResult => ({
|
|
845
1473
|
exitCode,
|
|
846
|
-
stdout: `${JSON.stringify({ version: 1, command, status })}\n`,
|
|
1474
|
+
stdout: `${JSON.stringify({ version: 1, command, status, ...body })}\n`,
|
|
847
1475
|
stderr: "",
|
|
848
1476
|
});
|
|
849
1477
|
|