@amerged/ohmyhost-cli 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @amerged/ohmyhost-cli
2
2
 
3
- ohmyho.st cli client, version 0.1.11.
3
+ ohmyho.st cli client, version 0.1.13.
4
4
 
5
5
  ```sh
6
6
  npm install --global @amerged/ohmyhost-cli
package/dist/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire as __ohmyhostCreateRequire } from "node:module"; const require = __ohmyhostCreateRequire(import.meta.url);
3
3
  import {
4
4
  runProductionCli
5
- } from "./chunk-WT2RRQUB.js";
5
+ } from "./chunk-ZWAAQEOZ.js";
6
6
  import "./chunk-4HFQ54FP.js";
7
7
 
8
8
  // apps/product-cli/dist/bin.js
@@ -15002,6 +15002,8 @@ var recognizedCommandLabel = (argv) => {
15002
15002
  return "project status";
15003
15003
  if (value === "project" && values[0] === "handle" && values[1] === "check")
15004
15004
  return "project handle check";
15005
+ if (value === "project" && values[0] === "handle" && values[1] === "set")
15006
+ return "project handle set";
15005
15007
  if (value === "project" && values[0] === "dev-access" && values[1] === "create")
15006
15008
  return "project dev-access create";
15007
15009
  if (value === "database" && values[0] === "compute" && values[1] === "set")
@@ -15444,6 +15446,25 @@ var parseProductCliCommand = (argv) => {
15444
15446
  }
15445
15447
  if (first === "project") {
15446
15448
  const subcommand = values.shift();
15449
+ if (subcommand === "handle" && values[0] === "set") {
15450
+ values.shift();
15451
+ if (yes || wait || follow || stdin)
15452
+ throw new InvalidCommandError();
15453
+ const options2 = parseOptions(values, [
15454
+ "--project",
15455
+ "--handle",
15456
+ "--if-match",
15457
+ "--idempotency-key"
15458
+ ]);
15459
+ return {
15460
+ kind: "project-handle-set",
15461
+ projectId: options2["--project"],
15462
+ handle: options2["--handle"],
15463
+ ifMatch: options2["--if-match"],
15464
+ idempotencyKey: options2["--idempotency-key"],
15465
+ credentialStore
15466
+ };
15467
+ }
15447
15468
  if (subcommand === "handle" && values.shift() === "check") {
15448
15469
  if (yes || wait || follow || stdin)
15449
15470
  throw new InvalidCommandError();
@@ -16751,6 +16772,26 @@ var PROBLEM_POLICY = {
16751
16772
  retryable: true,
16752
16773
  action: "Retry project creation with the same idempotency key."
16753
16774
  },
16775
+ project_handle_taken: {
16776
+ status: 409,
16777
+ retryable: false,
16778
+ action: "Run 'ohmyhost project handle check' for a free address, then rename to one of the alternatives it returns."
16779
+ },
16780
+ project_handle_invalid: {
16781
+ status: 409,
16782
+ retryable: false,
16783
+ action: "Choose one to five lowercase words of letters and digits, three to fifty-nine characters, avoiding reserved words."
16784
+ },
16785
+ project_handle_unchanged: {
16786
+ status: 409,
16787
+ retryable: false,
16788
+ action: "The project already answers on this address; no rename is needed."
16789
+ },
16790
+ project_rename_blocked: {
16791
+ status: 409,
16792
+ retryable: true,
16793
+ action: "Wait for the running operation to finish, read the project again, then rename with the new ETag."
16794
+ },
16754
16795
  project_identity_unavailable: {
16755
16796
  status: 503,
16756
16797
  retryable: true,
@@ -16816,6 +16857,11 @@ var PROBLEM_POLICY = {
16816
16857
  retryable: false,
16817
16858
  action: "Run 'ohmyhost init', apply the matching ohmyhost framework skill, then plan the new commit."
16818
16859
  },
16860
+ repository_configuration_missing: {
16861
+ status: 409,
16862
+ retryable: false,
16863
+ action: "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."
16864
+ },
16819
16865
  migration_filename_noncanonical: {
16820
16866
  status: 409,
16821
16867
  retryable: false,
@@ -17074,7 +17120,7 @@ var parseDevAccessTicket = (value) => {
17074
17120
  ]);
17075
17121
  const origin = input["origin"];
17076
17122
  const redeemUrl = input["redeem_url"];
17077
- if (!isUlid(input["ticket_id"]) || !isUlid(input["project_id"]) || !isUlid(input["environment_id"]) || typeof origin !== "string" || !/^https:\/\/dev-[a-z]+-[a-z]+-[a-z]+\.(?:dev\.)?check\.omh\.st$/u.test(origin) || typeof redeemUrl !== "string" || !/^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(redeemUrl) || new URL(redeemUrl).origin !== origin || !isIsoInstant(input["expires_at"])) {
17123
+ if (!isUlid(input["ticket_id"]) || !isUlid(input["project_id"]) || !isUlid(input["environment_id"]) || typeof origin !== "string" || !/^https:\/\/dev-[a-z0-9]+(?:-[a-z0-9]+){0,4}\.(?:dev\.)?check\.omh\.st$/u.test(origin) || typeof redeemUrl !== "string" || !/^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(redeemUrl) || new URL(redeemUrl).origin !== origin || !isIsoInstant(input["expires_at"])) {
17078
17124
  throw new ResponseContractError();
17079
17125
  }
17080
17126
  return input;
@@ -17197,7 +17243,7 @@ var parseProject = (value) => {
17197
17243
  "region",
17198
17244
  "created_at"
17199
17245
  ]);
17200
- if (!isUlid(input["id"]) || !isUlid(input["organization_id"]) || !boundedString(input["name"], 1, 128) || input["name"] !== input["name"].trim() || !boundedString(input["handle"], 5, 59) || !/^[a-z]+-[a-z]+-[a-z]+$/u.test(input["handle"]) || input["data_mode"] !== "shared" && input["data_mode"] !== "isolated" || input["region"] !== "us" && input["region"] !== "eu" || !isIsoInstant(input["created_at"])) {
17246
+ if (!isUlid(input["id"]) || !isUlid(input["organization_id"]) || !boundedString(input["name"], 1, 128) || input["name"] !== input["name"].trim() || !boundedString(input["handle"], 3, 59) || !/^[a-z0-9]+(?:-[a-z0-9]+){0,4}$/u.test(input["handle"]) || input["data_mode"] !== "shared" && input["data_mode"] !== "isolated" || input["region"] !== "us" && input["region"] !== "eu" || !isIsoInstant(input["created_at"])) {
17201
17247
  throw new ResponseContractError();
17202
17248
  }
17203
17249
  return input;
@@ -17215,6 +17261,7 @@ var parseProjectPage = (value) => {
17215
17261
  var parseProjectStatus = (value) => {
17216
17262
  const input = exactRecord2(value, [
17217
17263
  "project_id",
17264
+ "etag",
17218
17265
  "handle",
17219
17266
  "region",
17220
17267
  "lifecycle",
@@ -17228,7 +17275,7 @@ var parseProjectStatus = (value) => {
17228
17275
  "cleanup_state"
17229
17276
  ]);
17230
17277
  const handle = input["handle"];
17231
- if (!isUlid(input["project_id"]) || !boundedString(handle, 5, 59) || !/^[a-z]+-[a-z]+-[a-z]+$/u.test(handle) || input["region"] !== "us" && input["region"] !== "eu" || input["lifecycle"] !== "active" && input["lifecycle"] !== "deleting" && input["lifecycle"] !== "deleted" || input["dev_url"] !== null && ![`https://dev-${handle}.dev.check.omh.st`, `https://dev-${handle}.check.omh.st`].includes(String(input["dev_url"])) || input["prod_url"] !== null && ![`https://${handle}.dev.check.omh.st`, `https://${handle}.check.omh.st`].includes(String(input["prod_url"])) || input["cleanup_state"] !== "not_started" && input["cleanup_state"] !== "pending" && input["cleanup_state"] !== "completed" && input["cleanup_state"] !== "reconciliation_required") {
17278
+ if (!isUlid(input["project_id"]) || typeof input["etag"] !== "string" || !/^"sha256-[a-f0-9]{64}"$/u.test(input["etag"]) || !boundedString(handle, 3, 59) || !/^[a-z0-9]+(?:-[a-z0-9]+){0,4}$/u.test(handle) || input["region"] !== "us" && input["region"] !== "eu" || input["lifecycle"] !== "active" && input["lifecycle"] !== "deleting" && input["lifecycle"] !== "deleted" || input["dev_url"] !== null && ![`https://dev-${handle}.dev.check.omh.st`, `https://dev-${handle}.check.omh.st`].includes(String(input["dev_url"])) || input["prod_url"] !== null && ![`https://${handle}.dev.check.omh.st`, `https://${handle}.check.omh.st`].includes(String(input["prod_url"])) || input["cleanup_state"] !== "not_started" && input["cleanup_state"] !== "pending" && input["cleanup_state"] !== "completed" && input["cleanup_state"] !== "reconciliation_required") {
17232
17279
  throw new ResponseContractError();
17233
17280
  }
