@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.
@@ -0,0 +1,609 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, readFile, readdir, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ CredentialStoreIntegrityError,
8
+ CredentialStoreUnavailableError,
9
+ LEGACY_CREDENTIAL_ACCOUNT,
10
+ NativeNapiKeyringCredentialStore,
11
+ createNativeEntryFactory,
12
+ type CredentialStore,
13
+ type CurrentCredential,
14
+ type KeyringEntryFactory,
15
+ } from "./credential-store.js";
16
+
17
+ /**
18
+ * One saved interactive login: a verified user signed in to at most one organization. The name is
19
+ * the customer's alias; the user and organization identify it. None of these fields is a secret.
20
+ */
21
+ export interface LocalProfile {
22
+ readonly name: string;
23
+ readonly userId: string;
24
+ readonly organizationId: string | null;
25
+ readonly organizationName: string | null;
26
+ }
27
+
28
+ /** Saved logins of one platform environment: a non-secret index and one secret entry each. */
29
+ export interface ProfileRegistry {
30
+ list(): Promise<readonly LocalProfile[]>;
31
+ credential(profile: LocalProfile): CredentialStore;
32
+ /**
33
+ * Writes the credential first, so an index entry never names a missing login. `replaces` is the
34
+ * login this one supersedes, such as the organization-less login that was just bound.
35
+ */
36
+ save(
37
+ profile: LocalProfile,
38
+ credential: CurrentCredential,
39
+ replaces?: LocalProfile,
40
+ ): Promise<void>;
41
+ remove(profile: LocalProfile): Promise<void>;
42
+ /** The single entry of clients before named profiles, read only to migrate it. */
43
+ legacy(): CredentialStore;
44
+ }
45
+
46
+ /** The requested name already belongs to a login of another user or organization. */
47
+ export class ProfileNameConflictError extends Error {
48
+ public constructor(readonly profile: LocalProfile) {
49
+ super("The profile name is used by another saved login");
50
+ this.name = "ProfileNameConflictError";
51
+ }
52
+ }
53
+
54
+ /** A login beyond the most saved logins one environment keeps; nothing was written. */
55
+ export class ProfileLimitError extends Error {
56
+ public constructor(readonly saved: number) {
57
+ super("The saved logins of this environment reached their limit");
58
+ this.name = "ProfileLimitError";
59
+ }
60
+ }
61
+
62
+ const MAX_PROFILES = 64;
63
+ /** Each lost race means another writer finished a change, so this bounds concurrent writers. */
64
+ const MAX_INDEX_ATTEMPTS = 64;
65
+ const INDEX_FILE = /^index-([1-9][0-9]{0,14})\.json$/u;
66
+ const SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
67
+ const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,62}$/u;
68
+ const USER_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
69
+ const ORGANIZATION_ID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u;
70
+
71
+ export const isProfileName = (value: string): boolean => PROFILE_NAME.test(value);
72
+ export const isProfileUserId = (value: string): boolean => USER_ID.test(value);
73
+
74
+ /** The keyring account of one login; the key is its user and organization, never its alias. */
75
+ const profileAccount = (profile: LocalProfile): string =>
76
+ `profile:${profile.userId}:${profile.organizationId ?? "none"}`;
77
+
78
+ export const sameLogin = (left: LocalProfile, right: LocalProfile): boolean =>
79
+ profileAccount(left) === profileAccount(right);
80
+
81
+ /** Where one environment lists its saved logins. It holds names and IDs, never a token. */
82
+ export const profileIndexDirectory = (serviceName: string): string =>
83
+ join(homedir(), ".ohmyhost", "profiles", serviceName);
84
+
85
+ /** The index after saving `profile`, which supersedes its own earlier entry and `replaces`. */
86
+ const nextProfileIndex = (
87
+ current: readonly LocalProfile[],
88
+ profile: LocalProfile,
89
+ replaces?: LocalProfile,
90
+ ): LocalProfile[] => [
91
+ ...current.filter(
92
+ (saved) =>
93
+ !sameLogin(saved, profile) && (replaces === undefined || !sameLogin(saved, replaces)),
94
+ ),
95
+ profile,
96
+ ];
97
+
98
+ /** Refuses a name another login uses and one login too many. */
99
+ const admitProfile = (next: readonly LocalProfile[], profile: LocalProfile): void => {
100
+ const conflict = next.find((saved) => saved.name === profile.name && saved !== profile);
101
+ if (conflict !== undefined) throw new ProfileNameConflictError(conflict);
102
+ if (next.length > MAX_PROFILES) throw new ProfileLimitError(next.length - 1);
103
+ };
104
+
105
+ /**
106
+ * Tokens stay in the operating system's credential store, one entry per login. The list of logins
107
+ * is a separate non-secret file index, so saving or removing one login never rewrites a shared
108
+ * credential entry that another process may be changing at the same moment.
109
+ */
110
+ export class NativeKeyringProfileRegistry implements ProfileRegistry {
111
+ readonly #createEntry: KeyringEntryFactory;
112
+ readonly #index: ProfileIndexFiles;
113
+
114
+ public constructor(
115
+ createEntry: KeyringEntryFactory | undefined = undefined,
116
+ private readonly serviceName = "ohmyhost",
117
+ indexDirectory?: string,
118
+ ) {
119
+ if (!SERVICE_NAME.test(serviceName)) throw new CredentialStoreUnavailableError();
120
+ this.#createEntry = createEntry ?? createNativeEntryFactory(serviceName);
121
+ this.#index = new ProfileIndexFiles(indexDirectory ?? profileIndexDirectory(serviceName));
122
+ }
123
+
124
+ public async list(): Promise<readonly LocalProfile[]> {
125
+ return (await this.#index.read()).profiles;
126
+ }
127
+
128
+ public credential(profile: LocalProfile): CredentialStore {
129
+ return new NativeNapiKeyringCredentialStore(
130
+ this.#createEntry,
131
+ this.serviceName,
132
+ profileAccount(profile),
133
+ );
134
+ }
135
+
136
+ public legacy(): CredentialStore {
137
+ return new NativeNapiKeyringCredentialStore(
138
+ this.#createEntry,
139
+ this.serviceName,
140
+ LEGACY_CREDENTIAL_ACCOUNT,
141
+ );
142
+ }
143
+
144
+ public async save(
145
+ profile: LocalProfile,
146
+ credential: CurrentCredential,
147
+ replaces?: LocalProfile,
148
+ ): Promise<void> {
149
+ assertProfile(profile);
150
+ // Refused before anything is written, so a conflicting or surplus login changes nothing.
151
+ const before = await this.list();
152
+ admitProfile(nextProfileIndex(before, profile, replaces), profile);
153
+ await this.credential(profile).replace(credential);
154
+ try {
155
+ await this.#index.update((current) => {
156
+ const next = nextProfileIndex(current, profile, replaces);
157
+ admitProfile(next, profile);
158
+ return next;
159
+ });
160
+ } catch (error) {
161
+ // Another process changed the index meanwhile, or it could not be written: a login new to
162
+ // this computer leaves no token behind that no index entry names.
163
+ if (!before.some((saved) => sameLogin(saved, profile)))
164
+ await this.credential(profile).clear();
165
+ throw error;
166
+ }
167
+ if (replaces !== undefined && !sameLogin(replaces, profile))
168
+ await this.credential(replaces).clear();
169
+ }
170
+
171
+ public async remove(profile: LocalProfile): Promise<void> {
172
+ await this.credential(profile).clear();
173
+ await this.#index.update((current) =>
174
+ current.some((saved) => sameLogin(saved, profile))
175
+ ? current.filter((saved) => !sameLogin(saved, profile))
176
+ : undefined,
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The saved logins as numbered files that are never rewritten. A change reads the newest one and
183
+ * creates the next number through an exclusive hard link, so when two processes change the index at
184
+ * once exactly one wins and the other re-reads and applies its change on top: no login is lost, no
185
+ * lock can outlive a crashed process, and a crash leaves either the complete new file or none.
186
+ */
187
+ export class ProfileIndexFiles {
188
+ public constructor(private readonly directory: string) {}
189
+
190
+ public async read(): Promise<{
191
+ readonly version: number;
192
+ readonly profiles: readonly LocalProfile[];
193
+ }> {
194
+ for (let attempt = 0; attempt < MAX_INDEX_ATTEMPTS; attempt += 1) {
195
+ const version = await this.#newest();
196
+ if (version === 0) return { version, profiles: [] };
197
+ let serialized: string;
198
+ try {
199
+ serialized = await readFile(this.#file(version), "utf8");
200
+ } catch (error) {
201
+ // A newer change removed this file after it was listed; look again.
202
+ if (fileErrorCode(error) === "ENOENT") continue;
203
+ throw new CredentialStoreUnavailableError();
204
+ }
205
+ return { version, profiles: parseProfileIndex(serialized) };
206
+ }
207
+ throw new CredentialStoreUnavailableError();
208
+ }
209
+
210
+ /** Applies `change` to the newest index; `undefined` means nothing to change. */
211
+ public async update(
212
+ change: (current: readonly LocalProfile[]) => readonly LocalProfile[] | undefined,
213
+ ): Promise<void> {
214
+ try {
215
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
216
+ } catch {
217
+ throw new CredentialStoreUnavailableError();
218
+ }
219
+ for (let attempt = 0; attempt < MAX_INDEX_ATTEMPTS; attempt += 1) {
220
+ const { version, profiles } = await this.read();
221
+ const next = change(profiles);
222
+ if (next === undefined) return;
223
+ const temporary = join(this.directory, `.index-${randomUUID()}.tmp`);
224
+ try {
225
+ await writeDurably(temporary, serializeProfileIndex(next));
226
+ await link(temporary, this.#file(version + 1));
227
+ } catch (error) {
228
+ // Another process created this version first: apply the change to its result instead.
229
+ if (fileErrorCode(error) === "EEXIST") continue;
230
+ throw new CredentialStoreUnavailableError();
231
+ } finally {
232
+ await removeTemporary(temporary);
233
+ }
234
+ // A writer delayed since its read can link a number that newer changes already superseded
235
+ // and pruned; its file is then not the newest, so the change is applied again on top.
236
+ if ((await this.#newest()) !== version + 1) continue;
237
+ await this.#prune(version);
238
+ return;
239
+ }
240
+ throw new CredentialStoreUnavailableError();
241
+ }
242
+
243
+ async #newest(): Promise<number> {
244
+ let names: string[];
245
+ try {
246
+ names = await readdir(this.directory);
247
+ } catch (error) {
248
+ if (fileErrorCode(error) === "ENOENT") return 0;
249
+ throw new CredentialStoreUnavailableError();
250
+ }
251
+ return names.reduce((newest, name) => Math.max(newest, indexVersion(name)), 0);
252
+ }
253
+
254
+ /**
255
+ * Keeps the superseded version for readers that listed it just now; older ones go. Cleanup only:
256
+ * a file left behind is ignored, since readers take the newest version, and another process may
257
+ * be removing the same files at this moment.
258
+ */
259
+ async #prune(superseded: number): Promise<void> {
260
+ let names: string[];
261
+ try {
262
+ names = await readdir(this.directory);
263
+ } catch {
264
+ return;
265
+ }
266
+ for (const name of names) {
267
+ const version = indexVersion(name);
268
+ if (version === 0 || version >= superseded) continue;
269
+ try {
270
+ await unlink(join(this.directory, name));
271
+ } catch {
272
+ // Already removed by another process, or left for the next change to remove.
273
+ }
274
+ }
275
+ }
276
+
277
+ #file(version: number): string {
278
+ return join(this.directory, `index-${String(version)}.json`);
279
+ }
280
+ }
281
+
282
+ const indexVersion = (name: string): number => Number(INDEX_FILE.exec(name)?.[1] ?? 0);
283
+
284
+ /** Removes a temporary file. One never created, or one that stays, is harmless beside the index. */
285
+ const removeTemporary = async (path: string): Promise<void> => {
286
+ try {
287
+ await unlink(path);
288
+ } catch {
289
+ // Its write failed before the file existed, or it stays as an ignored leftover.
290
+ }
291
+ };
292
+
293
+ const fileErrorCode = (error: unknown): string | undefined =>
294
+ typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined;
295
+
296
+ /** Writes a new file and flushes it, so a hard link to it never exposes partial content. */
297
+ const writeDurably = async (path: string, text: string): Promise<void> => {
298
+ const handle = await open(path, "wx", 0o600);
299
+ try {
300
+ await handle.writeFile(text, "utf8");
301
+ await handle.sync();
302
+ } finally {
303
+ await handle.close();
304
+ }
305
+ };
306
+
307
+ const serializeProfileIndex = (profiles: readonly LocalProfile[]): string =>
308
+ JSON.stringify({
309
+ version: 1,
310
+ profiles: [...profiles]
311
+ .sort((left, right) => (left.name < right.name ? -1 : 1))
312
+ .map((profile) => ({
313
+ name: profile.name,
314
+ user_id: profile.userId,
315
+ organization_id: profile.organizationId,
316
+ organization_name: profile.organizationName,
317
+ })),
318
+ });
319
+
320
+ const parseProfileIndex = (serialized: string): readonly LocalProfile[] => {
321
+ try {
322
+ const value: unknown = JSON.parse(serialized);
323
+ if (!isRecord(value, ["version", "profiles"]) || value["version"] !== 1)
324
+ throw new Error("Invalid profile index");
325
+ const entries = value["profiles"];
326
+ if (!Array.isArray(entries) || entries.length > MAX_PROFILES)
327
+ throw new Error("Invalid profile index");
328
+ const profiles = entries.map((entry: unknown): LocalProfile => {
329
+ if (!isRecord(entry, ["name", "user_id", "organization_id", "organization_name"]))
330
+ throw new Error("Invalid profile");
331
+ const profile = {
332
+ name: entry["name"],
333
+ userId: entry["user_id"],
334
+ organizationId: entry["organization_id"],
335
+ organizationName: entry["organization_name"],
336
+ } as LocalProfile;
337
+ assertProfile(profile);
338
+ return profile;
339
+ });
340
+ if (
341
+ new Set(profiles.map((profile) => profile.name)).size !== profiles.length ||
342
+ new Set(profiles.map(profileAccount)).size !== profiles.length
343
+ )
344
+ throw new Error("Duplicate profile");
345
+ return profiles;
346
+ } catch {
347
+ throw new CredentialStoreIntegrityError();
348
+ }
349
+ };
350
+
351
+ const assertProfile = (profile: LocalProfile): void => {
352
+ if (
353
+ typeof profile.name !== "string" ||
354
+ !PROFILE_NAME.test(profile.name) ||
355
+ typeof profile.userId !== "string" ||
356
+ !USER_ID.test(profile.userId) ||
357
+ !(
358
+ profile.organizationId === null ||
359
+ (typeof profile.organizationId === "string" && ORGANIZATION_ID.test(profile.organizationId))
360
+ ) ||
361
+ !(
362
+ profile.organizationName === null ||
363
+ (typeof profile.organizationName === "string" &&
364
+ profile.organizationName.length > 0 &&
365
+ profile.organizationName.length <= 128 &&
366
+ !/\p{Cc}/u.test(profile.organizationName))
367
+ ) ||
368
+ (profile.organizationId === null && profile.organizationName !== null)
369
+ )
370
+ throw new CredentialStoreIntegrityError();
371
+ };
372
+
373
+ const isRecord = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
374
+ typeof value === "object" &&
375
+ value !== null &&
376
+ !Array.isArray(value) &&
377
+ Object.keys(value).sort().join(",") === [...keys].sort().join(",");
378
+
379
+ /** What a command or tool call asked for; every field is optional context, never a credential. */
380
+ export interface ProfileRequest {
381
+ /** A name given with this command or call. */
382
+ readonly name?: string;
383
+ /** The name this process is bound to through OHMYHOST_PROFILE. */
384
+ readonly bound?: string;
385
+ readonly organizationId?: string;
386
+ readonly userId?: string;
387
+ }
388
+
389
+ export type ProfileSelection =
390
+ | { readonly outcome: "selected"; readonly profile: LocalProfile }
391
+ | { readonly outcome: "none" }
392
+ | { readonly outcome: "not_found" }
393
+ | { readonly outcome: "ambiguous"; readonly candidates: readonly LocalProfile[] }
394
+ | {
395
+ readonly outcome: "conflict";
396
+ readonly field: "binding" | "organization" | "user";
397
+ readonly profile: LocalProfile | null;
398
+ };
399
+
400
+ /**
401
+ * Choose the login a command runs as. A requested name or binding is taken exactly; otherwise the
402
+ * requested organization and user narrow the saved logins, and only a single match is used. There
403
+ * is no machine-wide default, so another agent's choice can never retarget this command.
404
+ */
405
+ export const selectProfile = (
406
+ profiles: readonly LocalProfile[],
407
+ request: ProfileRequest,
408
+ ): ProfileSelection => {
409
+ if (request.name !== undefined && request.bound !== undefined && request.name !== request.bound)
410
+ return { outcome: "conflict", field: "binding", profile: null };
411
+ const named = request.name ?? request.bound;
412
+ if (named !== undefined) {
413
+ const profile = profiles.find((saved) => saved.name === named);
414
+ if (profile === undefined) return { outcome: "not_found" };
415
+ if (request.organizationId !== undefined && profile.organizationId !== request.organizationId)
416
+ return { outcome: "conflict", field: "organization", profile };
417
+ if (request.userId !== undefined && profile.userId !== request.userId)
418
+ return { outcome: "conflict", field: "user", profile };
419
+ return { outcome: "selected", profile };
420
+ }
421
+ const candidates = profiles.filter(
422
+ (profile) =>
423
+ (request.organizationId === undefined || profile.organizationId === request.organizationId) &&
424
+ (request.userId === undefined || profile.userId === request.userId),
425
+ );
426
+ const only = candidates[0];
427
+ if (candidates.length === 1 && only !== undefined) return { outcome: "selected", profile: only };
428
+ if (candidates.length > 1) return { outcome: "ambiguous", candidates };
429
+ return profiles.length === 0 ? { outcome: "none" } : { outcome: "not_found" };
430
+ };
431
+
432
+ /**
433
+ * Choose the login that `organization use` binds. A login already in that organization is the
434
+ * answer; otherwise the one login that has no organization yet is bound to it.
435
+ */
436
+ export const selectProfileForOrganization = (
437
+ profiles: readonly LocalProfile[],
438
+ request: ProfileRequest & { readonly organizationId: string },
439
+ ): ProfileSelection => {
440
+ const user = request.userId === undefined ? {} : { userId: request.userId };
441
+ if (request.name !== undefined || request.bound !== undefined)
442
+ return selectProfile(profiles, {
443
+ ...(request.name === undefined ? {} : { name: request.name }),
444
+ ...(request.bound === undefined ? {} : { bound: request.bound }),
445
+ ...user,
446
+ });
447
+ // A requested user narrows the candidates; a login of another user is never bound.
448
+ const candidates = profiles.filter(
449
+ (profile) => request.userId === undefined || profile.userId === request.userId,
450
+ );
451
+ const inOrganization = candidates.filter(
452
+ (profile) => profile.organizationId === request.organizationId,
453
+ );
454
+ if (inOrganization.length > 1) return { outcome: "ambiguous", candidates: inOrganization };
455
+ const unscoped = candidates.filter((profile) => profile.organizationId === null);
456
+ const [member] = inOrganization;
457
+ if (member !== undefined) return { outcome: "selected", profile: member };
458
+ const [open] = unscoped;
459
+ if (unscoped.length === 1 && open !== undefined) return { outcome: "selected", profile: open };
460
+ return selectProfile(profiles, user);
461
+ };
462
+
463
+ /** A readable alias from the organization name that no other saved login uses. */
464
+ export const defaultProfileName = (
465
+ profiles: readonly LocalProfile[],
466
+ login: Pick<LocalProfile, "userId" | "organizationId" | "organizationName">,
467
+ ): string => {
468
+ const same = profiles.find(
469
+ (profile) => profile.userId === login.userId && profile.organizationId === login.organizationId,
470
+ );
471
+ if (same !== undefined) return same.name;
472
+ const base =
473
+ slug(login.organizationName ?? "") || (login.organizationId === null ? "account" : "workspace");
474
+ const free = (name: string): boolean => profiles.every((profile) => profile.name !== name);
475
+ if (free(base)) return base;
476
+ const suffix = (login.organizationId ?? login.userId)
477
+ .replace(/[^A-Za-z0-9]/gu, "")
478
+ .slice(-6)
479
+ .toLowerCase();
480
+ for (let attempt = 1; attempt <= profiles.length + 1; attempt += 1) {
481
+ const candidate = `${base.slice(0, 48)}-${suffix}${attempt === 1 ? "" : `-${String(attempt)}`}`;
482
+ if (free(candidate)) return candidate;
483
+ }
484
+ throw new CredentialStoreIntegrityError();
485
+ };
486
+
487
+ const slug = (value: string): string =>
488
+ value
489
+ .normalize("NFKD")
490
+ .replace(/[\u0300-\u036f]/gu, "")
491
+ .toLowerCase()
492
+ .replace(/[^a-z0-9]+/gu, "-")
493
+ .replace(/^-+/u, "")
494
+ .slice(0, 40)
495
+ .replace(/-+$/u, "");
496
+
497
+ /** The customer-visible projection of a saved login; it carries no credential. */
498
+ export const publicProfile = (
499
+ profile: LocalProfile,
500
+ ): {
501
+ readonly name: string;
502
+ readonly user_id: string;
503
+ readonly organization_id: string | null;
504
+ readonly organization_name: string | null;
505
+ } => ({
506
+ name: profile.name,
507
+ user_id: profile.userId,
508
+ organization_id: profile.organizationId,
509
+ organization_name: profile.organizationName,
510
+ });
511
+
512
+ /** How to make room when an environment keeps the most saved logins it can. */
513
+ export const profileLimitAction = (commandPrefix: string): string =>
514
+ `Nothing was saved: this environment already keeps ${String(MAX_PROFILES)} saved logins, the most one computer holds. Remove one you no longer need with '${commandPrefix} logout --profile-name NAME --json' ('${commandPrefix} profile list --json' shows them), then retry.`;
515
+
516
+ /** A chosen login cannot run the command; the code and action say which context to fix. */
517
+ export class ProfileSelectionError extends Error {
518
+ public constructor(
519
+ readonly code:
520
+ | "authentication_required"
521
+ | "profile_not_found"
522
+ | "profile_selection_required"
523
+ | "profile_context_mismatch"
524
+ | "environment_token_context_mismatch"
525
+ | "linked_project_selection_required"
526
+ | "linked_project_organization_mismatch",
527
+ readonly exitCode: number,
528
+ readonly suggestedAction: string,
529
+ ) {
530
+ super(`${code}: ${suggestedAction}`);
531
+ this.name = "ProfileSelectionError";
532
+ }
533
+ }
534
+
535
+ /** How the caller passes a login: a CLI flag or an MCP tool argument. */
536
+ export type ProfileSelector = "cli" | "mcp";
537
+
538
+ /** Turn a selection that did not produce one login into the error that says how to get one. */
539
+ export const profileSelectionError = (
540
+ selection: Exclude<ProfileSelection, { readonly outcome: "selected" }>,
541
+ request: ProfileRequest,
542
+ commandPrefix: string,
543
+ selector: ProfileSelector,
544
+ ): ProfileSelectionError => {
545
+ const choose =
546
+ selector === "cli"
547
+ ? `pass --profile-name NAME (see '${commandPrefix} profile list --json') or set OHMYHOST_PROFILE for this process`
548
+ : "pass profile_name (see profile_list) or set OHMYHOST_PROFILE in this MCP server's configuration";
549
+ const login = `${commandPrefix} login${
550
+ request.organizationId === undefined ? "" : ` --organization ${request.organizationId}`
551
+ }${request.userId === undefined ? "" : ` --user ${request.userId}`}${
552
+ (request.name ?? request.bound) === undefined
553
+ ? ""
554
+ : ` --profile-name ${String(request.name ?? request.bound)}`
555
+ } --json`;
556
+ if (selection.outcome === "none")
557
+ return new ProfileSelectionError(
558
+ "authentication_required",
559
+ 5,
560
+ `No login is saved on this computer. Run '${login}'.`,
561
+ );
562
+ if (selection.outcome === "not_found")
563
+ return new ProfileSelectionError(
564
+ "profile_not_found",
565
+ 5,
566
+ `No saved login matches ${describeRequest(request)}. Run '${login}' to add it, signing in as that user; '${commandPrefix} profile list --json' shows the saved logins.`,
567
+ );
568
+ if (selection.outcome === "ambiguous")
569
+ return new ProfileSelectionError(
570
+ "profile_selection_required",
571
+ 2,
572
+ `Several saved logins could run this: ${selection.candidates
573
+ .map((profile) => profile.name)
574
+ .join(", ")}. Ask the customer which account to use, then ${choose}.`,
575
+ );
576
+ if (selection.field === "binding")
577
+ return new ProfileSelectionError(
578
+ "profile_context_mismatch",
579
+ 2,
580
+ `This call names profile ${String(request.name)}, but the process is bound to OHMYHOST_PROFILE=${String(request.bound)}. Use the bound profile or start a process for the other one.`,
581
+ );
582
+ const profile = selection.profile;
583
+ return new ProfileSelectionError(
584
+ "profile_context_mismatch",
585
+ 2,
586
+ selection.field === "organization"
587
+ ? `Profile ${String(profile?.name)} is signed in to organization ${String(profile?.organizationId ?? "none")}, but this targets organization ${String(request.organizationId)}. Use the profile of that organization or change the target; nothing was sent.`
588
+ : `Profile ${String(profile?.name)} belongs to user ${String(profile?.userId)}, not ${String(request.userId)}. Use that user's profile; nothing was sent.`,
589
+ );
590
+ };
591
+
592
+ const describeRequest = (request: ProfileRequest): string => {
593
+ const parts = [
594
+ ...((request.name ?? request.bound) === undefined
595
+ ? []
596
+ : [`profile ${String(request.name ?? request.bound)}`]),
597
+ ...(request.organizationId === undefined ? [] : [`organization ${request.organizationId}`]),
598
+ ...(request.userId === undefined ? [] : [`user ${request.userId}`]),
599
+ ];
600
+ return parts.length === 0 ? "this request" : parts.join(", ");
601
+ };
602
+
603
+ /** One line that says which account a command acts as, without any credential. */
604
+ export const describeProfile = (profile: LocalProfile): string =>
605
+ `profile ${profile.name} (user ${profile.userId}, ${
606
+ profile.organizationId === null
607
+ ? "no organization"
608
+ : `organization ${profile.organizationName === null ? "" : `${profile.organizationName} `}${profile.organizationId}`
609
+ })`;