Skip to content

Commit 16ca006

Browse files
feat: [PR-1680] add sf images command backed by v2 API
Adds top-level `sf images` command with list, get, and upload subcommands, pointing to the v2 images API endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a1a7064 commit 16ca006

6 files changed

Lines changed: 736 additions & 0 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { registerContracts } from "./lib/contracts/index.tsx";
2121
import { registerDev } from "./lib/dev.ts";
2222
import { registerLogin } from "./lib/login.ts";
2323
import { registerMe } from "./lib/me.ts";
24+
import { registerImages } from "./lib/images/index.ts";
2425
import { registerNodes } from "./lib/nodes/index.ts";
2526
import { analytics, IS_TRACKING_DISABLED } from "./lib/posthog.ts";
2627
import { registerScale } from "./lib/scale/index.tsx";
@@ -51,6 +52,7 @@ async function main() {
5152
registerMe(program);
5253
await registerVM(program);
5354
await registerNodes(program);
55+
registerImages(program);
5456
await registerZones(program);
5557

5658
// (development commands)

src/lib/images/get.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import console from "node:console";
2+
import { Command } from "@commander-js/extra-typings";
3+
import chalk from "chalk";
4+
import dayjs from "dayjs";
5+
import advanced from "dayjs/plugin/advancedFormat";
6+
import timezone from "dayjs/plugin/timezone";
7+
import utc from "dayjs/plugin/utc";
8+
import { Box, render, Text } from "ink";
9+
import Link from "ink-link";
10+
import { getAuthToken, loadConfig } from "../../helpers/config.ts";
11+
import { logAndQuit, logLoginMessageAndQuit } from "../../helpers/errors.ts";
12+
import { formatDate } from "../../helpers/format-time.ts";
13+
import { Row } from "../Row.tsx";
14+
15+
dayjs.extend(utc);
16+
dayjs.extend(advanced);
17+
dayjs.extend(timezone);
18+
19+
function ImageDisplay({
20+
image,
21+
download,
22+
}: {
23+
image: {
24+
name: string;
25+
id: string;
26+
upload_status: string;
27+
sha256_hash: string | null;
28+
};
29+
download: { download_url: string; expires_at: number } | null;
30+
}) {
31+
const expiresAt = download?.expires_at
32+
? new Date(download.expires_at * 1000)
33+
: null;
34+
const isExpired = expiresAt ? expiresAt < new Date() : false;
35+
36+
return (
37+
<Box flexDirection="column" padding={0} width={80}>
38+
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
39+
<Text color="cyan" bold>
40+
Image: {image.name} ({image.id})
41+
</Text>
42+
</Box>
43+
44+
<Box paddingX={1} flexDirection="column">
45+
<Row head="Status: " value={formatStatusInk(image.upload_status)} />
46+
{image.sha256_hash && <Row head="SHA256: " value={image.sha256_hash} />}
47+
{download && (
48+
<>
49+
<Row
50+
head="URL: "
51+
value={
52+
<Box flexDirection="column" paddingRight={1}>
53+
<Text color="cyan">Use curl or wget to download.</Text>
54+
<Link url={download.download_url} fallback={false}>
55+
{download.download_url}
56+
</Link>
57+
</Box>
58+
}
59+
/>
60+
{expiresAt && (
61+
<Row
62+
head="URL Expiry: "
63+
value={
64+
<Box gap={1}>
65+
<Text color={isExpired ? "red" : undefined}>
66+
{expiresAt.toISOString()}{" "}
67+
{chalk.blackBright(
68+
`(${formatDate(dayjs(expiresAt).toDate())} ${dayjs(
69+
expiresAt,
70+
).format("z")})`,
71+
)}
72+
</Text>
73+
{isExpired && <Text dimColor>(Expired)</Text>}
74+
</Box>
75+
}
76+
/>
77+
)}
78+
</>
79+
)}
80+
</Box>
81+
</Box>
82+
);
83+
}
84+
85+
function formatStatusInk(status: string): React.ReactElement {
86+
switch (status) {
87+
case "started":
88+
return <Text color="green">Started</Text>;
89+
case "uploading":
90+
return <Text color="yellow">Uploading</Text>;
91+
case "completed":
92+
return <Text color="cyan">Completed</Text>;
93+
case "failed":
94+
return <Text color="red">Failed</Text>;
95+
default:
96+
return <Text dimColor>Unknown</Text>;
97+
}
98+
}
99+
100+
const get = new Command("get")
101+
.description("Get image details and download URL")
102+
.argument("<id>", "Image ID or name")
103+
.option("--json", "Output JSON")
104+
.action(async (id, opts) => {
105+
const token = await getAuthToken();
106+
if (!token) {
107+
logLoginMessageAndQuit();
108+
}
109+
const config = await loadConfig();
110+
const baseUrl = `${config.api_url}/v2/images`;
111+
112+
const res = await fetch(`${baseUrl}/${encodeURIComponent(id)}`, {
113+
headers: { Authorization: `Bearer ${token}` },
114+
});
115+
if (!res.ok) {
116+
logAndQuit(`Failed to get image: ${res.status} ${res.statusText}`);
117+
}
118+
const image = await res.json();
119+
120+
// Fetch download URL if image is completed
121+
let download = null;
122+
if (image.upload_status === "completed") {
123+
const downloadRes = await fetch(
124+
`${baseUrl}/${encodeURIComponent(id)}/download`,
125+
{ headers: { Authorization: `Bearer ${token}` } },
126+
);
127+
if (downloadRes.ok) {
128+
download = await downloadRes.json();
129+
}
130+
}
131+
132+
if (opts.json) {
133+
console.log(JSON.stringify({ ...image, download }, null, 2));
134+
return;
135+
}
136+
137+
render(<ImageDisplay image={image} download={download} />);
138+
});
139+
140+
export default get;

src/lib/images/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Command } from "@commander-js/extra-typings";
2+
import get from "./get.tsx";
3+
import list from "./list.ts";
4+
import upload from "./upload.ts";
5+
6+
export function registerImages(program: Command) {
7+
const images = program
8+
.command("images")
9+
.alias("image")
10+
.description("Manage images")
11+
.showHelpAfterError()
12+
.addHelpText(
13+
"after",
14+
`
15+
Examples:\n
16+
\x1b[2m# Upload an image file\x1b[0m
17+
$ sf images upload -f ./my-image.raw -n my-image
18+
19+
\x1b[2m# List all images\x1b[0m
20+
$ sf images list
21+
22+
\x1b[2m# Get image details and download URL\x1b[0m
23+
$ sf images get <image-id>
24+
`,
25+
)
26+
.addCommand(list)
27+
.addCommand(upload)
28+
.addCommand(get)
29+
.action(() => {
30+
images.help();
31+
});
32+
}

