Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/commands/redirect-add.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -37,9 +37,7 @@ const RedirectAdd = ({ domain, host, target, type }: RedirectAddProps) => {
<Box>
<Text color="green">βœ” </Text>
<Text>
<Text bold>
{host || "@"}.{domain}
</Text>{" "}
<Text bold>{toRedirectHost(domain, host)}</Text>{" "}
<Text color="cyan">{type}</Text> β†’ {target}
</Text>
</Box>
Expand Down
4 changes: 2 additions & 2 deletions src/commands/redirect-delete.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -11,7 +11,7 @@ interface RedirectDeleteProps {
}

const RedirectDelete = ({ domain, host, yes }: RedirectDeleteProps) => {
const source = `${host || "@"}.${domain}`
const source = toRedirectHost(domain, host)
return (
<DangerousAction
yes={yes}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/redirect-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const RedirectList = ({ domain }: { domain: string }) => {
return <Text color="gray">No web redirects found.</Text>

const rows = redirects.map((r) => ({
SOURCE: `${r.host || "@"}.${domain}`,
SOURCE: r.host,
TYPE: r.type,
TARGET: r.url ?? "β€”",
}))
Expand Down
57 changes: 57 additions & 0 deletions src/commands/redirect-update.tsx
Original file line number Diff line number Diff line change
@@ -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<Error | null>(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 <CommandError error={error} />
if (!done) return <SpinnerAction label={`Updating redirect ${host}…`} />

const flags = describeFlags(patch)

return (
<Box>
<Text color="green">βœ” </Text>
<Text>
<Text bold>{host}</Text>
{patch.url ? ` β†’ ${patch.url}` : ""}
{flags.length > 0 ? ` (${flags.join(", ")})` : ""}
</Text>
</Box>
)
}

export default RedirectUpdate
42 changes: 42 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`,
Expand Down Expand Up @@ -312,6 +316,44 @@ redirect
),
)

redirect
.command("update <domain> <source> [target]")
.description("Update a web redirect in place (its source host cannot change)")
.option("-t, --type <type>", "Redirect type: http301, http302, or cloak")
.option("-p, --protocol <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 }
},
() => <RedirectUpdate domain={d} host={host} patch={patch} />,
)
},
)

redirect
.command("delete <domain> <source>")
.description("Delete a web redirect")
Expand Down
86 changes: 86 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
setDnsRecord,
exportZone,
checkDomain,
toRedirectHost,
addRedirect,
updateRedirect,
deleteRedirect,
} from "./api.js"
import { authErrorKind } from "./errors.js"

Expand Down Expand Up @@ -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")
})
})
36 changes: 32 additions & 4 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
DomainCheck,
DnsRecord,
GandiError,
RedirectPatch,
TokenInfo,
WebRedir,
} from "../types/gandi.js"
Expand Down Expand Up @@ -82,6 +83,19 @@ export const listRedirects = (
): Promise<WebRedir[]> =>
request<WebRedir[]>(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,
Expand All @@ -91,17 +105,31 @@ export const addRedirect = (
): Promise<void> =>
request<void>(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<void> =>
request<void>(
apiKey,
`/domain/domains/${domain}/webredirs/${toRedirectHost(domain, host)}`,
{ method: "PATCH", body: JSON.stringify(patch) },
)

export const deleteRedirect = (
apiKey: string,
domain: string,
host: string,
): Promise<void> =>
request<void>(apiKey, `/domain/domains/${domain}/webredirs/${host}`, {
method: "DELETE",
})
request<void>(
apiKey,
`/domain/domains/${domain}/webredirs/${toRedirectHost(domain, host)}`,
{ method: "DELETE" },
)

export const listDnsRecords = (
apiKey: string,
Expand Down
10 changes: 10 additions & 0 deletions src/types/gandi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down