Skip to content
Draft
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,22 @@ 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.

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.

```bash
lfc sites create ./report.html --name "perf report"
# ✓ Created site a1b2c3d4e5 (perf report)
# https://a1b2c3d4e5.sites.lifecycle.example.com
# https://ui.lifecycle.example.com/sites/open/a1b2c3d4e5

lfc sites create ./dist # whole directory
lfc sites create ./dist --visibility public --yes
lfc sites list --mine
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 extend a1b2c3d4e5 # push out the TTL/expiry
lfc sites delete a1b2c3d4e5 --yes
```
Expand All @@ -185,7 +192,7 @@ Add a `.lfcsiteignore` file to the uploaded directory for additional ignore patt
```bash
# Examples
lfc builds get my-env --json | jq '.deploys[] | {name: .deployable.name, url: .publicUrl}'
lfc sites create ./coverage --json | jq -r .url
lfc sites create ./coverage --json | jq -r .openUrl
lfc builds status my-env --watch && run-smoke-tests "$(lfc builds get my-env --json | jq -r '.deploys[0].publicUrl')"
```

Expand Down
8 changes: 5 additions & 3 deletions src/commands/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,12 @@ Schema validation (offline, no auth needed):

Static sites:

lfc sites create <file-or-dir> [--name <label>] # returns a stable URL
lfc sites list [--mine]
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 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

Expand Down Expand Up @@ -193,7 +195,7 @@ Validate a lifecycle.yaml before pushing (guardrail):

Publish a report or artifact for humans:

lfc sites create ./coverage --name "coverage report" --json | jq -r .url
lfc sites create ./coverage --name "coverage report" --visibility public --yes --json | jq -r .openUrl

## Troubleshooting a stuck user

Expand Down
181 changes: 111 additions & 70 deletions src/commands/sites.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,52 @@
import fs from 'node:fs';

import * as clack from '@clack/prompts';
import { Command } from 'commander';
import { Command, Option } from 'commander';
import pc from 'picocolors';

import { decodeJwt } from '../lib/auth.js';
import { loadTokens } from '../lib/config.js';
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 type { Site, SitesCapabilities, SiteVisibility } from '../lib/types.js';
import { prepareSiteUpload, type PreparedSiteUpload } from '../lib/zip.js';

