@amerged/ohmyhost-mcp 0.1.22 → 0.1.24

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.
Files changed (87) hide show
  1. package/README.md +5 -2
  2. package/dist/{chunk-NLBFEH6H.js → chunk-EEFUMPKG.js} +25 -24
  3. package/dist/index.js +1 -1
  4. package/dist/stdio-main.js +734 -269
  5. package/package.json +1 -1
  6. package/src/apps/mcp/package.json +47 -0
  7. package/src/apps/mcp/{local-client.ts → src/local-client.ts} +69 -12
  8. package/src/apps/mcp/{local-server.ts → src/local-server.ts} +327 -145
  9. package/src/apps/product-cli/package.json +45 -0
  10. package/src/apps/product-cli/{bin.ts → src/bin.ts} +3 -0
  11. package/src/apps/product-cli/{cli.ts → src/cli.ts} +363 -87
  12. package/src/apps/product-cli/{command.ts → src/command.ts} +58 -11
  13. package/src/apps/product-cli/{composition.ts → src/composition.ts} +98 -21
  14. package/src/apps/product-cli/{index.ts → src/index.ts} +1 -0
  15. package/src/apps/product-cli/{product-api.ts → src/product-api.ts} +65 -19
  16. package/src/apps/product-cli/{repository-init.ts → src/repository-init.ts} +79 -29
  17. package/src/apps/product-cli/{runtime-contract.ts → src/runtime-contract.ts} +16 -309
  18. package/src/apps/product-cli/{user-token-file.ts → src/user-token-file.ts} +46 -35
  19. package/src/apps/product-cli/{workspace.ts → src/workspace.ts} +6 -0
  20. package/src/packages/agent-skills/package.json +24 -0
  21. package/src/packages/agent-skills/src/generated-skill-resources.ts +165 -0
  22. package/src/packages/agent-skills/{index.ts → src/index.ts} +1 -1
  23. package/src/packages/contracts/package.json +137 -0
  24. package/src/packages/contracts/src/browser-capabilities.ts +133 -0
  25. package/src/packages/contracts/src/customer-auth-admission.ts +36 -0
  26. package/src/packages/contracts/{database-access.ts → src/database-access.ts} +10 -2
  27. package/src/packages/contracts/{framework-admission.ts → src/framework-admission.ts} +88 -13
  28. package/src/packages/contracts/src/framework-build-script.mjs +22 -0
  29. package/src/packages/contracts/src/framework-build-script.ts +1 -0
  30. package/src/packages/contracts/{index.ts → src/index.ts} +17 -16
  31. package/src/packages/contracts/src/operation-data-change-result.ts +86 -0
  32. package/src/packages/contracts/{operation-deployment-result.ts → src/operation-deployment-result.ts} +2 -2
  33. package/src/packages/contracts/{operation-failure.ts → src/operation-failure.ts} +24 -12
  34. package/src/packages/contracts/src/problem-policy.ts +498 -0
  35. package/src/packages/contracts/src/project-data-changes.ts +90 -0
  36. package/src/packages/contracts/src/secret-names.ts +27 -0
  37. package/src/packages/sdk-ts/package.json +21 -0
  38. package/src/packages/sdk-ts/{generated → src/generated}/index.ts +15 -0
  39. package/src/packages/sdk-ts/{generated → src/generated}/sdk.gen.ts +114 -5
  40. package/src/packages/sdk-ts/{generated → src/generated}/types.gen.ts +209 -10
  41. package/src/packages/workos-auth-contracts/package.json +39 -0
  42. package/src/packages/agent-skills/generated-skill-resources.ts +0 -165
  43. /package/src/apps/mcp/{index.ts → src/index.ts} +0 -0
  44. /package/src/apps/mcp/{stdio-main.ts → src/stdio-main.ts} +0 -0
  45. /package/src/apps/product-cli/{credential-store.ts → src/credential-store.ts} +0 -0
  46. /package/src/apps/product-cli/{device-token-expiry.ts → src/device-token-expiry.ts} +0 -0
  47. /package/src/apps/product-cli/{file-credential-store.ts → src/file-credential-store.ts} +0 -0
  48. /package/src/apps/product-cli/{profile-store.ts → src/profile-store.ts} +0 -0
  49. /package/src/apps/product-cli/{project-link-store.ts → src/project-link-store.ts} +0 -0
  50. /package/src/apps/product-cli/{psql-session.ts → src/psql-session.ts} +0 -0
  51. /package/src/packages/contracts/{application-root.ts → src/application-root.ts} +0 -0
  52. /package/src/packages/contracts/{billing.ts → src/billing.ts} +0 -0
  53. /package/src/packages/contracts/{credit-pricing.ts → src/credit-pricing.ts} +0 -0
  54. /package/src/packages/contracts/{database-compute.ts → src/database-compute.ts} +0 -0
  55. /package/src/packages/contracts/{database-migration-admission.ts → src/database-migration-admission.ts} +0 -0
  56. /package/src/packages/contracts/{feedback.ts → src/feedback.ts} +0 -0
  57. /package/src/packages/contracts/{framework-family.ts → src/framework-family.ts} +0 -0
  58. /package/src/packages/contracts/{functions.ts → src/functions.ts} +0 -0
  59. /package/src/packages/contracts/{platform-origins.ts → src/platform-origins.ts} +0 -0
  60. /package/src/packages/contracts/{project-context.ts → src/project-context.ts} +0 -0
  61. /package/src/packages/contracts/{project-exports.ts → src/project-exports.ts} +0 -0
  62. /package/src/packages/contracts/{user-api-keys.ts → src/user-api-keys.ts} +0 -0
  63. /package/src/packages/sdk-ts/{generated → src/generated}/client/client.gen.ts +0 -0
  64. /package/src/packages/sdk-ts/{generated → src/generated}/client/index.ts +0 -0
  65. /package/src/packages/sdk-ts/{generated → src/generated}/client/types.gen.ts +0 -0
  66. /package/src/packages/sdk-ts/{generated → src/generated}/client/utils.gen.ts +0 -0
  67. /package/src/packages/sdk-ts/{generated → src/generated}/client.gen.ts +0 -0
  68. /package/src/packages/sdk-ts/{generated → src/generated}/core/auth.gen.ts +0 -0
  69. /package/src/packages/sdk-ts/{generated → src/generated}/core/bodySerializer.gen.ts +0 -0
  70. /package/src/packages/sdk-ts/{generated → src/generated}/core/params.gen.ts +0 -0
  71. /package/src/packages/sdk-ts/{generated → src/generated}/core/pathSerializer.gen.ts +0 -0
  72. /package/src/packages/sdk-ts/{generated → src/generated}/core/queryKeySerializer.gen.ts +0 -0
  73. /package/src/packages/sdk-ts/{generated → src/generated}/core/serverSentEvents.gen.ts +0 -0
  74. /package/src/packages/sdk-ts/{generated → src/generated}/core/types.gen.ts +0 -0
  75. /package/src/packages/sdk-ts/{generated → src/generated}/core/utils.gen.ts +0 -0
  76. /package/src/packages/sdk-ts/{index.ts → src/index.ts} +0 -0
  77. /package/src/packages/sdk-ts/{managed-mail-response.ts → src/managed-mail-response.ts} +0 -0
  78. /package/src/packages/sdk-ts/{managed-mail.ts → src/managed-mail.ts} +0 -0
  79. /package/src/packages/workos-auth-contracts/{device-flow.ts → src/device-flow.ts} +0 -0
  80. /package/src/packages/workos-auth-contracts/{index.ts → src/index.ts} +0 -0
  81. /package/src/packages/workos-auth-contracts/{management-rpc.ts → src/management-rpc.ts} +0 -0
  82. /package/src/packages/workos-auth-contracts/{organizations.ts → src/organizations.ts} +0 -0
  83. /package/src/packages/workos-auth-contracts/{parsing.ts → src/parsing.ts} +0 -0
  84. /package/src/packages/workos-auth-contracts/{session-status.ts → src/session-status.ts} +0 -0
  85. /package/src/packages/workos-auth-contracts/{tokens.ts → src/tokens.ts} +0 -0
  86. /package/src/packages/workos-auth-contracts/{user-api-keys.ts → src/user-api-keys.ts} +0 -0
  87. /package/src/packages/workos-auth-contracts/{webhooks.ts → src/webhooks.ts} +0 -0
@@ -9,6 +9,7 @@ import {
9
9
  import { assertOrganizationAccount } from "@ohmyhost/contracts/credit-pricing";
10
10
  import { assertDatabaseCompute } from "@ohmyhost/contracts/database-compute";
11
11
  import cliPackage from "../package.json" with { type: "json" };
12
+ import { isUserSecretName } from "@ohmyhost/contracts/secret-names";
12
13
  import {
13
14
  assertProjectContext,
14
15
  assertProjectNotesReceipt,
@@ -72,6 +73,7 @@ import {
72
73
  parseGithubOrganizationConnectionStatus,
73
74
  parseCloudflareDnsAuthorizationStatus,
74
75
  parseDeploymentPlan,
76
+ parseProjectDataChangePlan,
75
77
  parseCurrentIdentity,
76
78
  parseOrganization,
77
79
  type PublicAccount,
@@ -307,7 +309,7 @@ const HELP_USAGE = Object.freeze({
307
309
  "ohmyhost export get EXPORT_ULID --project ULID --json (poll the original job; signed ZIP download lasts 24 hours)",
308
310
  "credits account": "ohmyhost credits account --organization ULID --json",
309
311
  "referral link":
310
- "ohmyhost referral link --organization ULID --json (the workspace's link to share; a new user who signs up through it starts with a free Paid month and 1,000 credits, and their first payment gives this workspace the same)",
312
+ "ohmyhost referral link --organization ULID --json (the workspace's link to share; a new user whose first workspace comes from it starts with 30 days of Paid and 1,000 credits, and its first payment gives this workspace 1,000 credits plus 30 days of Paid unless a subscription or an unbounded grant already covers it)",
311
313
  "credits balance": "ohmyhost credits balance --organization ULID --json",
312
314
  "billing recharge get": "ohmyhost billing recharge get --organization ULID --json",
313
315
  "billing recharge set":
@@ -340,7 +342,11 @@ const HELP_USAGE = Object.freeze({
340
342
  "feedback submit":
341
343
  "ohmyhost feedback submit --organization ULID --kind bug|issue|feature_request --title TITLE --description REDACTED_REPORT [--project ULID] [--environment ULID] [--operation ULID] [--error-code CODE] [--client-version VERSION] --idempotency-key KEY --json",
342
344
  "project create":
343
- "ohmyhost project create --organization ULID --name NAME [--data-mode shared|isolated] [--dev-access-mode protected|public] [--region us|eu] --idempotency-key KEY --json (the region is chosen once: us is the default, eu places the database, files and builds in the EU; it cannot be changed later)",
345
+ "ohmyhost project create --organization ULID --name NAME [--data-mode shared|isolated] [--dev-access-mode protected|public] [--region us|eu] --idempotency-key KEY --json (data mode is optional and defaults to shared; later assignments use project data plan/change without copying data; the region is chosen once: us is the default, eu places the database, files and builds in the EU; it cannot be changed later)",
346
+ "project data plan":
347
+ "ohmyhost project data plan --project ULID --change isolate_prod_keeps_data|isolate_dev_keeps_data|share_prod_keeps_data|reset_dev --json (shared is the optional creation default; review which data is kept or deleted; no data is copied)",
348
+ "project data change":
349
+ "ohmyhost project data change --project ULID --change isolate_prod_keeps_data|isolate_dev_keeps_data|share_prod_keeps_data|reset_dev --if-match ETAG --confirmation-token TOKEN --idempotency-key KEY --yes [--wait] --json (Owner only; use the reviewed plan's guards and the same key after uncertainty; no data is copied)",
344
350
  "project list": "ohmyhost project list [--cursor ULID] [--limit LIMIT] --json",
345
351
  "project context": "ohmyhost project context --project ULID --json",
346
352
  "project notes set":
@@ -356,7 +362,7 @@ const HELP_USAGE = Object.freeze({
356
362
  "ohmyhost project dev-access mode --project ULID --mode protected|public --idempotency-key KEY --yes --json",
357
363
  "project flag status": "ohmyhost project flag status --project ULID --json",
358
364
  "project flag set":
359
- "ohmyhost project flag set --project ULID --enabled true|false --idempotency-key KEY --json (shows the small Powered by ohmyho.st flag on the production site; while it shows, a Free workspace may connect its own domain without the domain fee and each Paid period adds 250 credits)",
365
+ "ohmyhost project flag set --project ULID --enabled true|false --idempotency-key KEY --json (shows the small Powered by ohmyho.st flag on the production site; while it shows, this project's custom domain uses no domain credits and also works on Free, and each Paid period bought through Stripe adds 250 credits to the workspace, however many projects show the flag)",
360
366
  "project handle check":
361
367
  "ohmyhost project handle check --handle HANDLE --json (is this address free? answers with a reason and free alternatives; the address becomes HANDLE.check.omh.st)",
362
368
  "project handle set":
@@ -454,6 +460,90 @@ export const cliMetadataResult = (kind: "help" | "version", topic?: string): Cli
454
460
  })
455
461
  : success("version", { status: "succeeded", cli_version: CLI_VERSION });
456
462
 
463
+ /**
464
+ * How to correct a command the parser refused. It quotes the command's usage line only, never an
465
+ * argument, so a secret typed into the wrong place is not echoed back.
466
+ */
467
+ export const invalidCommandAction = (argv: readonly string[], commandPrefix: string): string => {
468
+ const label = recognizedCommandLabel(argv);
469
+ if (label === "secret set" || label === "secret delete") {
470
+ const action = label === "secret set" ? "set" : "delete";
471
+ const at = argv.findIndex((value, index) => value === "secret" && argv[index + 1] === action);
472
+ const name = at < 0 ? undefined : argv[at + 2];
473
+ if (name !== undefined && !isUserSecretName(name, action))
474
+ return action === "set"
475
+ ? "Choose a customer secret name of at most 128 uppercase letters, digits and underscores, starting with a letter. OHMYHOST_* and DATABASE_URL, HYPERDRIVE, BETTER_AUTH_SECRET, BETTER_AUTH_URL, ASSETS, FILES, IMAGES and STORAGE are reserved for the platform. Rename the application's own variable and its source references; platform bindings are installed automatically. Nothing was sent; changing only the idempotency key will not help."
476
+ : "Choose an existing customer secret name of at most 128 uppercase letters, digits and underscores, starting with a letter. The installed platform keys OHMYHOST_STORAGE_KEY, OHMYHOST_MAIL_KEY and BETTER_AUTH_SECRET cannot be deleted. Older reserved-name collisions can be removed with secret delete; deployed platform keys stay managed by ohmyho.st. Nothing was sent; changing only the idempotency key will not help.";
477
+ }
478
+ const usage = Object.hasOwn(HELP_USAGE, label)
479
+ ? HELP_USAGE[label as CustomerCommandLabel].split("\n")[0]
480
+ : undefined;
481
+ if (usage === undefined)
482
+ return "Unknown command; nothing was sent. 'ohmyhost --help --json' lists every command with its flags. If a documented command is missing, this CLI is older than https://ohmyho.st/client-release.json: install the published version.";
483
+ // A required --project may come from this checkout's link; an optional one never needs it.
484
+ const linkable = /(?:^|\s)--project ULID/u.test(usage) && !argv.includes("--project");
485
+ return `The arguments of '${label}' are invalid or incomplete; nothing was sent. Usage: ${usage}.${
486
+ linkable
487
+ ? ` --project may be omitted only inside a directory linked with '${commandPrefix} link' for this OHMYHOST_ENVIRONMENT; otherwise pass --project ULID.`
488
+ : ""
489
+ }`;
490
+ };
491
+
492
+ /** The next step after an unexpected client failure; the stderr diagnostic line identifies it. */
493
+ export const INTERNAL_ERROR_ACTION =
494
+ "The ohmyhost client stopped on an unexpected error; the stderr line starting with [ohmyhost: identifies it. Compare 'ohmyhost --version' with https://ohmyho.st/client-release.json and install the published client if it is older, then run the same command again with the same --idempotency-key. If the current client fails the same way, report it with feedback submit (kind bug, error code internal_error) and quote that stderr line.";
495
+
496
+ /** The local credential store or saved-login index failed; a sandbox is the usual cause. */
497
+ export const CREDENTIAL_STORE_UNAVAILABLE_ACTION =
498
+ "This computer's credential store (for example macOS Keychain or Windows Credential Manager) or the saved-login list in ~/.ohmyhost/profiles could not be read or written; nothing was changed. If the agent runs in a sandbox, run the command with access to them before asking the customer to sign in again. If the store is locked, ask the customer to unlock it, then run the command again. On a server, container or CI runner without a credential store, do not run login: load OHMYHOST_TOKEN from a private env file with a token from the portal's API Tokens page.";
499
+
500
+ const RESPONSE_CONTRACT_INVALID_ACTION =
501
+ "The ohmyho.st API answered in a form this client version does not accept, usually because the client is older than the service. Compare 'ohmyhost --version' with https://ohmyho.st/client-release.json; if it is older, install the published CLI and MCP and reload MCP. Then run the same command again with the same --idempotency-key: the change may already have been applied. If the current version answers this too, report it with feedback submit (kind bug, error code response_contract_invalid).";
502
+
503
+ const REQUEST_TIMEOUT_ACTION =
504
+ "The ohmyho.st API did not answer within 30 seconds. Run the same command again with the same --idempotency-key; the change may already be under way, and a new key could apply it twice.";
505
+
506
+ const LOGOUT_UNCONFIRMED_ACTION =
507
+ "The saved login was removed from this computer, but ohmyho.st did not confirm that its session ended. Running logout again cannot retry this, because the login is gone; do not sign in again just to revoke it. Tell the customer the session end was not confirmed.";
508
+
509
+ const STATEMENT_FILE_INVALID_ACTION =
510
+ "Put one INSERT, UPDATE or DELETE statement that starts with that keyword (no leading comment or WITH), UTF-8, at most 64 KiB, in the file named by --statement-file, then rerun with the same --idempotency-key.";
511
+
512
+ /** The command that signs a saved login in again as the same user, organization and alias. */
513
+ const signInCommand = (commandPrefix: string, profile: LocalProfile | undefined): string =>
514
+ profile === undefined
515
+ ? `${commandPrefix} login --json`
516
+ : `${commandPrefix} login${
517
+ profile.organizationId === null ? "" : ` --organization ${profile.organizationId}`
518
+ } --user ${profile.userId} --profile-name ${profile.name} --json`;
519
+
520
+ /** The sign-in service ended a saved login, which was then removed from this computer. */
521
+ export const endedLoginAction = (
522
+ commandPrefix: string,
523
+ profile: LocalProfile | undefined,
524
+ ): string =>
525
+ `The sign-in of ${
526
+ profile === undefined
527
+ ? "this computer's saved login"
528
+ : `saved login ${profile.name} (user ${profile.userId}${
529
+ profile.organizationId === null ? "" : `, organization ${profile.organizationId}`
530
+ })`
531
+ } ended and it was removed. Run '${signInCommand(commandPrefix, profile)}' to sign it in again.`;
532
+
533
+ /** Renewing a saved login failed without ending it; it was kept. */
534
+ export const refreshFailedAction = (
535
+ commandPrefix: string,
536
+ profile: LocalProfile | undefined,
537
+ ): string =>
538
+ `Renewing the saved login${profile === undefined ? "" : ` ${profile.name}`} at the sign-in service failed. Run the same command again in a minute, with the same --idempotency-key if it has one. If it fails again, run '${signInCommand(commandPrefix, profile)}' and send the customer the link and code.`;
539
+
540
+ /** The flags a failed login is repeated with; the boundary proof needs brace-free signatures. */
541
+ interface LoginFlags {
542
+ readonly organizationId: string | undefined;
543
+ readonly userId: string | undefined;
544
+ readonly profileName: string | undefined;
545
+ }
546
+
457
547
  class ProductCliFailure extends Error {
458
548
  public constructor(
459
549
  public readonly exitCode: number,
@@ -489,7 +579,7 @@ export class ProductCli {
489
579
  2,
490
580
  "invalid_command",
491
581
  false,
492
- "Check 'ohmyhost --help'.",
582
+ invalidCommandAction(argv, this.dependencies.commandPrefix),
493
583
  );
494
584
  }
495
585
  this.recordDiagnostic({
@@ -497,7 +587,13 @@ export class ProductCli {
497
587
  subsystem: "parser",
498
588
  errorKind: "unexpected",
499
589
  });
500
- return failure("unknown", 1, "internal_error", false, "Retry the command.");
590
+ return failure(
591
+ recognizedCommandLabel(argv),
592
+ 1,
593
+ "internal_error",
594
+ false,
595
+ INTERNAL_ERROR_ACTION,
596
+ );
501
597
  }
502
598
  return this.runCommand(command, signal);
503
599
  }
@@ -788,6 +884,9 @@ export class ProductCli {
788
884
  });
789
885
  }
790
886
  if (command.kind === "project-create") return await this.createProject(command, signal);
887
+ if (command.kind === "project-data-plan") return await this.planProjectData(command, signal);
888
+ if (command.kind === "project-data-change")
889
+ return await this.changeProjectData(command, signal);
791
890
  if (command.kind === "project-list") return await this.listProjects(command, signal);
792
891
  if (command.kind === "project-handle-check")
793
892
  return await this.checkProjectHandle(command, signal);
@@ -871,23 +970,21 @@ export class ProductCli {
871
970
  );
872
971
  }
873
972
  if (command.kind === "database-write") {
874
- const file = await open(command.statementFile, "r"),
875
- buffer = Buffer.alloc(65_537);
876
- let statement: string;
973
+ const statement = await readStatementFile(command.statementFile);
877
974
  try {
878
- const stat = await file.stat();
879
- if (!stat.isFile() || stat.size > 65_536) throw new InvalidCommandError();
880
- let length = 0;
881
- while (length < buffer.length) {
882
- const read = await file.read(buffer, length, buffer.length - length, null);
883
- if (read.bytesRead === 0) break;
884
- length += read.bytesRead;
885
- }
886
- if (length > 65_536) throw new InvalidCommandError();
887
- statement = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
888
- } finally {
889
- buffer.fill(0);
890
- await file.close();
975
+ // The statement alone decides whether the file is usable; its parameters are checked next.
976
+ parseProjectDatabaseWrite({
977
+ environment: command.environment,
978
+ statement,
979
+ parameters: [],
980
+ });
981
+ } catch {
982
+ throw new ProductCliFailure(
983
+ 2,
984
+ "statement_file_invalid",
985
+ false,
986
+ STATEMENT_FILE_INVALID_ACTION,
987
+ );
891
988
  }
892
989
  const body = parseProjectDatabaseWrite({
893
990
  environment: command.environment,
@@ -1173,7 +1270,13 @@ export class ProductCli {
1173
1270
  expiresAt,
1174
1271
  };
1175
1272
  const account = await this.dependencies.accountProfile(async () => signedIn.accessToken);
1176
- if (!isProfileUserId(account.userId)) throw this.unverifiedLogin();
1273
+ // A failed verification repeats this login with the flags it was given.
1274
+ const loginFlags = {
1275
+ organizationId,
1276
+ userId: expectedUserId,
1277
+ profileName: this.dependencies.profileName,
1278
+ };
1279
+ if (!isProfileUserId(account.userId)) throw this.unverifiedLogin(loginFlags);
1177
1280
  if (expectedUserId !== undefined && account.userId !== expectedUserId)
1178
1281
  throw new ProductCliFailure(
1179
1282
  4,
@@ -1206,11 +1309,11 @@ export class ProductCli {
1206
1309
  scopedId === null
1207
1310
  ? null
1208
1311
  : account.organizations.find((candidate) => candidate.id === scopedId);
1209
- if (organization === undefined) throw this.unverifiedLogin();
1312
+ if (organization === undefined) throw this.unverifiedLogin(loginFlags);
1210
1313
  try {
1211
1314
  assertVerifiedLogin(identity, account.userId, scopedId);
1212
1315
  } catch (error) {
1213
- if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1316
+ if (error instanceof AccountVerificationError) throw this.unverifiedLogin(loginFlags);
1214
1317
  throw error;
1215
1318
  }
1216
1319
  const login = {
@@ -1276,12 +1379,16 @@ export class ProductCli {
1276
1379
  }
1277
1380
 
1278
1381
  /** The provider answered with another account than the one this login requested. */
1279
- private unverifiedLogin(): ProductCliFailure {
1382
+ private unverifiedLogin(flags: LoginFlags): ProductCliFailure {
1280
1383
  return new ProductCliFailure(
1281
1384
  4,
1282
1385
  "login_verification_failed",
1283
1386
  true,
1284
- `The signed-in account could not be verified; nothing was saved. Run '${this.dependencies.commandPrefix} login --json' again.`,
1387
+ `The signed-in account could not be verified; nothing was saved. Run '${this.dependencies.commandPrefix} login${
1388
+ flags.organizationId === undefined ? "" : ` --organization ${flags.organizationId}`
1389
+ }${flags.userId === undefined ? "" : ` --user ${flags.userId}`}${
1390
+ flags.profileName === undefined ? "" : ` --profile-name ${flags.profileName}`
1391
+ } --json' again with the same flags and send the customer the new link and code.`,
1285
1392
  );
1286
1393
  }
1287
1394
 
@@ -1317,7 +1424,12 @@ export class ProductCli {
1317
1424
  this.dependencies.lifecycleTimeoutMs,
1318
1425
  );
1319
1426
  } catch (error) {
1320
- if (error instanceof AccountVerificationError) throw this.unverifiedLogin();
1427
+ if (error instanceof AccountVerificationError)
1428
+ throw this.unverifiedLogin({
1429
+ organizationId: organization.id,
1430
+ userId: profile.userId,
1431
+ profileName: profile.name,
1432
+ });
1321
1433
  if (!(error instanceof ProfileOrganizationFixedError)) throw error;
1322
1434
  throw new ProductCliFailure(
1323
1435
  2,
@@ -1375,21 +1487,12 @@ export class ProductCli {
1375
1487
  "Remote token revocation is not available in this release.",
1376
1488
  );
1377
1489
  }
1490
+ // The login is already gone, so nothing this computer holds can repeat the revocation.
1378
1491
  if (isTimeout(revocationError)) {
1379
- throw new ProductCliFailure(
1380
- 8,
1381
- "request_timeout",
1382
- true,
1383
- "Remote revocation timed out; local credentials were removed.",
1384
- );
1492
+ throw new ProductCliFailure(8, "request_timeout", false, LOGOUT_UNCONFIRMED_ACTION);
1385
1493
  }
1386
1494
  if (revocationError instanceof LifecycleTransportError) {
1387
- throw new ProductCliFailure(
1388
- 6,
1389
- "token_revocation_failed",
1390
- true,
1391
- "Remote revocation failed; the local credential was removed.",
1392
- );
1495
+ throw new ProductCliFailure(6, "token_revocation_failed", false, LOGOUT_UNCONFIRMED_ACTION);
1393
1496
  }
1394
1497
  throw revocationError;
1395
1498
  }
@@ -1446,6 +1549,38 @@ export class ProductCli {
1446
1549
  return success("operation reconcile", { status: "accepted", reconciliation });
1447
1550
  }
1448
1551
 
1552
+ private async planProjectData(
1553
+ command: Extract<ProductCliCommand, { kind: "project-data-plan" }>,
1554
+ signal: AbortSignal,
1555
+ ): Promise<CliResult> {
1556
+ const api = await this.authenticatedApi(command.credentialStore, signal);
1557
+ return success("project data plan", {
1558
+ status: "planned",
1559
+ plan: parseProjectDataChangePlan(
1560
+ await api.planProjectDataChange({ projectId: command.projectId, change: command.change }),
1561
+ ),
1562
+ });
1563
+ }
1564
+ private async changeProjectData(
1565
+ command: Extract<ProductCliCommand, { kind: "project-data-change" }>,
1566
+ signal: AbortSignal,
1567
+ ): Promise<CliResult> {
1568
+ const api = await this.authenticatedApi(command.credentialStore, signal);
1569
+ let operation = parseOperation(
1570
+ await api.createProjectDataChange({
1571
+ projectId: command.projectId,
1572
+ change: command.change,
1573
+ ifMatch: command.ifMatch,
1574
+ confirmationToken: command.confirmationToken,
1575
+ idempotencyKey: command.idempotencyKey,
1576
+ }),
1577
+ );
1578
+ if (command.wait) operation = await this.waitForOperation(api, operation, signal);
1579
+ return success("project data change", {
1580
+ status: command.wait ? "completed" : "accepted",
1581
+ operation,
1582
+ });
1583
+ }
1449
1584
  private async createProject(
1450
1585
  command: Extract<ProductCliCommand, { kind: "project-create" }>,
1451
1586
  signal: AbortSignal,
@@ -1833,13 +1968,33 @@ export class ProductCli {
1833
1968
  environment: command.environment,
1834
1969
  }),
1835
1970
  );
1836
- let operation = parseOperation(
1837
- await api.createDeployment({
1971
+ let accepted: unknown;
1972
+ try {
1973
+ accepted = await api.createDeployment({
1838
1974
  projectId: command.projectId,
1839
1975
  planId: plan === null ? String(command.planId) : String(plan["id"]),
1840
1976
  idempotencyKey: command.idempotencyKey,
1841
- }),
1842
- );
1977
+ });
1978
+ } catch (error) {
1979
+ // A rerun of --commit re-plans, so its key meets a new plan ID; a new key would build the
1980
+ // same commit a second time. The deployment the key started is followed instead.
1981
+ const reused = plan === null ? undefined : reusedKeyProblem(error);
1982
+ if (reused === undefined) throw error;
1983
+ return failure(
1984
+ "deploy",
1985
+ 7,
1986
+ reused.code,
1987
+ false,
1988
+ `This key already started a deployment from an earlier run of this command; a new key would build the same commit again. Run 'ohmyhost project status --project ${command.projectId} --json' and follow latest_operation with 'ohmyhost operation get OPERATION_ULID --json'.`,
1989
+ undefined,
1990
+ undefined,
1991
+ reused.requestId,
1992
+ reused.docsUrl,
1993
+ undefined,
1994
+ reused.message,
1995
+ );
1996
+ }
1997
+ let operation = parseOperation(accepted);
1843
1998
  if (command.wait) operation = await this.waitForOperation(api, operation, signal);
1844
1999
  // Dev access follows the project-selected mode; the clean URL alone is insufficient when protected.
1845
2000
  const notes =
@@ -1861,35 +2016,62 @@ export class ProductCli {
1861
2016
  signal: AbortSignal,
1862
2017
  ): Promise<CliResult> {
1863
2018
  const api = await this.authenticatedApi(command.credentialStore, signal);
2019
+ // The operation read answers an unknown, foreign or mistyped ID before any stream opens.
2020
+ let operation = parseOperation(await api.getOperation(command.operationId));
1864
2021
  let eventCount = 0;
1865
- for await (const event of await api.streamOperationEvents(command.operationId, signal)) {
1866
- const parsed = parseOperationEvent(event);
1867
- this.dependencies.operationEventSink?.write(`${JSON.stringify(parsed)}\n`);
1868
- eventCount += 1;
1869
- if (
1870
- parsed["type"] === "OperationSucceeded" ||
1871
- parsed["type"] === "OperationFailed" ||
1872
- parsed["type"] === "OperationCancelled"
1873
- ) {
1874
- break;
2022
+ let lastEventId: string | undefined;
2023
+ for (let connection = 0; connection < OPERATION_STREAM_CONNECTIONS; connection += 1) {
2024
+ if (connection > 0)
2025
+ await this.dependencies.waitUntil(
2026
+ new Date(this.dependencies.now().getTime() + 1_000),
2027
+ signal,
2028
+ );
2029
+ const events =
2030
+ lastEventId === undefined
2031
+ ? await api.streamOperationEvents(command.operationId, signal)
2032
+ : await api.streamOperationEvents(command.operationId, signal, lastEventId);
2033
+ for await (const event of events) {
2034
+ const parsed = parseOperationEvent(event);
2035
+ this.dependencies.operationEventSink?.write(`${JSON.stringify(parsed)}\n`);
2036
+ eventCount += 1;
2037
+ lastEventId = String(parsed["event_id"]);
2038
+ if (
2039
+ parsed["type"] === "OperationSucceeded" ||
2040
+ parsed["type"] === "OperationFailed" ||
2041
+ parsed["type"] === "OperationCancelled"
2042
+ )
2043
+ return success("logs", { status: "completed", event_count: eventCount });
1875
2044
  }
2045
+ throwIfAborted(signal);
2046
+ // The server also closes a stream without a final event, for example when an interactive
2047
+ // sign-in expires. Only the operation itself says whether it ended meanwhile.
2048
+ operation = parseOperation(await api.getOperation(command.operationId));
2049
+ if (operation.state !== "queued" && operation.state !== "running")
2050
+ return success("logs", { status: "completed", event_count: eventCount, operation });
1876
2051
  }
1877
- return success("logs", { status: "completed", event_count: eventCount });
2052
+ throw new ProductCliFailure(
2053
+ 8,
2054
+ "operation_stream_ended",
2055
+ true,
2056
+ `The event stream ended before operation ${command.operationId} finished, and it is still running; nothing was cancelled. Poll 'ohmyhost operation get ${command.operationId} --json' about every 60 seconds until it is succeeded, failed or cancelled.`,
2057
+ operation,
2058
+ );
1878
2059
  }
1879
2060
  private async deploymentLogs(
1880
2061
  command: Extract<ProductCliCommand, { kind: "deployment-logs" }>,
1881
2062
  signal: AbortSignal,
1882
2063
  ): Promise<CliResult> {
1883
2064
  const api = await this.authenticatedApi(command.credentialStore, signal);
1884
- let count = 0;
1885
- for await (const event of await api.streamDeploymentLogs(
1886
- { projectId: command.projectId, deploymentId: command.deploymentId },
1887
- signal,
1888
- )) {
1889
- this.dependencies.operationEventSink?.write(`${JSON.stringify(event)}\n`);
1890
- count += 1;
1891
- }
1892
- return success("deployment logs", { status: "completed", event_count: count });
2065
+ // The diagnostics are one bounded page; a plain read answers a missing deployment at once,
2066
+ // where its event stream retried a 404 without end.
2067
+ const page = await api.getDeploymentLogs({
2068
+ projectId: command.projectId,
2069
+ deploymentId: command.deploymentId,
2070
+ limit: 100,
2071
+ });
2072
+ for (const item of page.items)
2073
+ this.dependencies.operationEventSink?.write(`${JSON.stringify(item)}\n`);
2074
+ return success("deployment logs", { status: "completed", event_count: page.items.length });
1893
2075
  }
1894
2076
 
1895
2077
  private async planRollback(
@@ -2016,7 +2198,7 @@ export class ProductCli {
2016
2198
  )
2017
2199
  : secret;
2018
2200
  return success("secret set", {
2019
- status: command.wait ? "ready" : "stored",
2201
+ status: command.wait && completed.delivery_state === "ready" ? "ready" : "stored",
2020
2202
  secret: completed,
2021
2203
  });
2022
2204
  } finally {
@@ -2047,7 +2229,7 @@ export class ProductCli {
2047
2229
  )
2048
2230
  : deleted;
2049
2231
  return success("secret delete", {
2050
- status: command.wait ? "ready" : deleted.status,
2232
+ status: command.wait && delivery.delivery_state === "ready" ? "ready" : deleted.status,
2051
2233
  secret: { name: deleted.name, ...secretDeliveryProjection(delivery) },
2052
2234
  });
2053
2235
  }
@@ -2069,13 +2251,16 @@ export class ProductCli {
2069
2251
  10,
2070
2252
  "secret_delivery_failed",
2071
2253
  true,
2072
- "Inspect secret metadata and deployment logs, then retry reconciliation.",
2254
+ `The secret is saved, but delivering it to the running app failed (last_error in 'ohmyhost secret list --project ${projectId} --environment ${environmentId} --json'). Do not set the value again: ohmyho.st retries delivery automatically, and the next deployment of this environment also delivers it. Read secret list again in a few minutes; if delivery_state stays failed, report last_error with feedback submit (kind bug, error code secret_delivery_failed).`,
2073
2255
  );
2074
2256
  }
2257
+ // Before an environment's first deployment nothing runs to deliver to: the value arrives
2258
+ // with that deployment, so waiting could only time out.
2075
2259
  if (
2076
- page.delivery_state === "ready" &&
2077
- page.desired_generation >= desiredGeneration &&
2078
- page.applied_generation >= desiredGeneration
2260
+ page.delivery_state === "not_deployed" ||
2261
+ (page.delivery_state === "ready" &&
2262
+ page.desired_generation >= desiredGeneration &&
2263
+ page.applied_generation >= desiredGeneration)
2079
2264
  ) {
2080
2265
  // Every item carries the environment's generations; only the item with the stored
2081
2266
  // secret's name describes the secret that was just set. A deletion has no item left
@@ -2099,7 +2284,7 @@ export class ProductCli {
2099
2284
  8,
2100
2285
  "secret_delivery_wait_timeout",
2101
2286
  true,
2102
- "Run 'ohmyhost secret list --json' to inspect delivery metadata.",
2287
+ `The secret is saved, but it had not reached the running app after about 2 minutes. Read 'ohmyhost secret list --project ${projectId} --environment ${environmentId} --json': pending means delivery is still under way, and not_deployed means it arrives with the first deployment. Do not set the value again.`,
2103
2288
  );
2104
2289
  }
2105
2290
 
@@ -2135,7 +2320,7 @@ export class ProductCli {
2135
2320
  8,
2136
2321
  "operation_wait_timeout",
2137
2322
  true,
2138
- `Run 'ohmyhost operation get ${operation.id} --json' to inspect the existing operation before resubmitting a mutation.`,
2323
+ `The operation is still running; this command stopped waiting after about 2 minutes and cancelled nothing. Poll 'ohmyhost operation get ${operation.id} --json' about every 60 seconds until it is succeeded, failed or cancelled. Do not submit the same change again; a new key starts a second operation.`,
2139
2324
  operation,
2140
2325
  );
2141
2326
  if (operation.state === "failed" && operation.error !== undefined)
@@ -2181,8 +2366,14 @@ export class ProductCli {
2181
2366
  } catch (error) {
2182
2367
  if (!(error instanceof TokenRefreshRejectedError)) throw error;
2183
2368
  // The provider ended this login; only this saved login is removed.
2369
+ const ended = this.#profile;
2184
2370
  await this.forgetLogin(store);
2185
- throw new ProductCliFailure(5, "authentication_required", false, this.loginAction());
2371
+ throw new ProductCliFailure(
2372
+ 5,
2373
+ "authentication_required",
2374
+ false,
2375
+ endedLoginAction(this.dependencies.commandPrefix, ended),
2376
+ );
2186
2377
  }
2187
2378
  if (refresh.refreshed) await store.replace(refresh.credential);
2188
2379
  return refresh.credential.accessToken;
@@ -2239,14 +2430,14 @@ export class ProductCli {
2239
2430
  );
2240
2431
  }
2241
2432
  if (isTimeout(error)) {
2242
- throw new ProductCliFailure(8, "request_timeout", true, "Retry the command.");
2433
+ throw new ProductCliFailure(8, "request_timeout", true, REQUEST_TIMEOUT_ACTION);
2243
2434
  }
2244
2435
  if (error instanceof LifecycleTransportError) {
2245
2436
  throw new ProductCliFailure(
2246
2437
  5,
2247
2438
  "token_refresh_failed",
2248
2439
  true,
2249
- "Run 'ohmyhost login' if retrying does not succeed.",
2440
+ refreshFailedAction(this.dependencies.commandPrefix, this.#profile),
2250
2441
  );
2251
2442
  }
2252
2443
  throw error;
@@ -2322,10 +2513,16 @@ const classifyFailure = (
2322
2513
  actingAs?: Readonly<Record<string, unknown>>,
2323
2514
  ): CliResult => {
2324
2515
  if (signal.aborted || isCancellation(error)) {
2325
- return failure(command, 130, "command_cancelled", true, "Run the command again.");
2516
+ return failure(
2517
+ command,
2518
+ 130,
2519
+ "command_cancelled",
2520
+ true,
2521
+ "The command was stopped by an interrupt or termination signal, for example the shell's command timeout, before it finished. Run it again with the same --idempotency-key; a change may already be under way. Give login and commands with --wait or --follow several minutes, or run them in the background.",
2522
+ );
2326
2523
  }
2327
2524
  if (isTimeout(error)) {
2328
- return failure(command, 8, "request_timeout", true, "Retry the command.");
2525
+ return failure(command, 8, "request_timeout", true, REQUEST_TIMEOUT_ACTION);
2329
2526
  }
2330
2527
  if (error instanceof ProductCliFailure) {
2331
2528
  return failure(
@@ -2343,25 +2540,45 @@ const classifyFailure = (
2343
2540
  2,
2344
2541
  "secret_input_invalid",
2345
2542
  false,
2346
- "Pipe a non-empty UTF-8 value of at most 5120 bytes through standard input.",
2543
+ command === "export create"
2544
+ ? "Pipe the ZIP password through standard input: non-blank UTF-8 of at most 1024 bytes. A final line break becomes part of the password."
2545
+ : "Pipe a non-empty UTF-8 value of at most 5120 bytes through standard input.",
2347
2546
  );
2348
2547
  }
2349
2548
  if (error instanceof WorkosContractError) {
2350
2549
  if (error.code === "access_denied") {
2351
- return failure(command, 4, "device_authorization_denied", false, "Run login again.");
2550
+ return failure(
2551
+ command,
2552
+ 4,
2553
+ "device_authorization_denied",
2554
+ false,
2555
+ "The customer declined the sign-in in the browser; nothing was saved. Ask the customer whether to sign in, then run the same login command again and send them the new link and code.",
2556
+ );
2352
2557
  }
2353
2558
  if (error.code === "expired_token") {
2354
- return failure(command, 4, "device_authorization_expired", false, "Run login again.");
2559
+ return failure(
2560
+ command,
2561
+ 4,
2562
+ "device_authorization_expired",
2563
+ false,
2564
+ "Nobody finished the sign-in within 30 minutes; nothing was saved. Run the same login command again and send the customer the new link and code at once.",
2565
+ );
2355
2566
  }
2356
- return failure(command, 4, "device_authorization_failed", true, "Run login again.");
2567
+ return failure(
2568
+ command,
2569
+ 4,
2570
+ "device_authorization_failed",
2571
+ true,
2572
+ "The sign-in service answered unexpectedly; nothing was saved. Run the same login command again; if it fails twice more, ask the customer to try again later.",
2573
+ );
2357
2574
  }
2358
2575
  if (error instanceof CredentialStoreUnavailableError) {
2359
2576
  return failure(
2360
2577
  command,
2361
2578
  3,
2362
2579
  "credential_store_unavailable",
2363
- true,
2364
- "Unlock or repair the selected credential store and retry.",
2580
+ false,
2581
+ CREDENTIAL_STORE_UNAVAILABLE_ACTION,
2365
2582
  );
2366
2583
  }
2367
2584
  if (error instanceof ResponseContractError) {
@@ -2370,7 +2587,7 @@ const classifyFailure = (
2370
2587
  7,
2371
2588
  "response_contract_invalid",
2372
2589
  false,
2373
- "Retry only after the client or server contract is repaired.",
2590
+ RESPONSE_CONTRACT_INVALID_ACTION,
2374
2591
  );
2375
2592
  }
2376
2593
  if (error instanceof PublicResponseTooLargeError) {
@@ -2396,6 +2613,7 @@ const classifyFailure = (
2396
2613
  problem.requestId,
2397
2614
  problem.docsUrl,
2398
2615
  ACCOUNT_SENSITIVE_PROBLEMS.has(problem.code) ? actingAs : undefined,
2616
+ problem.message,
2399
2617
  );
2400
2618
  } catch {
2401
2619
  return failure(
@@ -2403,7 +2621,7 @@ const classifyFailure = (
2403
2621
  7,
2404
2622
  "response_contract_invalid",
2405
2623
  false,
2406
- "Retry only after the client or server contract is repaired.",
2624
+ RESPONSE_CONTRACT_INVALID_ACTION,
2407
2625
  );
2408
2626
  }
2409
2627
  }
@@ -2413,13 +2631,67 @@ const classifyFailure = (
2413
2631
  7,
2414
2632
  "response_contract_invalid",
2415
2633
  false,
2416
- "Retry only after the client or server contract is repaired.",
2634
+ RESPONSE_CONTRACT_INVALID_ACTION,
2417
2635
  );
2418
2636
  }
2419
2637
  if (error instanceof NetworkTransportError) {
2420
- return failure(command, 8, "network_error", true, "Check connectivity and retry.");
2638
+ return failure(
2639
+ command,
2640
+ 8,
2641
+ "network_error",
2642
+ true,
2643
+ "The request did not reach the ohmyho.st API, or its answer was cut off. If the agent runs in a sandbox without network access, run the command with network access. Otherwise run the same command again with the same --idempotency-key; the change may already have been applied, and a new key could apply it twice.",
2644
+ );
2645
+ }
2646
+ return failure(command, 1, "internal_error", false, INTERNAL_ERROR_ACTION);
2647
+ };
2648
+
2649
+ /** A 409 idempotency_key_reused problem, with what its answer carries. */
2650
+ const reusedKeyProblem = (error: unknown): ReturnType<typeof parseSafeProblem> | undefined => {
2651
+ if (!looksLikeProblem(error)) return undefined;
2652
+ try {
2653
+ const problem = parseSafeProblem(error);
2654
+ return problem.code === "idempotency_key_reused" ? problem : undefined;
2655
+ } catch {
2656
+ return undefined;
2657
+ }
2658
+ };
2659
+
2660
+ /** One stream and at most three reconnects before an unfinished operation is handed back. */
2661
+ const OPERATION_STREAM_CONNECTIONS = 4;
2662
+
2663
+ /**
2664
+ * The single DML statement of `database write --statement-file`: a regular UTF-8 file of at most
2665
+ * 64 KiB. Anything else, including a missing file, is refused before any request.
2666
+ */
2667
+ const readStatementFile = async (path: string): Promise<string> => {
2668
+ const invalid = () =>
2669
+ new ProductCliFailure(2, "statement_file_invalid", false, STATEMENT_FILE_INVALID_ACTION);
2670
+ let file: Awaited<ReturnType<typeof open>>;
2671
+ try {
2672
+ file = await open(path, "r");
2673
+ } catch {
2674
+ throw invalid();
2675
+ }
2676
+ const buffer = Buffer.alloc(65_537);
2677
+ try {
2678
+ const stat = await file.stat();
2679
+ if (!stat.isFile() || stat.size > 65_536) throw invalid();
2680
+ let length = 0;
2681
+ while (length < buffer.length) {
2682
+ const read = await file.read(buffer, length, buffer.length - length, null);
2683
+ if (read.bytesRead === 0) break;
2684
+ length += read.bytesRead;
2685
+ }
2686
+ if (length > 65_536) throw invalid();
2687
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
2688
+ } catch (error) {
2689
+ if (error instanceof ProductCliFailure) throw error;
2690
+ throw invalid();
2691
+ } finally {
2692
+ buffer.fill(0);
2693
+ await file.close();
2421
2694
  }
2422
- return failure(command, 1, "internal_error", false, "Retry the command.");
2423
2695
  };
2424
2696
 
2425
2697
  const psqlUnavailable = (): CliResult =>
@@ -2448,6 +2720,7 @@ const failure = (
2448
2720
  requestId?: string,
2449
2721
  docsUrl?: string,
2450
2722
  actingAs?: Readonly<Record<string, unknown>>,
2723
+ message?: string,
2451
2724
  ): CliResult => {
2452
2725
  const document: CliErrorDocument = {
2453
2726
  version: 1,
@@ -2462,6 +2735,7 @@ const failure = (
2462
2735
  ...(requestId === undefined ? {} : { request_id: requestId }),
2463
2736
  ...(docsUrl === undefined ? {} : { docs_url: docsUrl }),
2464
2737
  ...(operation?.error === undefined ? {} : { message: operation.error.message }),
2738
+ ...(message === undefined ? {} : { message }),
2465
2739
  ...(actingAs === undefined ? {} : { acting_as: actingAs }),
2466
2740
  },
2467
2741
  };
@@ -2490,6 +2764,8 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2490
2764
  if (command.kind === "feedback-submit") return "feedback submit";
2491
2765
  if (command.kind === "feedback-status") return "feedback status";
2492
2766
  if (command.kind === "project-create") return "project create";
2767
+ if (command.kind === "project-data-plan") return "project data plan";
2768
+ if (command.kind === "project-data-change") return "project data change";
2493
2769
  if (command.kind === "organization-create") return "organization create";
2494
2770
  if (command.kind === "organization-list") return "organization list";
2495
2771
  if (command.kind === "organization-use") return "organization use";