From 61d4d96a67e626b908581ba06478bf8b02b85260 Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Sat, 1 Aug 2026 18:07:05 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(redirect):=20add=20update=20co?= =?UTF-8?q?mmand=20and=20normalise=20FQDN=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add redirect update capability that allows modifying redirects in place without recreating them (preserving certificates). Introduce toRedirectHost() normalisation to handle bare labels, apex notation, and idempotent FQDN qualification throughout the redirect command surface. Update README with examples. --- README.md | 3 +- src/commands/redirect-add.tsx | 6 +-- src/commands/redirect-delete.tsx | 4 +- src/commands/redirect-list.tsx | 2 +- src/commands/redirect-update.tsx | 57 +++++++++++++++++++++ src/index.tsx | 42 ++++++++++++++++ src/lib/api.test.ts | 86 ++++++++++++++++++++++++++++++++ src/lib/api.ts | 36 +++++++++++-- src/types/gandi.ts | 10 ++++ 9 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 src/commands/redirect-update.tsx 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