@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.
@@ -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
- NativeNapiKeyringCredentialStore,
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 { workspaceOf, type WorkspaceSession } from "./workspace.js";
47
- import { OwnerOnlyProjectLinkStore, readLinkedProjectId } from "./project-link-store.js";
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
- readonly credentialStore?: CredentialStore;
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 credentialStore?: CredentialStore;
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: "authentication_required" | "token_refresh_failed" | "invalid_environment_token",
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
- /** Private client composition data, never a public API or MCP response. */
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 profile = resolveProductCliEnvironment(overrides.environment);
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
- if (apiToken !== undefined)
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: overrides.productApiOrigin ?? profile.apiOrigin,
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
- const store =
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 credential = await currentCredential(stored, store, signal, now, refresher);
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: overrides.productApiOrigin ?? profile.apiOrigin,
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
- await store.clear();
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(argv, async () => {
227
- const profile = resolveProductCliEnvironment(environment);
228
- return readLinkedProjectId(
229
- join(overrides.workingDirectory ?? process.cwd(), profile.linkDirectory),
230
- profile.apiOrigin,
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
- if (parsed.linkedProjectId !== null)
235
- io.writeStderr(`ohmyhost: using linked project ${parsed.linkedProjectId}\n`);
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(argv))
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
- io.writeStdout(result.stdout);
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
- const result = cliMetadataResult(
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 profile: ReturnType<typeof resolveProductCliEnvironment>;
637
+ let platform: ReturnType<typeof resolveProductCliEnvironment>;
264
638
  try {
265
- profile = resolveProductCliEnvironment(environment);
639
+ platform = resolveProductCliEnvironment(environment);
266
640
  } catch {
267
- const result = errorDocument(
268
- recognizedCommandLabel(argv),
269
- 2,
270
- "invalid_environment",
271
- false,
272
- "Set OHMYHOST_ENVIRONMENT to development or production.",
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
- const result = errorDocument(
282
- recognizedCommandLabel(argv),
283
- 2,
284
- "invalid_environment_token",
285
- false,
286
- "Set OHMYHOST_TOKEN to a valid user API key, or unset it to use interactive login.",
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
- if (apiToken !== undefined && (command.kind === "login" || command.kind === "logout")) {
292
- const result = errorDocument(
293
- recognizedCommandLabel(argv),
294
- 2,
295
- "environment_token_active",
296
- false,
297
- "Unset OHMYHOST_TOKEN before changing interactive login credentials. Logout does not revoke a user API key.",
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
- const selectedOverrides = {
303
- ...overrides,
304
- credentialStore:
305
- overrides.credentialStore ??
306
- new NativeNapiKeyringCredentialStore(undefined, profile.credentialService),
307
- };
308
- if (command.kind === "logout" && !command.revoke) {
309
- return runOfflineLogout(io, diagnostics, selectedOverrides);
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
- // Login itself reads the account profile, so every authenticated command needs the origin.
312
- const productApiOrigin = profile.apiOrigin;
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
- credentialStore: selectedOverrides.credentialStore,
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: profile.commandPrefix,
321
- credentialStore: () => selectedOverrides.credentialStore,
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: profile.clientId,
827
+ clientId: platform.clientId,
325
828
  scope: "openid offline_access",
326
- verificationUri: profile.verificationUri,
829
+ verificationUri: platform.verificationUri,
327
830
  },
328
831
  transport,
329
- { now: () => new Date() },
832
+ { now },
330
833
  ),
331
834
  deviceAuthorizationPresenter: new StderrDeviceAuthorizationPresenter(io),
332
835
  productApi: (accessToken) =>
333
- new GeneratedSdkProductApi(
334
- productApiOrigin,
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
- createBoundedFetch(fetch, signal, REQUEST_TIMEOUT_MS, MAX_PUBLIC_RESPONSE_BYTES),
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: createBoundedFetch(fetch, signal, REQUEST_TIMEOUT_MS, MAX_PUBLIC_RESPONSE_BYTES),
858
+ fetch: boundedFetch(),
354
859
  }),
355
860
  signal,
356
861
  },
357
862
  ),
358
- projectLinkStore: new OwnerOnlyProjectLinkStore(
359
- join(process.cwd(), profile.linkDirectory),
360
- process.cwd(),
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: () => new Date(),
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
- const result = await cli.runCommand(command, signal);
377
- io.writeStdout(result.stdout);
378
- if (result.stderr.length > 0) io.writeStderr(result.stderr);
379
- return result.exitCode;
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
- * link is read only when the command needs a project it did not name.
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
- linkedProjectId: () => Promise<string | null>,
612
- ): Promise<{ readonly command: ProductCliCommand; readonly linkedProjectId: string | null }> {
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), linkedProjectId: null };
1215
+ return { command: parseProductCliCommand(argv), linked: null };
615
1216
  } catch (error) {
616
1217
  if (!(error instanceof InvalidCommandError) || argv.includes("--project")) throw error;
617
- let projectId: string | null;
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
- projectId = await linkedProjectId();
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
- await (overrides.credentialStore ?? new NativeNapiKeyringCredentialStore()).clear();
791
- result = resultDocument("logout", 0, "logged_out");
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 = (command: OutputCommand, exitCode: number, status: string): CliResult => ({
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