diff --git a/packages/cli/mocks/deleted_branches/projects/test/branches/GET.js b/packages/cli/mocks/deleted_branches/projects/test/branches/GET.js new file mode 100644 index 00000000..69866345 --- /dev/null +++ b/packages/cli/mocks/deleted_branches/projects/test/branches/GET.js @@ -0,0 +1,36 @@ +import { expect } from 'vitest'; + +const activeBranch = { + id: 'br-main-branch-123456', + name: 'main', + default: true, + current_state: 'ready', + created_at: '2021-01-01T00:00:00.000Z', + updated_at: '2021-01-01T00:00:00.000Z', +}; + +const deletedBranch = { + id: 'br-gone-branch-123456', + name: 'gone-branch', + current_state: 'ready', + created_at: '2021-01-01T00:00:00.000Z', + updated_at: '2021-01-01T00:00:00.000Z', + recovery: { + deleted_at: '2026-06-30T00:00:00.000Z', + recoverable_until: '2026-07-07T00:00:00.000Z', + deletion_method: 'user', + }, +}; + +export default function (req, res) { + const includeDeleted = req.query.include_deleted === 'true'; + if (!includeDeleted) { + expect(req.query.include_deleted).toBe('false'); + } + res.send({ + annotations: {}, + branches: includeDeleted + ? [activeBranch, deletedBranch] + : [activeBranch], + }); +} diff --git a/packages/cli/mocks/deleted_branches/projects/test/branches/br-gone-branch-123456/recover/POST.js b/packages/cli/mocks/deleted_branches/projects/test/branches/br-gone-branch-123456/recover/POST.js new file mode 100644 index 00000000..e85c1309 --- /dev/null +++ b/packages/cli/mocks/deleted_branches/projects/test/branches/br-gone-branch-123456/recover/POST.js @@ -0,0 +1,10 @@ +export default function (req, res) { + res.send({ + branch: { + id: 'br-gone-branch-123456', + name: 'gone-branch', + current_state: 'ready', + created_at: '2021-01-01T00:00:00.000Z', + }, + }); +} diff --git a/packages/cli/mocks/main/projects/test/branches/br-harddel-branch-123456/DELETE.js b/packages/cli/mocks/main/projects/test/branches/br-harddel-branch-123456/DELETE.js new file mode 100644 index 00000000..e5706081 --- /dev/null +++ b/packages/cli/mocks/main/projects/test/branches/br-harddel-branch-123456/DELETE.js @@ -0,0 +1,12 @@ +import { expect } from 'vitest'; + +export default function (req, res) { + expect(req.query.hard_delete).toBe('true'); + res.send({ + branch: { + id: 'br-harddel-branch-123456', + name: 'harddel-branch', + created_at: '2021-01-01T00:00:00.000Z', + }, + }); +} diff --git a/packages/cli/mocks/main/projects/test/branches/br-sunny-branch-123456/recover/POST.js b/packages/cli/mocks/main/projects/test/branches/br-sunny-branch-123456/recover/POST.js new file mode 100644 index 00000000..fb42628a --- /dev/null +++ b/packages/cli/mocks/main/projects/test/branches/br-sunny-branch-123456/recover/POST.js @@ -0,0 +1,10 @@ +export default function (req, res) { + res.send({ + branch: { + id: 'br-sunny-branch-123456', + name: 'sunny-branch', + current_state: 'ready', + created_at: '2021-01-01T00:00:00.000Z', + }, + }); +} diff --git a/packages/cli/src/__snapshots__/writer.test.ts.snap b/packages/cli/src/__snapshots__/writer.test.ts.snap index cdb9ace6..709f2517 100644 --- a/packages/cli/src/__snapshots__/writer.test.ts.snap +++ b/packages/cli/src/__snapshots__/writer.test.ts.snap @@ -58,6 +58,15 @@ exports[`writer > outputs table > outputs single data with title 1`] = ` " `; +exports[`writer > outputs table > outputs table with custom header label 1`] = ` +"┌──────────────────────────┐ +│ Recoverable Until │ +├──────────────────────────┤ +│ 2026-07-07T00:00:00.000Z │ +└──────────────────────────┘ +" +`; + exports[`writer > outputs table > outputs table with custom renderer 1`] = ` "T1 ┌──────────────┐ diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 8d0b974d..3a8d181b 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -541,11 +541,23 @@ export const getApiClient = ({ apiKey, apiHost }: ApiCallProps) => { body: data, }), ), - deleteProjectBranch: (projectId: string, branchId: string) => + deleteProjectBranch: ( + projectId: string, + branchId: string, + hardDelete?: boolean, + ) => call(() => raw.deleteProjectBranch({ client, path: { project_id: projectId, branch_id: branchId }, + ...(hardDelete ? { query: { hard_delete: true } } : {}), + }), + ), + recoverProjectBranch: (projectId: string, branchId: string) => + call(() => + raw.recoverProjectBranch({ + client, + path: { project_id: projectId, branch_id: branchId }, }), ), restoreProjectBranch: ( diff --git a/packages/cli/src/commands/__snapshots__/branches.test.ts.snap b/packages/cli/src/commands/__snapshots__/branches.test.ts.snap index 296b84bd..606dd555 100644 --- a/packages/cli/src/commands/__snapshots__/branches.test.ts.snap +++ b/packages/cli/src/commands/__snapshots__/branches.test.ts.snap @@ -189,6 +189,13 @@ created_at: 2021-01-01T00:00:00.000Z " `; +exports[`branches > delete by id with hard-delete 1`] = ` +"id: br-harddel-branch-123456 +name: harddel-branch +created_at: 2021-01-01T00:00:00.000Z +" +`; + exports[`branches > get by id 1`] = ` "name: test_branch id: br-sunny-branch-123456 @@ -228,6 +235,26 @@ updated_at: 2019-01-01T00:00:00Z " `; +exports[`branches > list with include-deleted shows deleted state and recovery 1`] = ` +"┌────────────────┬───────────────────────┬───────────────┬──────────────────────────┬──────────────────────────┐ +│ Name │ Id │ Current State │ Created At │ Recoverable Until │ +├────────────────┼───────────────────────┼───────────────┼──────────────────────────┼──────────────────────────┤ +│ [default] main │ br-main-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │ │ +├────────────────┼───────────────────────┼───────────────┼──────────────────────────┼──────────────────────────┤ +│ gone-branch │ br-gone-branch-123456 │ deleted │ 2021-01-01T00:00:00.000Z │ 2026-07-07T00:00:00.000Z │ +└────────────────┴───────────────────────┴───────────────┴──────────────────────────┴──────────────────────────┘ +" +`; + +exports[`branches > list without include-deleted omits deleted branches 1`] = ` +"┌────────────────┬───────────────────────┬───────────────┬──────────────────────────┐ +│ Name │ Id │ Current State │ Created At │ +├────────────────┼───────────────────────┼───────────────┼──────────────────────────┤ +│ [default] main │ br-main-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │ +└────────────────┴───────────────────────┴───────────────┴──────────────────────────┘ +" +`; + exports[`branches > list/table marks the branch pinned in .neon as [current] 1`] = ` "┌──────────────────────────────┬────────────────────────────┬───────────────┬──────────────────────────┬──────────────────────────┐ │ Name │ Id │ Current State │ Created At │ Expires At │ @@ -308,6 +335,32 @@ exports[`branches > list/yaml 1`] = ` " `; +exports[`branches > recover by id 1`] = ` +"id: br-sunny-branch-123456 +name: sunny-branch +current_state: ready +created_at: 2021-01-01T00:00:00.000Z +" +`; + +exports[`branches > recover by id/table output 1`] = ` +"branch +┌──────────────┬────────────────────────┬───────────────┬──────────────────────────┐ +│ Name │ Id │ Current State │ Created At │ +├──────────────┼────────────────────────┼───────────────┼──────────────────────────┤ +│ sunny-branch │ br-sunny-branch-123456 │ ready │ 2021-01-01T00:00:00.000Z │ +└──────────────┴────────────────────────┴───────────────┴──────────────────────────┘ +" +`; + +exports[`branches > recover by name 1`] = ` +"id: br-gone-branch-123456 +name: gone-branch +current_state: ready +created_at: 2021-01-01T00:00:00.000Z +" +`; + exports[`branches > remove expiration date 1`] = ` "id: br-sunny-branch-123456 name: test_branch diff --git a/packages/cli/src/commands/branches.test.ts b/packages/cli/src/commands/branches.test.ts index 6caed024..11ce3c85 100644 --- a/packages/cli/src/commands/branches.test.ts +++ b/packages/cli/src/commands/branches.test.ts @@ -18,6 +18,24 @@ describe("branches", () => { }); }); + test("list with include-deleted shows deleted state and recovery", async ({ + testCliCommand, + }) => { + await testCliCommand( + ["branches", "list", "--project-id", "test", "--include-deleted"], + { mockDir: "deleted_branches", outputTable: true }, + ); + }); + + test("list without include-deleted omits deleted branches", async ({ + testCliCommand, + }) => { + await testCliCommand(["branches", "list", "--project-id", "test"], { + mockDir: "deleted_branches", + outputTable: true, + }); + }); + test("list/table marks the branch pinned in .neon as [current]", async ({ testCliCommand, }) => { @@ -275,6 +293,49 @@ describe("branches", () => { ]); }); + test("delete by id with hard-delete", async ({ testCliCommand }) => { + await testCliCommand([ + "branches", + "delete", + "br-harddel-branch-123456", + "--project-id", + "test", + "--hard-delete", + ]); + }); + + /* recover */ + + test("recover by id", async ({ testCliCommand }) => { + await testCliCommand([ + "branches", + "recover", + "br-sunny-branch-123456", + "--project-id", + "test", + ]); + }); + + test("recover by id/table output", async ({ testCliCommand }) => { + await testCliCommand( + [ + "branches", + "recover", + "br-sunny-branch-123456", + "--project-id", + "test", + ], + { outputTable: true }, + ); + }); + + test("recover by name", async ({ testCliCommand }) => { + await testCliCommand( + ["branches", "recover", "gone-branch", "--project-id", "test"], + { mockDir: "deleted_branches" }, + ); + }); + /* rename */ test("rename", async ({ testCliCommand }) => { diff --git a/packages/cli/src/commands/branches.ts b/packages/cli/src/commands/branches.ts index 125c47a1..0e96cd24 100644 --- a/packages/cli/src/commands/branches.ts +++ b/packages/cli/src/commands/branches.ts @@ -29,6 +29,7 @@ export const BRANCH_FIELDS: readonly (keyof Branch)[] = [ "current_state", "created_at", "expires_at", + "recovery", ]; const BRANCH_FIELDS_RESET: readonly (keyof Branch)[] = [ @@ -60,7 +61,15 @@ export const builder = (argv: yargs.Argv) => .command( "list", "List branches", - (yargs) => yargs, + (yargs) => + yargs.options({ + "include-deleted": { + describe: + "Include deleted branches that are still recoverable", + type: "boolean", + default: false, + }, + }), (args) => list(args as any), ) .command( @@ -243,9 +252,23 @@ export const builder = (argv: yargs.Argv) => .command( "delete ", "Delete a branch", - (yargs) => yargs, + (yargs) => + yargs.options({ + "hard-delete": { + describe: + "Permanently delete the branch immediately, skipping the 7-day recovery window", + type: "boolean", + default: false, + }, + }), (args) => deleteBranch(args as any), ) + .command( + "recover ", + "Recover a deleted branch within its recovery window", + (yargs) => yargs, + (args) => recover(args as any), + ) .command( "get ", "Get a branch", @@ -305,11 +328,12 @@ export const handler = (args: yargs.Argv) => { return args; }; -const list = async (props: ProjectScopeProps) => { +const list = async (props: ProjectScopeProps & { includeDeleted: boolean }) => { const { data: { branches, annotations }, } = await props.apiClient.listProjectBranches({ projectId: props.projectId, + include_deleted: props.includeDeleted, }); // The branch pinned in the local context (.neon), so we can flag it as `[current]` — the // one commands target by default and that `neonctl env pull` would read. The context @@ -317,8 +341,14 @@ const list = async (props: ProjectScopeProps) => { const currentBranch = contextBranch(readContextFile(props.contextFile)); writer(props).end(branches, { fields: BRANCH_FIELDS, + renderHeaders: { + recovery: "Recoverable Until", + }, renderColumns: { expires_at: (br) => br.expires_at || "never", + current_state: (br) => + br.recovery ? "deleted" : (br.current_state ?? ""), + recovery: (br) => br.recovery?.recoverable_until ?? "", // Word labels (not symbols) so they read clearly and match the existing `[anon]`. name: (br) => { const annotation = annotations[br.id]; @@ -512,10 +542,16 @@ const setDefault = async (props: ProjectScopeProps & IdOrNameProps) => { }); }; -const deleteBranch = async (props: ProjectScopeProps & IdOrNameProps) => { +const deleteBranch = async ( + props: ProjectScopeProps & IdOrNameProps & { hardDelete: boolean }, +) => { const branchId = await branchIdFromProps(props); const { data } = await retryOnLock(() => - props.apiClient.deleteProjectBranch(props.projectId, branchId), + props.apiClient.deleteProjectBranch( + props.projectId, + branchId, + props.hardDelete, + ), ); // A 204 (branch already gone) carries no body; only a 200 returns it. if (data) { @@ -525,6 +561,32 @@ const deleteBranch = async (props: ProjectScopeProps & IdOrNameProps) => { } }; +const recover = async (props: ProjectScopeProps & IdOrNameProps) => { + const branchId = await branchIdResolve({ + branch: props.id, + apiClient: props.apiClient, + projectId: props.projectId, + includeDeleted: true, + }); + const { data } = await retryOnLock(() => + props.apiClient.recoverProjectBranch(props.projectId, branchId), + ); + const out = writer(props); + out.write(data.branch, { + fields: BRANCH_FIELDS, + title: "branch", + emptyMessage: "No branches have been found.", + }); + if (data.endpoints?.length) { + out.write(data.endpoints, { + fields: ["id", "created_at"], + title: "endpoints", + emptyMessage: "No endpoints have been found.", + }); + } + out.end(); +}; + const get = async (props: ProjectScopeProps & IdOrNameProps) => { const branchId = await branchIdFromProps(props); const { data } = await props.apiClient.getProjectBranch( diff --git a/packages/cli/src/utils/enrichers.ts b/packages/cli/src/utils/enrichers.ts index 41217c84..242243e8 100644 --- a/packages/cli/src/utils/enrichers.ts +++ b/packages/cli/src/utils/enrichers.ts @@ -8,10 +8,12 @@ export const branchIdResolve = async ({ branch, apiClient, projectId, + includeDeleted = false, }: { branch: string | number; apiClient: CommonProps["apiClient"]; projectId: string; + includeDeleted?: boolean; }) => { branch = branch.toString(); if (looksLikeBranchId(branch)) { @@ -20,6 +22,7 @@ export const branchIdResolve = async ({ const { data } = await apiClient.listProjectBranches({ projectId, + ...(includeDeleted ? { include_deleted: true } : {}), }); const branchData = data.branches.find((b: Branch) => b.name === branch); if (!branchData) { diff --git a/packages/cli/src/writer.test.ts b/packages/cli/src/writer.test.ts index 7f54be4c..62443f8d 100644 --- a/packages/cli/src/writer.test.ts +++ b/packages/cli/src/writer.test.ts @@ -119,5 +119,23 @@ describe("writer", () => { ).end(); expect(getData()).toMatchSnapshot(); }); + + it("outputs table with custom header label", () => { + const { stream, getData } = getMockWritable(); + const out = writer({ + output: "table", + out: stream, + }); + out.write( + { recovery: "2026-07-07T00:00:00.000Z" }, + { + fields: ["recovery"], + renderHeaders: { + recovery: "Recoverable Until", + }, + }, + ).end(); + expect(getData()).toMatchSnapshot(); + }); }); }); diff --git a/packages/cli/src/writer.ts b/packages/cli/src/writer.ts index ca6fa71f..8c08eeda 100644 --- a/packages/cli/src/writer.ts +++ b/packages/cli/src/writer.ts @@ -21,6 +21,8 @@ type WriteOutConfig = { renderColumns?: Partial< Record, (value: ExtractFromArray) => string> >; + // Custom header labels for specific columns (overrides the auto-derived name) + renderHeaders?: Partial, string>>; }; type Chunk = { data: any; config: WriteOutConfig }; @@ -62,7 +64,13 @@ const writeTable = ( chunks.forEach( ({ data, - config: { emptyMessage, fields, title, renderColumns = {} }, + config: { + emptyMessage, + fields, + title, + renderColumns = {}, + renderHeaders = {}, + }, }) => { const arrayData = Array.isArray(data) ? data : [data]; if (!arrayData.length && emptyMessage) { @@ -79,11 +87,15 @@ const writeTable = ( style: { head: ["green"], }, - head: fieldsFiltered.map((field: string) => - field - .split("_") - .map((word) => word[0].toUpperCase() + word.slice(1)) - .join(" "), + head: fieldsFiltered.map( + (field: string) => + renderHeaders[field] ?? + field + .split("_") + .map( + (word) => word[0].toUpperCase() + word.slice(1), + ) + .join(" "), ), }); arrayData.forEach((item) => {