diff --git a/README.md b/README.md
index 346909a..8a74c7e 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
- **Domain management** — list registered domains with expiry dates and statuses, renew them for one or more years, and toggle auto-renew.
- **Full DNS control** — list, create, update, and delete LiveDNS records with custom TTLs and every standard record type.
-- **Web redirects** — manage Gandi web forwarding with 301/302 rules straight from the terminal.
+- **Web redirects** — manage Gandi web forwarding with 301/302 rules straight from the terminal, including in-place updates that keep the existing certificate. Sources may be written as a bare label (`www`) or fully qualified (`www.example.com`).
- **Permission doctor** — `gandi doctor` reports your token's name, expiry, and scopes, and shows which commands each scope unlocks.
- **PAT authentication** — fine-grained, least-privilege Gandi Personal Access Tokens, with graceful guidance when a token is missing or rejected.
- **Script & AI friendly** — add `--json` to any command for structured output, non-zero exit codes on failure, and `--yes` to skip confirmations.
@@ -45,6 +45,7 @@ gandi dns add example.com A www 5.6.7.8
```sh
# Redirects
gandi redirect add example.com www https://example.org
+gandi redirect update example.com www https://example.net --type http302
# Doctor & scripting
gandi doctor
diff --git a/src/commands/redirect-add.tsx b/src/commands/redirect-add.tsx
index 054a2ec..018ef2d 100644
--- a/src/commands/redirect-add.tsx
+++ b/src/commands/redirect-add.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react"
import { Box, Text } from "ink"
-import { addRedirect } from "../lib/api.js"
+import { addRedirect, toRedirectHost } from "../lib/api.js"
import { getApiKey } from "../lib/config.js"
import SpinnerAction from "../components/spinner-action.js"
import CommandError from "../components/command-error.js"
@@ -37,9 +37,7 @@ const RedirectAdd = ({ domain, host, target, type }: RedirectAddProps) => {
✔
-
- {host || "@"}.{domain}
- {" "}
+ {toRedirectHost(domain, host)}{" "}
{type} → {target}
diff --git a/src/commands/redirect-delete.tsx b/src/commands/redirect-delete.tsx
index 9cc77f1..124c82f 100644
--- a/src/commands/redirect-delete.tsx
+++ b/src/commands/redirect-delete.tsx
@@ -1,6 +1,6 @@
import React from "react"
import { Box, Text } from "ink"
-import { deleteRedirect } from "../lib/api.js"
+import { deleteRedirect, toRedirectHost } from "../lib/api.js"
import { getApiKey } from "../lib/config.js"
import DangerousAction from "../components/dangerous-action.js"
@@ -11,7 +11,7 @@ interface RedirectDeleteProps {
}
const RedirectDelete = ({ domain, host, yes }: RedirectDeleteProps) => {
- const source = `${host || "@"}.${domain}`
+ const source = toRedirectHost(domain, host)
return (
{
return No web redirects found.
const rows = redirects.map((r) => ({
- SOURCE: `${r.host || "@"}.${domain}`,
+ SOURCE: r.host,
TYPE: r.type,
TARGET: r.url ?? "—",
}))
diff --git a/src/commands/redirect-update.tsx b/src/commands/redirect-update.tsx
new file mode 100644
index 0000000..1466a42
--- /dev/null
+++ b/src/commands/redirect-update.tsx
@@ -0,0 +1,57 @@
+import React, { useEffect, useState } from "react"
+import { Box, Text } from "ink"
+import { updateRedirect } from "../lib/api.js"
+import { getApiKey } from "../lib/config.js"
+import type { RedirectPatch } from "../types/gandi.js"
+import SpinnerAction from "../components/spinner-action.js"
+import CommandError from "../components/command-error.js"
+import { useExit } from "../hooks/use-exit.js"
+
+interface RedirectUpdateProps {
+ domain: string
+ host: string
+ patch: RedirectPatch
+}
+
+const describeFlags = (patch: RedirectPatch): string[] =>
+ [
+ patch.type && `type ${patch.type}`,
+ patch.protocol && `protocol ${patch.protocol}`,
+ patch.override !== undefined && `override ${patch.override}`,
+ ].filter((flag): flag is string => typeof flag === "string")
+
+const RedirectUpdate = ({ domain, host, patch }: RedirectUpdateProps) => {
+ const [done, setDone] = useState(false)
+ const [error, setError] = useState(null)
+ useExit(done)
+
+ useEffect(() => {
+ const run = async () => {
+ try {
+ await updateRedirect(getApiKey(), domain, host, patch)
+ setDone(true)
+ } catch (e) {
+ setError(e as Error)
+ }
+ }
+ run()
+ }, [])
+
+ if (error) return
+ if (!done) return
+
+ const flags = describeFlags(patch)
+
+ return (
+
+ ✔
+
+ {host}
+ {patch.url ? ` → ${patch.url}` : ""}
+ {flags.length > 0 ? ` (${flags.join(", ")})` : ""}
+
+
+ )
+}
+
+export default RedirectUpdate
diff --git a/src/index.tsx b/src/index.tsx
index cf3a19e..d8c5f65 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -17,6 +17,7 @@ import DnsGet from "./commands/dns-get.js"
import DnsDelete from "./commands/dns-delete.js"
import RedirectList from "./commands/redirect-list.js"
import RedirectAdd from "./commands/redirect-add.js"
+import RedirectUpdate from "./commands/redirect-update.js"
import RedirectDelete from "./commands/redirect-delete.js"
import CommandError from "./components/command-error.js"
import {
@@ -33,9 +34,12 @@ import {
exportZone,
listRedirects,
addRedirect,
+ updateRedirect,
deleteRedirect,
+ toRedirectHost,
getTokenInfo,
} from "./lib/api.js"
+import type { RedirectPatch } from "./types/gandi.js"
import { getApiKey } from "./lib/config.js"
// Exit cleanly when a downstream reader closes the pipe early (e.g. `| head`,
@@ -312,6 +316,44 @@ redirect
),
)
+redirect
+ .command("update [target]")
+ .description("Update a web redirect in place (its source host cannot change)")
+ .option("-t, --type ", "Redirect type: http301, http302, or cloak")
+ .option("-p, --protocol ", "Protocol: http, https, or httpsonly")
+ .option("--override", "Overwrite a conflicting DNS record")
+ .option(
+ "--no-override",
+ "Error rather than overwrite a conflicting DNS record",
+ )
+ .action(
+ (
+ d: string,
+ source: string,
+ target: string | undefined,
+ opts: { type?: string; protocol?: string; override?: boolean },
+ ) => {
+ const patch: RedirectPatch = {
+ ...(target !== undefined && { url: target }),
+ ...(opts.type !== undefined && { type: opts.type }),
+ ...(opts.protocol !== undefined && { protocol: opts.protocol }),
+ ...(opts.override !== undefined && { override: opts.override }),
+ }
+ const host = toRedirectHost(d, source)
+ execute(
+ async () => {
+ if (Object.keys(patch).length === 0)
+ throw new Error(
+ "Nothing to update — pass a target URL, --type, --protocol, or --override",
+ )
+ await updateRedirect(getApiKey(), d, source, patch)
+ return { ok: true, host, ...patch }
+ },
+ () => ,
+ )
+ },
+ )
+
redirect
.command("delete ")
.description("Delete a web redirect")
diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts
index 292f189..f2ef3fa 100644
--- a/src/lib/api.test.ts
+++ b/src/lib/api.test.ts
@@ -7,6 +7,10 @@ import {
setDnsRecord,
exportZone,
checkDomain,
+ toRedirectHost,
+ addRedirect,
+ updateRedirect,
+ deleteRedirect,
} from "./api.js"
import { authErrorKind } from "./errors.js"
@@ -155,3 +159,85 @@ describe("checkDomain", () => {
expect(fetchMock.mock.calls[0][0]).toContain("name=ex%20ample.com")
})
})
+
+describe("toRedirectHost", () => {
+ it("qualifies a bare label with the domain", () => {
+ expect(toRedirectHost("ex.com", "www")).toBe("www.ex.com")
+ })
+
+ it("leaves an already-qualified host untouched", () => {
+ expect(toRedirectHost("ex.com", "www.ex.com")).toBe("www.ex.com")
+ })
+
+ it("qualifies a multi-level label", () => {
+ expect(toRedirectHost("ex.com", "a.b")).toBe("a.b.ex.com")
+ })
+
+ it("treats an empty host and @ as the apex", () => {
+ expect(toRedirectHost("ex.com", "")).toBe("ex.com")
+ expect(toRedirectHost("ex.com", "@")).toBe("ex.com")
+ expect(toRedirectHost("ex.com", "ex.com")).toBe("ex.com")
+ })
+
+ it("is idempotent, so normalising twice is safe", () => {
+ const once = toRedirectHost("ex.com", "www")
+ expect(toRedirectHost("ex.com", once)).toBe(once)
+ })
+
+ it("does not mistake a domain that merely ends in the same letters", () => {
+ expect(toRedirectHost("ex.com", "www.notex.com")).toBe(
+ "www.notex.com.ex.com",
+ )
+ })
+})
+
+describe("addRedirect", () => {
+ it("POSTs a fully-qualified host even when given a bare label", async () => {
+ fetchMock.mockResolvedValue(res(201, { message: "ok" }))
+ await addRedirect("k", "ex.com", "www", "https://ex.org", "http301")
+ const [url, opts] = fetchMock.mock.calls[0]
+ expect(url).toContain("/domain/domains/ex.com/webredirs")
+ expect(opts.method).toBe("POST")
+ expect(JSON.parse(opts.body)).toEqual({
+ host: "www.ex.com",
+ url: "https://ex.org",
+ type: "http301",
+ })
+ })
+})
+
+describe("updateRedirect", () => {
+ it("PATCHes the fully-qualified host path", async () => {
+ fetchMock.mockResolvedValue(res(200, { message: "ok" }))
+ await updateRedirect("k", "ex.com", "www", { url: "https://ex.net" })
+ const [url, opts] = fetchMock.mock.calls[0]
+ expect(url).toContain("/domain/domains/ex.com/webredirs/www.ex.com")
+ expect(opts.method).toBe("PATCH")
+ })
+
+ it("sends only the supplied fields, so untouched ones are left alone", async () => {
+ fetchMock.mockResolvedValue(res(200, { message: "ok" }))
+ await updateRedirect("k", "ex.com", "www", { type: "http302" })
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
+ type: "http302",
+ })
+ })
+
+ it("distinguishes override:false from an omitted override", async () => {
+ fetchMock.mockResolvedValue(res(200, { message: "ok" }))
+ await updateRedirect("k", "ex.com", "www", { override: false })
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
+ override: false,
+ })
+ })
+})
+
+describe("deleteRedirect", () => {
+ it("DELETEs the fully-qualified host path, not the bare label", async () => {
+ fetchMock.mockResolvedValue(res(204, undefined))
+ await deleteRedirect("k", "ex.com", "www")
+ const [url, opts] = fetchMock.mock.calls[0]
+ expect(url).toMatch(/\/domain\/domains\/ex\.com\/webredirs\/www\.ex\.com$/)
+ expect(opts.method).toBe("DELETE")
+ })
+})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 82558a6..8b84125 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -3,6 +3,7 @@ import type {
DomainCheck,
DnsRecord,
GandiError,
+ RedirectPatch,
TokenInfo,
WebRedir,
} from "../types/gandi.js"
@@ -82,6 +83,19 @@ export const listRedirects = (
): Promise =>
request(apiKey, `/domain/domains/${domain}/webredirs`)
+// Gandi identifies a web redirect by its fully-qualified source host, in the
+// list response, in the {host} path segment and in the POST body alike — the
+// bare label 400s. Earlier versions assumed the label everywhere, which made
+// `redirect list` print `www.example.com.example.com` and sent `delete` to a
+// path the API rejects. Both spellings are accepted here and normalised to the
+// FQDN, so the fix does not break anyone's existing scripts.
+export const toRedirectHost = (domain: string, host: string): string =>
+ !host || host === "@"
+ ? domain
+ : host === domain || host.endsWith(`.${domain}`)
+ ? host
+ : `${host}.${domain}`
+
export const addRedirect = (
apiKey: string,
domain: string,
@@ -91,17 +105,31 @@ export const addRedirect = (
): Promise =>
request(apiKey, `/domain/domains/${domain}/webredirs`, {
method: "POST",
- body: JSON.stringify({ host, url, type }),
+ body: JSON.stringify({ host: toRedirectHost(domain, host), url, type }),
})
+export const updateRedirect = (
+ apiKey: string,
+ domain: string,
+ host: string,
+ patch: RedirectPatch,
+): Promise =>
+ request(
+ apiKey,
+ `/domain/domains/${domain}/webredirs/${toRedirectHost(domain, host)}`,
+ { method: "PATCH", body: JSON.stringify(patch) },
+ )
+
export const deleteRedirect = (
apiKey: string,
domain: string,
host: string,
): Promise =>
- request(apiKey, `/domain/domains/${domain}/webredirs/${host}`, {
- method: "DELETE",
- })
+ request(
+ apiKey,
+ `/domain/domains/${domain}/webredirs/${toRedirectHost(domain, host)}`,
+ { method: "DELETE" },
+ )
export const listDnsRecords = (
apiKey: string,
diff --git a/src/types/gandi.ts b/src/types/gandi.ts
index 8365517..a805236 100644
--- a/src/types/gandi.ts
+++ b/src/types/gandi.ts
@@ -46,6 +46,16 @@ export interface WebRedir {
override?: boolean
}
+// The PATCH body for a web redirect. Every field is optional and omitted keys
+// are left untouched, so an absent `override` is distinct from `override:false`.
+// `host` is deliberately absent: Gandi cannot move a redirect to another source.
+export interface RedirectPatch {
+ url?: string
+ type?: string
+ protocol?: string
+ override?: boolean
+}
+
export interface DnsRecord {
rrset_name: string
rrset_type: string