From 73e1073bd6e5d29583e6f5ad5605688c80367763 Mon Sep 17 00:00:00 2001 From: Patrick Posner Date: Mon, 15 Jun 2026 14:39:01 +0200 Subject: [PATCH] Add DB-backed redirect management commands --- README.md | 3 + docs/command-reference.md | 46 ++++++- docs/input-formats-and-limits.md | 11 ++ docs/workflows.md | 16 ++- package-lock.json | 4 +- package.json | 2 +- src/cli.ts | 80 +++++++++++- src/redirects.test.ts | 207 ++++++++++++++++++++++++++++++- src/redirects.ts | 155 ++++++++++++++++++++++- src/test-utils.ts | 14 ++- 10 files changed, 518 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f1e5c59..1418740 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ static-studio backups create static-studio backups restore --backup-id static-studio redirects create /old /new +static-studio redirects update --from-path /old --to-path /new +static-studio redirects disable +static-studio redirects enable static-studio redirects bulk-create redirects.json static-studio users list diff --git a/docs/command-reference.md b/docs/command-reference.md index f2154bd..d9087f4 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -475,6 +475,8 @@ List redirect rules. static-studio redirects list [--pull-zone-id ] ``` +Use DB-backed redirect IDs from the `dbRedirects` response for update, enable, disable, and DB delete operations. + ### `redirects create` Create a 301 redirect. @@ -485,15 +487,53 @@ static-studio redirects create [--pull-zone-id By default, the pull zone and domain are resolved from the site. +### `redirects update` + +Update a DB-backed redirect. `redirects edit` is an alias. + +```bash +static-studio redirects update [--from-path ] [--to-path ] [--active|--inactive] +``` + +At least one field is required. Default redirects can only be enabled or disabled. + +### `redirects enable` + +Enable a DB-backed redirect. + +```bash +static-studio redirects enable +``` + +### `redirects disable` + +Disable a DB-backed redirect. + +```bash +static-studio redirects disable +``` + ### `redirects delete` -Disable a redirect edge rule. +Delete a DB-backed redirect, or disable a legacy CDN edge rule. + +```bash +static-studio redirects delete [--db] [--edge-rule] [--pull-zone-id ] +``` + +By default, the CLI treats the ID as a DB redirect ID and falls back to a legacy edge rule when the DB redirect is not found. Use `--edge-rule` to force the legacy CDN path. + +After deleting or disabling the rule, the CLI refreshes edge rules for the site. + +### `redirects refresh` + +Refresh redirect edge rules from stored redirects. ```bash -static-studio redirects delete [--pull-zone-id ] +static-studio redirects refresh [--skip-import-existing] ``` -After disabling the rule, the CLI refreshes edge rules for the site. +By default, existing Studio-owned CDN redirects are imported before the refresh so older redirects can be managed through DB-backed commands. Use `--skip-import-existing` after out-of-band cleanup when you do not want CDN rules imported. ### `redirects bulk-create` diff --git a/docs/input-formats-and-limits.md b/docs/input-formats-and-limits.md index ca6dd57..a8ae967 100644 --- a/docs/input-formats-and-limits.md +++ b/docs/input-formats-and-limits.md @@ -88,6 +88,17 @@ Rules: By default, the CLI resolves `pullZoneId` and redirect domain from the site. Use `--pull-zone-id` or `--domain` to override them. +## Redirect Updates + +`redirects update` accepts `--from-path`, `--to-path`, `--active`, and `--inactive`. + +Rules: + +- At least one update field must be provided. +- `--active` and `--inactive` are mutually exclusive. +- `--from-path` and `--to-path` are trimmed, cannot be empty, and must be 2048 characters or fewer. +- Default redirects can only be enabled or disabled. + ## Team Email Files `team invite --file ` and `team bulk-invite ` accept files up to 64 KiB. diff --git a/docs/workflows.md b/docs/workflows.md index fa1252f..ffaa88f 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -129,7 +129,21 @@ List and remove redirect rules: ```bash static-studio redirects list -static-studio redirects delete +static-studio redirects delete +``` + +Edit and toggle DB-backed redirects: + +```bash +static-studio redirects update --from-path /old-page --to-path /new-page +static-studio redirects disable +static-studio redirects enable +``` + +Import older Studio-owned CDN redirect rules so they can be managed by ID: + +```bash +static-studio redirects refresh ``` ## Users and Team Members diff --git a/package-lock.json b/package-lock.json index 9e1dd53..1af3918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@simply-static/studio-cli", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@simply-static/studio-cli", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.637.0", diff --git a/package.json b/package.json index 003160d..3ad65a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@simply-static/studio-cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Command-line interface for Static Studio hosting workflows.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index da83894..68e9ca7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,15 @@ import { printValue } from "./output.js"; import { getSiteStatistics, listPerformanceReports, runPerformanceTest } from "./performance.js"; import { confirm } from "./prompt.js"; import { deleteSshKey, getSshInfo, listBackups, queueBackup, queueSshKey } from "./queues.js"; -import { bulkCreateRedirects, createRedirect, deleteRedirect, listRedirects } from "./redirects.js"; +import { + bulkCreateRedirects, + createRedirect, + deleteRedirect, + listRedirects, + refreshRedirectRules, + setRedirectActive, + updateRedirect, +} from "./redirects.js"; import { clearCache, createSite, @@ -88,6 +96,19 @@ function debugLogOptions(opts: ParsedOptions) { }; } +function redirectUpdateOptions(opts: ParsedOptions): { fromPath?: string; toPath?: string; isActive?: boolean } { + if (opts.active && opts.inactive) { + throw new CliError("Use only one of --active or --inactive."); + } + + return { + ...(opts.fromPath !== undefined ? { fromPath: opts.fromPath } : {}), + ...(opts.toPath !== undefined ? { toPath: opts.toPath } : {}), + ...(opts.active ? { isActive: true } : {}), + ...(opts.inactive ? { isActive: false } : {}), + }; +} + function printDebugLogResult(cmd: Command, result: Awaited>, outputFile?: string): void { if (globals(cmd).json) { print(cmd, result); @@ -725,12 +746,61 @@ redirects }); redirects - .command("delete ") - .description("disable a redirect edge rule") + .command("update ") + .alias("edit") + .description("update a DB-backed redirect") + .option("--from-path ", "new source path") + .option("--to-path ", "new target path") + .option("--active", "enable the redirect") + .option("--inactive", "disable the redirect") + .action(async (siteId: string, redirectId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await updateRedirect(supabase, siteId, redirectId, redirectUpdateOptions(opts))); + }); + }); + +redirects + .command("enable ") + .description("enable a DB-backed redirect") + .action(async (siteId: string, redirectId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await setRedirectActive(supabase, siteId, redirectId, true)); + }); + }); + +redirects + .command("disable ") + .description("disable a DB-backed redirect") + .action(async (siteId: string, redirectId: string, _localOpts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await setRedirectActive(supabase, siteId, redirectId, false)); + }); + }); + +redirects + .command("delete ") + .description("delete a DB-backed redirect or disable a legacy edge rule") .option("--pull-zone-id ", "override pull zone ID") - .action(async (siteId: string, ruleId: string, opts: ParsedOptions, cmd: Command) => { + .option("--db", "treat redirectId as a DB redirect ID") + .option("--edge-rule", "treat redirectId as a legacy CDN edge rule ID") + .action(async (siteId: string, redirectId: string, opts: ParsedOptions, cmd: Command) => { + await withAuth(cmd, async ({ supabase }) => { + print(cmd, await deleteRedirect(supabase, siteId, redirectId, opts)); + }); + }); + +redirects + .command("refresh ") + .description("refresh redirect edge rules from stored redirects") + .option("--skip-import-existing", "do not import existing Studio-owned CDN redirects before refresh") + .action(async (siteId: string, opts: ParsedOptions, cmd: Command) => { await withAuth(cmd, async ({ supabase }) => { - print(cmd, await deleteRedirect(supabase, siteId, ruleId, opts.pullZoneId)); + print( + cmd, + await refreshRedirectRules(supabase, siteId, { + importExistingRedirects: !opts.skipImportExisting, + }), + ); }); }); diff --git a/src/redirects.test.ts b/src/redirects.test.ts index 29cb4e3..cbaaebf 100644 --- a/src/redirects.test.ts +++ b/src/redirects.test.ts @@ -2,7 +2,13 @@ import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { bulkCreateRedirects } from "./redirects.js"; +import { + bulkCreateRedirects, + deleteRedirect, + refreshRedirectRules, + setRedirectActive, + updateRedirect, +} from "./redirects.js"; import { createSupabaseMock } from "./test-utils.js"; describe("bulkCreateRedirects", () => { @@ -22,3 +28,202 @@ describe("bulkCreateRedirects", () => { expect(functionCalls).toEqual([]); }); }); + +describe("updateRedirect", () => { + it("updates DB-backed redirects and refreshes edge rules without importing stale CDN rules", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await updateRedirect(supabase, "site-1", "10", { + fromPath: " /old ", + toPath: "/new", + }); + + expect(functionCalls).toEqual([ + { + name: "update-redirect", + body: { + siteId: "site-1", + redirectId: "10", + fromPath: "/old", + toPath: "/new", + }, + }, + { + name: "refresh-edge-rules", + body: { + siteId: "site-1", + importExistingRedirects: false, + }, + }, + ]); + }); + + it("requires at least one field to update", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await expect(updateRedirect(supabase, "site-1", "10", {})).rejects.toThrow( + "at least one redirect field", + ); + expect(functionCalls).toEqual([]); + }); +}); + +describe("setRedirectActive", () => { + it("updates the persisted redirect status", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await setRedirectActive(supabase, "site-1", "10", false); + + expect(functionCalls[0]).toEqual({ + name: "update-redirect", + body: { + siteId: "site-1", + redirectId: "10", + isActive: false, + }, + }); + }); +}); + +describe("deleteRedirect", () => { + it("deletes DB-backed redirects and refreshes edge rules without importing stale CDN rules", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await deleteRedirect(supabase, "site-1", "10", { db: true }); + + expect(functionCalls).toEqual([ + { + name: "delete-redirect", + body: { + siteId: "site-1", + redirectId: "10", + }, + }, + { + name: "refresh-edge-rules", + body: { + siteId: "site-1", + importExistingRedirects: false, + }, + }, + ]); + }); + + it("does not fall back to legacy edge rules when a default redirect cannot be deleted", async () => { + const { supabase, functionCalls } = createSupabaseMock( + { + redirects: { + data: { id: "10", is_default: true }, + error: null, + }, + }, + (name) => + name === "delete-redirect" + ? { error: "Redirect not found or cannot be deleted." } + : { ok: true }, + ); + + await expect(deleteRedirect(supabase, "site-1", "10")).rejects.toThrow( + "Redirect not found or cannot be deleted.", + ); + expect(functionCalls).toEqual([ + { + name: "delete-redirect", + body: { + siteId: "site-1", + redirectId: "10", + }, + }, + ]); + }); + + it("falls back to legacy CDN edge rules when the DB redirect is missing", async () => { + const { supabase, functionCalls } = createSupabaseMock( + { + redirects: { data: null, error: null }, + site: { + data: { id: "site-1", url: "https://example.test", pull_zone_id: "pull-1" }, + error: null, + }, + }, + (name) => + name === "delete-redirect" + ? { error: "Redirect not found or cannot be deleted." } + : { ok: true }, + ); + + await deleteRedirect(supabase, "site-1", "rule-1"); + + expect(functionCalls).toEqual([ + { + name: "delete-redirect", + body: { + siteId: "site-1", + redirectId: "rule-1", + }, + }, + { + name: "disable-redirect", + body: { + pullZoneId: "pull-1", + ruleId: "rule-1", + siteId: "site-1", + }, + }, + { + name: "refresh-edge-rules", + body: { + siteId: "site-1", + importExistingRedirects: true, + }, + }, + ]); + }); + + it("can still disable legacy CDN edge rule IDs", async () => { + const { supabase, functionCalls } = createSupabaseMock({ + site: { + data: { id: "site-1", url: "https://example.test", pull_zone_id: "pull-1" }, + error: null, + }, + }); + + await deleteRedirect(supabase, "site-1", "rule-1", { edgeRule: true }); + + expect(functionCalls).toEqual([ + { + name: "disable-redirect", + body: { + pullZoneId: "pull-1", + ruleId: "rule-1", + siteId: "site-1", + }, + }, + { + name: "refresh-edge-rules", + body: { + siteId: "site-1", + importExistingRedirects: true, + }, + }, + ]); + }); +}); + +describe("refreshRedirectRules", () => { + it("passes through the importExistingRedirects option", async () => { + const { supabase, functionCalls } = createSupabaseMock({}); + + await refreshRedirectRules(supabase, "site-1", { importExistingRedirects: true }); + + expect(functionCalls).toEqual([ + { + name: "refresh-edge-rules", + body: { + siteId: "site-1", + importExistingRedirects: true, + }, + }, + ]); + }); +}); diff --git a/src/redirects.ts b/src/redirects.ts index 813fdcb..8400494 100644 --- a/src/redirects.ts +++ b/src/redirects.ts @@ -7,6 +7,23 @@ import { assertReadableFileWithinLimit, assertSafeId } from "./validation.js"; const MAX_BULK_REDIRECTS = 1000; const MAX_BULK_REDIRECT_FILE_BYTES = 512 * 1024; +const MAX_REDIRECT_PATH_LENGTH = 2048; + +export interface UpdateRedirectOptions { + fromPath?: string; + toPath?: string; + isActive?: boolean; +} + +export interface DeleteRedirectOptions { + pullZoneId?: string; + db?: boolean; + edgeRule?: boolean; +} + +export interface RefreshRedirectRulesOptions { + importExistingRedirects?: boolean; +} function cleanHost(url: string): string { return url.replace(/^https?:\/\//i, "").replace(/\/$/, ""); @@ -30,6 +47,58 @@ async function resolveRedirectContext( }; } +function normalizeOptionalRedirectPath(value: string | undefined, label: string): string | undefined { + if (value === undefined) return undefined; + const clean = String(value).trim(); + if (!clean) { + throw new CliError(`${label} cannot be empty.`); + } + if (clean.length > MAX_REDIRECT_PATH_LENGTH) { + throw new CliError(`${label} must be ${MAX_REDIRECT_PATH_LENGTH} characters or fewer.`); + } + return clean; +} + +function shouldFallbackToLegacyEdgeRule(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + return message.includes("not found") || message.includes("function not found"); +} + +function isAmbiguousDeleteRedirectError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + return message.includes("not found or cannot be deleted"); +} + +async function isDefaultRedirect( + supabase: SupabaseClient, + safeSiteId: string, + safeRedirectId: string, +): Promise { + const { data, error } = await supabase + .from("redirects") + .select("id, is_default") + .eq("site_id", safeSiteId) + .eq("id", safeRedirectId) + .maybeSingle(); + + if (error) throw new CliError(error.message); + return Boolean((data as { is_default?: unknown } | null)?.is_default); +} + +export async function refreshRedirectRules( + supabase: SupabaseClient, + siteId: string, + options: RefreshRedirectRulesOptions = {}, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + return invokeFunction(supabase, "refresh-edge-rules", { + siteId: safeSiteId, + ...(options.importExistingRedirects === undefined + ? {} + : { importExistingRedirects: options.importExistingRedirects }), + }); +} + export async function listRedirects( supabase: SupabaseClient, siteId: string, @@ -43,6 +112,49 @@ export async function listRedirects( }); } +export async function updateRedirect( + supabase: SupabaseClient, + siteId: string, + redirectId: string, + options: UpdateRedirectOptions, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const safeRedirectId = assertSafeId(redirectId, "redirectId"); + const fromPath = normalizeOptionalRedirectPath(options.fromPath, "fromPath"); + const toPath = normalizeOptionalRedirectPath(options.toPath, "toPath"); + const body: { + siteId: string; + redirectId: string; + fromPath?: string; + toPath?: string; + isActive?: boolean; + } = { + siteId: safeSiteId, + redirectId: safeRedirectId, + }; + + if (fromPath !== undefined) body.fromPath = fromPath; + if (toPath !== undefined) body.toPath = toPath; + if (options.isActive !== undefined) body.isActive = Boolean(options.isActive); + + if (body.fromPath === undefined && body.toPath === undefined && body.isActive === undefined) { + throw new CliError("Provide at least one redirect field to update."); + } + + const result = await invokeFunction(supabase, "update-redirect", body); + await refreshRedirectRules(supabase, safeSiteId, { importExistingRedirects: false }); + return result; +} + +export async function setRedirectActive( + supabase: SupabaseClient, + siteId: string, + redirectId: string, + isActive: boolean, +): Promise { + return updateRedirect(supabase, siteId, redirectId, { isActive }); +} + export async function createRedirect( supabase: SupabaseClient, siteId: string, @@ -61,20 +173,55 @@ export async function createRedirect( }); } -export async function deleteRedirect( +async function deleteLegacyEdgeRule( supabase: SupabaseClient, - siteId: string, + safeSiteId: string, ruleId: string, pullZoneId?: string, ): Promise { - const safeSiteId = assertSafeId(siteId, "siteId"); const context = await resolveRedirectContext(supabase, safeSiteId, pullZoneId); const result = await invokeFunction(supabase, "disable-redirect", { pullZoneId: context.pullZoneId, ruleId, siteId: safeSiteId, }); - await invokeFunction(supabase, "refresh-edge-rules", { siteId: safeSiteId }); + await refreshRedirectRules(supabase, safeSiteId, { importExistingRedirects: true }); + return result; +} + +export async function deleteRedirect( + supabase: SupabaseClient, + siteId: string, + redirectId: string, + options: DeleteRedirectOptions = {}, +): Promise { + const safeSiteId = assertSafeId(siteId, "siteId"); + const safeRedirectId = assertSafeId(redirectId, "redirectId"); + if (options.db && options.edgeRule) { + throw new CliError("Use only one of --db or --edge-rule."); + } + + if (options.edgeRule) { + return deleteLegacyEdgeRule(supabase, safeSiteId, safeRedirectId, options.pullZoneId); + } + + let result: unknown; + try { + result = await invokeFunction(supabase, "delete-redirect", { + siteId: safeSiteId, + redirectId: safeRedirectId, + }); + } catch (error) { + if (options.db || !shouldFallbackToLegacyEdgeRule(error)) { + throw error; + } + if (isAmbiguousDeleteRedirectError(error) && (await isDefaultRedirect(supabase, safeSiteId, safeRedirectId))) { + throw error; + } + return deleteLegacyEdgeRule(supabase, safeSiteId, safeRedirectId, options.pullZoneId); + } + + await refreshRedirectRules(supabase, safeSiteId, { importExistingRedirects: false }); return result; } diff --git a/src/test-utils.ts b/src/test-utils.ts index ac64780..516ce09 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -6,6 +6,9 @@ type TableResult = { type TableResolver = (table: string) => TableResult; type TableInput = Record | TableResolver; +type FunctionResolver = (name: string, body: unknown) => unknown | Promise; +type FunctionResult = Record | unknown[] | string | number | boolean | null | undefined; +type FunctionInput = FunctionResult | FunctionResolver; export interface QueryCall { table: string; @@ -100,7 +103,7 @@ class QueryMock implements PromiseLike { export function createSupabaseMock( tables: TableInput, - functionResult: unknown = { ok: true }, + functionResult: FunctionInput = { ok: true }, ): { supabase: any; calls: QueryCall[]; @@ -122,8 +125,13 @@ export function createSupabaseMock( }, functions: { async invoke(name: string, options: { body?: unknown } = {}) { - functionCalls.push({ name, body: options.body ?? {} }); - return { data: functionResult, error: null }; + const body = options.body ?? {}; + functionCalls.push({ name, body }); + const data = + typeof functionResult === "function" + ? await functionResult(name, body) + : functionResult; + return { data, error: null }; }, }, },