src/lib/images/list.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import console from "node:console";
2+
import { Command } from "@commander-js/extra-typings";
3+
import chalk from "chalk";
4+
import Table from "cli-table3";
5+
import ora from "ora";
6+
import { getAuthToken, loadConfig } from "../../helpers/config.ts";
7+
import { logAndQuit, logLoginMessageAndQuit } from "../../helpers/errors.ts";
8+
import { formatDate } from "../../helpers/format-time.ts";
9+
10+
const list = new Command("list")
11+
.alias("ls")
12+
.description("List images")
13+
.showHelpAfterError()
14+
.option("--json", "Output in JSON format")
15+
.addHelpText(
16+
"after",
17+
`
18+
Examples:\n
19+
\x1b[2m# List all images\x1b[0m
20+
$ sf images list
21+
22+
\x1b[2m# Get detailed info for a specific image\x1b[0m
23+
$ sf images get <image-id>
24+
25+
\x1b[2m# List images in JSON format\x1b[0m
26+
$ sf images list --json
27+
`,
28+
)
29+
.action(async (options) => {
30+
const token = await getAuthToken();
31+
if (!token) {
32+
logLoginMessageAndQuit();
33+
}
34+
const config = await loadConfig();
35+
36+
const spinner = ora("Fetching images...").start();
37+
const res = await fetch(`${config.api_url}/v2/images`, {
38+
headers: { Authorization: `Bearer ${token}` },
39+
});
40+
spinner.stop();
41+
42+
if (!res.ok) {
43+
logAndQuit(`Failed to list images: ${res.status} ${res.statusText}`);
44+
}
45+
46+
const result = await res.json();
47+
48+
if (options.json) {
49+
console.log(JSON.stringify(result, null, 2));
50+
return;
51+
}
52+
53+
const images = result.data;
54+
55+
if (images.length === 0) {
56+
console.log("No images found.");
57+
console.log(chalk.gray("\nUpload your first image:"));
58+
console.log(" sf images upload -f ./my-image.img -n my-image");
59+
return;
60+
}
61+
62+
// Sort images by created_at (newest first)
63+
const sortedImages = [...images].sort(
64+
(a: { created_at: number }, b: { created_at: number }) => {
65+
return (b.created_at || 0) - (a.created_at || 0);
66+
},
67+
);
68+
const imagesToShow = sortedImages.slice(0, 5);
69+
70+
const table = new Table({
71+
head: [
72+
chalk.cyan("NAME"),
73+
chalk.cyan("ID"),
74+
chalk.cyan("STATUS"),
75+
chalk.cyan("CREATED"),
76+
],
77+
style: {
78+
head: [],
79+
border: ["gray"],
80+
},
81+
});
82+
83+
for (const image of imagesToShow) {
84+
const createdAt = image.created_at
85+
? formatDate(new Date(image.created_at * 1000))
86+
: "Unknown";
87+
88+
const status = formatStatus(image.upload_status);
89+
90+
table.push([image.name, image.id, status, createdAt]);
91+
}
92+
93+
if (images.length > 5) {
94+
table.push([
95+
{
96+
colSpan: 4,
97+
content: chalk.blackBright(
98+
`${images.length - 5} older ${
99+
images.length - 5 === 1 ? "image" : "images"
100+
} not shown. Use sf images list --json to list all images.`,
101+
),
102+
},
103+
]);
104+
}
105+
106+
console.log(table.toString());
107+
108+
console.log(chalk.gray("\nNext steps:"));
109+
const firstImage = sortedImages[0];
110+
if (firstImage) {
111+
console.log(` sf images get ${chalk.cyan(firstImage.id)}`);
112+
}
113+
});
114+
115+
function formatStatus(status: string): string {
116+
switch (status) {
117+
case "started":
118+
return chalk.green("Started");
119+
case "uploading":
120+
return chalk.yellow("Uploading");
121+
case "completed":
122+
return chalk.cyan("Completed");
123+
case "failed":
124+
return chalk.red("Failed");
125+
default:
126+
return chalk.gray("Unknown");
127+
}
128+
}
129+
130+
export default list;

0 commit comments

Comments
 (0)