17234
17281
  if (input["source"] !== null)
@@ -17538,13 +17585,14 @@ var parseSafeProblem = (value) => {
17538
17585
  if (typeof code !== "string" || !(code in PROBLEM_POLICY))
17539
17586
  throw new ResponseContractError();
17540
17587
  const policy = PROBLEM_POLICY[code];
17541
- if (input["status"] !== policy.status || !boundedString(input["type"], 1, 512) || !isHttpsUrl(input["type"]) || !boundedString(input["title"], 1, 160) || !boundedString(input["request_id"], 1, 128) || typeof input["retryable"] !== "boolean" || !boundedString(input["suggested_action"], 1, 500) || input["detail"] !== void 0 && !boundedString(input["detail"], 1, 2e3) || input["instance"] !== void 0 && !boundedString(input["instance"], 1, 512)) {
17588
+ if (input["status"] !== policy.status || !boundedString(input["type"], 1, 512) || !isHttpsUrl(input["type"]) || !boundedString(input["title"], 1, 160) || !boundedString(input["request_id"], 1, 128) || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(input["request_id"]) || typeof input["retryable"] !== "boolean" || !boundedString(input["suggested_action"], 1, 500) || input["detail"] !== void 0 && !boundedString(input["detail"], 1, 2e3) || input["instance"] !== void 0 && !boundedString(input["instance"], 1, 512)) {
17542
17589
  throw new ResponseContractError();
17543
17590
  }
17544
17591
  const retryAfter = input["retry_after_seconds"];
17545
17592
  if (retryAfter !== void 0 && (code !== "rate_limited" || typeof retryAfter !== "number" || !Number.isSafeInteger(retryAfter) || retryAfter < 1 || retryAfter > 86400))
17546
17593
  throw new ResponseContractError();
17547
17594
  return {
17595
+ requestId: input["request_id"],
17548
17596
  code,
17549
17597
  retryable: policy.retryable,
17550
17598
  suggestedAction: (code === "framework_conversion_required" ? parseFrameworkConversionDetail(input["detail"]) ?? policy.action : policy.action) + (retryAfter === void 0 ? "" : ` Retry after ${String(retryAfter)} seconds.`),
@@ -17818,7 +17866,7 @@ function exact2(v, keys) {
17818
17866
  // apps/product-cli/package.json
17819
17867
  var package_default = {
17820
17868
  name: "@ohmyhost/product-cli",
17821
- version: "0.1.11",
17869
+ version: "0.1.13",
17822
17870
  private: true,
17823
17871
  ohmyhost: {
17824
17872
  deployment: "production",
@@ -18182,6 +18230,7 @@ var HELP_USAGE = Object.freeze({
18182
18230
  "project status": "ohmyhost project status --project ULID --json",
18183
18231
  "project dev-access create": "ohmyhost project dev-access create --project ULID --json",
18184
18232
  "project handle check": "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)",
18233
+ "project handle set": "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)",
18185
18234
  "database compute set": "ohmyhost database compute set --project ULID --environment dev|prod --profile standard|performance --idempotency-key KEY --yes [--wait] --json\n 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.",
18186
18235
  "database compute get": "ohmyhost database compute get --project ULID [--environment dev|prod] --json",
18187
18236
  "database write": "ohmyhost database write --project ULID --environment dev|prod --statement-file PATH --idempotency-key KEY [--parameters-json JSON] --yes --json",
@@ -18458,6 +18507,8 @@ var ProductCli = class {
18458
18507
  return await this.listProjects(command, signal);
18459
18508
  if (command.kind === "project-handle-check")
18460
18509
  return await this.checkProjectHandle(command, signal);
18510
+ if (command.kind === "project-handle-set")
18511
+ return await this.changeProjectHandle(command, signal);
18461
18512
  if (command.kind === "database-compute-set") {
18462
18513
  const api = await this.authenticatedApi(command.credentialStore, signal);
18463
18514
  let operation = parseOperation(await api.changeDatabaseCompute(command));
@@ -18797,6 +18848,16 @@ var ProductCli = class {
18797
18848
  }));
18798
18849
  return success("project list", { status: "succeeded", projects });
18799
18850
  }
18851
+ async changeProjectHandle(command, signal) {
18852
+ const api = await this.authenticatedApi(command.credentialStore, signal);
18853
+ const operation = parseOperation(await api.changeProjectHandle({
18854
+ projectId: command.projectId,
18855
+ handle: command.handle,
18856
+ ifMatch: command.ifMatch,
18857
+ idempotencyKey: command.idempotencyKey
18858
+ }));
18859
+ return success("project handle set", { status: "accepted", operation });
18860
+ }
18800
18861
  async checkProjectHandle(command, signal) {
18801
18862
  const api = await this.authenticatedApi(command.credentialStore, signal);
18802
18863
  const handle = await api.checkProjectHandle({ handle: command.handle });
@@ -19306,7 +19367,7 @@ var classifyFailure = (command, error, signal) => {
19306
19367
  if (looksLikeProblem(error)) {
19307
19368
  try {
19308
19369
  const problem = parseSafeProblem(error);
19309
- return failure(command, 7, problem.code, problem.retryable, problem.suggestedAction, void 0, problem.retryAfterSeconds);
19370
+ return failure(command, 7, problem.code, problem.retryable, problem.suggestedAction, void 0, problem.retryAfterSeconds, problem.requestId);
19310
19371
  } catch {
19311
19372
  return failure(command, 7, "response_contract_invalid", false, "Retry only after the client or server contract is repaired.");
19312
19373
  }
@@ -19326,7 +19387,7 @@ var success = (command, body) => ({
19326
19387
  `,
19327
19388
  stderr: ""
19328
19389
  });
19329
- var failure = (command, exitCode, code, retryable, suggestedAction, operation, retryAfterSeconds) => {
19390
+ var failure = (command, exitCode, code, retryable, suggestedAction, operation, retryAfterSeconds, requestId) => {
19330
19391
  const document = {
19331
19392
  version: 1,
19332
19393
  command,
@@ -19337,6 +19398,7 @@ var failure = (command, exitCode, code, retryable, suggestedAction, operation, r
19337
19398
  retryable,
19338
19399
  suggested_action: suggestedAction,
19339
19400
  ...retryAfterSeconds === void 0 ? {} : { retry_after_seconds: retryAfterSeconds },
19401
+ ...requestId === void 0 ? {} : { request_id: requestId },
19340
19402
  ...operation?.error === void 0 ? {} : { message: operation.error.message }
19341
19403
  }
19342
19404
  };
@@ -19408,6 +19470,8 @@ var parsedCommandName = (command) => {
19408
19470
  return "project dev-access create";
19409
19471
  if (command.kind === "project-handle-check")
19410
19472
  return "project handle check";
19473
+ if (command.kind === "project-handle-set")
19474
+ return "project handle set";
19411
19475
  if (command.kind === "database-write")
19412
19476
  return "database write";
19413
19477
  if (command.kind === "database-query")
@@ -21106,6 +21170,31 @@ var getCloudflareDnsAuthorizationStatus = (parameters, options) => {
21106
21170
  ...params
21107
21171
  });
21108
21172
  };
21173
+ var changeProjectHandle = (parameters, options) => {
21174
+ const params = buildClientParams([parameters], [
21175
+ {
21176
+ args: [
21177
+ { in: "headers", key: "X-Request-Id" },
21178
+ { in: "headers", key: "Idempotency-Key" },
21179
+ { in: "headers", key: "If-Match" },
21180
+ { in: "path", key: "project_id" },
21181
+ { in: "body", key: "handle" }
21182
+ ]
21183
+ }
21184
+ ]);
21185
+ return (options?.client ?? client).put({
21186
+ responseStyle: "data",
21187
+ security: [{ scheme: "bearer", type: "http" }],
21188
+ url: "/v1/projects/{project_id}/handle",
21189
+ ...options,
21190
+ ...params,
21191
+ headers: {
21192
+ "Content-Type": "application/json",
21193
+ ...options?.headers,
21194
+ ...params.headers
21195
+ }
21196
+ });
21197
+ };
21109
21198
  var configureProjectDomains = (parameters, options) => {
21110
21199
  const params = buildClientParams([parameters], [
21111
21200
  {
@@ -21852,6 +21941,14 @@ var GeneratedSdkProductApi = class {
21852
21941
  async listProjects(input) {
21853
21942
  return listProjects(input, { client: this.#client });
21854
21943
  }
21944
+ async changeProjectHandle(input) {
21945
+ return changeProjectHandle({
21946
+ project_id: input.projectId,
21947
+ "Idempotency-Key": input.idempotencyKey,
21948
+ "If-Match": input.ifMatch,
21949
+ handle: input.handle
21950
+ }, { client: this.#client });
21951
+ }
21855
21952
  async checkProjectHandle(input) {
21856
21953
  return getProjectHandleAvailability({ handle: input.handle }, { client: this.#client });
21857
21954
  }
@@ -22116,7 +22213,7 @@ var OwnerOnlyProjectLinkStore = class {
22116
22213
  // apps/product-cli/dist/repository-init.js
22117
22214
  import { createHash } from "node:crypto";
22118
22215
  import { lstat as lstat2, open as open3, readFile, readdir, stat } from "node:fs/promises";
22119
- import { posix, resolve as resolve2 } from "node:path";
22216
+ import { dirname as dirname2, posix, resolve as resolve2 } from "node:path";
22120
22217
 
22121
22218
  // packages/contracts/dist/application-root.js
22122
22219
  var APPLICATION_ROOT_PATTERN = /^(?:\.|[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*)$/u;
@@ -23363,6 +23460,24 @@ async function assertRepositoryRoot(root) {
23363
23460
  if (!information.isDirectory()) {
23364
23461
  throw new RepositoryInitError("repository_not_directory", "Repository path is not a directory");
23365
23462
  }
23463
+ for (let ancestor = root; ; ancestor = dirname2(ancestor)) {
23464
+ let marker;
23465
+ try {
23466
+ marker = await lstat2(resolve2(ancestor, ".git"));
23467
+ } catch (error) {
23468
+ if (error.code !== "ENOENT") {
23469
+ throw new RepositoryInitError("repository_unreadable", "Could not determine the Git repository root");
23470
+ }
23471
+ }
23472
+ if (marker !== void 0) {
23473
+ if (ancestor !== root) {
23474
+ throw new RepositoryInitError("repository_root_required", "Run ohmyhost init from the Git repository root and select the application directory with --root; ohmyhost.yaml must be at the repository root.");
23475
+ }
23476
+ return;
23477
+ }
23478
+ if (dirname2(ancestor) === ancestor)
23479
+ return;
23480
+ }
23366
23481
  }
23367
23482
  async function collectRepositoryFiles(root) {
23368
23483
  const files = [];
@@ -24340,7 +24455,7 @@ var runOfflineInit = async (command, io, diagnostics) => {
24340
24455
  });
24341
24456
  const code = error instanceof RepositoryInitBlockedError ? "repository_init_blocked" : error instanceof RepositoryInitConflictError ? "repository_configuration_exists" : known ? error.code : "internal_error";
24342
24457
  const exitCode = error instanceof RepositoryInitBlockedError ? 9 : known ? 2 : 1;
24343
- const result = errorDocument("init", exitCode, code, false, error instanceof RepositoryInitConflictError ? "Keep the existing ohmyhost.yaml or remove it explicitly before retrying." : error instanceof RepositoryInitError && error.code === "project_name_invalid" ? "Pass a lowercase deployment slug with '--project', then rerun init." : "Run 'ohmyhost init --dry-run --json' and resolve every reported blocker.");
24458
+ const result = errorDocument("init", exitCode, code, false, error instanceof RepositoryInitConflictError ? "Keep the existing ohmyhost.yaml or remove it explicitly before retrying." : error instanceof RepositoryInitError && error.code === "repository_root_required" ? "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." : error instanceof RepositoryInitError && error.code === "project_name_invalid" ? "Pass a lowercase deployment slug with '--project', then rerun init." : "Run 'ohmyhost init --dry-run --json' and resolve every reported blocker.");
24344
24459
  io.writeStdout(result.stdout);
24345
24460
  return result.exitCode;
24346
24461
  }
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  selectWorkspace,
48
48
  selectedWorkspaceId,
49
49
  workspaceOf
50
- } from "./chunk-WT2RRQUB.js";
50
+ } from "./chunk-ZWAAQEOZ.js";
51
51
  import {
52
52
  CredentialStoreIntegrityError,
53
53
  CredentialStoreUnavailableError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerged/ohmyhost-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -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:
@@ -3,6 +3,7 @@
3
3
  export {
4
4
  applyPaidProjectDomain,
5
5
  changeDatabaseCompute,
6
+ changeProjectHandle,
6
7
  completeCloudflareDnsAuthorization,
7
8
  completeGithubSourceAuthorization,
8
9
  configureBillingRecharge,
@@ -101,6 +102,11 @@ export type {
101
102
  ChangeDatabaseComputeErrors,
102
103
  ChangeDatabaseComputeResponse,
103
104
  ChangeDatabaseComputeResponses,
105
+ ChangeProjectHandleData,
106
+ ChangeProjectHandleError,
107
+ ChangeProjectHandleErrors,
108
+ ChangeProjectHandleResponse,
109
+ ChangeProjectHandleResponses,
104
110
  ClientOptions,
105
111
  CloudflareDnsAuthorization,
106
112
  CloudflareDnsAuthorizationStatus,
@@ -15,6 +15,8 @@ import type {
15
15
  ApplyPaidProjectDomainResponses,
16
16
  ChangeDatabaseComputeErrors,
17
17
  ChangeDatabaseComputeResponses,
18
+ ChangeProjectHandleErrors,
19
+ ChangeProjectHandleResponses,
18
20
  CompleteCloudflareDnsAuthorizationErrors,
19
21
  CompleteCloudflareDnsAuthorizationResponses,
20
22
  CompleteGithubSourceAuthorizationErrors,
@@ -2070,6 +2072,54 @@ export const getCloudflareDnsAuthorizationStatus = <ThrowOnError extends boolean
2070
2072
  });
2071
2073
  };
2072
2074
 
2075
+ /**
2076
+ * Move a project to a chosen address
2077
+ *
2078
+ * Starts a durable operation that re-publishes the project's Dev and Prod gateways at the chosen address and stores it only once both serve it. The previous address stops answering and returns to the pool for any project to claim. Ask GET /v1/project-handles/{handle} first; a taken, unusable or unchanged address is refused before any gateway moves, and a project with an operation in flight is refused until it finishes.
2079
+ */
2080
+ export const changeProjectHandle = <ThrowOnError extends boolean = true>(
2081
+ parameters: {
2082
+ "X-Request-Id"?: string;
2083
+ "Idempotency-Key": string;
2084
+ "If-Match": string;
2085
+ project_id: Ulid;
2086
+ handle: string;
2087
+ },
2088
+ options?: Options<never, ThrowOnError>,
2089
+ ): RequestResult<ChangeProjectHandleResponses, ChangeProjectHandleErrors, ThrowOnError, "data"> => {
2090
+ const params = buildClientParams(
2091
+ [parameters],
2092
+ [
2093
+ {
2094
+ args: [
2095
+ { in: "headers", key: "X-Request-Id" },
2096
+ { in: "headers", key: "Idempotency-Key" },
2097
+ { in: "headers", key: "If-Match" },
2098
+ { in: "path", key: "project_id" },
2099
+ { in: "body", key: "handle" },
2100
+ ],
2101
+ },
2102
+ ],
2103
+ );
2104
+ return (options?.client ?? client).put<
2105
+ ChangeProjectHandleResponses,
2106
+ ChangeProjectHandleErrors,
2107
+ ThrowOnError,
2108
+ "data"
2109
+ >({
2110
+ responseStyle: "data",
2111
+ security: [{ scheme: "bearer", type: "http" }],
2112
+ url: "/v1/projects/{project_id}/handle",
2113
+ ...options,
2114
+ ...params,
2115
+ headers: {
2116
+ "Content-Type": "application/json",
2117
+ ...options?.headers,
2118
+ ...params.headers,
2119
+ },
2120
+ });
2121
+ };
2122
+
2073
2123
  /**
2074
2124
  * Apply the server-derived project domains
2075
2125
  *
@@ -865,6 +865,10 @@ export type ProjectHandleAvailability = {
865
865
  };
866
866
 
867
867
  export type ProjectStatus = {
868
+ /**
869
+ * Optimistic-concurrency token for changing this project address; use it as If-Match on the handle endpoint.
870
+ */
871
+ etag: string;
868
872
  project_id: Ulid;
869
873
  handle: string;
870
874
  region: HostingRegion;
@@ -912,9 +916,14 @@ export type ProblemDetails = {
912
916
  | "mail_domain_required"
913
917
  | "storage_jurisdiction_conflict"
914
918
  | "framework_conversion_required"
919
+ | "repository_configuration_missing"
915
920
  | "migration_filename_noncanonical"
916
921
  | "environment_secret_mutation_blocked"
917
922
  | "workers_runtime_incompatible"
923
+ | "project_handle_invalid"
924
+ | "project_handle_taken"
925
+ | "project_handle_unchanged"
926
+ | "project_rename_blocked"
918
927
  | "payload_too_large"
919
928
  | "rate_limited"
920
929
  | "insufficient_organization_credits"
@@ -3812,6 +3821,80 @@ export type GetCloudflareDnsAuthorizationStatusResponses = {
3812
3821
  export type GetCloudflareDnsAuthorizationStatusResponse =
3813
3822
  GetCloudflareDnsAuthorizationStatusResponses[keyof GetCloudflareDnsAuthorizationStatusResponses];
3814
3823
 
3824
+ export type ChangeProjectHandleData = {
3825
+ body: {
3826
+ /**
3827
+ * One to five lowercase words of letters and digits.
3828
+ */
3829
+ handle: string;
3830
+ };
3831
+ headers: {
3832
+ /**
3833
+ * Optional caller-provided correlation identifier.
3834
+ */
3835
+ "X-Request-Id"?: string;
3836
+ /**
3837
+ * Identifies one mutation and its canonical request payload.
3838
+ */
3839
+ "Idempotency-Key": string;
3840
+ /**
3841
+ * Exact ETag returned for the current project or deployment state.
3842
+ */
3843
+ "If-Match": string;
3844
+ };
3845
+ path: {
3846
+ /**
3847
+ * Project identifier.
3848
+ */
3849
+ project_id: Ulid;
3850
+ };
3851
+ query?: never;
3852
+ url: "/v1/projects/{project_id}/handle";
3853
+ };
3854
+
3855
+ export type ChangeProjectHandleErrors = {
3856
+ /**
3857
+ * The request failed.
3858
+ */
3859
+ 400: ProblemDetails;
3860
+ /**
3861
+ * The request failed.
3862
+ */
3863
+ 401: ProblemDetails;
3864
+ /**
3865
+ * The request failed.
3866
+ */
3867
+ 403: ProblemDetails;
3868
+ /**
3869
+ * The resource does not exist or is not visible to the authenticated principal.
3870
+ */
3871
+ 404: ProblemDetails;
3872
+ /**
3873
+ * The idempotency key was already used with a different canonical request.
3874
+ */
3875
+ 409: IdempotencyConflictProblem;
3876
+ /**
3877
+ * The request failed.
3878
+ */
3879
+ 429: ProblemDetails;
3880
+ /**
3881
+ * The request failed.
3882
+ */
3883
+ 503: ProblemDetails;
3884
+ };
3885
+
3886
+ export type ChangeProjectHandleError = ChangeProjectHandleErrors[keyof ChangeProjectHandleErrors];
3887
+
3888
+ export type ChangeProjectHandleResponses = {
3889
+ /**
3890
+ * The mutation was accepted for asynchronous processing.
3891
+ */
3892
+ 202: Operation;
3893
+ };
3894
+
3895
+ export type ChangeProjectHandleResponse =
3896
+ ChangeProjectHandleResponses[keyof ChangeProjectHandleResponses];
3897
+
3815
3898
  export type ConfigureProjectDomainsData = {
3816
3899
  body?: never;
3817
3900
  headers: {