Skip to content
Open
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
406 changes: 387 additions & 19 deletions www/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check ."
"lint": "prettier --check .",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/adapter-vercel": "^6.3.3",
Expand All @@ -48,7 +49,8 @@
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"vite": "^8.0.16"
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"dependencies": {
"@vercel/analytics": "^2.0.1"
Expand Down
4 changes: 3 additions & 1 deletion www/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface PageData {
cliLinks: import('$lib/cli-release').CliLinks;
}
// interface PageState {}
interface Platform {
env: {
Expand Down
101 changes: 101 additions & 0 deletions www/src/lib/cli-release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { resolveCliLinks } from './cli-release';

const RELEASES_PAGE = 'https://github.com/microsoft/Foundry-Local/releases';

function makeRelease(tag: string, publishedAt: string | null, opts: { draft?: boolean } = {}) {
return {
tag_name: tag,
draft: opts.draft ?? false,
prerelease: true,
published_at: publishedAt,
html_url: `https://github.com/microsoft/Foundry-Local/releases/tag/${tag}`
};
}

// Minimal fetch stubs. Types are loosened because resolveCliLinks only reads `ok` and `json()`.
const okFetch = (body: unknown) =>
(async () => ({ ok: true, json: async () => body })) as unknown as typeof fetch;
const statusFetch = (ok: boolean) =>
(async () => ({ ok, json: async () => [] })) as unknown as typeof fetch;
const throwingFetch = () =>
(async () => {
throw new Error('network down');
}) as unknown as typeof fetch;

describe('resolveCliLinks', () => {
it('resolves the newest cli-preview release by published date, not array order', async () => {
const releases = [
makeRelease('cli-preview-0.10.0', '2026-06-04T20:35:47Z'),
makeRelease('cli-preview-0.10.2', '2026-07-14T21:39:03Z'),
makeRelease('cli-preview-0.10.1', '2026-06-22T23:45:41Z')
];
const links = await resolveCliLinks(okFetch(releases));
expect(links.tag).toBe('cli-preview-0.10.2');
expect(links.version).toBe('0.10.2');
expect(links.releasePage).toBe(
'https://github.com/microsoft/Foundry-Local/releases/tag/cli-preview-0.10.2'
);
});

it('ignores non cli-preview tags and draft releases', async () => {
const releases = [
makeRelease('v-unrelated', '2027-01-01T00:00:00Z'),
makeRelease('cli-preview-0.11.0', '2026-08-01T00:00:00Z', { draft: true }),
makeRelease('cli-preview-0.10.2', '2026-07-14T21:39:03Z')
];
const links = await resolveCliLinks(okFetch(releases));
expect(links.tag).toBe('cli-preview-0.10.2');
});

it('treats a null published_at as oldest, deterministically, regardless of array order', async () => {
const releases = [
makeRelease('cli-preview-0.10.3', null),
makeRelease('cli-preview-0.10.2', '2026-07-14T21:39:03Z')
];
const links = await resolveCliLinks(okFetch(releases));
expect(links.tag).toBe('cli-preview-0.10.2');
});

it('sends a bearer Authorization header only when a token is provided', async () => {
const seen: Array<Record<string, string> | undefined> = [];
const spyFetch = ((_url: string, init?: { headers?: Record<string, string> }) => {
seen.push(init?.headers);
return Promise.resolve({
ok: true,
json: async () => [makeRelease('cli-preview-0.10.2', '2026-07-14T21:39:03Z')]
});
}) as unknown as typeof fetch;

await resolveCliLinks(spyFetch, 'secret-token');
await resolveCliLinks(spyFetch);

expect(seen[0]?.Authorization).toBe('Bearer secret-token');
expect(seen[1]?.Authorization).toBeUndefined();
});

it('falls back to the releases page when the API responds non-OK', async () => {
const links = await resolveCliLinks(statusFetch(false));
expect(links.tag).toBeNull();
expect(links.version).toBeNull();
expect(links.releasePage).toBe(RELEASES_PAGE);
});

it('falls back to the releases page when no cli-preview release exists', async () => {
const links = await resolveCliLinks(okFetch([makeRelease('v1.0.0', '2027-01-01T00:00:00Z')]));
expect(links.tag).toBeNull();
expect(links.releasePage).toBe(RELEASES_PAGE);
});

it('falls back to the releases page when fetch throws', async () => {
const links = await resolveCliLinks(throwingFetch());
expect(links.tag).toBeNull();
expect(links.releasePage).toBe(RELEASES_PAGE);
});

it('falls back when the API returns a non-array error object (e.g. rate limited)', async () => {
const links = await resolveCliLinks(okFetch({ message: 'API rate limit exceeded' }));
expect(links.tag).toBeNull();
expect(links.releasePage).toBe(RELEASES_PAGE);
});
});
73 changes: 73 additions & 0 deletions www/src/lib/cli-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Resolves the latest `cli-preview-*` GitHub release at build time so the site never hardcodes a
// specific preview tag (issue #924). Returns the release's page URL; the platform download buttons
// link there and let the user pick their architecture/variant (mirroring the arch-safe `winget`/
// `brew` install path in platform.ts). If release discovery fails, it falls back to the general
// releases page.

const OWNER_REPO = 'microsoft/Foundry-Local';
const RELEASES_PAGE = `https://github.com/${OWNER_REPO}/releases`;
const RELEASES_API = `https://api.github.com/repos/${OWNER_REPO}/releases?per_page=30`;
const CLI_TAG_PREFIX = 'cli-preview-';

export interface CliLinks {
/** Release tag (e.g. "cli-preview-0.10.2"), or null when discovery failed. */
tag: string | null;
/** Version portion of the tag (e.g. "0.10.2"), or null when discovery failed. */
version: string | null;
/** The resolved release's page, or the general releases page on fallback. */
releasePage: string;
Comment on lines +17 to +18
}

interface GitHubRelease {
tag_name: string;
draft: boolean;
published_at: string | null;
html_url: string;
}

function fallbackLinks(): CliLinks {
return { tag: null, version: null, releasePage: RELEASES_PAGE };
}

/**
* Fetch the repo's releases and resolve the newest non-draft `cli-preview-*` release.
* Pass the SvelteKit-provided `fetch` from a server `load` so this runs at build/prerender time.
* An optional `token` (from the build environment) is sent as a bearer credential to raise the
* GitHub API rate limit; it is never required and never exposed to the client.
*/
export async function resolveCliLinks(
fetchFn: typeof fetch = fetch,
token?: string
): Promise<CliLinks> {
try {
const headers: Record<string, string> = { Accept: 'application/vnd.github+json' };
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const response = await fetchFn(RELEASES_API, { headers });
if (!response.ok) {
return fallbackLinks();
}

const releases = (await response.json()) as GitHubRelease[];
const latest = releases
.filter((release) => release.tag_name?.startsWith(CLI_TAG_PREFIX) && !release.draft)
// Coerce invalid/missing published_at to 0 so a null date sorts oldest deterministically.
.sort(
(a, b) => (Date.parse(b.published_at ?? '') || 0) - (Date.parse(a.published_at ?? '') || 0)
)[0];

if (!latest) {
return fallbackLinks();
}

return {
tag: latest.tag_name,
version: latest.tag_name.slice(CLI_TAG_PREFIX.length) || null,
releasePage: latest.html_url ?? RELEASES_PAGE
};
} catch {
return fallbackLinks();
}
}
22 changes: 13 additions & 9 deletions www/src/lib/components/download-dropdown.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
import { Download, Copy, Check, ExternalLink } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';

const CLI_RELEASE_URL =
'https://github.com/microsoft/Foundry-Local/releases/tag/cli-preview-0.10.0';
import { page } from '$app/stores';

interface Props {
variant?: 'default' | 'ghost' | 'outline';
Expand All @@ -24,6 +22,10 @@

const isMobile = new IsMobile();

// CLI download links resolve to the latest cli-preview-* release at build time (issue #924),
// falling back to the releases page when discovery fails. Data comes from the root layout load.
const cliLinks = $derived($page.data.cliLinks);

// Platform SVG icons
const AppleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282z"/></svg>`;

Expand Down Expand Up @@ -54,26 +56,26 @@
crossPlatformCommand: string;
};

const cliInstallOptions: CliInstallOption[] = [
const cliInstallOptions: CliInstallOption[] = $derived([
{
id: 'windows',
label: 'Windows CLI',
href: CLI_RELEASE_URL,
href: cliLinks.releasePage,
icon: WindowsIcon
},
{
id: 'macos',
label: 'macOS CLI',
href: CLI_RELEASE_URL,
href: cliLinks.releasePage,
icon: AppleIcon
},
{
id: 'linux',
label: 'Linux CLI',
href: CLI_RELEASE_URL,
href: cliLinks.releasePage,
icon: LinuxIcon
}
];
]);

const sdkInstallOptions: SdkInstallOption[] = [
{
Expand Down Expand Up @@ -195,7 +197,9 @@
<span class="mt-0.5 inline-flex shrink-0" aria-hidden="true">{@html item.icon}</span>
<div class="flex flex-1 flex-col gap-1 px-2">
<span class="font-medium">{item.label}</span>
<code class="text-muted-foreground text-xs break-all">cli-preview-0.10.0 on GitHub</code>
<code class="text-muted-foreground text-xs break-all"
>{cliLinks.tag ? `${cliLinks.tag} on GitHub` : 'Download from GitHub'}</code
>
</div>
<ExternalLink class="size-4 shrink-0 opacity-50" aria-hidden="true" />
</a>
Expand Down
13 changes: 13 additions & 0 deletions www/src/routes/+layout.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
import { resolveCliLinks } from '$lib/cli-release';

// Resolve the latest cli-preview-* release at build time (the layout is prerendered) and expose it to
// every route, so the CLI download links never hardcode a stale preview tag (issue #924). A
// GITHUB_TOKEN in the build environment raises the GitHub API rate limit (60/hr -> 5000/hr) so
// resolution is reliable; without it the call is unauthenticated and degrades to the releases page.
export const load: LayoutServerLoad = async ({ fetch }) => {
return {
cliLinks: await resolveCliLinks(fetch, env.GITHUB_TOKEN)
};
};
25 changes: 7 additions & 18 deletions www/src/routes/models/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,13 @@
const KNOWN_DEVICES = ['cpu', 'gpu', 'npu'];
const MODEL_QUERY_PARAM = 'model';
const CLI_RUN_COMMAND = 'foundry run qwen2.5-0.5b';
const CLI_RELEASE_URL =
'https://github.com/microsoft/Foundry-Local/releases/tag/cli-preview-0.10.0';
const CLI_INSTALL_LINKS = [
{
id: 'windows-cli',
label: 'Windows',
href: CLI_RELEASE_URL
},
{
id: 'macos-cli',
label: 'macOS',
href: CLI_RELEASE_URL
},
{
id: 'linux-cli',
label: 'Linux',
href: CLI_RELEASE_URL
}
// CLI download links resolve to the latest cli-preview-* release at build time (issue #924),
// falling back to the releases page when discovery fails. Data comes from the root layout load.
$: cliLinks = $page.data.cliLinks;
$: CLI_INSTALL_LINKS = [
{ id: 'windows-cli', label: 'Windows', href: cliLinks.releasePage },
{ id: 'macos-cli', label: 'macOS', href: cliLinks.releasePage },
{ id: 'linux-cli', label: 'Linux', href: cliLinks.releasePage }
];

// Debounce timer for search
Expand Down
10 changes: 10 additions & 0 deletions www/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';

// Standalone vitest config (kept separate from vite.config.ts so unit tests run in a plain Node
// environment without loading the SvelteKit plugin). Suited to pure-logic modules like cli-release.ts.
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts']
}
});