From 8ace5fe8c9fc832ef7de1eef5bb1b297aa6a5cf7 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Mon, 12 Oct 2026 16:27:35 -0700 Subject: [PATCH 1/3] feat(sites): add rename command --- README.md | 1 + src/commands/llms.ts | 1 + src/commands/sites.ts | 14 ++++++++++++++ src/lib/api.ts | 10 +++++++++- src/lib/generated/index.ts | 1 + src/lib/generated/renameSiteBody.ts | 20 ++++++++++++++++++++ tests/sites-command.test.ts | 22 ++++++++++++++++++++++ tests/sites.test.ts | 9 +++++++++ 8 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/lib/generated/renameSiteBody.ts diff --git a/README.md b/README.md index 6df5c4d..eb58d88 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ lfc sites list --public --search report lfc sites get a1b2c3d4e5 lfc sites update a1b2c3d4e5 ./dist # replace content (new version) lfc sites visibility a1b2c3d4e5 private --yes +lfc sites rename a1b2c3d4e5 "Q3 report" # change the display name; id and URL stay the same lfc sites extend a1b2c3d4e5 # push out the TTL/expiry lfc sites delete a1b2c3d4e5 --yes # recoverable until its restore deadline lfc sites list --deleted # your deleted sites that can still be restored diff --git a/src/commands/llms.ts b/src/commands/llms.ts index 274973d..af6ec72 100644 --- a/src/commands/llms.ts +++ b/src/commands/llms.ts @@ -156,6 +156,7 @@ Static sites: lfc sites get lfc sites update lfc sites visibility --yes # keeps the content URL + lfc sites rename # display name only; id and URL stay the same lfc sites extend # push out the expiry lfc sites delete --yes # recoverable until restorableUntil lfc sites restore # undo a delete; same id and URL diff --git a/src/commands/sites.ts b/src/commands/sites.ts index 3a4dbba..c8e7420 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -226,6 +226,20 @@ export function registerSitesCommands(program: Command): void { }), ); + sites + .command('rename ') + .description('Rename a site (its id and URL stay the same)') + .action( + runAction(async (ctx, siteId: string, name: string) => { + const current = await ctx.api.getSite(siteId); + if (!current.permissions.canEdit) + throw new Error('Your current access does not allow you to rename this site.'); + const site = await ctx.api.renameSite(siteId, name, current.accessRevision); + if (ctx.json) printJson(site); + else process.stderr.write(`${pc.green('✓')} Renamed ${pc.bold(site.id)} to ${pc.bold(site.name ?? name)}\n`); + }), + ); + sites .command('extend ') .description("Extend a site's expiration (TTL)") diff --git a/src/lib/api.ts b/src/lib/api.ts index f0de85f..80b8657 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,6 @@ import { getAccessToken } from './auth.js'; import type { Profile } from './config.js'; -import type { ListSitesParams } from './generated/index.js'; +import type { ListSitesParams, RenameSiteBody } from './generated/index.js'; import { matchBuilds, type Selector } from './resolve.js'; import type { ApiEnvelope, @@ -287,6 +287,14 @@ export class ApiClient { return (env.data as { site: Site }).site; } + async renameSite(siteId: string, name: string, expectedAccessRevision?: number): Promise { + const body: RenameSiteBody = { name, expectedAccessRevision }; + const env = await this.request<{ site: Site }>('PATCH', `/api/v2/sites/${encodeURIComponent(siteId)}`, { + json: body, + }); + return (env.data as { site: Site }).site; + } + async deleteSite(siteId: string, expectedAccessRevision?: number): Promise { const env = await this.request<{ site: Site }>('DELETE', `/api/v2/sites/${encodeURIComponent(siteId)}`, { query: { expectedAccessRevision }, diff --git a/src/lib/generated/index.ts b/src/lib/generated/index.ts index d8520e6..e0115e4 100644 --- a/src/lib/generated/index.ts +++ b/src/lib/generated/index.ts @@ -703,6 +703,7 @@ export * from './removeRepositoryParams.js'; export * from './removeRepositoryResponse.js'; export * from './removeRepositorySuccessResponse.js'; export * from './removeRepositorySuccessResponseAllOf.js'; +export * from './renameSiteBody.js'; export * from './repository.js'; export * from './repositorySearchResult.js'; export * from './respondToAgentPendingAction200.js'; diff --git a/src/lib/generated/renameSiteBody.ts b/src/lib/generated/renameSiteBody.ts new file mode 100644 index 0000000..97971ec --- /dev/null +++ b/src/lib/generated/renameSiteBody.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v7.4.1 🍺 + * Do not edit manually. + * Lifecycle API + * API documentation for lifecycle + * OpenAPI spec version: 2.0.0 + */ + +export type RenameSiteBody = { + /** + * @minLength 1 + * @maxLength 255 + */ + name: string; + /** + * @minimum 1 + * @maximum 2147483647 + */ + expectedAccessRevision?: number; +}; diff --git a/tests/sites-command.test.ts b/tests/sites-command.test.ts index 469ecc3..c8c2325 100644 --- a/tests/sites-command.test.ts +++ b/tests/sites-command.test.ts @@ -15,6 +15,7 @@ const api = vi.hoisted(() => ({ listSites: vi.fn(), deleteSite: vi.fn(), restoreSite: vi.fn(), + renameSite: vi.fn(), })); const ctxState = vi.hoisted(() => ({ json: true })); const prompts = vi.hoisted(() => ({ confirm: vi.fn() })); @@ -245,3 +246,24 @@ describe('restore, delete and list --deleted', () => { expect(written).not.toMatch(/permanent/i); }); }); + +describe('rename', () => { + it('renames using the current access revision', async () => { + api.getSite.mockResolvedValue({ id: 'owned', accessRevision: 7, permissions: { canEdit: true } }); + api.renameSite.mockResolvedValue({ id: 'owned', name: 'New name' }); + const command = new Command(); + registerSitesCommands(command); + await command.parseAsync(['sites', 'rename', 'owned', 'New name'], { from: 'user' }); + expect(api.renameSite).toHaveBeenCalledWith('owned', 'New name', 7); + }); + + it('does not rename when the current permission denies editing', async () => { + api.getSite.mockResolvedValue({ id: 'owned', accessRevision: 7, permissions: { canEdit: false } }); + const command = new Command(); + registerSitesCommands(command); + await expect(command.parseAsync(['sites', 'rename', 'owned', 'New name'], { from: 'user' })).rejects.toThrow( + 'current access', + ); + expect(api.renameSite).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/sites.test.ts b/tests/sites.test.ts index 7dd2837..9a8b175 100644 --- a/tests/sites.test.ts +++ b/tests/sites.test.ts @@ -76,6 +76,15 @@ describe('Sites V1 contracts', () => { expect((fetchMock.mock.calls[0]![0] as unknown as URL).searchParams.get('expectedAccessRevision')).toBe('8'); expect((fetchMock.mock.calls[1]![0] as unknown as URL).searchParams.get('expectedAccessRevision')).toBe('9'); }); + it('patches the name and revision on rename', async () => { + const fetchMock = mockResponse({ site: { id: 'test' } }); + await client.renameSite('test/id', 'New name', 5); + const url = fetchMock.mock.calls[0]![0] as unknown as URL; + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(url.pathname).toBe('/api/v2/sites/test%2Fid'); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ name: 'New name', expectedAccessRevision: 5 }); + }); it('sends the revision on restore', async () => { const fetchMock = mockResponse({ site: { id: 'test' } }); await client.restoreSite('test/id', 3); From 3fc8708318ae6edaf9e7947241fef8263de0eafb Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Mon, 12 Oct 2026 16:30:45 -0700 Subject: [PATCH 2/3] fix(sites): trim rename name before sending --- src/commands/sites.ts | 4 +++- tests/sites-command.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/sites.ts b/src/commands/sites.ts index c8e7420..b2759bb 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -230,7 +230,9 @@ export function registerSitesCommands(program: Command): void { .command('rename ') .description('Rename a site (its id and URL stay the same)') .action( - runAction(async (ctx, siteId: string, name: string) => { + runAction(async (ctx, siteId: string, rawName: string) => { + const name = rawName.trim(); + if (!name || name.length > 255) throw new Error('The name must be 1-255 characters.'); const current = await ctx.api.getSite(siteId); if (!current.permissions.canEdit) throw new Error('Your current access does not allow you to rename this site.'); diff --git a/tests/sites-command.test.ts b/tests/sites-command.test.ts index c8c2325..54e09e8 100644 --- a/tests/sites-command.test.ts +++ b/tests/sites-command.test.ts @@ -257,6 +257,20 @@ describe('rename', () => { expect(api.renameSite).toHaveBeenCalledWith('owned', 'New name', 7); }); + it('trims the name and rejects a blank one before any request', async () => { + api.getSite.mockResolvedValue({ id: 'owned', accessRevision: 7, permissions: { canEdit: true } }); + api.renameSite.mockResolvedValue({ id: 'owned', name: 'New name' }); + const command = new Command(); + registerSitesCommands(command); + await command.parseAsync(['sites', 'rename', 'owned', ' New name '], { from: 'user' }); + expect(api.renameSite).toHaveBeenCalledWith('owned', 'New name', 7); + api.renameSite.mockClear(); + await expect(command.parseAsync(['sites', 'rename', 'owned', ' '], { from: 'user' })).rejects.toThrow( + '1-255 characters', + ); + expect(api.renameSite).not.toHaveBeenCalled(); + }); + it('does not rename when the current permission denies editing', async () => { api.getSite.mockResolvedValue({ id: 'owned', accessRevision: 7, permissions: { canEdit: false } }); const command = new Command(); From a30e09d4ef9856ae09cf1d74b2ed04f346524215 Mon Sep 17 00:00:00 2001 From: Vigneshraj Sekar Babu Date: Mon, 12 Oct 2026 16:47:11 -0700 Subject: [PATCH 3/3] fix(sites): reject control characters in rename --- src/commands/sites.ts | 2 ++ tests/sites-command.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/commands/sites.ts b/src/commands/sites.ts index b2759bb..05c288e 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -233,6 +233,8 @@ export function registerSitesCommands(program: Command): void { runAction(async (ctx, siteId: string, rawName: string) => { const name = rawName.trim(); if (!name || name.length > 255) throw new Error('The name must be 1-255 characters.'); + if (/[\p{Cc}\u202A-\u202E\u2066-\u2069]/u.test(name)) + throw new Error('The name cannot contain control or text-direction characters.'); const current = await ctx.api.getSite(siteId); if (!current.permissions.canEdit) throw new Error('Your current access does not allow you to rename this site.'); diff --git a/tests/sites-command.test.ts b/tests/sites-command.test.ts index 54e09e8..4b53a64 100644 --- a/tests/sites-command.test.ts +++ b/tests/sites-command.test.ts @@ -271,6 +271,15 @@ describe('rename', () => { expect(api.renameSite).not.toHaveBeenCalled(); }); + it('rejects control characters before any request', async () => { + const command = new Command(); + registerSitesCommands(command); + await expect( + command.parseAsync(['sites', 'rename', 'owned', 'bad\u001b[31mname'], { from: 'user' }), + ).rejects.toThrow('control or text-direction'); + expect(api.renameSite).not.toHaveBeenCalled(); + }); + it('does not rename when the current permission denies editing', async () => { api.getSite.mockResolvedValue({ id: 'owned', accessRevision: 7, permissions: { canEdit: false } }); const command = new Command();