feat: one click deployments for appwrite sites

This commit is contained in:
Atharva Deosthale
2025-09-03 11:32:37 +05:30
parent 1c0af8d5be
commit ef1cbeebbc
4 changed files with 768 additions and 0 deletions
@@ -0,0 +1,365 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import { page as pageStore } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Card } from '$lib/components';
import { Button, Form } from '$lib/elements/forms';
import { Wizard } from '$lib/layout';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import {
Fieldset,
Layout,
Icon,
Typography,
Input,
Tag,
Selector
} from '@appwrite.io/pink-svelte';
import { IconGithub, IconExternalLink, IconPencil } from '@appwrite.io/pink-icons-svelte';
import { writable } from 'svelte/store';
import Domain from '../domain.svelte';
import { Adapter, BuildRuntime, Framework, ID } from '@appwrite.io/console';
import { CustomId } from '$lib/components';
import { getFrameworkIcon } from '$lib/stores/sites';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
import { iconPath } from '$lib/stores/app';
import type { PageData } from './$types';
export let data: PageData;
let showExitModal = false;
let showCustomId = false;
let formComponent: Form;
let isSubmitting = writable(false);
// Default values
let name = data.repository.name;
let id = ID.unique();
let domain = data.repository.name.toLowerCase().replace(/[^a-z0-9]/g, '-');
let domainIsValid = true;
// Get URL params
const urlParams = new URLSearchParams($pageStore.url.search);
const preset = urlParams.get('preset') || 'nextjs';
// Map preset string to Framework enum
const presetMap = {
nextjs: Framework.Nextjs,
react: Framework.React,
vue: Framework.Vue,
nuxt: Framework.Nuxt,
sveltekit: Framework.Sveltekit,
astro: Framework.Astro,
vite: Framework.Vite,
other: Framework.Other
};
let framework = presetMap[preset.toLowerCase()] || Framework.Nextjs;
let branch = '';
let rootDir = '';
let variables: Array<{ key: string; value: string; secret: boolean }> = [];
// Build configuration - use from URL params or defaults
let installCommand = urlParams.get('install') || '';
let buildCommand = urlParams.get('build') || '';
let outputDirectory = urlParams.get('output') || '';
// Initialize environment variables from query params
if (data.envKeys.length > 0) {
variables = data.envKeys.map((key) => ({ key, value: '', secret: false }));
}
// Framework options
const frameworkOptions = [
{ key: Framework.Nextjs, name: 'Next.js', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.React, name: 'React', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Vue, name: 'Vue', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Nuxt, name: 'Nuxt', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Sveltekit, name: 'SvelteKit', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Astro, name: 'Astro', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Vite, name: 'Vite', buildRuntime: BuildRuntime.Node210 },
{ key: Framework.Other, name: 'Other', buildRuntime: BuildRuntime.Static1 }
];
$: selectedFramework = frameworkOptions.find((f) => f.key === framework) || frameworkOptions[0];
// Update build commands when framework changes (only if not provided via URL)
const hasCustomCommands =
urlParams.get('install') || urlParams.get('build') || urlParams.get('output');
$: if (framework && data.frameworks && !hasCustomCommands) {
const fw = data.frameworks.frameworks.find((f) => f.key === framework);
if (fw && fw.adapters && fw.adapters.length > 0) {
const adapter = fw.adapters[0];
installCommand = adapter.installCommand || '';
buildCommand = adapter.buildCommand || '';
outputDirectory = adapter.outputDirectory || '';
}
}
async function create() {
if (!domainIsValid) {
addNotification({
type: 'error',
message: 'Domain is not valid'
});
return;
}
try {
// Create site with build configuration
let site = await sdk.forProject(page.params.region, page.params.project).sites.create(
id || ID.unique(),
name,
framework,
selectedFramework.buildRuntime,
undefined, // enabled
undefined, // logging
undefined, // timeout
installCommand || undefined,
buildCommand || undefined,
outputDirectory || undefined,
framework === Framework.Other ? Adapter.Static : undefined, // adapter
undefined, // installationId
undefined, // fallbackFile
undefined, // providerRepositoryId
undefined, // branch
false, // silentMode
rootDir || undefined
);
// Add domain
await sdk
.forProject(page.params.region, page.params.project)
.proxy.createSiteRule(
`${domain}.${$regionalConsoleVariables._APP_DOMAIN_SITES}`,
site.$id
);
// Add variables
const promises = variables.map((variable) =>
sdk
.forProject(page.params.region, page.params.project)
.sites.createVariable(site.$id, variable.key, variable.value, variable.secret)
);
await Promise.all(promises);
// Fetch tags from GitHub API
let latestTag = '';
try {
const tagsResponse = await fetch(
`https://api.github.com/repos/${data.repository.owner}/${data.repository.name}/tags`
);
if (tagsResponse.ok) {
const tags = await tagsResponse.json();
if (tags.length > 0) {
latestTag = tags[0].name;
} else {
addNotification({
type: 'error',
message:
'No tags found in repository. Please create a tag before deploying.'
});
return;
}
} else {
addNotification({
type: 'error',
message: 'Failed to fetch tags from GitHub.'
});
return;
}
} catch (error) {
addNotification({
type: 'error',
message: 'Failed to fetch tags from GitHub: ' + error.message
});
return;
}
// Create deployment from GitHub repository using the latest tag
const deployment = await sdk
.forProject(page.params.region, page.params.project)
.sites.createTemplateDeployment(
site.$id,
data.repository.name,
data.repository.owner,
rootDir || '.',
latestTag,
true
);
trackEvent(Submit.SiteCreate, {
source: 'deploy-button',
framework: framework,
repository: data.repository.url
});
await goto(
`${base}/project-${page.params.region}-${page.params.project}/sites/create-site/deploying?site=${site.$id}&deployment=${deployment.$id}`
);
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
trackError(e, Submit.SiteCreate);
}
}
</script>
<svelte:head>
<title>Deploy {data.repository.name} - Appwrite</title>
</svelte:head>
<Wizard
title="Deploy site"
bind:showExitModal
href={`${base}/project-${page.params.region}-${page.params.project}/sites/`}
confirmExit>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Layout.Stack gap="xl">
<Card padding="s" radius="s">
<Layout.Stack gap="m">
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
Repository
</Typography.Text>
<Layout.Stack direction="row" alignItems="center" gap="s">
<Icon icon={IconGithub} size="m" />
<Typography.Text variant="m-400">
{data.repository.owner}/{data.repository.name}
</Typography.Text>
<Button secondary size="s" external href={data.repository.url}>
<Icon icon={IconExternalLink} slot="end" size="s" />
</Button>
</Layout.Stack>
</Layout.Stack>
</Card>
<Fieldset legend="Details">
<Layout.Stack gap="l">
<Layout.Stack gap="s">
<Input.Text
label="Name"
id="name"
name="name"
bind:value={name}
required
placeholder="Enter name" />
{#if showCustomId}
<CustomId bind:id bind:show={showCustomId} name="Site" />
{:else}
<div>
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" />
Site ID
</Tag>
</div>
{/if}
</Layout.Stack>
<Input.Select
id="framework"
label="Framework"
placeholder="Select framework"
bind:value={framework}
options={frameworkOptions.map((fw) => ({
value: fw.key,
label: fw.name,
leadingHtml: `<img src='${$iconPath(getFrameworkIcon(fw.key), 'color')}' style='inline-size: var(--icon-size-m)' />`
}))} />
</Layout.Stack>
</Fieldset>
<Fieldset legend="Git configuration">
<Layout.Stack gap="m">
<Input.Text
label="Production branch"
placeholder="Leave empty to use default branch"
bind:value={branch} />
<Input.Text label="Root directory" placeholder="./" bind:value={rootDir} />
</Layout.Stack>
</Fieldset>
<Fieldset legend="Build configuration">
<Layout.Stack gap="m">
<Input.Text
label="Install command"
placeholder={installCommand || 'npm install'}
bind:value={installCommand} />
<Input.Text
label="Build command"
placeholder={buildCommand || 'npm run build'}
bind:value={buildCommand} />
<Input.Text
label="Output directory"
placeholder={outputDirectory || 'dist'}
bind:value={outputDirectory} />
</Layout.Stack>
</Fieldset>
{#if variables.length > 0}
<Fieldset legend="Environment variables">
<Layout.Stack gap="m">
{#each variables as variable, i}
<Layout.Stack direction="row" gap="s" alignItems="flex-end">
<Input.Text
label={i === 0 ? 'Key' : null}
value={variable.key}
readonly
style="flex: 1" />
<Input.Text
label={i === 0 ? 'Value' : null}
placeholder="Enter value"
required
bind:value={variable.value}
style="flex: 2" />
<div
style="display: flex; flex-direction: column; gap: 0.25rem; min-width: 80px; align-items: center;">
{#if i === 0}
<span
style="font-size: 0.875rem; color: var(--color-neutral-70);"
>Secret</span>
{/if}
<div
style="height: 2.5rem; display: flex; align-items: center; justify-content: center;">
<Selector.Checkbox
size="s"
id="secret-{i}"
bind:checked={variable.secret} />
</div>
</div>
</Layout.Stack>
{/each}
</Layout.Stack>
</Fieldset>
{/if}
<Domain bind:domain bind:domainIsValid />
{#if !data.installations?.total}
<Card isDashed padding="xs">
<Layout.Stack direction="row" gap="s" alignItems="center">
<Icon icon={IconGithub} size="m" />
<Typography.Text variant="m-400">
Note: You can connect your GitHub account later to enable automatic
deployments
</Typography.Text>
</Layout.Stack>
</Card>
{/if}
</Layout.Stack>
</Form>
<svelte:fragment slot="footer">
<Button fullWidthMobile size="s" secondary on:click={() => (showExitModal = true)}>
Cancel
</Button>
<Button
fullWidthMobile
size="s"
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting || !domainIsValid}>
Deploy
</Button>
</svelte:fragment>
</Wizard>
@@ -0,0 +1,50 @@
import { sdk } from '$lib/stores/sdk';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { PageLoad } from './$types';
import type { Models } from '@appwrite.io/console';
export const load: PageLoad = async ({ url, params }) => {
// Get repository URL from query params
const repoUrl = url.searchParams.get('repo');
if (!repoUrl) {
redirect(302, `${base}/project-${params.region}-${params.project}/sites`);
}
// Parse repository information
const repoMatch = repoUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\?\s]+)/);
if (!repoMatch) {
redirect(302, `${base}/project-${params.region}-${params.project}/sites`);
}
const [, owner, repoName] = repoMatch;
// Clean repository name (remove .git extension if present)
const cleanRepoName = repoName.replace(/\.git$/, '');
// Get environment variables from query params
const envParam = url.searchParams.get('env');
const envKeys = envParam ? envParam.split(',').map((key: string) => key.trim()) : [];
// Try to fetch installations
let installations: Models.InstallationList | null = null;
try {
installations = await sdk.forProject(params.region, params.project).vcs.listInstallations();
} catch (error) {
// If error, user might not have GitHub connected
installations = null;
}
// Fetch frameworks list
const frameworks = await sdk.forProject(params.region, params.project).sites.listFrameworks();
return {
repository: {
url: repoUrl,
owner,
name: cleanRepoName
},
envKeys,
installations,
frameworks
};
};
+263
View File
@@ -0,0 +1,263 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import CustomId from '$lib/components/customId.svelte';
import { Button, Form, InputSelect } from '$lib/elements/forms';
import type { AllowedRegions } from '$lib/sdk/billing.js';
import { app } from '$lib/stores/app';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { isCloud } from '$lib/system';
import { ID, Query, type Models, Region } from '@appwrite.io/console';
import { IconGithub, IconPencil, IconPlusSm } from '@appwrite.io/pink-icons-svelte';
import { Card, Divider, Icon, Input, Layout, Tag, Typography } from '@appwrite.io/pink-svelte';
import { filterRegions } from '$lib/helpers/regions';
import { loadAvailableRegions } from '$routes/(console)/regions';
import { regions as regionsStore } from '$lib/stores/organization';
let { data } = $props();
let projects = $state<Models.ProjectList>();
let selectedProject = $state<string>();
let selectedOrg = $state(
data?.organizations?.total ? data.organizations.teams[0].$id : undefined
);
let projectName = $state<string>();
let showCustomId = $state(false);
let region = $state<AllowedRegions>();
let id = $state<string>();
async function fetchProjects() {
projects = await sdk.forConsole.projects.list([
Query.equal('teamId', selectedOrg),
Query.orderDesc('')
]);
selectedProject = projects?.total ? projects.projects[0].$id : null;
}
async function handleSubmit() {
if (selectedProject === null) {
try {
const p = await sdk.forConsole.projects.create(
id ?? ID.unique(),
projectName,
selectedOrg,
isCloud ? (region as Region) : undefined
);
trackEvent(Submit.ProjectCreate, {
customId: !!id,
selectedOrg,
teamId: selectedOrg,
source: 'deploy-button'
});
selectedProject = p.$id;
const deployUrl = buildDeployUrl(p);
await goto(deployUrl);
} catch (e) {
trackError(e, Submit.ProjectCreate);
addNotification({
type: 'error',
message: e.message
});
}
} else {
const project = projects.projects.find((p) => p.$id === selectedProject);
const deployUrl = buildDeployUrl(project);
await goto(deployUrl);
}
}
function buildDeployUrl(project: Models.Project) {
// Use the selected region or default to 'default' if not available
const projectRegion = isCloud ? region : 'default';
const url = new URL(
`${base}/project-${projectRegion}-${project.$id}/sites/create-site/deploy`,
window.location.origin
);
url.searchParams.set('repo', data.repository.url);
if (data.envKeys.length > 0) {
url.searchParams.set('env', data.envKeys.join(','));
}
// Pass through all the original URL params
const currentUrl = new URL(window.location.href);
const preset = currentUrl.searchParams.get('preset');
const install = currentUrl.searchParams.get('install');
const build = currentUrl.searchParams.get('build');
const output = currentUrl.searchParams.get('output');
if (preset) url.searchParams.set('preset', preset);
if (install) url.searchParams.set('install', install);
if (build) url.searchParams.set('build', build);
if (output) url.searchParams.set('output', output);
return url.toString();
}
$effect(() => {
if (selectedOrg !== undefined) {
fetchProjects();
}
});
// Load regions when organization is selected
$effect(() => {
if (isCloud && selectedOrg) {
loadAvailableRegions(selectedOrg);
}
});
// Set default region when regions are loaded
$effect(() => {
if (isCloud && $regionsStore.regions?.length > 0 && !region) {
region = $regionsStore.regions.find((r) => r.default)?.$id as AllowedRegions;
}
});
</script>
<svelte:head>
<title>Deploy {data.repository.name} - Appwrite</title>
</svelte:head>
<div
style="display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 2rem;">
<div style="max-width: 480px; width: 100%;">
<div style="text-align: center; margin-bottom: 2rem;">
{#if $app.themeInUse === 'dark'}
<img
src="/console/images/appwrite-logo-dark.svg"
alt="Appwrite"
width="160"
height="30" />
{:else}
<img
src="/console/images/appwrite-logo-light.svg"
alt="Appwrite"
width="160"
height="30" />
{/if}
</div>
<Card.Base padding="m" radius="l">
<Layout.Stack gap="xl">
<Layout.Stack gap="l">
<Card.Base variant="secondary" padding="s" radius="s">
<Layout.Stack gap="m">
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
Repository
</Typography.Text>
<Layout.Stack direction="row" alignItems="center" gap="s">
<Icon icon={IconGithub} size="m" />
<Typography.Text variant="m-400">
{data.repository.owner}/{data.repository.name}
</Typography.Text>
</Layout.Stack>
{#if data.envKeys.length > 0}
<Divider />
<Layout.Stack gap="s">
<Typography.Text
variant="m-500"
color="--fgcolor-neutral-primary">
Environment Variables Required
</Typography.Text>
<Layout.Stack direction="row" gap="xs" wrap="wrap">
{#each data.envKeys as envKey}
<Tag size="s">{envKey}</Tag>
{/each}
</Layout.Stack>
</Layout.Stack>
{/if}
</Layout.Stack>
</Card.Base>
<Form onSubmit={handleSubmit}>
<Layout.Stack gap="xl">
<InputSelect
id="organization"
label="Organization"
required
placeholder="Select an organization"
options={data.organizations.teams.map((o: any) => ({
label: o.name,
value: o.$id
}))}
bind:value={selectedOrg} />
{#if projects?.total}
{#key selectedProject}
<InputSelect
id="project"
label="Project"
required
options={[
...projects.projects.map((p) => ({
label: p.name,
value: p.$id
})),
{
label: 'Create project',
leadingIcon: IconPlusSm,
value: null
}
]}
bind:value={selectedProject} />
{/key}
{/if}
{#if selectedProject === null}
<Layout.Stack direction="column" gap="s">
<Input.Text
label="Name"
placeholder="Project name"
required
bind:value={projectName} />
{#if !showCustomId}
<div>
<Tag
size="s"
on:click={() => {
showCustomId = true;
}}
><Icon slot="start" icon={IconPencil} size="s" /> Project
ID</Tag>
</div>
{/if}
<CustomId
bind:show={showCustomId}
name="Project"
isProject
bind:id />
</Layout.Stack>
{#if isCloud}
<Layout.Stack gap="xs">
<Input.Select
required
bind:value={region}
placeholder="Select a region"
options={filterRegions($regionsStore.regions || [])}
label="Region" />
<Typography.Text>
Region cannot be changed after creation
</Typography.Text>
</Layout.Stack>
{/if}
{/if}
<Divider />
<Layout.Stack direction="row-reverse">
<div>
<Button
disabled={!selectedOrg ||
(!selectedProject && !projectName && !region)}
submit>
<span class="text">Continue</span>
</Button>
</div>
</Layout.Stack>
</Layout.Stack>
</Form>
</Layout.Stack>
</Layout.Stack>
</Card.Base>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
import { sdk } from '$lib/stores/sdk.js';
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { isCloud } from '$lib/system';
import { BillingPlan } from '$lib/constants';
import { ID, type Models } from '@appwrite.io/console';
import type { OrganizationList } from '$lib/stores/organization';
import { redirectTo } from '$routes/store';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, url }) => {
const { account } = await parent();
// Store the full URL for redirect after auth
const fullUrl = url.pathname + url.search;
// Check if user is authenticated
if (!account) {
redirectTo.set(fullUrl);
redirect(302, base + '/login?redirect=' + encodeURIComponent(fullUrl));
}
// Get repository URL from query params
const repoUrl = url.searchParams.get('repo');
if (!repoUrl) {
redirect(302, base + '/');
}
// Parse repository information
const repoMatch = repoUrl.match(/github\.com[\/:]([^\/]+)\/([^\/\?\s]+)/);
if (!repoMatch) {
redirect(302, base + '/');
}
const [, owner, repoName] = repoMatch;
// Clean repository name (remove .git extension if present)
const cleanRepoName = repoName.replace(/\.git$/, '');
// Get environment variables from query params
const envParam = url.searchParams.get('env');
const envKeys = envParam ? envParam.split(',').map((key: string) => key.trim()) : [];
// Get organizations
let organizations: Models.TeamList<Record<string, unknown>> | OrganizationList | undefined;
if (isCloud) {
organizations = await sdk.forConsole.billing.listOrganization();
} else {
organizations = await sdk.forConsole.teams.list();
}
// Create default organization if none exists - matches console's onboarding behavior
if (!organizations?.total) {
let org = null;
try {
if (isCloud) {
org = await sdk.forConsole.billing.createOrganization(
ID.unique(),
'Personal Projects',
BillingPlan.FREE,
null,
null
);
} else {
org = await sdk.forConsole.teams.create(ID.unique(), 'Personal Projects');
}
// Refetch organizations after creation
if (isCloud) {
organizations = await sdk.forConsole.billing.listOrganization();
} else {
organizations = await sdk.forConsole.teams.list();
}
} catch (e) {
// If organization creation fails, still redirect to deploy page
// The page will handle showing an error
console.error('Failed to create default organization:', e);
}
}
return {
account,
organizations,
repository: {
url: repoUrl,
owner,
name: cleanRepoName
},
envKeys
};
};