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