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 bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getAppBanner } from "./lib/app-banner.ts";
import { registerBalance } from "./lib/balance.ts";
import { registerContracts } from "./lib/contracts/index.tsx";
import { registerDev } from "./lib/dev.ts";
import { registerImages } from "./lib/images/index.ts";
import { registerLogin } from "./lib/login.ts";
import { registerMe } from "./lib/me.ts";
import { registerNodes } from "./lib/nodes/index.ts";
Expand Down Expand Up @@ -51,6 +52,7 @@ async function main() {
registerMe(program);
await registerVM(program);
await registerNodes(program);
registerImages(program);
await registerZones(program);

// (development commands)
Expand Down
141 changes: 141 additions & 0 deletions src/lib/images/get.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import console from "node:console";
import { Command } from "@commander-js/extra-typings";
import chalk from "chalk";
import dayjs from "dayjs";
import advanced from "dayjs/plugin/advancedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { Box, render, Text } from "ink";
import Link from "ink-link";
import { apiClient } from "../../apiClient.ts";
import { logAndQuit } from "../../helpers/errors.ts";
import { formatDate } from "../../helpers/format-time.ts";
import { Row } from "../Row.tsx";

dayjs.extend(utc);
dayjs.extend(advanced);
dayjs.extend(timezone);

function ImageDisplay({
image,
download,
}: {
image: {
name: string;
id: string;
upload_status: string;
sha256_hash: string | null;
};
download: { download_url: string; expires_at: number } | null;
}) {
const expiresAt = download?.expires_at
? new Date(download.expires_at * 1000)
: null;
const isExpired = expiresAt ? expiresAt < new Date() : false;

return (
<Box flexDirection="column" padding={0} width={80}>
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
<Text color="cyan" bold>
Image: {image.name} ({image.id})
</Text>
</Box>

<Box paddingX={1} flexDirection="column">
<Row head="Status: " value={formatStatusInk(image.upload_status)} />
{image.sha256_hash && <Row head="SHA256: " value={image.sha256_hash} />}
{download && (
<>
<Row
head="URL: "
value={
<Box flexDirection="column" paddingRight={1}>
<Text color="cyan">Use curl or wget to download.</Text>
<Link url={download.download_url} fallback={false}>
{download.download_url}
</Link>
</Box>
}
/>
{expiresAt && (
<Row
head="URL Expiry: "
value={
<Box gap={1}>
<Text color={isExpired ? "red" : undefined}>
{expiresAt.toISOString()}{" "}
{chalk.blackBright(
`(${formatDate(dayjs(expiresAt).toDate())} ${dayjs(
expiresAt,
).format("z")})`,
)}
</Text>
{isExpired && <Text dimColor>(Expired)</Text>}
</Box>
}
/>
)}
</>
)}
</Box>
</Box>
);
}

function formatStatusInk(status: string): React.ReactElement {
switch (status) {
case "started":
return <Text color="green">Started</Text>;
case "uploading":
return <Text color="yellow">Uploading</Text>;
case "completed":
return <Text color="cyan">Completed</Text>;
case "failed":
return <Text color="red">Failed</Text>;
default:
return <Text dimColor>Unknown</Text>;
}
}

const get = new Command("get")
.description("Get image details and download URL")
.argument("<id>", "Image ID or name")
.option("--json", "Output JSON")
.action(async (id, opts) => {
const client = await apiClient();

const { data: image, response } = await client.GET("/v2/images/{id}", {
params: { path: { id } },
});
if (!response.ok || !image) {
logAndQuit(
`Failed to get image: ${response.status} ${response.statusText}`,
);
}

// Fetch download URL if image is completed
let download = null;
if (image.upload_status === "completed") {
const { data: downloadData } = await client.GET(
"/v2/images/{id}/download",
{ params: { path: { id } } },
);
if (downloadData) {
download = downloadData;
}
}

if (opts.json) {
console.log(JSON.stringify({ ...image, download }, null, 2));
return;
}

render(
<ImageDisplay
image={{ ...image, sha256_hash: image.sha256_hash ?? null }}
download={download}
/>,
);
});

export default get;
32 changes: 32 additions & 0 deletions src/lib/images/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Command } from "@commander-js/extra-typings";
import get from "./get.tsx";
import list from "./list.ts";
import upload from "./upload.ts";

export function registerImages(program: Command) {
const images = program
.command("images")
.alias("image")
.description("Manage images")
.showHelpAfterError()
.addHelpText(
"after",
`
Examples:\n
\x1b[2m# Upload an image file\x1b[0m
$ sf images upload -f ./my-image.raw -n my-image

\x1b[2m# List all images\x1b[0m
$ sf images list

\x1b[2m# Get image details and download URL\x1b[0m
$ sf images get <image-id>
`,
)
.addCommand(list)
.addCommand(upload)
.addCommand(get)
.action(() => {
images.help();
});
}
122 changes: 122 additions & 0 deletions src/lib/images/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import console from "node:console";
import { Command } from "@commander-js/extra-typings";
import chalk from "chalk";
import Table from "cli-table3";
import ora from "ora";
import { apiClient } from "../../apiClient.ts";
import { logAndQuit } from "../../helpers/errors.ts";
import { formatDate } from "../../helpers/format-time.ts";

const list = new Command("list")
.alias("ls")
.description("List images")
.showHelpAfterError()
.option("--json", "Output in JSON format")
.addHelpText(
"after",
`
Examples:\n
\x1b[2m# List all images\x1b[0m
$ sf images list

\x1b[2m# Get detailed info for a specific image\x1b[0m
$ sf images get <image-id>

\x1b[2m# List images in JSON format\x1b[0m
$ sf images list --json
`,
)
.action(async (options) => {
const client = await apiClient();

const spinner = ora("Fetching images...").start();
const { data: result, response } = await client.GET("/v2/images");
spinner.stop();

if (!response.ok || !result) {
logAndQuit(
`Failed to list images: ${response.status} ${response.statusText}`,
);
}

if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}

const images = result.data;

if (images.length === 0) {
console.log("No images found.");
console.log(chalk.gray("\nUpload your first image:"));
console.log(" sf images upload -f ./my-image.img -n my-image");
return;
}

// Sort images by created_at (newest first)
const sortedImages = [...images].sort((a, b) => {
return (b.created_at || 0) - (a.created_at || 0);
});
const imagesToShow = sortedImages.slice(0, 5);

const table = new Table({
head: [
chalk.cyan("NAME"),
chalk.cyan("ID"),
chalk.cyan("STATUS"),
chalk.cyan("CREATED"),
],
style: {
head: [],
border: ["gray"],
},
});

for (const image of imagesToShow) {
const createdAt = image.created_at
? formatDate(new Date(image.created_at * 1000))
: "Unknown";

const status = formatStatus(image.upload_status);

table.push([image.name, image.id, status, createdAt]);
}

if (images.length > 5) {
table.push([
{
colSpan: 4,
content: chalk.blackBright(
`${images.length - 5} older ${
images.length - 5 === 1 ? "image" : "images"
} not shown. Use sf images list --json to list all images.`,
),
},
]);
}

console.log(table.toString());

console.log(chalk.gray("\nNext steps:"));
const firstImage = sortedImages[0];
if (firstImage) {
console.log(` sf images get ${chalk.cyan(firstImage.id)}`);
}
});

function formatStatus(status: string): string {
switch (status) {
case "started":
return chalk.green("Started");
case "uploading":
return chalk.yellow("Uploading");
case "completed":
return chalk.cyan("Completed");
case "failed":
return chalk.red("Failed");
default:
return chalk.gray("Unknown");
}
}

export default list;
Loading