/** Build the multipart form for a site upload from a zip, html file, or directory. */
async function uploadForm(upload: PreparedSiteUpload, name?: string): Promise<FormData> {
async function uploadForm(upload: PreparedSiteUpload, name?: string, visibility?: SiteVisibility): Promise<FormData> {
const form = new FormData();
form.append('file', await fs.openAsBlob(upload.filePath, { type: upload.contentType }), upload.fileName);
if (name) form.append('name', name);
if (visibility) form.append('visibility', visibility);
return form;
}

async function prepareUpload(ctx: Ctx, target: string): Promise<PreparedSiteUpload> {
let config;
try {
config = await ctx.api.getSitesConfig();
} catch {
throw new Error('Could not load sites lifecycle. Try again later.');
}

return prepareSiteUpload(target, config);
async function prepareUpload(ctx: Ctx, target: string, capabilities?: SitesCapabilities): Promise<PreparedSiteUpload> {
return prepareSiteUpload(target, capabilities ?? (await ctx.api.getSitesCapabilities()));
}

function userEmail(ctx: Ctx): string | undefined {
const tokens = loadTokens(ctx.profileName);
if (!tokens) return undefined;
try {
return decodeJwt(tokens.accessToken).email as string | undefined;
} catch {
return undefined;
async function confirmVisibility(visibility: SiteVisibility, yes?: boolean): Promise<boolean> {
if (yes) return true;
if (!process.stdin.isTTY) throw new Error('Use --yes to confirm a visibility change in non-interactive mode');
const ok = await clack.confirm({
message:
visibility === 'public'
? 'Publish this site so anyone with the link can view it?'
: 'Make this site private? Its URL stays the same, but saved copies cannot be recalled.',
});
if (ok !== true) {
process.stderr.write('Aborted.\n');
return false;
}
return true;
}

function printSite(
ctx: Ctx,
site: {
id: string;
url: string;
name?: string | null;
status: string;
expiresAt?: string | null;
fileCount?: number;
sizeBytes?: number;
},
verb: string,
): void {
function printSite(ctx: Ctx, site: Site, verb: string): void {
if (ctx.json) {
printJson(site);
return;
}
process.stderr.write(`${pc.green('✓')} ${verb} site ${pc.bold(site.id)}${site.name ? ` (${site.name})` : ''}\n`);
process.stdout.write(`${site.url}\n`);
const extras: string[] = [];
process.stdout.write(`${site.openUrl}\n`);
const extras: string[] = [site.visibility === 'private' ? 'private · only you' : 'public · anyone with the link'];
if (site.fileCount != null) extras.push(`${site.fileCount} files`);
if (site.sizeBytes != null) extras.push(formatBytes(site.sizeBytes));
if (site.expiresAt) extras.push(`expires ${new Date(site.expiresAt).toLocaleString()}`);
Expand All @@ -74,51 +62,72 @@ export function registerSitesCommands(program: Command): void {
sites
.command('list')
.description('List hosted sites')
.option('-m, --mine', 'only sites created/updated by me')
.option('-m, --mine', 'only sites owned by this identity')
.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; page: number; limit: number }) => {
const user = opts.mine ? userEmail(ctx) : undefined;
if (opts.mine && !user) throw new Error('Cannot resolve your email — log in first (`lfc login`)');
const { items, pagination } = await ctx.api.listSites({ page: opts.page, limit: opts.limit, user });
if (ctx.json) {
printJson({ sites: items, pagination });
return;
}
if (items.length === 0) {
process.stdout.write(pc.dim('No sites found.\n'));
return;
}
const rows = items.map(s => [
pc.bold(s.id),
s.name ?? '',
statusColor(s.status),
link(s.url),
formatBytes(s.sizeBytes),
s.expiresAt ? formatAge(s.expiresAt).replace(' ago', '') : '∞',
s.createdBy ?? '',
]);
process.stdout.write(
renderTable(['id', 'name', 'status', 'url', 'size', 'expires in', 'created by'], rows) + '\n',
);
if (pagination?.totalPages && Number(pagination.totalPages) > 1) {
runAction(
async (ctx, opts: { mine?: boolean; public?: boolean; search?: string; page: number; limit: number }) => {
const view = siteListView(opts);
const { items, pagination } = await ctx.api.listSites({
page: opts.page,
limit: opts.limit,
view,
q: opts.search,
});
if (ctx.json) {
printJson({ sites: items, pagination });
return;
}
if (items.length === 0) {
process.stdout.write(pc.dim('No sites found.\n'));
return;
}
const rows = items.map(s => [
pc.bold(s.id),
s.name ?? '',
statusColor(s.status),
link(s.openUrl),
formatBytes(s.sizeBytes),
s.expiresAt ? formatAge(s.expiresAt).replace(' ago', '') : '∞',
s.visibility,
s.currentRole === 'owner' ? 'owner' : 'view only',
]);
process.stdout.write(
pc.dim(`page ${pagination.page}/${pagination.totalPages} · ${pagination.totalItems} total\n`),
renderTable(['id', 'name', 'status', 'url', 'size', 'expires in', 'visibility', 'access'], rows) + '\n',
);
}
}),
if (pagination?.total && Number(pagination.total) > 1) {
process.stdout.write(
pc.dim(`page ${pagination.current}/${pagination.total} · ${pagination.items} total\n`),
);
}
},
),
);

sites
.command('create <path>')
.description('Upload a .zip, .html file, or directory and get back the site id + URL')
.option('--name <name>', 'display name for the site')
.addOption(
new Option('--visibility <visibility>', 'private or public; defaults depend on your credential').choices([
'private',
'public',
]),
)
.option('-y, --yes', 'confirm public publishing without a prompt')
.action(
runAction(async (ctx, target: string, opts: { name?: string }) => {
const upload = await prepareUpload(ctx, target);
runAction(async (ctx, target: string, opts: { name?: string; visibility?: SiteVisibility; yes?: boolean }) => {
const capabilities = await ctx.api.getSitesCapabilities();
if (!capabilities.enabled || !capabilities.canCreate)
throw new Error('Creating Sites is unavailable for this credential.');
validateSiteVisibility(opts.visibility, capabilities);
if (opts.visibility === 'public' && !(await confirmVisibility('public', opts.yes))) return;
const upload = await prepareUpload(ctx, target, capabilities);
try {
const form = await uploadForm(upload, opts.name);
const form = await uploadForm(upload, opts.name, opts.visibility);
const site = await ctx.api.createSite(form);
printSite(ctx, site, 'Created');
} finally {
Expand All @@ -140,7 +149,10 @@ export function registerSitesCommands(program: Command): void {
process.stdout.write(`${pc.bold(site.id)}${site.name ? ` ${site.name}` : ''}\n`);
const fields: Array<[string, string]> = [
['status', statusColor(site.status)],
['url', link(site.url)],
['url', link(site.openUrl)],
['content url', link(site.contentUrl)],
['visibility', site.visibility],
['access', site.currentRole === 'owner' ? 'owner' : 'view only'],
['size', `${formatBytes(site.sizeBytes)}${site.fileCount != null ? ` (${site.fileCount} files)` : ''}`],
['created', `${site.createdAt ?? ''} ${pc.dim(site.createdBy ?? '')}`],
['updated', `${site.updatedAt ?? ''} ${pc.dim(site.updatedBy ?? '')}`],
Expand All @@ -157,9 +169,14 @@ export function registerSitesCommands(program: Command): void {
.description("Replace a site's content with a new .zip, .html file, or directory")
.action(
runAction(async (ctx, siteId: string, target: string) => {
const current = await ctx.api.getSite(siteId);
if (!current.permissions.canEdit)
throw new Error('Your current access does not allow you to replace its content.');
const upload = await prepareUpload(ctx, target);
try {
const form = await uploadForm(upload);
form.append('expectedAccessRevision', String(current.accessRevision));
form.append('expectedContentRevision', String(current.contentRevision));
const site = await ctx.api.replaceSiteContent(siteId, form);
printSite(ctx, site, 'Updated');
} finally {
Expand All @@ -173,7 +190,10 @@ export function registerSitesCommands(program: Command): void {
.description("Extend a site's expiration (TTL)")
.action(
runAction(async (ctx, siteId: string) => {
const site = await ctx.api.extendSite(siteId);
const current = await ctx.api.getSite(siteId);
if (!current.permissions.canEdit)
throw new Error('Your current access does not allow you to extend its expiry.');
const site = await ctx.api.extendSite(siteId, current.accessRevision);
if (ctx.json) printJson(site);
else
process.stderr.write(
Expand All @@ -188,6 +208,9 @@ export function registerSitesCommands(program: Command): void {
.option('-y, --yes', 'skip the confirmation prompt')
.action(
runAction(async (ctx, siteId: string, opts: { yes?: boolean }) => {
const current = await ctx.api.getSite(siteId);
if (!current.permissions.canDelete)
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.` });
Expand All @@ -196,9 +219,27 @@ export function registerSitesCommands(program: Command): void {
return;
}
}
const site = await ctx.api.deleteSite(siteId);
const site = await ctx.api.deleteSite(siteId, current.accessRevision);
if (ctx.json) printJson(site);
else process.stderr.write(`${pc.green('✓')} Deleted site ${siteId}\n`);
}),
);
sites
.command('visibility <siteId> <visibility>')
.description('Publish a site or make it private (keeps its content URL)')
.option('-y, --yes', 'skip the confirmation prompt')
.action(
runAction(async (ctx, siteId: string, value: string, opts: { yes?: boolean }) => {
if (value !== 'private' && value !== 'public') throw new Error('Visibility must be private or public.');
const site = await ctx.api.getSite(siteId);
if (!site.permissions.canChangeVisibility)
throw new Error('Your current access does not allow you to change its visibility.');
if (site.visibility === value) {
printSite(ctx, site, 'Unchanged');
return;
}
if (!(await confirmVisibility(value, opts.yes))) return;
printSite(ctx, await ctx.api.setSiteVisibility(siteId, value, site.accessRevision), 'Updated');
}),
);
}
Loading
Loading