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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/commands/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ Static sites:
lfc sites get <id>
lfc sites update <id> <file-or-dir>
lfc sites visibility <id> <private|public> --yes # keeps the content URL
lfc sites rename <id> <name> # display name only; id and URL stay the same
lfc sites extend <id> # push out the expiry
lfc sites delete <id> --yes # recoverable until restorableUntil
lfc sites restore <id> # undo a delete; same id and URL
Expand Down
18 changes: 18 additions & 0 deletions src/commands/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,24 @@ export function registerSitesCommands(program: Command): void {
}),
);

sites
.command('rename <siteId> <name>')
.description('Rename a site (its id and URL stay the same)')
.action(
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.');
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 <siteId>')
.description("Extend a site's expiration (TTL)")
Expand Down
10 changes: 9 additions & 1 deletion src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -287,6 +287,14 @@ export class ApiClient {
return (env.data as { site: Site }).site;
}

async renameSite(siteId: string, name: string, expectedAccessRevision?: number): Promise<Site> {
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<Site> {
const env = await this.request<{ site: Site }>('DELETE', `/api/v2/sites/${encodeURIComponent(siteId)}`, {
query: { expectedAccessRevision },
Expand Down
1 change: 1 addition & 0 deletions src/lib/generated/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 20 additions & 0 deletions src/lib/generated/renameSiteBody.ts
Original file line number Diff line number Diff line change
@@ -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;
};
45 changes: 45 additions & 0 deletions tests/sites-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));
Expand Down Expand Up @@ -245,3 +246,47 @@ 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('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('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();
registerSitesCommands(command);
await expect(command.parseAsync(['sites', 'rename', 'owned', 'New name'], { from: 'user' })).rejects.toThrow(
'current access',
);
expect(api.renameSite).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions tests/sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading