diff --git a/src/lib/helpers/env.ts b/src/lib/helpers/env.ts new file mode 100644 index 0000000000..322538c6b1 --- /dev/null +++ b/src/lib/helpers/env.ts @@ -0,0 +1,20 @@ +/** + * Parses environment variable parameter string. + * Supports both KEY and KEY=value formats. + * @param envParam - Comma-separated string of env vars (e.g., "KEY1,KEY2=value2") + * @returns Array of objects with key and value properties + */ +export function parseEnvParam(envParam: string | null): Array<{ key: string; value: string }> { + if (!envParam) return []; + return envParam.split(',').map((entry: string) => { + const trimmed = entry.trim(); + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + return { key: trimmed, value: '' }; + } + return { + key: trimmed.substring(0, eqIndex), + value: trimmed.substring(eqIndex + 1) + }; + }); +} diff --git a/src/lib/helpers/github.ts b/src/lib/helpers/github.ts index b173d6d221..8e34412452 100644 --- a/src/lib/helpers/github.ts +++ b/src/lib/helpers/github.ts @@ -3,6 +3,11 @@ export function getNestedRootDirectory(repository: string): string | null { return match ? match[1] : null; } +export function getBranchFromUrl(repository: string): string | null { + const match = repository.match(/\/tree\/([^/?#]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + export function getRepositoryInfo( repository: string ): { owner: string; name: string; url: string } | null { @@ -34,7 +39,6 @@ export async function getLatestTag(owner: string, name: string): Promise { + try { + const branchesResponse = await fetch( + `https://api.github.com/repos/${owner}/${name}/branches` + ); + if (!branchesResponse.ok) { + return null; + } + + const branches = await branchesResponse.json(); + return branches.map((branch) => branch.name); + } catch (error) { + return null; + } +} + +export async function validateBranch( + owner: string, + repo: string, + branch: string +): Promise { + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}` + ); + return response.ok; + } catch (error) { + return false; + } +} diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte index 6166daa519..fd4f0138b9 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte @@ -17,7 +17,7 @@ import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; import { iconPath } from '$lib/stores/app'; import type { PageData } from './$types'; - import { getLatestTag } from '$lib/helpers/github'; + import { getDefaultBranch, getBranches, validateBranch } from '$lib/helpers/github'; import { writable } from 'svelte/store'; import Link from '$lib/elements/link.svelte'; @@ -42,8 +42,9 @@ let selectedScopes = $state([]); let rootDir = $state(data.repository?.rootDirectory); let variables = $state>([]); - - let latestTag = $state(null); + let branches = $state([]); + let selectedBranch = $state(''); + let loadingBranches = $state(false); const specificationOptions = $derived( data.specificationsList?.specifications?.map((size) => ({ @@ -63,8 +64,8 @@ })) || [] ); - onMount(() => { - const runtimeParam = data.runtime || page.url.searchParams.get('runtime') || 'node-18.0'; + onMount(async () => { + const runtimeParam = data.runtime || page.url.searchParams.get('runtime') || Runtime.Node22; runtime = runtimeParam as Runtime; entrypoint = page.url.searchParams.get('entrypoint') || ''; @@ -76,23 +77,81 @@ specification = specificationOptions[0].value; } - if (data.envKeys.length > 0) { - variables = data.envKeys.map((key) => ({ key, value: '', secret: false })); + // Initialize environment variables from query params (with prefilled values if provided) + if (data.envVars.length > 0) { + variables = data.envVars.map((env) => ({ + key: env.key, + value: env.value, + secret: false + })); } - getLatestTag(data.repository.owner, data.repository.name).then( - (tagName) => (latestTag = tagName) - ); + // Load branches and set default branch + if (data.repository?.owner && data.repository?.name) { + loadingBranches = true; + try { + // Check for branch param from URL + const branchParam = page.url.searchParams.get('branch'); + + const [branchList, defaultBranch, isBranchValid] = await Promise.all([ + getBranches(data.repository.owner, data.repository.name), + getDefaultBranch(data.repository.owner, data.repository.name), + branchParam + ? validateBranch(data.repository.owner, data.repository.name, branchParam) + : Promise.resolve(false) + ]); + + if (branchList && branchList.length > 0) { + branches = branchList; + + if (branchParam && isBranchValid) { + // Use the provided branch if it's valid + selectedBranch = branchParam; + } else { + // Fall back to default branch, or first branch if default not found + selectedBranch = + defaultBranch && branchList.includes(defaultBranch) + ? defaultBranch + : branchList[0]; + } + } else { + // Branch list is empty or null + addNotification({ + type: 'error', + message: + 'Failed to load branches from repository. Please check the repository URL or try again.' + }); + } + } catch (error) { + addNotification({ + type: 'error', + message: + 'Failed to load branches from repository. Please check the repository URL or try again.' + }); + } finally { + loadingBranches = false; + } + } else { + // Repository info is missing + addNotification({ + type: 'error', + message: 'Repository information is missing. Please check the repository URL.' + }); + } }); async function create() { + if (!selectedBranch || branches.length === 0) { + addNotification({ + type: 'error', + message: 'Please wait for branches to load or check the repository URL.' + }); + return; + } + $isSubmitting = true; try { - if (!latestTag) { - latestTag = await getLatestTag(data.repository.owner, data.repository.name); - } - // Create function with configuration const func = await sdk .forProject(page.params.region, page.params.project) @@ -126,7 +185,7 @@ await Promise.all(promises); - // Create deployment from GitHub repository using the latest tag + // Create deployment from GitHub repository using the selected branch await sdk .forProject(page.params.region, page.params.project) .functions.createTemplateDeployment({ @@ -134,8 +193,8 @@ repository: data.repository.name, owner: data.repository.owner, rootDirectory: rootDir || '.', - type: Type.Tag, - reference: latestTag ?? '1.0.0', + type: Type.Branch, + reference: selectedBranch, activate: true }); @@ -220,6 +279,22 @@ +
+ + ({ + value: branch, + label: branch + }))} /> + +
+
- {#if data.envKeys.length > 0} + {#if data.envVars.length > 0}
{#each variables as variable, i} @@ -276,7 +351,12 @@ fullWidthMobile submissionLoader forceShowLoader={$isSubmitting} - disabled={!name || !runtime || !specification || $isSubmitting}> + disabled={!name || + !runtime || + !specification || + !selectedBranch || + branches.length === 0 || + $isSubmitting}> Deploy function diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.ts b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.ts index 8169ab7089..072ea6ba24 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.ts @@ -1,11 +1,23 @@ import { sdk } from '$lib/stores/sdk'; -import { redirect } from '@sveltejs/kit'; -import { base } from '$app/paths'; +import { redirect, isRedirect } from '@sveltejs/kit'; +import { base, resolve } from '$app/paths'; import type { PageLoad } from './$types'; -import { getNestedRootDirectory, getRepositoryInfo } from '$lib/helpers/github'; +import { + getNestedRootDirectory, + getRepositoryInfo, + getDefaultBranch, + validateBranch +} from '$lib/helpers/github'; +import { parseEnvParam } from '$lib/helpers/env'; +import { ID, Runtime, Type } from '@appwrite.io/console'; export const load: PageLoad = async ({ url, params, parent }) => { - const { installations: vcsInstallations, runtimesList, specificationsList } = await parent(); + const { + installations: vcsInstallations, + runtimesList, + specificationsList, + regionalConsoleVariables + } = await parent(); const repository = url.searchParams.get('repo') || url.searchParams.get('repository'); @@ -19,10 +31,102 @@ export const load: PageLoad = async ({ url, params, parent }) => { } const envParam = url.searchParams.get('env'); - const envKeys = envParam ? envParam.split(',').map((key: string) => key.trim()) : []; + const envVars = parseEnvParam(envParam); const runtime = url.searchParams.get('runtime'); + // Quick mode - create function and redirect directly to function page + const quickMode = url.searchParams.get('quick') === 'true'; + + if (quickMode) { + try { + const runtimeParam = runtime || Runtime.Node22; + const selectedRuntime = runtimeParam as Runtime; + + const entrypoint = url.searchParams.get('entrypoint') || ''; + const installCommand = url.searchParams.get('install') || ''; + const rootDir = + getNestedRootDirectory(repository) || url.searchParams.get('rootDir') || '.'; + + // Get branch - validate provided or use default + const branchParam = url.searchParams.get('branch'); + let selectedBranch: string; + + if (branchParam) { + const isValid = await validateBranch(info.owner, info.name, branchParam); + if (isValid) { + selectedBranch = branchParam; + } else { + selectedBranch = (await getDefaultBranch(info.owner, info.name)) || 'main'; + } + } else { + selectedBranch = (await getDefaultBranch(info.owner, info.name)) || 'main'; + } + + // Get first available specification + const specification = + specificationsList?.specifications?.[0]?.slug || 's-0.5vcpu-512mb'; + + // Create function + const func = await sdk.forProject(params.region, params.project).functions.create({ + functionId: ID.unique(), + name: info.name, + runtime: selectedRuntime, + execute: ['any'], + entrypoint: entrypoint || undefined, + commands: installCommand || undefined, + providerSilentMode: false, + specification + }); + + // Add auto-generated domain + await sdk.forProject(params.region, params.project).proxy.createFunctionRule({ + domain: `${ID.unique()}.${regionalConsoleVariables._APP_DOMAIN_FUNCTIONS}`, + functionId: func.$id + }); + + // Add variables (empty values used as empty strings) + await Promise.all( + envVars.map((variable) => + sdk.forProject(params.region, params.project).functions.createVariable({ + functionId: func.$id, + key: variable.key, + value: variable.value, + secret: false + }) + ) + ); + + // Create deployment + await sdk.forProject(params.region, params.project).functions.createTemplateDeployment({ + functionId: func.$id, + repository: info.name, + owner: info.owner, + rootDirectory: rootDir, + type: Type.Branch, + reference: selectedBranch, + activate: true + }); + + // Redirect to function page + const functionPath = resolve( + '/(console)/project-[region]-[project]/functions/function-[function]', + { + region: params.region, + project: params.project, + function: func.$id + } + ); + redirect(302, functionPath); + } catch (e) { + console.error('Failed to create function:', e); + // Re-throw redirects (they're not errors) + if (isRedirect(e)) { + throw e; + } + } + } + let installations = vcsInstallations || null; if (!installations) { try { @@ -35,7 +139,7 @@ export const load: PageLoad = async ({ url, params, parent }) => { } return { - envKeys, + envVars, runtime, runtimesList, installations, diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte index 5dc5b86c37..3f2578a320 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte @@ -19,7 +19,7 @@ import { iconPath } from '$lib/stores/app'; import type { PageData } from './$types'; import { writable } from 'svelte/store'; - import { getLatestTag } from '$lib/helpers/github'; + import { getDefaultBranch, getBranches, validateBranch } from '$lib/helpers/github'; import Link from '$lib/elements/link.svelte'; let { @@ -44,6 +44,9 @@ let domainIsValid = $state(false); let framework = $state(Framework.Nextjs); let variables = $state>([]); + let branches = $state([]); + let selectedBranch = $state(''); + let loadingBranches = $state(false); // Track if we have custom commands from URL let hasCustomCommands = $state(false); @@ -86,7 +89,7 @@ } }); - onMount(() => { + onMount(async () => { const preset = page.url.searchParams.get('preset') || 'nextjs'; // Map preset string to Framework enum @@ -113,9 +116,66 @@ } } - // Initialize environment variables from query params - if (data.envKeys.length > 0) { - variables = data.envKeys.map((key) => ({ key, value: '', secret: false })); + // Initialize environment variables from query params (with prefilled values if provided) + if (data.envVars.length > 0) { + variables = data.envVars.map((env) => ({ + key: env.key, + value: env.value, + secret: false + })); + } + + // Load branches and set default branch + if (data.repository?.owner && data.repository?.name) { + loadingBranches = true; + try { + // Check for branch param from URL + const branchParam = page.url.searchParams.get('branch'); + + const [branchList, defaultBranch, isBranchValid] = await Promise.all([ + getBranches(data.repository.owner, data.repository.name), + getDefaultBranch(data.repository.owner, data.repository.name), + branchParam + ? validateBranch(data.repository.owner, data.repository.name, branchParam) + : Promise.resolve(false) + ]); + + if (branchList && branchList.length > 0) { + branches = branchList; + + if (branchParam && isBranchValid) { + // Use the provided branch if it's valid + selectedBranch = branchParam; + } else { + // Fall back to default branch, or first branch if default not found + selectedBranch = + defaultBranch && branchList.includes(defaultBranch) + ? defaultBranch + : branchList[0]; + } + } else { + // Branch list is empty or null + addNotification({ + type: 'error', + message: + 'Failed to load branches from repository. Please check the repository URL or try again.' + }); + } + } catch (error) { + addNotification({ + type: 'error', + message: + 'Failed to load branches from repository. Please check the repository URL or try again.' + }); + } finally { + loadingBranches = false; + } + } else { + // Repository info is missing + addNotification({ + type: 'error', + message: 'Repository information is missing. Please check the repository URL.' + }); } }); @@ -128,6 +188,14 @@ return; } + if (!selectedBranch || branches.length === 0) { + addNotification({ + type: 'error', + message: 'Please wait for branches to load or check the repository URL.' + }); + return; + } + $isSubmitting = true; try { @@ -161,10 +229,7 @@ ); await Promise.all(promises); - // Fetch latest tag from GitHub - const latestTag = await getLatestTag(data.repository.owner, data.repository.name); - - // Create deployment from GitHub repository using the latest tag + // Create deployment from GitHub repository using the selected branch const deployment = await sdk .forProject(page.params.region, page.params.project) .sites.createTemplateDeployment({ @@ -172,8 +237,8 @@ repository: data.repository.name, owner: data.repository.owner, rootDirectory: rootDir || '.', - type: Type.Tag, - reference: latestTag ?? '1.0.0', + type: Type.Branch, + reference: selectedBranch, activate: true }); @@ -252,6 +317,17 @@
+ ({ + value: branch, + label: branch + }))} />
diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.ts index 90f10807db..5e3e2da665 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.ts @@ -1,10 +1,17 @@ import { sdk } from '$lib/stores/sdk'; -import { redirect } from '@sveltejs/kit'; -import { base } from '$app/paths'; +import { redirect, isRedirect } from '@sveltejs/kit'; +import { base, resolve } from '$app/paths'; import type { PageLoad } from './$types'; -import { getNestedRootDirectory, getRepositoryInfo } from '$lib/helpers/github'; +import { + getNestedRootDirectory, + getRepositoryInfo, + getDefaultBranch, + validateBranch +} from '$lib/helpers/github'; +import { parseEnvParam } from '$lib/helpers/env'; +import { Adapter, BuildRuntime, Framework, ID, Type } from '@appwrite.io/console'; -export const load: PageLoad = async ({ url, params }) => { +export const load: PageLoad = async ({ url, params, parent }) => { const repository = url.searchParams.get('repo') || url.searchParams.get('repository'); if (!repository) { @@ -17,7 +24,118 @@ export const load: PageLoad = async ({ url, params }) => { } const envParam = url.searchParams.get('env'); - const envKeys = envParam ? envParam.split(',').map((key: string) => key.trim()) : []; + const envVars = parseEnvParam(envParam); + + // Quick mode - create site and redirect directly to deploying page + const quickMode = url.searchParams.get('quick') === 'true'; + + if (quickMode) { + try { + const { regionalConsoleVariables } = await parent(); + + const preset = url.searchParams.get('preset') || 'nextjs'; + const framework = (Object.values(Framework) as string[]).includes(preset.toLowerCase()) + ? (preset.toLowerCase() as Framework) + : Framework.Nextjs; + + // Get framework defaults + const frameworks = await sdk + .forProject(params.region, params.project) + .sites.listFrameworks(); + const fw = frameworks.frameworks.find((f) => f.key === framework); + + let installCommand = url.searchParams.get('install') || ''; + let buildCommand = url.searchParams.get('build') || ''; + let outputDirectory = url.searchParams.get('output') || ''; + + // Use framework defaults if not provided + if (!installCommand && !buildCommand && !outputDirectory && fw?.adapters?.length > 0) { + const adapter = fw.adapters[0]; + installCommand = adapter.installCommand || ''; + buildCommand = adapter.buildCommand || ''; + outputDirectory = adapter.outputDirectory || ''; + } + + // Get branch - validate provided or use default + const branchParam = url.searchParams.get('branch'); + let selectedBranch: string; + + if (branchParam) { + const isValid = await validateBranch(info.owner, info.name, branchParam); + if (isValid) { + selectedBranch = branchParam; + } else { + selectedBranch = (await getDefaultBranch(info.owner, info.name)) || 'main'; + } + } else { + selectedBranch = (await getDefaultBranch(info.owner, info.name)) || 'main'; + } + + const rootDir = + getNestedRootDirectory(repository) || url.searchParams.get('rootDir') || '.'; + const buildRuntime = + framework === Framework.Other ? BuildRuntime.Static1 : BuildRuntime.Node210; + + // Create site + const site = await sdk.forProject(params.region, params.project).sites.create({ + siteId: ID.unique(), + name: info.name, + framework, + buildRuntime, + installCommand: installCommand || undefined, + buildCommand: buildCommand || undefined, + outputDirectory: outputDirectory || undefined, + adapter: framework === Framework.Other ? Adapter.Static : undefined, + providerSilentMode: false + }); + + // Add auto-generated domain + await sdk.forProject(params.region, params.project).proxy.createSiteRule({ + domain: `${ID.unique()}.${regionalConsoleVariables._APP_DOMAIN_SITES}`, + siteId: site.$id + }); + + // Add variables (empty values used as empty strings) + await Promise.all( + envVars.map((variable) => + sdk.forProject(params.region, params.project).sites.createVariable({ + siteId: site.$id, + key: variable.key, + value: variable.value, + secret: false + }) + ) + ); + + // Create deployment + const deployment = await sdk + .forProject(params.region, params.project) + .sites.createTemplateDeployment({ + siteId: site.$id, + repository: info.name, + owner: info.owner, + rootDirectory: rootDir, + type: Type.Branch, + reference: selectedBranch, + activate: true + }); + + // Redirect to deploying page + const deployingPath = resolve( + '/(console)/project-[region]-[project]/sites/create-site/deploying', + { + region: params.region, + project: params.project + } + ); + redirect(302, `${deployingPath}?site=${site.$id}&deployment=${deployment.$id}`); + } catch (e) { + // Re-throw redirects (they're not errors) + if (isRedirect(e)) { + throw e; + } + } + } const [frameworks, installations] = await Promise.all([ sdk.forProject(params.region, params.project).sites.listFrameworks(), @@ -28,7 +146,7 @@ export const load: PageLoad = async ({ url, params }) => { ]); return { - envKeys, + envVars, frameworks, installations, repository: { diff --git a/src/routes/(public)/functions/deploy/+page.svelte b/src/routes/(public)/functions/deploy/+page.svelte index 4b353d17ba..bb22a07fa2 100644 --- a/src/routes/(public)/functions/deploy/+page.svelte +++ b/src/routes/(public)/functions/deploy/+page.svelte @@ -1,17 +1,20 @@