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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Runs fully offline (no auth needed) except `--repo/--branch`. Exit codes: `0` va

Upload a ZIP, a single HTML file, or a directory (auto-zipped) and get a stable URL back:

User and personal-key uploads are private by default. Service-key uploads default to public for automation. An explicit unsupported visibility is rejected before upload. Only the owner can replace, extend, delete, or change visibility. Public sites allow anyone with the link to view them.
User and personal-key uploads are private by default. Service-key uploads default to public for automation. An explicit unsupported visibility is rejected before upload. Only the owner can replace, extend, delete, restore, or change visibility. Public sites allow anyone with the link to view them.

Use `openUrl` for a stable opening link. Visibility changes keep the Site ID and content URL; saved copies cannot be recalled. Named-user sharing is not included in this version.

Expand All @@ -176,7 +176,9 @@ lfc sites get a1b2c3d4e5
lfc sites update a1b2c3d4e5 ./dist # replace content (new version)
lfc sites visibility a1b2c3d4e5 private --yes
lfc sites extend a1b2c3d4e5 # push out the TTL/expiry
lfc sites delete a1b2c3d4e5 --yes
lfc sites delete a1b2c3d4e5 --yes # recoverable until its restore deadline
lfc sites list --deleted # your deleted sites that can still be restored
lfc sites restore a1b2c3d4e5 # same id and URL as before
```

Directory uploads honor default ignores such as `.git`, `node_modules`, `.DS_Store`, `.next/cache`, `dist/.cache`, and `coverage`.
Expand Down
5 changes: 3 additions & 2 deletions src/commands/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,13 @@ Static sites:

lfc sites create <file-or-dir> [--name <label>] [--visibility private|public]
# User uploads default to private. Add --visibility public --yes to publish.
lfc sites list [--mine | --public]
lfc sites list [--mine | --public | --deleted] # --deleted shows your restorable deleted 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 extend <id> # push out the expiry
lfc sites delete <id> --yes
lfc sites delete <id> --yes # recoverable until restorableUntil
lfc sites restore <id> # undo a delete; same id and URL

## Recipes for common agent tasks

Expand Down
72 changes: 67 additions & 5 deletions src/commands/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import pc from 'picocolors';

import { runAction, type Ctx } from '../lib/context.js';
import { formatAge, formatBytes, link, printJson, renderTable, statusColor } from '../lib/output.js';
import { siteListView, validateSiteVisibility } from '../lib/sites.js';
import { describeRetention, siteListView, validateSiteVisibility } from '../lib/sites.js';
import type { Site, SitesCapabilities, SiteVisibility } from '../lib/types.js';
import { prepareSiteUpload, type PreparedSiteUpload } from '../lib/zip.js';

Expand All @@ -19,6 +19,14 @@ async function uploadForm(upload: PreparedSiteUpload, name?: string, visibility?
return form;
}

async function retentionDays(ctx: Ctx): Promise<number | undefined> {
try {
return (await ctx.api.getSitesCapabilities()).deletedRetentionDays;
} catch {
return undefined;
}
}

async function prepareUpload(ctx: Ctx, target: string, capabilities?: SitesCapabilities): Promise<PreparedSiteUpload> {
return prepareSiteUpload(target, capabilities ?? (await ctx.api.getSitesCapabilities()));
}
Expand Down Expand Up @@ -69,13 +77,20 @@ export function registerSitesCommands(program: Command): void {
.command('list')
.description('List hosted sites')
.option('-m, --mine', 'only sites owned by this identity')
.option(
'--deleted',
'list your deleted sites that can still be restored (implies --mine; cannot be combined with --public)',
)
.option('--public', 'only public sites')
.option('--search <query>', 'search site name or id')
.option('-p, --page <n>', 'page number', v => Number(v), 1)
.option('-n, --limit <n>', 'items per page', v => Number(v), 25)
.action(
runAction(
async (ctx, opts: { mine?: boolean; public?: boolean; search?: string; page: number; limit: number }) => {
async (
ctx,
opts: { mine?: boolean; public?: boolean; deleted?: boolean; search?: string; page: number; limit: number },
) => {
const view = siteListView(opts);
const { items, pagination } = await ctx.api.listSites({
page: opts.page,
Expand All @@ -87,6 +102,22 @@ export function registerSitesCommands(program: Command): void {
printJson({ sites: items, pagination });
return;
}
if (opts.deleted) {
const retention = describeRetention(await retentionDays(ctx));
if (items.length === 0) {
process.stdout.write(pc.dim(`No recently deleted sites. ${retention}\n`));
return;
}
const deletedRows = items.map(s => [
pc.bold(s.id),
s.name ?? '',
s.deletedAt ? formatAge(s.deletedAt) : '',
s.restorableUntil ? new Date(s.restorableUntil).toLocaleString() : '',
]);
process.stdout.write(renderTable(['id', 'name', 'deleted', 'restorable until'], deletedRows) + '\n');
process.stdout.write(pc.dim(`${retention} Restore with: lfc sites restore <id>\n`));
return;
}
if (items.length === 0) {
process.stdout.write(pc.dim('No sites found.\n'));
return;
Expand Down Expand Up @@ -214,7 +245,7 @@ export function registerSitesCommands(program: Command): void {

sites
.command('delete <siteId>')
.description('Delete a hosted site')
.description('Delete a hosted site (recoverable with `lfc sites restore` until its restore deadline)')
.option('-y, --yes', 'skip the confirmation prompt')
.action(
runAction(async (ctx, siteId: string, opts: { yes?: boolean }) => {
Expand All @@ -223,17 +254,48 @@ export function registerSitesCommands(program: Command): void {
throw new Error('Your current access does not allow you to delete this site.');
if (!opts.yes) {
if (!process.stdin.isTTY) throw new Error('Refusing to delete without --yes in non-interactive mode');
const ok = await clack.confirm({ message: `Delete site ${siteId}? Its URL stops working immediately.` });
const days = await retentionDays(ctx);
const restoreNote =
days === undefined
? ''
: days === 0
? ' It is removed at the next cleanup.'
: ` You can restore it for ${days} ${days === 1 ? 'day' : 'days'}.`;
const ok = await clack.confirm({
message: `Delete site ${siteId}? Its URL stops working immediately.${restoreNote}`,
});
if (ok !== true) {
process.stderr.write('Aborted.\n');
return;
}
}
const site = await ctx.api.deleteSite(siteId, current.accessRevision);
if (ctx.json) printJson(site);
else process.stderr.write(`${pc.green('✓')} Deleted site ${siteId}\n`);
else {
process.stderr.write(`${pc.green('✓')} Deleted site ${siteId}\n`);
if (site.restorableUntil)
process.stderr.write(
` Restore it with ${pc.bold(`lfc sites restore ${siteId}`)} until ${new Date(site.restorableUntil).toLocaleString()}.\n`,
);
}
}),
);

sites
.command('restore <siteId>')
.description('Restore a deleted site (same id and URL) while it is still restorable')
.action(
runAction(async (ctx, siteId: string) => {
// GET /sites/{id} returns 404 for deleted sites, so the revision comes from the deleted list.
const { items } = await ctx.api.listSites({ page: 1, limit: 100, view: 'deleted', q: siteId });
const current = items.find(s => s.id === siteId);
if (!current) throw new Error('Site not found or no longer restorable.');
if (!current.permissions.canRestore)
throw new Error('Your current access does not allow you to restore this site.');
printSite(ctx, await ctx.api.restoreSite(siteId, current.accessRevision), 'Restored');
}),
);

sites
.command('visibility <siteId> <visibility>')
.description('Publish a site or make it private (keeps its content URL)')
Expand Down
7 changes: 7 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ export class ApiClient {
return (env.data as { site: Site }).site;
}

async restoreSite(siteId: string, expectedAccessRevision?: number): Promise<Site> {
const env = await this.request<{ site: Site }>('POST', `/api/v2/sites/${encodeURIComponent(siteId)}/restore`, {
query: { expectedAccessRevision },
});
return (env.data as { site: Site }).site;
}

async extendSite(siteId: string, expectedAccessRevision?: number): Promise<Site> {
const env = await this.request<{ site: Site }>('POST', `/api/v2/sites/${encodeURIComponent(siteId)}/extend`, {
query: { expectedAccessRevision },
Expand Down
3 changes: 3 additions & 0 deletions src/lib/generated/listSitesParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import type { ListSitesView } from './listSitesView.js';

export type ListSitesParams = {
/**
* deleted lists the caller's deleted sites that can still be restored.
*/
view?: ListSitesView;
q?: string;
/**
Expand Down
1 change: 1 addition & 0 deletions src/lib/generated/listSitesView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export const ListSitesView = {
mine: 'mine',
public: 'public',
all: 'all',
deleted: 'deleted',
} as const;
7 changes: 7 additions & 0 deletions src/lib/generated/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface Site {
updatedAt: string | null;
/** @nullable */
expiresAt: string | null;
/** @nullable */
deletedAt: string | null;
/**
* Last moment a deleted site can be restored; null unless the site is deleted.
* @nullable
*/
restorableUntil: string | null;
/** @minimum 0 */
fileCount: number;
/** @minimum 0 */
Expand Down
1 change: 1 addition & 0 deletions src/lib/generated/sitePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export type SitePermissions = {
canEdit: boolean;
canDelete: boolean;
canChangeVisibility: boolean;
canRestore: boolean;
};
1 change: 1 addition & 0 deletions src/lib/generated/siteStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export const SiteStatus = {
active: 'active',
deleted: 'deleted',
expired: 'expired',
purged: 'purged',
} as const;
5 changes: 5 additions & 0 deletions src/lib/generated/sitesCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ export interface SitesCapabilities {
defaultVisibility: SitesCapabilitiesDefaultVisibility;
allowedVisibilities: SitesCapabilitiesAllowedVisibilitiesItem[];
upload: SitesCapabilitiesUpload;
/**
* Days a deleted site stays restorable before its content is purged.
* @minimum 0
*/
deletedRetentionDays: number;
}
12 changes: 11 additions & 1 deletion src/lib/sites.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import type { ListSitesView } from './generated/index.js';
import type { SitesCapabilities, SiteVisibility } from './types.js';

export function siteListView(opts: { mine?: boolean; public?: boolean }): ListSitesView {
export function siteListView(opts: { mine?: boolean; public?: boolean; deleted?: boolean }): ListSitesView {
if (opts.deleted) {
if (opts.public) throw new Error('--deleted and --public cannot be combined.');
return 'deleted';
}
if (opts.mine && opts.public) throw new Error('--mine and --public cannot be combined.');
return opts.mine ? 'mine' : opts.public ? 'public' : 'all';
}
export function validateSiteVisibility(visibility: SiteVisibility | undefined, capabilities: SitesCapabilities): void {
if (visibility && !capabilities.allowedVisibilities.includes(visibility))
throw new Error(`Visibility ${visibility} is not supported for this credential. No files were uploaded.`);
}

export function describeRetention(days: number | undefined): string {
if (days === undefined) return "Deleted sites can be restored until they're permanently removed.";
if (days === 0) return 'Deleted sites are permanently removed at the next cleanup.';
return `Deleted sites are kept for ${days} ${days === 1 ? 'day' : 'days'}, then permanently removed.`;
}
Loading
Loading