@amerged/ohmyhost-mcp 0.1.11 → 0.1.13

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.
@@ -254,6 +254,7 @@ interface CliErrorDocument {
254
254
  readonly suggested_action: string;
255
255
  readonly message?: string;
256
256
  readonly retry_after_seconds?: number;
257
+ readonly request_id?: string;
257
258
  };
258
259
  }
259
260
 
@@ -312,6 +313,8 @@ const HELP_USAGE = Object.freeze({
312
313
  "project dev-access create": "ohmyhost project dev-access create --project ULID --json",
313
314
  "project handle check":
314
315
  "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)",
316
+ "project handle set":
317
+ "ohmyhost project handle set --project ULID --handle HANDLE --if-match ETAG --idempotency-key KEY --json (moves the project to a free address; the old one stops working and anyone may claim it)",
315
318
  "database compute set":
316
319
  "ohmyhost database compute set --project ULID --environment dev|prod --profile standard|performance --idempotency-key KEY --yes [--wait] --json\n" +
317
320
  " standard: Free 0.25 CU/1 GB/60s idle, Paid 0.5 CU/2 GB/60s idle. performance: Paid 1 CU/4 GB/300s idle, 2.5x database compute credits per equal active minute; longer idle time also consumes credits.",
@@ -691,6 +694,8 @@ export class ProductCli {
691
694
  if (command.kind === "project-list") return await this.listProjects(command, signal);
692
695
  if (command.kind === "project-handle-check")
693
696
  return await this.checkProjectHandle(command, signal);
697
+ if (command.kind === "project-handle-set")
698
+ return await this.changeProjectHandle(command, signal);
694
699
  if (command.kind === "database-compute-set") {
695
700
  const api = await this.authenticatedApi(command.credentialStore, signal);
696
701
  let operation = parseOperation(await api.changeDatabaseCompute(command));
@@ -1169,6 +1174,22 @@ export class ProductCli {
1169
1174
  return success("project list", { status: "succeeded", projects });
1170
1175
  }
1171
1176
 
1177
+ private async changeProjectHandle(
1178
+ command: Extract<ProductCliCommand, { kind: "project-handle-set" }>,
1179
+ signal: AbortSignal,
1180
+ ): Promise<CliResult> {
1181
+ const api = await this.authenticatedApi(command.credentialStore, signal);
1182
+ const operation = parseOperation(
1183
+ await api.changeProjectHandle({
1184
+ projectId: command.projectId,
1185
+ handle: command.handle,
1186
+ ifMatch: command.ifMatch,
1187
+ idempotencyKey: command.idempotencyKey,
1188
+ }),
1189
+ );
1190
+ return success("project handle set", { status: "accepted", operation });
1191
+ }
1192
+
1172
1193
  private async checkProjectHandle(
1173
1194
  command: Extract<ProductCliCommand, { kind: "project-handle-check" }>,
1174
1195
  signal: AbortSignal,
@@ -2030,6 +2051,7 @@ const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSig
2030
2051
  problem.suggestedAction,
2031
2052
  undefined,
2032
2053
  problem.retryAfterSeconds,
2054
+ problem.requestId,
2033
2055
  );
2034
2056
  } catch {
2035
2057
  return failure(
@@ -2079,6 +2101,7 @@ const failure = (
2079
2101
  suggestedAction: string,
2080
2102
  operation?: ReturnType<typeof parseOperation>,
2081
2103
  retryAfterSeconds?: number,
2104
+ requestId?: string,
2082
2105
  ): CliResult => {
2083
2106
  const document: CliErrorDocument = {
2084
2107
  version: 1,
@@ -2090,6 +2113,7 @@ const failure = (
2090
2113
  retryable,
2091
2114
  suggested_action: suggestedAction,
2092
2115
  ...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }),
2116
+ ...(requestId === undefined ? {} : { request_id: requestId }),
2093
2117
  ...(operation?.error === undefined ? {} : { message: operation.error.message }),
2094
2118
  },
2095
2119
  };
@@ -2129,6 +2153,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
2129
2153
  if (command.kind === "project-status") return "project status";
2130
2154
  if (command.kind === "project-dev-access-create") return "project dev-access create";
2131
2155
  if (command.kind === "project-handle-check") return "project handle check";
2156
+ if (command.kind === "project-handle-set") return "project handle set";
2132
2157
  if (command.kind === "database-write") return "database write";
2133
2158
  if (command.kind === "database-query") return "database query";
2134
2159
  if (command.kind === "database-access-create") return "database access create";
@@ -132,6 +132,15 @@ export type ProductCliCommand =
132
132
  readonly idempotencyKey: string;
133
133
  readonly credentialStore: "native";
134
134
  }
135
+ | {
136
+ /** Moves a project to a chosen address once its gateways serve it. */
137
+ readonly kind: "project-handle-set";
138
+ readonly projectId: string;
139
+ readonly handle: string;
140
+ readonly ifMatch: string;
141
+ readonly idempotencyKey: string;
142
+ readonly credentialStore: "native";
143
+ }
135
144
  | {
136
145
  /** Asks whether a project address is free; the candidate is sent exactly as the agent typed it. */
137
146
  readonly kind: "project-handle-check";
@@ -494,6 +503,7 @@ export type RecognizedCommandLabel =
494
503
  | "project status"
495
504
  | "project dev-access create"
496
505
  | "project handle check"
506
+ | "project handle set"
497
507
  | "database write"
498
508
  | "database query"
499
509
  | "database access create"
@@ -585,6 +595,8 @@ export const recognizedCommandLabel = (argv: readonly string[]): RecognizedComma
585
595
  if (value === "project" && values[0] === "status") return "project status";
586
596
  if (value === "project" && values[0] === "handle" && values[1] === "check")
587
597
  return "project handle check";
598
+ if (value === "project" && values[0] === "handle" && values[1] === "set")
599
+ return "project handle set";
588
600
  if (value === "project" && values[0] === "dev-access" && values[1] === "create")
589
601
  return "project dev-access create";
590
602
  if (value === "database" && values[0] === "compute" && values[1] === "set")
@@ -1054,6 +1066,24 @@ export const parseProductCliCommand = (argv: readonly string[]): ProductCliComma
1054
1066
  }
1055
1067
  if (first === "project") {
1056
1068
  const subcommand = values.shift();
1069
+ if (subcommand === "handle" && values[0] === "set") {
1070
+ values.shift();
1071
+ if (yes || wait || follow || stdin) throw new InvalidCommandError();
1072
+ const options = parseOptions(values, [
1073
+ "--project",
1074
+ "--handle",
1075
+ "--if-match",
1076
+ "--idempotency-key",
1077
+ ]);
1078
+ return {
1079
+ kind: "project-handle-set",
1080
+ projectId: options["--project"],
1081
+ handle: options["--handle"],
1082
+ ifMatch: options["--if-match"],
1083
+ idempotencyKey: options["--idempotency-key"],
1084
+ credentialStore,
1085
+ };
1086
+ }
1057
1087
  if (subcommand === "handle" && values.shift() === "check") {
1058
1088
  if (yes || wait || follow || stdin) throw new InvalidCommandError();
1059
1089
  const options = parseOptions(values, ["--handle"]);
@@ -429,9 +429,11 @@ const runOfflineInit = async (
429
429
  false,
430
430
  error instanceof RepositoryInitConflictError
431
431
  ? "Keep the existing ohmyhost.yaml or remove it explicitly before retrying."
432
- : error instanceof RepositoryInitError && error.code === "project_name_invalid"
433
- ? "Pass a lowercase deployment slug with '--project', then rerun init."
434
- : "Run 'ohmyhost init --dry-run --json' and resolve every reported blocker.",
432
+ : error instanceof RepositoryInitError && error.code === "repository_root_required"
433
+ ? "Run ohmyhost init from the Git repository root with --root set to the application directory (for example --root website); commit the root ohmyhost.yaml before planning."
434
+ : error instanceof RepositoryInitError && error.code === "project_name_invalid"
435
+ ? "Pass a lowercase deployment slug with '--project', then rerun init."
436
+ : "Run 'ohmyhost init --dry-run --json' and resolve every reported blocker.",
435
437
  );
436
438
  io.writeStdout(result.stdout);
437
439
  return result.exitCode;
@@ -83,6 +83,7 @@ import {
83
83
  listEnvironmentSecrets,
84
84
  listFunctionRuns,
85
85
  listProjects,
86
+ changeProjectHandle,
86
87
  getProjectHandleAvailability,
87
88
  planDeployment,
88
89
  planPaidProjectDomain,
@@ -211,6 +212,12 @@ export interface ProductApi {
211
212
  getAccountProfile(): Promise<readonly PublicWorkspace[]>;
212
213
  listProjects(input: { readonly cursor?: string; readonly limit: number }): Promise<ProjectPage>;
213
214
  checkProjectHandle(input: { readonly handle: string }): Promise<ProjectHandleAvailability>;
215
+ changeProjectHandle(input: {
216
+ readonly projectId: string;
217
+ readonly handle: string;
218
+ readonly ifMatch: string;
219
+ readonly idempotencyKey: string;
220
+ }): Promise<Operation>;
214
221
  getProjectStatus(projectId: string): Promise<ProjectStatus>;
215
222
  writeProjectDatabase(
216
223
  input: ProjectDatabaseWriteRequest & { projectId: string; idempotencyKey: string },
@@ -585,6 +592,23 @@ export class GeneratedSdkProductApi implements ProductApi {
585
592
  return listProjects(input, { client: this.#client });
586
593
  }
587
594
 
595
+ public async changeProjectHandle(input: {
596
+ readonly projectId: string;
597
+ readonly handle: string;
598
+ readonly ifMatch: string;
599
+ readonly idempotencyKey: string;
600
+ }): Promise<Operation> {
601
+ return changeProjectHandle(
602
+ {
603
+ project_id: input.projectId,
604
+ "Idempotency-Key": input.idempotencyKey,
605
+ "If-Match": input.ifMatch,
606
+ handle: input.handle,
607
+ },
608
+ { client: this.#client },
609
+ );
610
+ }
611
+
588
612
  public async checkProjectHandle(input: {
589
613
  readonly handle: string;
590
614
  }): Promise<ProjectHandleAvailability> {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { lstat, open, readFile, readdir, stat } from "node:fs/promises";
3
- import { posix, resolve } from "node:path";
3
+ import { dirname, posix, resolve } from "node:path";
4
4
 
5
5
  import {
6
6
  normalizeApplicationRoot,
@@ -876,6 +876,30 @@ async function assertRepositoryRoot(root: string): Promise<void> {
876
876
  if (!information.isDirectory()) {
877
877
  throw new RepositoryInitError("repository_not_directory", "Repository path is not a directory");
878
878
  }
879
+ // A Git worktree uses a .git file; never read or follow that file's target.
880
+ for (let ancestor = root; ; ancestor = dirname(ancestor)) {
881
+ let marker;
882
+ try {
883
+ marker = await lstat(resolve(ancestor, ".git"));
884
+ } catch (error) {
885
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
886
+ throw new RepositoryInitError(
887
+ "repository_unreadable",
888
+ "Could not determine the Git repository root",
889
+ );
890
+ }
891
+ }
892
+ if (marker !== undefined) {
893
+ if (ancestor !== root) {
894
+ throw new RepositoryInitError(
895
+ "repository_root_required",
896
+ "Run ohmyhost init from the Git repository root and select the application directory with --root; ohmyhost.yaml must be at the repository root.",
897
+ );
898
+ }
899
+ return;
900
+ }
901
+ if (dirname(ancestor) === ancestor) return;
902
+ }
879
903
  }
880
904
 
881
905
  async function collectRepositoryFiles(root: string): Promise<string[]> {
@@ -129,6 +129,29 @@ const PROBLEM_POLICY = {
129
129
  retryable: true,
130
130
  action: "Retry project creation with the same idempotency key.",
131
131
  },
132
+ project_handle_taken: {
133
+ status: 409,
134
+ retryable: false,
135
+ action:
136
+ "Run 'ohmyhost project handle check' for a free address, then rename to one of the alternatives it returns.",
137
+ },
138
+ project_handle_invalid: {
139
+ status: 409,
140
+ retryable: false,
141
+ action:
142
+ "Choose one to five lowercase words of letters and digits, three to fifty-nine characters, avoiding reserved words.",
143
+ },
144
+ project_handle_unchanged: {
145
+ status: 409,
146
+ retryable: false,
147
+ action: "The project already answers on this address; no rename is needed.",
148
+ },
149
+ project_rename_blocked: {
150
+ status: 409,
151
+ retryable: true,
152
+ action:
153
+ "Wait for the running operation to finish, read the project again, then rename with the new ETag.",
154
+ },
132
155
  project_identity_unavailable: {
133
156
  status: 503,
134
157
  retryable: true,
@@ -196,6 +219,12 @@ const PROBLEM_POLICY = {
196
219
  action:
197
220
  "Run 'ohmyhost init', apply the matching ohmyhost framework skill, then plan the new commit.",
198
221
  },
222
+ repository_configuration_missing: {
223
+ status: 409,
224
+ retryable: false,
225
+ action:
226
+ "Place ohmyhost.yaml at the Git repository root and set applicationRoot to the application directory (for example website). Commit and push the change, then plan the new commit.",
227
+ },
199
228
  migration_filename_noncanonical: {
200
229
  status: 409,
201
230
  retryable: false,
@@ -278,6 +307,7 @@ export class ResponseContractError extends Error {
278
307
  }
279
308
 
280
309
  export interface SafeProblem {
310
+ readonly requestId: string;
281
311
  readonly retryAfterSeconds?: number;
282
312
  readonly code: ProblemCode;
283
313
  readonly retryable: boolean;
@@ -676,9 +706,9 @@ export const parseDevAccessTicket = (value: unknown): PublicDevAccessTicket => {
676
706
  !isUlid(input["project_id"]) ||
677
707
  !isUlid(input["environment_id"]) ||
678
708
  typeof origin !== "string" ||
679
- !/^https:\/\/dev-[a-z]+-[a-z]+-[a-z]+\.(?:dev\.)?check\.omh\.st$/u.test(origin) ||
709
+ !/^https:\/\/dev-[a-z0-9]+(?:-[a-z0-9]+){0,4}\.(?:dev\.)?check\.omh\.st$/u.test(origin) ||
680
710
  typeof redeemUrl !== "string" ||
681
- !/^https:\/\/dev-[a-z]+-[a-z]+-[a-z]+\.(?:dev\.)?check\.omh\.st\/\.ohmyhost\/dev-access\/redeem\?stage=dev&ticket=[A-Za-z0-9_-]{43}$/u.test(
711
+ !/^https:\/\/dev-[a-z0-9]+(?:-[a-z0-9]+){0,4}\.(?:dev\.)?check\.omh\.st\/\.ohmyhost\/dev-access\/redeem\?stage=dev&ticket=[A-Za-z0-9_-]{43}$/u.test(
682
712
  redeemUrl,
683
713
  ) ||
684
714
  new URL(redeemUrl).origin !== origin ||
@@ -880,8 +910,8 @@ const parseProject = (value: unknown): PublicProject => {
880
910
  !isUlid(input["organization_id"]) ||
881
911
  !boundedString(input["name"], 1, 128) ||
882
912
  input["name"] !== input["name"].trim() ||
883
- !boundedString(input["handle"], 5, 59) ||
884
- !/^[a-z]+-[a-z]+-[a-z]+$/u.test(input["handle"]) ||
913
+ !boundedString(input["handle"], 3, 59) ||
914
+ !/^[a-z0-9]+(?:-[a-z0-9]+){0,4}$/u.test(input["handle"]) ||
885
915
  (input["data_mode"] !== "shared" && input["data_mode"] !== "isolated") ||
886
916
  (input["region"] !== "us" && input["region"] !== "eu") ||
887
917
  !isIsoInstant(input["created_at"])
@@ -910,6 +940,7 @@ export const parseProjectPage = (
910
940
  export const parseProjectStatus = (value: unknown): Readonly<Record<string, unknown>> => {
911
941
  const input = exactRecord(value, [
912
942
  "project_id",
943
+ "etag",
913
944
  "handle",
914
945
  "region",
915
946
  "lifecycle",
@@ -925,8 +956,10 @@ export const parseProjectStatus = (value: unknown): Readonly<Record<string, unkn
925
956
  const handle = input["handle"];
926
957
  if (
927
958
  !isUlid(input["project_id"]) ||
928
- !boundedString(handle, 5, 59) ||
929
- !/^[a-z]+-[a-z]+-[a-z]+$/u.test(handle) ||
959
+ typeof input["etag"] !== "string" ||
960
+ !/^"sha256-[a-f0-9]{64}"$/u.test(input["etag"]) ||
961
+ !boundedString(handle, 3, 59) ||
962
+ !/^[a-z0-9]+(?:-[a-z0-9]+){0,4}$/u.test(handle) ||
930
963
  (input["region"] !== "us" && input["region"] !== "eu") ||
931
964
  (input["lifecycle"] !== "active" &&
932
965
  input["lifecycle"] !== "deleting" &&
@@ -1540,6 +1573,7 @@ export const parseSafeProblem = (value: unknown): SafeProblem => {
1540
1573
  !isHttpsUrl(input["type"]) ||
1541
1574
  !boundedString(input["title"], 1, 160) ||
1542
1575
  !boundedString(input["request_id"], 1, 128) ||
1576
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(input["request_id"]) ||
1543
1577
  typeof input["retryable"] !== "boolean" ||
1544
1578
  !boundedString(input["suggested_action"], 1, 500) ||
1545
1579
  (input["detail"] !== undefined && !boundedString(input["detail"], 1, 2_000)) ||
@@ -1558,6 +1592,7 @@ export const parseSafeProblem = (value: unknown): SafeProblem => {
1558
1592
  )
1559
1593
  throw new ResponseContractError();
1560
1594
  return {
1595
+ requestId: input["request_id"],
1561
1596
  code: code as ProblemCode,
1562
1597
  retryable: policy.retryable,
1563
1598
  suggestedAction: