>(({ className, ...props }, ref) => (
| [role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
================================================
FILE: apps/dashboard/src/components/ui/toast.tsx
================================================
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & VariantProps
>(({ className, variant, ...props }, ref) => {
return (
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef;
type ToastActionElement = React.ReactElement;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
================================================
FILE: apps/dashboard/src/components/ui/toaster.tsx
================================================
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
export function Toaster() {
const { toasts } = useToast();
return (
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
{title && {title}}
{description && {description}}
{action}
);
})}
);
}
================================================
FILE: apps/dashboard/src/components/ui/tooltip.tsx
================================================
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, sideOffset = 4, ...props }, ref) => (
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
================================================
FILE: apps/dashboard/src/containers/Layout/index.tsx
================================================
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
{children}
);
};
================================================
FILE: apps/dashboard/src/hooks/use-mobile.tsx
================================================
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
================================================
FILE: apps/dashboard/src/hooks/use-toast.ts
================================================
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
// eslint-disable-next-line
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map(t => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach(toast => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter(t => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach(listener => {
listener(memoryState);
});
}
type Toast = Omit;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: open => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
================================================
FILE: apps/dashboard/src/index.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--radius: 0.5rem;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
================================================
FILE: apps/dashboard/src/lib/api.ts
================================================
import { getRefreshToken, getToken, logout, setTokens } from '@/lib/auth.ts';
export class ApiClient {
private baseUrl: string;
constructor() {
// @ts-ignore using window.env for vite
this.baseUrl = window?.env?.VITE_OTA_API_URL || import.meta.env.VITE_OTA_API_URL;
if (!this.baseUrl) {
throw new Error('Missing VITE_OTA_API_URL environment variable');
}
}
private populateHeaders(headers: Headers) {
const token = getToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
private async request(endpoint: string, options: RequestInit = {}): Promise {
const url = `${this.baseUrl}${endpoint}`;
const headers = new Headers(options.headers);
this.populateHeaders(headers);
const response = await fetch(url, { ...options, headers });
const refreshToken = getRefreshToken();
if (response.status === 401 && refreshToken) {
await this.refreshTokens(refreshToken);
return this.request(endpoint, options);
}
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json() as Promise;
}
private async refreshTokens(refreshToken: string) {
try {
const form = new URLSearchParams();
form.append('refreshToken', refreshToken);
const response = await fetch(`${this.baseUrl}/auth/refreshToken`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const data = await response.json();
setTokens(data.token, data.refreshToken);
localStorage.setItem('accessToken', data.token);
localStorage.setItem('refreshToken', data.refreshToken);
} catch (error) {
console.error('Failed to refresh token:', error);
logout();
}
}
public async login(password: string) {
const form = new URLSearchParams();
form.append('password', password);
return this.request<{ token: string; refreshToken: string }>(`/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
}
public async updateChannelBranchMapping(
branchName: string,
payload: {
releaseChannel: string;
}
) {
return this.request(`/api/branch/${branchName}/updateChannelBranchMapping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
public async getChannels() {
return this.request<
{
releaseChannelId: string;
releaseChannelName: string;
branchName?: string | null;
branchId?: string | null;
}[]
>('/api/channels', {
method: 'GET',
});
}
public async getBranches() {
return this.request<
{
branchName: string;
branchId: string;
releaseChannel?: string | null;
}[]
>('/api/branches', {
method: 'GET',
});
}
public async getRuntimeVersions(branch: string) {
return this.request<
{
runtimeVersion: string;
lastUpdatedAt: string;
createdAt: string;
numberOfUpdates: number;
}[]
>(`/api/branch/${branch}/runtimeVersions`, {
method: 'GET',
});
}
public async getUpdates(branch: string, runtimeVersion: string) {
return this.request<
{
updateUUID: string;
createdAt: string;
updateId: string;
platform: string;
commitHash: string;
message?: string;
}[]
>(`/api/branch/${branch}/runtimeVersion/${runtimeVersion}/updates`, {
method: 'GET',
});
}
public async getUpdateDetails(branch: string, runtimeVersion: string, updateId: string) {
return this.request<{
updateUUID: string;
createdAt: string;
updateId: string;
platform: string;
commitHash: string;
message?: string;
type: number;
expoConfig: string;
}>(`/api/branch/${branch}/runtimeVersion/${runtimeVersion}/updates/${updateId}`, {
method: 'GET',
});
}
public async getSettings() {
return this.request<{
BASE_URL: string;
EXPO_APP_ID: string;
EXPO_ACCESS_TOKEN: string;
CACHE_MODE: string;
REDIS_HOST: string;
REDIS_PORT: string;
STORAGE_MODE: string;
S3_BUCKET_NAME: string;
LOCAL_BUCKET_BASE_PATH: string;
KEYS_STORAGE_TYPE: string;
AWSSM_EXPO_PUBLIC_KEY_SECRET_ID: string;
AWSSM_EXPO_PRIVATE_KEY_SECRET_ID: string;
PUBLIC_EXPO_KEY_B64: string;
PUBLIC_LOCAL_EXPO_KEY_PATH: string;
PRIVATE_LOCAL_EXPO_KEY_PATH: string;
AWS_REGION: string;
AWS_BASE_ENDPOINT: string;
AWS_S3_FORCE_PATH_STYLE: string;
AWS_ACCESS_KEY_ID: string;
S3_CDN_PREFIX: string;
CLOUDFRONT_DOMAIN: string;
CLOUDFRONT_KEY_PAIR_ID: string;
CLOUDFRONT_PRIVATE_KEY_B64: string;
AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID: string;
PRIVATE_LOCAL_CLOUDFRONT_KEY_PATH: string;
PROMETHEUS_ENABLED: string;
}>(`/api/settings`, {
method: 'GET',
});
}
}
export const api = new ApiClient();
================================================
FILE: apps/dashboard/src/lib/auth.ts
================================================
export const isAuthenticated = () => {
return !!localStorage.getItem('token') && !!localStorage.getItem('refreshToken');
};
export const getToken = () => {
return localStorage.getItem('token');
};
export const getRefreshToken = () => {
return localStorage.getItem('refreshToken');
};
export const setTokens = (token: string, refreshToken: string) => {
localStorage.setItem('token', token);
localStorage.setItem('refreshToken', refreshToken);
};
export const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
};
================================================
FILE: apps/dashboard/src/lib/utils.ts
================================================
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
================================================
FILE: apps/dashboard/src/main.tsx
================================================
import { BrowserRouter } from 'react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/App.tsx';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
);
================================================
FILE: apps/dashboard/src/pages/Channels/components/SelectBranch/index.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { ApiError } from '@/components/APIError';
import { Combobox } from '@/components/Combobox';
export const SelectBranch = ({
currentBranch,
onChange,
loading,
}: {
onChange: (branchId?: string | null) => void;
loading?: boolean;
currentBranch?: string | null;
}) => {
const { data, isLoading, error } = useQuery({
queryKey: [`branches`],
enabled: true,
queryFn: () => api.getBranches(),
});
const allBranches =
data
?.filter(d => !!d.branchId)
?.map(d => {
return {
branchName: d.branchName,
id: d.branchId,
};
}) ?? [];
if (error) {
return ;
}
return (
{
return {
label: b.branchName,
value: b.id,
};
})}
value={currentBranch || ''}
onChange={onChange}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Channels/index.tsx
================================================
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { ApiError } from '@/components/APIError';
import { DataTable } from '@/components/DataTable';
import { SelectBranch } from '@/pages/Channels/components/SelectBranch';
import { useCallback, useState } from 'react';
import { useToast } from '@/hooks/use-toast.ts';
export const Channels = () => {
const { data, isLoading, error, refetch } = useQuery({
queryKey: [`channels`],
enabled: true,
queryFn: () => api.getChannels(),
});
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const mutation = useMutation({
mutationKey: ['update-branch'],
mutationFn: async ({
branchName,
releaseChannelId,
}: {
branchName: string;
releaseChannelId: string;
}) => {
return api.updateChannelBranchMapping(branchName, {
releaseChannel: releaseChannelId,
});
},
});
const onBranchChange = useCallback(
(channelId: string) => async (branchName?: string | null) => {
if (!branchName) return;
setLoading(true);
try {
await mutation.mutateAsync({
branchName,
releaseChannelId: channelId,
});
await refetch();
toast({
title: 'Branch updated',
description: `Branch updated to ${branchName}`,
duration: 2000,
});
} catch (error) {
toast({
title: 'Error updating branch',
description: (error as { message: string }).message,
variant: 'destructive',
});
} finally {
setLoading(false);
}
},
[mutation, toast]
);
return (
Channels
{!!error && }
{
return (
{value.row.original.releaseChannelName}
);
},
},
{
header: 'Branch',
accessorKey: 'releaseChannelName',
cell: ({ row }) => {
console.log(row);
return (
);
},
},
]}
data={data ?? []}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Login/index.tsx
================================================
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.tsx';
import { Input } from '@/components/ui/input.tsx';
import { Button } from '@/components/ui/button.tsx';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form.tsx';
import { useCallback } from 'react';
import { setTokens } from '@/lib/auth.ts';
import { useNavigate } from 'react-router';
import { api } from '@/lib/api.ts';
const FormSchema = z.object({
password: z.string().min(1, {
message: 'Password is required',
}),
});
export const Login = () => {
const form = useForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
password: '',
},
});
const navigate = useNavigate();
const onSubmit = useCallback(
async (data: z.infer) => {
try {
const response = await api.login(data.password);
setTokens(response.token, response.refreshToken);
navigate('/');
} catch {
form.setError('password', {
type: 'server',
message: 'Error logging in',
});
}
},
[form]
);
return (
Admin password
);
};
================================================
FILE: apps/dashboard/src/pages/Logout/index.tsx
================================================
import { useEffect } from 'react';
import { logout } from '@/lib/auth.ts';
import { useNavigate } from 'react-router';
export const Logout = () => {
const navigate = useNavigate();
useEffect(() => {
logout();
navigate('/login');
}, [navigate]);
return null;
};
================================================
FILE: apps/dashboard/src/pages/Settings/index.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { DataTable } from '@/components/DataTable';
import { ApiError } from '@/components/APIError';
export const Settings = () => {
const { data, isLoading, error } = useQuery({
queryKey: ['settings'],
queryFn: () => api.getSettings(),
});
return (
Settings
{!!error && }
({
key,
value,
}))}
loading={isLoading}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Updates/components/BranchesTable/index.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { ApiError } from '@/components/APIError';
import { DataTable } from '@/components/DataTable';
import { Box, GitBranch } from 'lucide-react';
import { useSearchParams } from 'react-router';
export const BranchesTable = () => {
const [, setSearchParams] = useSearchParams();
const { data, isLoading, error } = useQuery({
queryKey: ['branches'],
queryFn: () => api.getBranches(),
});
return (
{!!error && }
{
return (
);
},
},
{
header: 'Release channel',
size: 10,
maxSize: 10,
accessorKey: 'releaseChannel',
cell: value => {
const releaseChannel = value.row.original.releaseChannel;
if (!releaseChannel) return N/A;
return (
{value.row.original.releaseChannel}
);
},
},
]}
data={data ?? []}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Updates/components/RuntimeVersionsTable/index.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { ApiError } from '@/components/APIError';
import { DataTable } from '@/components/DataTable';
import { GitBranch, Milestone } from 'lucide-react';
import { useSearchParams } from 'react-router';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Badge } from '@/components/ui/badge.tsx';
export const RuntimeVersionsTable = ({ branch }: { branch: string }) => {
const [, setSearchParams] = useSearchParams();
const { data, isLoading, error } = useQuery({
queryKey: ['runtimeVersions'],
queryFn: () => api.getRuntimeVersions(branch),
});
return (
{branch}
{!!error && }
{
return (
);
},
},
{
header: 'Created at',
accessorKey: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return (
{date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
})}
);
},
},
{
header: 'Last update',
accessorKey: 'lastUpdatedAt',
cell: ({ row }) => {
const date = new Date(row.original.lastUpdatedAt);
return (
{date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
})}
);
},
},
{
header: '# Updates',
accessorKey: 'numberOfUpdates',
cell: ({ row }) => {
return {row.original.numberOfUpdates};
},
},
]}
data={data ?? []}
defaultSorting={[{ id: 'createdAt', desc: true }]}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Updates/components/UpdatesTable/index.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api.ts';
import { ApiError } from '@/components/APIError';
import { DataTable } from '@/components/DataTable';
import { GitBranch, Milestone, Rss } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Badge } from '@/components/ui/badge.tsx';
import apple from '@/assets/apple.svg';
import android from '@/assets/android.svg';
import { UpdateDetailsRef, UpdateDetailsSheet } from '@/components/UpdateDetailsSheet';
import { useRef } from 'react';
export const UpdatesTable = ({
branch,
runtimeVersion,
}: {
branch: string;
runtimeVersion: string;
}) => {
const sheetRef = useRef(null);
const { data, isLoading, error } = useQuery({
queryKey: ['updates'],
queryFn: () => api.getUpdates(branch, runtimeVersion),
});
return (
{branch}
{runtimeVersion}
{!!error && }
{
return (
{value.row.original.updateId}
);
},
},
{
header: 'UUID',
accessorKey: 'updateUUID',
cell: value => {
return value.row.original.updateUUID;
},
},
{
header: 'Platform',
accessorKey: 'platform',
cell: value => {
const isIos = value.row.original.platform === 'ios';
const isAndroid = value.row.original.platform === 'android';
return (
{isIos &&  }
{isAndroid &&  }
);
},
},
{
header: 'Message',
accessorKey: 'message',
cell: value => {
const msg = value.row.original.message;
return msg ? (
{msg}
) : (
-
);
},
},
{
header: 'Commit',
accessorKey: 'commitHash',
cell: value => {
return (
{value.row.original.commitHash.slice(0, 7)}
);
},
},
{
header: 'Published at',
accessorKey: 'createdAt',
cell: ({ row }) => {
const date = new Date(row.original.createdAt);
return (
{date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
})}
);
},
},
]}
data={data ?? []}
defaultSorting={[{ id: 'createdAt', desc: true }]}
onRowClick={row => {
sheetRef?.current?.openSheet(row);
}}
/>
);
};
================================================
FILE: apps/dashboard/src/pages/Updates/index.tsx
================================================
import { useSearchParams } from 'react-router';
import { useMemo } from 'react';
import { BranchesTable } from '@/pages/Updates/components/BranchesTable';
import { RuntimeVersionsTable } from '@/pages/Updates/components/RuntimeVersionsTable';
import { UpdatesTable } from '@/pages/Updates/components/UpdatesTable';
export const Updates = () => {
const [searchParams] = useSearchParams();
const currentBranch = searchParams.get('branch');
const runtimeVersion = searchParams.get('runtimeVersion');
const component = useMemo(() => {
if (!currentBranch) {
return ;
}
if (!runtimeVersion) {
return ;
}
return ;
}, [currentBranch, runtimeVersion]);
return (
Updates
{component}
);
};
================================================
FILE: apps/dashboard/src/vite-env.d.ts
================================================
///
================================================
FILE: apps/dashboard/tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
theme: {
extend: {
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
}
}
},
plugins: [require("tailwindcss-animate")],
}
================================================
FILE: apps/dashboard/tsconfig.app.json
================================================
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}
================================================
FILE: apps/dashboard/tsconfig.json
================================================
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
================================================
FILE: apps/dashboard/tsconfig.node.json
================================================
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: apps/dashboard/vite.config.ts
================================================
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import * as path from 'node:path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '/dashboard',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
================================================
FILE: apps/docs/.gitignore
================================================
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
================================================
FILE: apps/docs/README.md
================================================
# Website
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER= yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
================================================
FILE: apps/docs/docs/advanced/_category_.json
================================================
{
"label": "Advanced",
"position": 6
}
================================================
FILE: apps/docs/docs/advanced/prometheus.mdx
================================================
---
sidebar_position: 1
---
# Prometheus and Grafana
## Prometheus
You can monitor the **Expo Open OTA** server using Prometheus. The server exposes metrics on the `/metrics` endpoint.
To activate the Prometheus feature, set the `PROMETHEUS_ENABLED` environment variable to `true`.
If you are using our [Helm chart](/docs/deployment/helm), the environment variable will be automatically set for you if `prometheus.io/scrape: "true"` is present in `podAnnotations`.
## Grafana Dashboard
You can use the following dashboard to visualize the metrics exposed by the server:
```json
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0,211,255,1)",
"name": "Annotations & Alerts",
"target": {
"limit": 100,
"matchAny": false,
"tags": [],
"type": "dashboard"
},
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 145,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 10,
"panels": [],
"title": "Users",
"type": "row"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 1
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "horizontal",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"last"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "max(\n sum by (instance) (global_active_users_total)\n)",
"legendFormat": "Total Unique Active Users",
"range": true,
"refId": "A"
}
],
"title": "🟢 Total Unique Active Users (4h)",
"type": "stat"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 1
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "max by (runtime, update) (\n sum by (platform, runtime, branch, update, instance) (\n active_users_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\"}\n )\n)",
"legendFormat": "{{platform}}",
"range": true,
"refId": "A"
}
],
"title": "📊 Unique Active Users by Runtime And Update",
"type": "timeseries"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 1
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "max by (platform) (\n sum by (platform, instance) (\n global_active_users_total{platform=~\"$platform\"}\n )\n)",
"legendFormat": "{{platform}}",
"range": true,
"refId": "A"
}
],
"title": "📊 Unique Active Users by Platform",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 9
},
"id": 9,
"panels": [],
"title": "Errors",
"type": "row"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 10
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "sum(update_error_users_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\"})\n by (update)",
"legendFormat": "{{platform}}",
"range": true,
"refId": "A"
}
],
"title": "🚨 Fatal error per update",
"type": "timeseries"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "none"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 10
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "sum(update_error_users_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\"})",
"legendFormat": "{{platform}}",
"range": true,
"refId": "A"
}
],
"title": "🚨 Update error users total",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 18
},
"id": 8,
"panels": [],
"title": "Updates",
"type": "row"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 19
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"expr": "sum(update_downloads_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\"}) by (update)",
"legendFormat": "{{update}}",
"refId": "A"
}
],
"title": "⬇️ Total Update Downloads by Update",
"type": "timeseries"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 19
},
"id": 11,
"options": {
"legend": {
"calcs": [],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "sum(update_downloads_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\"}) by (runtime)",
"legendFormat": "{{update}}",
"range": true,
"refId": "A"
}
],
"title": "⬇️ Total Update Downloads by Runtime",
"type": "timeseries"
},
{
"datasource": {
"uid": "$DS_PROM"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 27
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"datasource": {
"uid": "$DS_PROM"
},
"editorMode": "code",
"expr": "sum by (update, runtime) (update_downloads_total{platform=~\"$platform\", runtime=~\"$runtime\", branch=~\"$branch\", update=~\"$update\", updateType=~\"$updateType|.*\"})",
"legendFormat": "{{updateType}}",
"range": true,
"refId": "A"
}
],
"title": "⚡ Update Downloads Rate Over Time by Runtime & Update",
"type": "timeseries"
}
],
"preload": false,
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"oss",
"metrics",
"ota-server"
],
"templating": {
"list": [
{
"current": {
"text": "All",
"value": "$__all"
},
"includeAll": true,
"label": "Data Source",
"name": "DS_PROM",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
},
{
"allValue": ".*",
"current": {
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "CcrOqjRnk"
},
"includeAll": true,
"label": "Platform",
"name": "platform",
"options": [],
"query": "label_values(active_users_total, platform)",
"refresh": 1,
"type": "query"
},
{
"allValue": ".*",
"current": {
"text": "2.0.0",
"value": "2.0.0"
},
"datasource": {
"type": "prometheus",
"uid": "CcrOqjRnk"
},
"includeAll": true,
"label": "Runtime",
"name": "runtime",
"options": [],
"query": "label_values(active_users_total, runtime)",
"refresh": 1,
"type": "query"
},
{
"allValue": ".*",
"current": {
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "CcrOqjRnk"
},
"includeAll": true,
"label": "Branch",
"name": "branch",
"options": [],
"query": "label_values(active_users_total, branch)",
"refresh": 1,
"type": "query"
},
{
"allValue": ".*",
"current": {
"text": "All",
"value": "$__all"
},
"datasource": {
"type": "prometheus",
"uid": "CcrOqjRnk"
},
"includeAll": true,
"label": "Update",
"name": "update",
"options": [],
"query": "label_values(active_users_total, update)",
"refresh": 1,
"type": "query"
}
]
},
"time": {
"from": "now-15m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "🚀 OTA Server Ultimate Metrics Dashboard",
"uid": "ota-metrics-dashboard",
"version": 13,
"weekStart": ""
}
```
================================================
FILE: apps/docs/docs/dashboard.mdx
================================================
---
sidebar_position: 5
---
import useBaseUrl from '@docusaurus/useBaseUrl';
# Dashboard
The **Expo-Open-OTA Dashboard** is a web interface that allows you to:
- 🔍 View and manage your **Expo branches**
- 🔄 Track **runtime versions**
- 📦 Monitor and manage **OTA updates**
- 🔀 **Switch channel-to-branch mappings in one click** — useful for instant rollbacks, A/B testing, or promoting a staging branch to production without republishing
## Features
### Updates
Browse your branches, drill down into runtime versions, and inspect individual OTA updates with their metadata (commit hash, platform, message, etc.).
### Channels
View all your release channels and **change which branch a channel points to** with a single click. This is especially useful for:
- **Instant rollback**: Point a production channel back to a previous branch
- **Testing**: Temporarily route a channel to a test branch
- **Promotion**: Switch a channel from staging to production without rebuilding
### Settings
View the current server configuration at a glance.
## How to Enable the Dashboard
To activate the dashboard, configure the following environment variables:
### **1️⃣ Enable the Dashboard**
Set the `USE_DASHBOARD` environment variable to `true`:
```sh
USE_DASHBOARD=true
```
### **2️⃣ Set the Admin Password**
Set the `ADMIN_PASSWORD` environment variable to a secure password:
```sh
ADMIN_PASSWORD=your-password
```
Once enabled, the dashboard will be available at:
```sh
http:///dashboard
```
================================================
FILE: apps/docs/docs/deployment/_category_.json
================================================
{
"label": "Deployment",
"position": 3,
"link": {
"type": "generated-index",
"title": "Deployment",
"description": "Discover how to deploy your self-hosted Expo Updates Server."
}
}
================================================
FILE: apps/docs/docs/deployment/custom.mdx
================================================
---
sidebar_position: 3
---
# Custom Deployment
Deploy **Expo Open OTA** on your own infrastructure with docker.
## Pull docker image
```bash
docker pull ghcr.io/axelmarciano/expo-open-ota:latest
```
## Run docker container
You can use a .env file to set the [environment variables required by the server](/docs/reference/environment) and run
```bash
docker run --rm -it --env-file .env --platform linux/amd64 ghcr.io/axelmarciano/expo-open-ota:latest
```
Or you can pass the environment variables directly to the docker run command
```bash
docker run --rm -it -e PORT=3000 -e ENV_KEY=value ... --platform linux/amd64 ghcr.io/axelmarciano/expo-open-ota:latest
```
The server is now running on port **3000**.
:::warning
A public HTTPS endpoint is required for the expo client to fetch the updates. You can use a reverse proxy like Nginx or Traefik to expose the server to the internet.
:::
================================================
FILE: apps/docs/docs/deployment/helm.mdx
================================================
---
sidebar_position: 2
---
# Helm
Deploy **Expo Open OTA** using Helm, a package manager for Kubernetes.
A ready-to-use Helm chart is available to deploy **Expo Open OTA** on your Kubernetes cluster.
## Prerequisites
A running Kubernetes cluster and Helm installed on your local machine are required to deploy the application.
If you are not familiar with Helm or Kubernetes, we recommend you to deploy the server with [custom docker deployment](/docs/deployment/custom) or [railway](/docs/deployment/railway).
Clone the repository and navigate to the `helm` directory.
```bash
git clone https://github.com/axelmarciano/expo-open-ota
cd expo-open-ota/helm
```
## Configuration
The Helm chart uses a set of configurable values defined in `values.yaml`. These values can be overridden by passing a custom `values.yaml` file when deploying the chart.
### Conditional Logic for Environment Variables
The environment variables used by the application depend on the following key settings:
- **`secretName`**: If defined, environment variables are loaded from the specified Kubernetes secret instead of being set directly.
- **`storageMode`**:
- `s3`: Requires `AWS_REGION` and `S3_BUCKET_NAME` to be set. Optionally supports `AWS_BASE_ENDPOINT` for S3-compatible object storage and `AWS_S3_FORCE_PATH_STYLE=true` for providers that require path-style addressing.
- `local`: Requires `LOCAL_BUCKET_BASE_PATH` to be set.
- **`keysStorageType`**:
- `aws-secrets-manager`: Requires AWS Secrets Manager variables (`AWSSM_EXPO_PUBLIC_KEY_SECRET_ID`, `AWSSM_EXPO_PRIVATE_KEY_SECRET_ID`).
- `local`: Requires local key paths (`PRIVATE_LOCAL_EXPO_KEY_PATH`, `PUBLIC_LOCAL_EXPO_KEY_PATH`).
- `environment`: Requires base64-encoded keys (`PUBLIC_EXPO_KEY_B64`, `PRIVATE_EXPO_KEY_B64`).
- **`useCloudfrontRedirect`**:
- If `true`, requires `CLOUDFRONT_DOMAIN`, `CLOUDFRONT_KEY_PAIR_ID`, and a CloudFront private key (`CLOUDFRONT_PRIVATE_KEY_B64`, `PRIVATE_CLOUDFRONT_KEY_PATH`, or `AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID`, depending on `keysStorageType`).
- **`useAWSAccessKeys`**:
- If `true`, requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to be set.
- **`useGenericCDN`**:
- If `true`, requires `S3_CDN_PREFIX` to be set.
### Deployment
To install the Helm chart with default values:
```bash
helm install expo-open-ota -n NAMESPACE ./chart
```
To override values, create a custom `my-values.yaml` file and run:
```bash
helm install expo-open-ota ./chart -n NAMESPACE -f my-values.yaml
```
To upgrade an existing release:
```bash
helm upgrade expo-open-ota -n NAMESPACE ./chart -f my-values.yaml
```
For additional configuration details, refer to the [Environment Variables](/docs/reference/environment) documentation.
## Ingress Configuration
The Ingress configuration is crucial for exposing Expo Open OTA through a specific domain and must match the BASE_URL defined in the application.
================================================
FILE: apps/docs/docs/deployment/railway.mdx
================================================
---
sidebar_position: 1
---
# Railway
Deploy **Expo Open OTA** using Railway, a platform for deploying and managing applications.
## Prepare environment variables
Prepare your [environment variables](/docs/reference/environment).
## Deploy on Railway using template
[](https://railway.com/deploy/MGW3k1?referralCode=OEHlEK&utm_medium=integration&utm_source=template&utm_campaign=generic)
Fill the environment variables in the project settings.
================================================
FILE: apps/docs/docs/deployment/testing.mdx
================================================
---
sidebar_position: 3
---
# Local testing
If you want to test **Expo Open OTA** locally, you can use the provided docker-compose file to run the server locally
and expose it on internet using reverse proxy like ngrok.
## Clone the repository
```bash
git clone https://github.com/axelmarciano/expo-open-ota
cd expo-open-ota
```
## Setup .env
Setup [environment variables](/docs/reference/environment) by creating a `.env` file in the root directory of the project.
## Setup the dashboard (local)
The dashboard isn’t served at **/dashboard** when running locally. Start it in dev mode:
```bash
cd apps/dashboard
npm install
npm run dev
```
## Run the server using docker-compose
```bash
docker-compose build
docker-compose up
```
The server is now running on port **3000**.
## 🚀 Running the Example App
The example app is located in apps/example-app and is designed to help you test the update server locally.
:::note
ℹ️ The app must be run in release mode to properly test OTA behavior.
:::
### Setup certificates
The signing certificates required for update validation are located in:
```bash
apps/example-app/certs/certificate-dev.pem
apps/example-app/certs/public-key.pem
```
To enable update signature verification, you must configure these certificates in your server environment.
[Ref](/docs/server-configuration/key-store#expo-signing-certificate)
### Build the Example App
```bash
yarn prebuild_production
OR
yarn prebuild_staging
```
### Configure the Expo Project
Create an Expo project in your Expo dashboard with two branches:
- `staging`
- `production`
Each branch should have its own release channel. Then retrieve the Project ID from the Expo dashboard.
### Update app.json
In apps/example-app/app.json, replace ``YOUR_PROJECT_ID`` with your actual Expo project ID:
``` json
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "YOUR_PROJECT_ID"
}
}
```
### Android Setup
```bash
yarn prebuild_production # Prebuild the app with the 'production' config
cd android
./gradlew clean # Clean previous builds
./gradlew assembleRelease # Build the release APK
./gradlew installRelease # Install the release APK on a connected device/emulator
```
### IOS
```bash
cd ios
pod cache clean --all
pod install
```
Then:
1. Open the iOS project in Xcode: open ios/*.xcworkspace
2. Go to: Product → Scheme → Edit Scheme
3. Under Run, set Build Configuration to Release
### Testing OTA Updates
Once the app is installed in release mode, use the following commands to test OTA updates:
```bash
yarn release_production # Publish an update to the 'production' channel
yarn release_staging # Publish an update to the 'staging' channel
```
The app will dynamically switch between channels at runtime if configured accordingly.
================================================
FILE: apps/docs/docs/eoas/_category_.json
================================================
{
"label": "Configure Your App",
"position": 2,
"link": {
"type": "doc",
"id": "eoas/intro"
}
}
================================================
FILE: apps/docs/docs/eoas/configure.mdx
================================================
---
sidebar_position: 2
---
# Configure your Expo Project
## Prerequisites
A running and deployed **Expo Open OTA** server is required to configure your Expo project. If you haven't deployed the server yet, follow the [deployment guide](/docs/category/deployment) to get started.
You also need to have your expo signing certificate ready. If you haven't generated one yet, follow the [generate signing certificate](/docs/server-configuration/key-store#expo-signing-certificate) guide.
## Initialize your Expo project with Expo Open OTA
Run the following command in your Expo project to initialize the project for OTA updates.
```bash
npx eoas init
```
And follow the instructions.
After running the command, the app.config.js file will be updated with the necessary configuration for OTA updates.
## Create a new build
A new build of your application must be submitted. This ensures that the new configuration is applied to the app.
You can follow this [EAS Guide](https://docs.expo.dev/build/introduction/) to create a new build.
================================================
FILE: apps/docs/docs/eoas/intro.mdx
================================================
---
sidebar_position: 1
---
# EOAS
EOAS (**Expo Open Application Service**) is an npm package maintained by the **Expo Open OTA** project.
## Purpose
EOAS is designed to simplify the setup and management of projects using **Expo Open OTA**. It provides a streamlined way to configure the project and handle update publications efficiently.
## Features
### **Project Configuration**
EOAS helps automate essential setup steps, including:
- **Generating the certificate for code signing**
- Ensures that updates are properly signed and verified before being delivered to end-users.
- **Configuring `app.config.js`**
- Automatically updates the Expo configuration to point to the correct **Expo Open OTA server**, ensuring that published updates are fetched correctly.
- **Leveraging existing Expo logic**
- EOAS relies heavily on Expo’s built-in mechanisms for calculating the project configuration and generating fingerprints.
- This ensures consistency with Expo’s ecosystem and avoids redundant logic, making integration seamless.
### **Update Management**
EOAS provides commands to publish updates either:
- **Locally**, from a developer’s machine.
- **From CI/CD pipelines**, ensuring automated and consistent update deployment.
### **Authentication with EAS Credentials**
- EOAS uses **Expo Application Services (EAS) credentials** to authenticate update publications on the **Expo Open OTA server**.
- This ensures that only authorized users and CI/CD environments can publish updates, enhancing security and control.
================================================
FILE: apps/docs/docs/eoas/publish.mdx
================================================
---
sidebar_position: 3
---
# Publish OTA updates
## Runtime version
EOAS uses official Expo packages to resolve the runtime version of your project.
It supports the fingerprint policy.
## Publish an update
To publish an update, run the following command in your Expo project:
```bash
npx eoas publish --branch [--nonInteractive] [--outputDir ] [--platform ] [--packageRunner ] [--message ] [--dumpSourcemap]
```
This command will retrieve the expo credentials from your .expo/state.json file or an EXPO_TOKEN in your runtime environment to authenticate
the request to the Expo API.
:::warning
🚨EOAS publish will create a new build of your app. Do not forget to pass the necessary environment variables to the runtime environment.
Example: `EXPO_TOKEN=your_token RELEASE_CHANNEL=staging npx eoas publish --branch `.
Or with dotenv: `dotenv -e .env.local -- npx eoas publish --branch `.
:::
## Update message
You can attach a short description to each update using the `--message` (or `-m`) flag:
```bash
npx eoas publish --branch production -m "Fix login crash on Android"
```
If omitted, EOAS defaults to the latest git commit message, following the same behavior as EAS Update.
The message is visible in the [dashboard](/docs/dashboard) updates table.
## Package runner
By default, EOAS uses `npx` to spawn Expo CLI commands (`expo export`, `expo config`). If your project uses a different package manager, you can override this.
**Resolution priority:**
1. `--packageRunner` CLI flag
2. `EOAS_PACKAGE_RUNNER` environment variable
3. `packageManager` field in the nearest `package.json` (e.g. `"packageManager": "bun@1.3.6"` → `bunx`)
4. Falls back to `npx`
| `packageManager` value | Resolved runner |
| --- | --- |
| `bun@x.x.x` | `bunx` |
| `pnpm@x.x.x` | `pnpx` |
| `yarn@x.x.x` | `npx` |
| `npm@x.x.x` | `npx` |
```bash
# Auto-detected from package.json (zero config)
npx eoas publish --branch production
# Explicit flag
npx eoas publish --branch production --packageRunner bunx
# Via environment variable
EOAS_PACKAGE_RUNNER=bunx eoas publish --branch production
```
## Source maps
Pass `--dumpSourcemap` to emit Hermes source maps alongside the bundle in the output directory. This forwards `--dump-sourcemap` to the underlying `expo export` call.
```bash
npx eoas publish --branch production --dumpSourcemap
```
The source maps land next to each platform's bundle (e.g. `dist/_expo/static/js/ios/*.map`) and are not uploaded to the OTA server. They can be uploaded to a symbolication service like Sentry or PostHog from the same `dist/` directory, guaranteeing the source maps match the bundle that was published.
## CI/CD
You can automate the process of publishing updates by integrating the `npx eoas publish --nonInteractive` command in your CI/CD pipeline.
However, you need to make sure that the EXPO_TOKEN is set up in your CI/CD environment.
(Do not forget the `--nonInteractive` flag to avoid interactive prompts)
================================================
FILE: apps/docs/docs/eoas/republish.mdx
================================================
---
sidebar_position: 5
---
# Republish
The `republish` command lets you resend an existing update to a specified branch and platform. It walks you through selecting the runtime version and the exact update to republish.
## Usage
```bash
npx eoas republish --branch [--platform ]
```
## Options
- `--branch `: Name of the branch to which the update will be republished.
- `--platform `: Platform to which the update will be republished. If not specified, all platforms will be used.
## Description
Republishing reuses the same code and assets from a previous update, assigning them a new update ID on the target branch and platform. Use it to re‑trigger deployments, recover from collisions, or reapply a past release.
================================================
FILE: apps/docs/docs/eoas/rollback.mdx
================================================
---
sidebar_position: 4
---
# Rollback
The `rollback` command allows you to publish a rollback update to a specific branch and runtime version.
⚠️ Not compatible with `disableAntiBrickingMeasure`, as expo-updates will ignore embedded updates in that case.
## Usage
Specify the branch and target runtime version to perform a rollback:
```bash
npx eoas rollback --branch [--platform ]
```
## Description
A rollback update reverts the application on the specified branch to the embedded update without requiring a new native build.
================================================
FILE: apps/docs/docs/getting-started/_category_.json
================================================
{
"label": "Getting Started",
"position": 1,
"link": {
"type": "doc",
"id": "getting-started/introduction"
}
}
================================================
FILE: apps/docs/docs/getting-started/introduction.mdx
================================================
---
sidebar_position: 1
---
# Introduction
**Expo Open OTA** is an open-source, multi-cloud OTA update server for Expo and React Native applications. It implements the [Expo Updates protocol](https://docs.expo.dev/technical-specs/expo-updates-1/) and supports **AWS S3**, **Google Cloud Storage**, and any **S3-compatible** provider (Cloudflare R2, MinIO, DigitalOcean Spaces).
:::warning
**Expo Open OTA** is not affiliated with Expo. It is an independent open-source project.
:::
## How It Works
Expo Open OTA works by redirecting the `expo-updates` package of your application to a custom OTA server that implements several key endpoints:
### 1. `/manifest`
This endpoint is called by the Expo application on launch or when executing `checkForUpdateAsync()`. The `expo-updates` package includes several headers in its request:
- `expo-channel-name`
- `expo-protocol-version`
- `expo-platform`
- `expo-runtime-version`
Based on these headers, the server determines whether an update is available. The update is retrieved from the branch associated with the given channel in the Expo account.
### 2. `/assets`
When an update is available, a list of assets is sent back to the client. These assets are accessed via the `/assets` endpoint, which:
- Signs and compresses the files.
- Returns the required assets to the Expo client.
If a CDN is configured (CloudFront) or if using GCS, the returned URL is a pre-signed/signed link and the client downloads directly from the storage provider. Otherwise, the server returns the asset directly.
### 3. `/requestUploadUrl` & `/uploadLocalFile`
These routes are used by the `eoas` package to publish updates to the chosen storage solution, whether it's S3, Google Cloud Storage, or a local file system.
`/uploadLocalFile` is used to upload the file to the server when [storage mode](/docs/server-configuration/storage?storage=local) is set to `local`.
## Why Self-Host Your OTA Update server?
There are several reasons why you might want to self-host your updates instead of relying on the official Expo service:
### 1. **Cost Considerations**
Expo's pricing model for OTA updates is based on the number of Monthly Active Users (MAUs). For large-scale applications, costs can add up quickly. Below is a brief breakdown of their pricing:
- **1,000 MAUs**: Free
- **Next 199,000 MAUs**: $0.005 per MAU
- **Next 300,000 MAUs**: $0.00375 per MAU
- **Next 500,000 MAUs**: $0.0034 per MAU
- **Next 1,000,000 MAUs**: $0.003 per MAU
- [Full pricing details](https://expo.dev/pricing)
Self-hosting removes the dependency on Expo's pricing structure, giving you full control over your costs.
### 2. **Full Control Over Your Infrastructure**
By hosting your own OTA server, you can:
- Store update files on your own infrastructure.
- Secure your files using custom certificates and authentication mechanisms.
- Ensure compliance with specific security requirements.
### 3. **Custom Network and Security Constraints**
One of the key motivations for this project came from my experience at **Skeat** ([skeatapp.com](https://skeatapp.com)), where we needed to deploy applications within highly controlled network environments. Many of our clients operate in restricted setups where:
- Internet access is limited.
- Network traffic must be routed through proxies and VPNs.
Self-hosting an Expo OTA server allows **full control** over network flows, ensuring seamless deployments even in highly secured environments.
## Why Does This Project Rely on Expo?
Although we self-host OTA updates, Expo remains an essential part of our workflow for several reasons:
### 1. **EAS (Expo Application Services) is Great**
EAS provides powerful features for **building, signing, and submitting applications**. These functionalities are industry-standard and difficult to replace, making them **worth every penny**.
### 2. **Branch & Release Channel Management**
We currently use Expo's API to authenticate uploads and manage **branch-to-release channel mappings**, ensuring smooth versioning and deployment.
### 3. **Potential for Future Independence**
At present, this project relies on Expo for managing release channels and branches. However, we aim to implement our own release and versioning logic in the future. This would allow for greater autonomy, reducing dependence on Expo while maintaining flexibility for developers.
---
By self-hosting **Expo Open OTA**, you gain the flexibility, security, and control needed for large-scale or restricted-network deployments, while still benefiting from Expo's powerful development tools.
================================================
FILE: apps/docs/docs/getting-started/prerequisites.mdx
================================================
---
sidebar_position: 2
---
# Prerequisites
To get started with **Expo Open OTA**, you need to review the following prerequisites:
:::note
Some of the environment variables required for the server are listed below. You can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
## Expo Token & Project ID
To interact with the Expo API, you need an **Expo token** and **project ID**. These credentials authenticate update publishing
and allow the server to fetch the release channel-to-branch mappings.
### How to Get Your Expo Token
1. Go to the [Access tokens page](https://expo.dev/settings/access-tokens) on your expo dashboard.
2. Click on the **+ Create token** button.
3. Enter a name for your token and click **Create**.
4. Copy the generated token and store it in a safe place.
:::info
This token will be used as the `EXPO_ACCESS_TOKEN` environment variable.
:::
### How to Get Your Project ID
#### From EAS CLI
1. Ensure you have the [EAS CLI](https://github.com/expo/eas-cli) installed.
2. Ensure you are logged in to your Expo account by running
```bash title="cd ./my-expo-project"
eas account:view
```
3. On your terminal go to the root directory of your Expo project.
4. Run
```bash title="cd ./my-expo-project"
eas project:info
```
to get the project ID.
#### From Expo Dashboard
1. Login to your [Expo dashboard](https://expo.dev).
2. Go to the **Projects page**
3. Click on the project you want to get the ID for.
4. The project ID is displayed on the top of the page.
:::info
This ID will be used as the `EXPO_APP_ID` environment variable.
:::
## JWT Secret
A JWT Secret can be used to sign and verify some of the requests made to the server. This secret is used to sign the JWT token
that is sent to the client when they request an update manifest.
To generate a JWT secret, you can use the following command:
```bash title="Generate JWT Secret"
openssl rand -base64 32
```
:::info
This secret will be used as the `JWT_SECRET` environment variable.
:::
## Base URL
The base URL is the URL where your server will be hosted. This URL is used to generate the URLs for the assets that are sent to the client.
Example: `https://my-ota-server.com`
:::info
This URL will be used as the `BASE_URL` environment variable.
:::
================================================
FILE: apps/docs/docs/getting-started/quick-start.mdx
================================================
---
sidebar_position: 3
---
# Quick Start
Get your first OTA update running in minutes with a minimal local setup.
:::tip
This guide uses **local storage, local keys, and local cache** — the simplest configuration possible. For production setups (S3, Redis, CloudFront), see [Server Configuration](/docs/category/server-configuration).
:::
## 1. Get your Expo credentials
You need two things from Expo:
- **Access Token**: Create one at [expo.dev/settings/access-tokens](https://expo.dev/settings/access-tokens)
- **Project ID**: Run `eas project:info` in your Expo project, or find it in your [Expo dashboard](https://expo.dev)
## 2. Generate signing certificates
In your Expo project directory, generate the signing key pair:
```bash title="cd ./my-expo-project"
npx eoas generate-certs
```
This creates three files in `certs/`:
- `private-key.pem` and `public-key.pem` — used by the server to sign and verify updates
- `certificate.pem` — commit this to your Expo project (used by the client to verify updates)
## 3. Start the server
Pull and run the Docker image with a minimal configuration:
```bash
docker run --rm -it \
-p 3000:3000 \
-e BASE_URL=http://localhost:3000 \
-e EXPO_ACCESS_TOKEN=your-expo-token \
-e EXPO_APP_ID=your-project-id \
-e JWT_SECRET=$(openssl rand -base64 32) \
-e STORAGE_MODE=local \
-e KEYS_STORAGE_TYPE=local \
-e PUBLIC_LOCAL_EXPO_KEY_PATH=/keys/public-key.pem \
-e PRIVATE_LOCAL_EXPO_KEY_PATH=/keys/private-key.pem \
-e CACHE_MODE=local \
-e USE_DASHBOARD=true \
-e ADMIN_PASSWORD=admin \
-v $(pwd)/certs:/keys:ro \
-v ./updates:/updates \
ghcr.io/axelmarciano/expo-open-ota:latest
```
The server is now running on `http://localhost:3000`. You can verify with:
```bash
curl http://localhost:3000/hc
```
## 4. Configure your Expo app
In your Expo project, point your app to your local server:
```bash title="cd ./my-expo-project"
npx eoas init
```
Follow the prompts — it will update your `app.config.js` to use your Expo Open OTA server.
:::warning
After running `eoas init`, you need to create a **new build** of your app for the configuration to take effect. See [EAS Build guide](https://docs.expo.dev/build/introduction/).
:::
## 5. Create a release channel
Your app uses a **release channel** to know which branch to pull updates from. The server queries Expo for this channel→branch mapping when your app checks for updates.
Go to your [Expo dashboard](https://expo.dev), navigate to your project under **Over-the-air-updates → Channels**, and create a channel (e.g. `production`) pointing to the branch you'll publish to (e.g. `production`).
:::info
Branches are created automatically when you publish. Channels must be created manually on [expo.dev](https://expo.dev).
If you have the EAS CLI installed, you can also run `eas channel:create production`.
:::
## 6. Publish your first update
```bash title="cd ./my-expo-project"
npx eoas publish --branch production
```
That's it! Your app will now receive OTA updates from your self-hosted server.
## Next steps
You now have a working local setup. To move to production:
- **[Configure Your App](/docs/eoas/intro)** — Learn about publishing, rollback, and republishing updates
- **[Deployment](/docs/category/deployment)** — Deploy on Railway, Docker, or Kubernetes
- **[Server Configuration](/docs/category/server-configuration)** — Switch to S3 storage, Redis cache, and CloudFront CDN
- **[Dashboard](/docs/dashboard)** — Enable the web UI to monitor your updates
================================================
FILE: apps/docs/docs/reference/_category_.json
================================================
{
"label": "Reference",
"position": 7,
"link": {
"type": "generated-index",
"title": "Reference",
"description": "Complete reference documentation for Expo Open OTA."
}
}
================================================
FILE: apps/docs/docs/reference/environment.mdx
================================================
---
sidebar_position: 1
---
# Environment variables
The **Expo Open OTA** server requires several environment variables to be set in order to function correctly. These variables are used to configure the server, interact with the Expo API, and manage the server's behavior.
You can set these variables in a `.env` file for local development or in your deployment environment.
## Supported Environment Variables
### 🌍 **API Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `BASE_URL` | ✅ | Root URL of your server | `https://ota.mysite.com` | [Ref](/docs/getting-started/prerequisites#base-url) |
### 🔑 **Authentication & Security**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `JWT_SECRET` | ✅ | JWT secret used to sign some endpoints | `Random string` | [Ref](/docs/getting-started/prerequisites#jwt-secret) |
### 📱 **Expo Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `EXPO_APP_ID` | ✅ | The ID of the Expo project | `Random string` | [Ref](/docs/getting-started/prerequisites#how-to-get-your-project-id) |
| `EXPO_ACCESS_TOKEN` | ✅ | Expo access token | `Random string` | [Ref](/docs/getting-started/prerequisites#how-to-get-your-expo-token) |
### ⚡ **Cache Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `CACHE_MODE` | ✅ | `local`, `redis`, or `redis-sentinel` | `local` | [Ref](/docs/server-configuration/cache) |
| `REDIS_HOST` | ✅ if CACHE_MODE = `redis` | Redis host | `127.0.0.1` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_PORT` | ✅ if CACHE_MODE = `redis` | Redis port | `6379` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_PASSWORD` | ✅ if CACHE_MODE = `redis` | Redis password | `password` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_USE_TLS` | ❌ | Enable TLS/SSL connection | `true` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_USERNAME` | ❌ | Redis username for ACL authentication | `myuser` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_CA_CERT_B64` | ❌ | Base64-encoded CA certificate for TLS | `Base64 string` | [Ref](/docs/server-configuration/cache?cache=redis) |
| `REDIS_SENTINEL_ADDRS` | ✅ if CACHE_MODE = `redis-sentinel` | Comma-separated list of Sentinel addresses | `sentinel-0:26379,sentinel-1:26379,sentinel-2:26379` | [Ref](/docs/server-configuration/cache?cache=redis-sentinel) |
| `REDIS_SENTINEL_MASTER_NAME` | ❌ | Sentinel master name (defaults to `mymaster`) | `mymaster` | [Ref](/docs/server-configuration/cache?cache=redis-sentinel) |
### 📦 **Storage Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `STORAGE_MODE` | ✅ | `local`, `s3` or `gcs` | `local` | [Ref](/docs/server-configuration/storage) |
| `S3_BUCKET_NAME` | ✅ if STORAGE_MODE = `s3` | S3 bucket name | `my-bucket` | [Ref](/docs/server-configuration/storage?storage=s3) |
| `S3_KEY_PREFIX` | ❌ | Key prefix for namespacing inside a shared bucket (multi-app) | `myapp/` | [Ref](/docs/server-configuration/storage?storage=s3) |
| `GCS_BUCKET_NAME` | ✅ if STORAGE_MODE = `gcs` | GCS bucket name | `my-bucket` | [Ref](/docs/server-configuration/storage?storage=gcs) |
| `GOOGLE_APPLICATION_CREDENTIALS_B64` | ✅ if STORAGE_MODE = `gcs` (for signed URLs) | Base64-encoded service account JSON | `Base64 string` | [Ref](/docs/server-configuration/storage?storage=gcs) |
| `LOCAL_BUCKET_BASE_PATH` | ✅ if STORAGE_MODE = `local` | Path to store assets | `/path/to/assets` | [Ref](/docs/server-configuration/storage?storage=local) |
### 🔐 **Key store Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `KEYS_STORAGE_TYPE` | ✅ | `environment`, `aws-secrets-manager`, or `local` | `environment` | [Ref](/docs/server-configuration/key-store) |
#### **AWS Secrets Manager Key Store**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `AWSSM_EXPO_PUBLIC_KEY_SECRET_ID` | ✅ if KEYS_STORAGE_TYPE = `aws-secrets-manager` | Expo public key secret name in AWS | `my-expo-public-key` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
| `AWSSM_EXPO_PRIVATE_KEY_SECRET_ID` | ✅ if KEYS_STORAGE_TYPE = `aws-secrets-manager` | Expo private key secret name in AWS | `my-expo-private-key` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
#### **Environment-Based Key Store**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `PUBLIC_EXPO_KEY_B64` | ✅ if KEYS_STORAGE_TYPE = `environment` | Base64-encoded Expo public key | `Base64 string` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
| `PRIVATE_EXPO_KEY_B64` | ✅ if KEYS_STORAGE_TYPE = `environment` | Base64-encoded Expo private key | `Base64 string` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
#### **Local Key Store**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `PRIVATE_LOCAL_EXPO_KEY_PATH` | ✅ if KEYS_STORAGE_TYPE = `local` | Path to the Expo private key | `/path/to/private-key.pem` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
| `PUBLIC_LOCAL_EXPO_KEY_PATH` | ✅ if KEYS_STORAGE_TYPE = `local` | Path to the Expo public key | `/path/to/public-key.pem` | [Ref](/docs/server-configuration/key-store#expo-signing-certificate) |
### ☁️ **AWS & CloudFront Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `AWS_REGION` | ✅ if using `aws-secrets-manager` or `s3` | AWS Region | `us-east-1` | [Ref](/docs/server-configuration/key-store?keyStore=aws-secrets-manager#key-store-configuration), [Storage](/docs/server-configuration/storage?storage=s3) |
| `AWS_BASE_ENDPOINT` | ❌ | Custom S3-compatible endpoint for alternative object storage | `https://account-id.r2.cloudflarestorage.com` | [Storage](/docs/server-configuration/storage?storage=s3) |
| `AWS_S3_FORCE_PATH_STYLE` | ❌ | Force path-style S3 addressing instead of virtual-hosted-style URLs | `true` | [Storage](/docs/server-configuration/storage?storage=s3) |
| `AWS_ACCESS_KEY_ID` | ✅ if using `aws-secrets-manager` or `s3` without IAM roles | AWS Access Key ID | `ACCESSKEYID` | [Ref](/docs/server-configuration/key-store?keyStore=aws-secrets-manager#key-store-configuration), [Storage](/docs/server-configuration/storage?storage=s3) |
| `AWS_SECRET_ACCESS_KEY` | ✅ if using `aws-secrets-manager` or `s3` without IAM roles | AWS Secret Access Key | `SECRETACCESSKEY` | [Ref](/docs/server-configuration/key-store?keyStore=aws-secrets-manager#key-store-configuration), [Storage](/docs/server-configuration/storage?storage=s3) |
#### **CloudFront Settings**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `CLOUDFRONT_DOMAIN` | ❌ | CloudFront domain | `https://XXX.cloudfront.net` | [Ref](/docs/server-configuration/cdn/cloudfront) |
| `CLOUDFRONT_KEY_PAIR_ID` | ✅ if CLOUDFRONT_DOMAIN is set | CloudFront key pair ID | `Random string` | [Ref](/docs/server-configuration/cdn/cloudfront) |
| `CLOUDFRONT_PRIVATE_KEY_B64` | ✅ if using `environment` & CLOUDFRONT_DOMAIN is set | Base64 CloudFront private key | `Base64 string` | [Ref](/docs/server-configuration/cdn/cloudfront) |
| `AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID` | ✅ if using `aws-secrets-manager` & CLOUDFRONT_DOMAIN is set | CloudFront private key in AWS Secrets Manager | `my-cloudfront-private-key` | [Ref](/docs/server-configuration/cdn/cloudfront) |
| `PRIVATE_LOCAL_CLOUDFRONT_KEY_PATH` | ✅ if using `local` & CLOUDFRONT_DOMAIN is set | Path to CloudFront private key | `/path/to/cloudfront-private-key.pem` | [Ref](/docs/server-configuration/cdn/cloudfront) |
#### **Generic CDN Settings**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `S3_CDN_PREFIX` | ❌ | CDN domain prefix for S3 assets | `https://cdn.example.com` | [Ref](/docs/server-configuration/cdn/generic) |
#### **Prometheus Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `PROMETHEUS_ENABLED` | ❌ (Automatic) | Automatically set to `true` if `prometheus.io/scrape: "true"` is present in `podAnnotations`, otherwise must be explicitly set to `true` | `true` | |
#### **Dashboard Configuration**
| Name | Required | Description | Example | Reference |
| --- | --- | --- | --- | --- |
| `USE_DASHBOARD` | ❌ | Enable the dashboard | `true` | [Ref](/docs/dashboard) |
| `ADMIN_PASSWORD` | ✅ if USE_DASHBOARD is set | Admin password | `Random string` | [Ref](/docs/dashboard) |
================================================
FILE: apps/docs/docs/server-configuration/_category_.json
================================================
{
"label": "Server Configuration",
"position": 4,
"link": {
"type": "generated-index",
"title": "Server Configuration",
"description": "Configure storage, key management, caching, and CDN for your Expo Open OTA server."
}
}
================================================
FILE: apps/docs/docs/server-configuration/cache.mdx
================================================
---
sidebar_position: 3
id: cache
---
# Caching
The **Expo Open OTA server** uses a cache to improve performance and reduce server load by avoiding repeated computations.
## Cache Usage
The cache is primarily used for:
1. **Storing the computed `lastUpdateId` for a given platform and runtime version**
- This prevents the need to recompute the last update for every request, significantly speeding up responses.
2. **Caching the computed manifest**
- Manifest generation can be an expensive operation.
- By caching the results, we reduce response times and improve overall performance.
:::note
The environment variables required for each storage solution are listed below, you can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
:::warning
This cache solution is not recommended for production use. It is intended for development and testing purposes only.
If you really want to use it in production, make sure to not have multiple instances of the server running, as the cache is stored locally and not shared between instances.
:::
Local cache is the default cache solution used by the server. It stores the cache in memory and is not shared between instances of the server. This means that the cache is lost when the server is restarted.
No additional configuration is required to use the local cache.
To use Redis as your cache solution, you need to set the following environment variables:
```bash title=".env"
REDIS_HOST=your-redis-host
REDIS_PORT=your-redis-port
REDIS_PASSWORD=your-redis-password
REDIS_USE_TLS=true // optional if you are using a TLS connection
REDIS_USERNAME=your-redis-username // optional for ACL authentication
REDIS_CA_CERT_B64=base64-encoded-ca-certificate // optional for TLS with custom CA
```
:::tip TLS/SSL Configuration
If you're using Redis with TLS/SSL and a custom CA certificate:
- Set `REDIS_USE_TLS=true`
- Set `REDIS_CA_CERT_B64` to your base64-encoded PEM certificate
- To encode your certificate: `cat certificate.pem | base64 -w 0`
:::
:::info ACL Authentication
If your Redis server uses ACL (Access Control Lists):
- Set `REDIS_USERNAME` to your Redis username
- Ensure `REDIS_PASSWORD` is set to the corresponding password
:::
To use Redis Sentinel for high availability with automatic master failover, set the following environment variables:
```bash title=".env"
CACHE_MODE=redis-sentinel
REDIS_SENTINEL_ADDRS=sentinel-0:26379,sentinel-1:26379,sentinel-2:26379
REDIS_SENTINEL_MASTER_NAME=mymaster // optional, defaults to "mymaster"
REDIS_PASSWORD=your-redis-password // optional if auth is enabled
REDIS_USE_TLS=true // optional if you are using a TLS connection
REDIS_USERNAME=your-redis-username // optional for ACL authentication
REDIS_CA_CERT_B64=base64-encoded-ca-certificate // optional for TLS with custom CA
```
:::info How it works
The server uses `go-redis` `NewFailoverClient`, which:
1. Connects to Sentinel nodes to discover the current master.
2. Automatically follows master failover — when Sentinel promotes a new master, the client reconnects transparently.
3. Requires no application restart on failover.
:::
:::tip TLS/ACL/CA settings
The same `REDIS_PASSWORD`, `REDIS_USERNAME`, `REDIS_USE_TLS`, and `REDIS_CA_CERT_B64` variables used for direct Redis connections also apply to Sentinel mode.
:::
================================================
FILE: apps/docs/docs/server-configuration/cdn/_category_.json
================================================
{
"label": "CDN",
"position": 4,
"link": {
"type": "doc",
"id": "server-configuration/cdn/intro"
}
}
================================================
FILE: apps/docs/docs/server-configuration/cdn/cloudfront.mdx
================================================
---
sidebar_position: 2
---
# Cloudfront
import BrowserWindow from '@site/src/components/BrowserWindow';
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The cloudfront CDN feature requires your storage mode to be set to `s3`. You can follow the [storage guide](/docs/server-configuration/storage) to set up your storage solution.
:::note
The environment variables required for each cdn are listed below, you can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
## Generate key pair
On your terminal type the following commands:
```bash title="Generate key pair"
openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem
```
## Create Cloudfront Key Group
+ Go to the [Cloudfront Public keys page](https://us-east-1.console.aws.amazon.com/cloudfront/v4/home#/publickey) on your AWS console.
+ Click on the **Create public key** button.
+ Enter a name for your key and upload the public key generated in the previous step.
+ Go to the [Cloudfront Key groups page](https://us-east-1.console.aws.amazon.com/cloudfront/v4/home#/keygrouplist)
+ Click on the **Create key group** button.
+ Enter a name for your key group and select the public key you created in the previous step.
+ You fill find the key group ID in the key groups table. This ID will be used as environment variable by the server:
```bash title=".env"
CLOUDFRONT_KEY_PAIR_ID=your-public-key-id
```
## Create Cloudfront Origin Access Control Settings (OAC)
+ Go to the [Cloudfront Origin Access Identity page](https://us-east-1.console.aws.amazon.com/cloudfront/v4/home#/originAccess)
+ Click on the **Create control setting** button.
+ Enter and name and check Sign requests (recommended), leave *Do not override authorization header* **empty**.
+ Set S3 as Origin Type
## Create Cloudfront distribution
+ Go to the [Cloudfront Distributions page](https://us-east-1.console.aws.amazon.com/cloudfront/v4/home#/distributions)
+ Click on the **Create distribution** button.
### Origin
+ Select the S3 bucket you created in the [storage guide](/docs/server-configuration/storage) as the origin.
+ Leave Origin Path empty.
+ In Origin access check "Origin access control settings (recommended)"
+ In Origin access control select the OAC you created in the previous step.
### Default Cache Behavior Settings
+ Check *Yes* for **Compress objects automatically**.
+ Select HTTPS only for Viewer Protocol Policy.
+ Allowed HTTP methods: *GET, HEAD*
+ Check **Yes** for Restrict Viewer Access
++ Set trusted key groups as Trusted authorization type and select the key group you created in the previous step.
+ In cache Key and origins requests set cache policy as CachingOptimized
### Other settings
You are free to configure the other settings based on what you need for your application.
It's recommended to use a alternate domain name for your distribution.
+ The server will use the domain name or alternate domain name as the `CLOUDFRONT_DOMAIN` environment variable.
```bash title=".env"
CLOUDFRONT_DOMAIN=your-cloudfront-domain
```
## Setup Bucket Policy
+ Go to the [S3 Buckets page](https://us-east-1.console.aws.amazon.com/s3/home)
+ Click on the bucket you created in the [storage guide](/docs/server-configuration/storage)
+ Go to the **Permissions** tab
+ Click on the **Bucket Policy** button
+ Add the following policy to the bucket policy editor:
```json title="Bucket Policy"
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::{{YOUR_BUCKET_NAME}}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::{{AWS_ACCOUNT_ID}}:distribution/{{YOUR_CLOUDFRONT_DISTRIBUTION_ID}}"
}
}
}
]
}
```
## Summary of Environment Variables
### General Environment Variables
```bash title=".env"
CLOUDFRONT_KEY_PAIR_ID=your-public-key-id
CLOUDFRONT_DOMAIN=your-cloudfront-domain
```
### Keys Storage Environment Variables
You will need to setup the private key in the server environment variables. You can follow the [keys storage guide](/docs/server-configuration/key-store) to set up your keys storage solution.
```bash title="Encode keys"
echo -n "your private key" | base64
```
```bash title=".env"
KEYS_STORAGE_TYPE=environment
PRIVATE_CLOUDFRONT_KEY_B64=base64-encoded-cloudfront-private-key
```
```bash title=".env"
AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID=The secret name of the cloudfront private key
```
```bash title=".env"
PRIVATE_LOCAL_CLOUDFRONT_KEY_PATH=/path/to/cloudfront-private-key.pem
```
================================================
FILE: apps/docs/docs/server-configuration/cdn/generic.mdx
================================================
---
sidebar_position: 2
---
# Generic
The generic CDN feature requires your storage mode to be set to `s3`. You can follow the [storage guide](/docs/server-configuration/storage) to set up your storage solution.
:::note
The environment variables required for each CDN are listed below. You can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
:::warning
The generic CDN feature currently only supports public endpoints (i.e. the resources served by CDN URL must be accessible without any signature or token).
:::
## Configure environment variables
Specify your CDN accelerated domain in `S3_CDN_PREFIX` variable.
```bash title=".env"
S3_CDN_PREFIX=https://cdn.example.com
```
If your CDN domain requires bucket name to be set, please specify it in the prefix manually.
```bash title=".env"
S3_CDN_PREFIX=https://cdn.example.com/my-bucket
```
================================================
FILE: apps/docs/docs/server-configuration/cdn/intro.mdx
================================================
---
sidebar_position: 1
---
import DocCardList from '@theme/DocCardList';
# CDN
The CDN feature in **Expo Open OTA** allows you to serve your assets through a Content Delivery Network (CDN) to improve the performance of your app updates.
Currently only CloudFront is supported as a CDN provider.
:::info Optional
This feature is entirely optional. If you don't configure a CDN, the server will serve assets directly. You can safely skip this section if you don't need CDN integration.
:::
## Available CDNs
================================================
FILE: apps/docs/docs/server-configuration/key-store.mdx
================================================
---
sidebar_position: 2
---
# Key Store
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The **Expo Open OTA** server requires several keys and secrets to interact with the Expo API and your CDN.
The **Key store** is a module that manages how these keys are accessed by the server.
You can use 3 different key stores:
1. **Local Key Store**: Keys are stored in a directory on the server as *.pem files.
2. **Environment Variables**: Keys are stored as environment variables in base64 format.
3. **AWS Secrets Manager**: Keys are stored in AWS Secrets Manager and securely accessed by the server.
:::note
The environment variables required for key store configuration are listed below. You can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
## Keys
The following keys are used by the server:
1. **Expo signing key pairs**: Used to sign and verify the updates returned by the server to `expo-updates`. The key pair consists of a public and private key and are **required** by the server.
2. **Cloudfront private key**: Used to sign the Cloudfront URLs for the assets. This key is **optional** and only required if you are using Cloudfront as your CDN.
## Expo signing certificate
To generate expo signing key pairs :
1. On your terminal, go to the root directory of your expo project.
2. Run the following command:
```bash title="cd ./my-expo-project"
npx eoas generate-certs
```
Three files will be generated in the `certs` directory:
1. `private-key.pem`: The private key used to sign the updates.
2. `public-key.pem`: The public key used to verify the updates.
Those two keys are used by the server to sign and verify the updates.
3. `certificate.pem`: Used by your expo client to verify the updates. It should be committed to your expo project.
## Cloudfront private key
:::note
This key is only required if you are using Cloudfront as your CDN.
:::
Please refer to this [section](/docs/server-configuration/cdn/cloudfront#generate-key-pair) on how to generate a Cloudfront private key.
## Key Store Configuration
:::warning
This key store is not recommended for production use. It is intended for development and testing purposes only.
:::
To use local key store you will need to set the following environment variables:
```bash title=".env"
KEYS_STORAGE_TYPE=local
PUBLIC_LOCAL_EXPO_KEY_PATH=/path/to/public-key.pem
PRIVATE_LOCAL_EXPO_KEY_PATH=/path/to/private-key.pem
PRIVATE_LOCAL_CLOUDFRONT_KEY_PATH=/path/to/cloudfront-private-key.pem
```
You will have to encode the keys in base64 format and set the following environment variables:
```bash title="Encode keys"
echo -n "your-private-key" | base64
```
Then set the following environment variables:
```bash title=".env"
KEYS_STORAGE_TYPE=environment
PUBLIC_EXPO_KEY_B64=base64-encoded-public-key
PRIVATE_EXPO_KEY_B64=base64-encoded-private-key
PRIVATE_CLOUDFRONT_KEY_B64=base64-encoded-cloudfront-private-key
```
:::note
If you are not familiar with AWS Secrets Manager, you can refer to the [official documentation](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html).
:::
1. Create a secret for each keys in AWS Secrets Manager.
2. Set the following environment variables:
```bash title=".env"
KEYS_STORAGE_TYPE=aws-secrets-manager
AWS_KEYS_PRIVATE_EXPO_KEY_SECRET_ID=The secret name of the expo private key
AWS_KEYS_PUBLIC_EXPO_KEY_SECRET_ID=The secret name of the expo public key
AWSSM_CLOUDFRONT_PRIVATE_KEY_SECRET_ID=The secret name of the cloudfront private key
AWS_REGION=your-region
```
If your are not using AWS IAM roles, you also need to set the following environment variables:
```bash title=".env"
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
```
:::info
The server use the same AWS credentials for [S3 Storage](/docs/server-configuration/storage?storage=s3) and AWS Secrets Manager. Please ensure to setup the correct ACLs and permissions for the keys.
:::
================================================
FILE: apps/docs/docs/server-configuration/storage.mdx
================================================
---
sidebar_position: 1
id: storage
---
# Storage
**Expo Open OTA** supports multiple storage solutions for hosting your update assets: **Amazon S3**, **Google Cloud Storage (GCS)** and **Local File System**. This guide will help you set up your storage solution and configure your server to use it.
:::note
The environment variables required for each storage solution are listed below, you can set them in a `.env` file in the root of the project or keep them in a safe place to prepare for deployment.
:::
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
:::warning
This storage solution is not recommended for production use. It is intended for development and testing purposes only.
If you really want to use it in production, make sure to not have multiple instances of the server running, as the assets are stored locally and not shared between instances.
:::
To use the local file system as your storage solution, you need to set the `STORAGE_MODE` and `LOCAL_BUCKET_BASE_PATH` environment variable to the path where you want to store your assets. The server will create the necessary directories and store the assets in the specified location.
```bash title=".env"
STORAGE_MODE=local
LOCAL_BUCKET_BASE_PATH=/path/to/your/assets
```
To enable Amazon S3 as your storage solution, you need to set the following environment variables:
```bash title=".env"
STORAGE_MODE=s3
AWS_REGION=your-region
S3_BUCKET_NAME=your-bucket-name
```
**For S3-compatible object storage (e.g., Cloudflare R2, MinIO, DigitalOcean Spaces):**
```bash title=".env"
STORAGE_MODE=s3
AWS_REGION=auto
AWS_BASE_ENDPOINT=https://account-id.r2.cloudflarestorage.com
S3_BUCKET_NAME=your-bucket-name
```
If your provider requires path-style addressing instead of virtual-hosted-style URLs, also set:
```bash title=".env"
AWS_S3_FORCE_PATH_STYLE=true
```
If your are not using AWS IAM roles, you also need to set the following environment variables:
```bash title=".env"
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
```
You don't need to allow public read access to the assets, as the server will generate pre-signed URLs for the assets for CDN if configured.
If CDN is not configured, the server will return the asset directly.
#### Multi-app bucket sharing
If you want to share a single S3 bucket between multiple applications, you can use the `S3_KEY_PREFIX` environment variable to namespace all keys under a specific prefix:
```bash title=".env"
S3_KEY_PREFIX=myapp
```
All objects will be stored under `myapp////...`. This allows multiple independent Expo Open OTA instances to coexist in the same bucket without conflicts.
:::note
A trailing slash is automatically added if omitted (`myapp` → `myapp/`).
:::
To enable Google Cloud Storage as your storage solution, set the following environment variables:
```bash title=".env"
STORAGE_MODE=gcs
GCS_BUCKET_NAME=your-bucket-name
GOOGLE_APPLICATION_CREDENTIALS_B64=
```
#### Setting up GCP credentials
1. In the GCP Console, go to **IAM & Admin > Service Accounts**
2. Create a new service account (or use an existing one)
3. Go to your bucket in **Cloud Storage > Buckets**, open the **Permissions** tab
4. Click **Grant Access**, add your service account with the **Storage Admin** role
5. Back in Service Accounts, go to **Keys > Add Key > Create new key > JSON**
6. Encode the downloaded JSON file to base64:
```bash
base64 -i /path/to/service-account.json | tr -d '\n'
```
7. Set the output as `GOOGLE_APPLICATION_CREDENTIALS_B64` in your `.env`
:::tip
The base64 credential is used both for authenticating API calls (read, write, delete objects) and for generating signed URLs.
:::
#### How asset delivery works
Unlike S3 where you can optionally configure CloudFront as a CDN, GCS uses **direct signed URLs** for asset delivery. When a client requests an update asset, the server generates a short-lived signed URL (15 minutes) and redirects the client to download the file directly from GCS — the server never proxies the file content itself.
This is automatic when `GOOGLE_APPLICATION_CREDENTIALS_B64` is set. No additional CDN configuration is needed.
#### Permissions
The service account needs at minimum the **Storage Admin** role on your bucket. This covers:
- `storage.objects.get` — reading objects
- `storage.objects.list` — listing branches, runtime versions, and updates
- `storage.objects.create` — uploading new updates
- `storage.objects.delete` — removing updates
- Signed URL generation for asset delivery
================================================
FILE: apps/docs/docusaurus.config.ts
================================================
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
title: 'Expo Open OTA',
tagline:
'Self-hosted Expo updates for React Native. Multi-cloud (AWS S3, Google Cloud Storage), CDN-ready, and production-grade — an open-source Go server implementing the Expo Updates protocol.',
favicon: 'img/favicon.ico',
scripts: [
{
src: 'https://plausible.io/js/script.js',
defer: true,
'data-domain': 'axelmarciano.github.io/expo-open-ota',
},
],
// Set the production url of your site here
url: 'https://axelmarciano.github.io',
// Set the // pathname under which your site is served
// For GitHub pages deployment, it is often '//'
baseUrl: '/expo-open-ota',
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: 'axelmarciano', // Usually your GitHub org/user name.
projectName: 'expo-open-ota', // Usually your repo name.
deploymentBranch: 'gh-pages',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: 'en',
locales: ['en'],
},
presets: [
[
'classic',
{
docs: {
sidebarPath: './sidebars.ts',
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/axelmarciano/expo-open-ota/tree/main/apps/docs/',
},
blog: {
showReadingTime: true,
feedOptions: {
type: ['rss', 'atom'],
xslt: true,
},
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:
'https://github.com/axelmarciano/expo-open-ota/tree/main/apps/docs/',
// Useful options to enforce blogging best practices
onInlineTags: 'warn',
onInlineAuthors: 'warn',
onUntruncatedBlogPosts: 'warn',
},
theme: {
customCss: './src/css/custom.css',
},
} satisfies Preset.Options,
],
],
themeConfig: {
// Replace with your project's social card
image: './static/img/social_card.png',
navbar: {
title: 'Expo Open OTA',
items: [
{
type: 'docSidebar',
sidebarId: 'docSidebar',
position: 'left',
label: 'Documentation',
},
{
href: 'https://github.com/axelmarciano/expo-open-ota',
label: 'GitHub',
position: 'right',
},
{
href: 'mailto:expoopenota@gmail.com',
label: 'Contact',
position: 'right',
},
],
},
footer: {
style: 'dark',
links: [],
copyright: `Copyright © ${new Date().getFullYear()} Axel Marciano. Distributed under the MIT License.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
} satisfies Preset.ThemeConfig,
}
export default config;
================================================
FILE: apps/docs/package.json
================================================
{
"name": "docs",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.7.0",
"@docusaurus/tsconfig": "3.7.0",
"@docusaurus/types": "3.7.0",
"typescript": "~5.6.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=18.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
================================================
FILE: apps/docs/sidebars.ts
================================================
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
docSidebar: [{type: 'autogenerated', dirName: '.'}],
};
export default sidebars;
================================================
FILE: apps/docs/src/components/BrowserWindow/index.tsx
================================================
import React, {type CSSProperties, type ReactNode} from 'react';
import clsx from 'clsx';
import styles from './styles.module.css';
interface Props {
children: ReactNode;
minHeight?: number;
url: string;
style?: CSSProperties;
bodyStyle?: CSSProperties;
}
export default function BrowserWindow({
children,
minHeight,
url = 'http://localhost:3000',
style,
bodyStyle,
}: Props): ReactNode {
return (
);
}
================================================
FILE: apps/docs/src/components/BrowserWindow/styles.module.css
================================================
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.browserWindow {
border: 3px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-lw);
margin-bottom: var(--ifm-leading);
}
.browserWindowHeader {
align-items: center;
background: var(--ifm-color-emphasis-200);
display: flex;
padding: 0.5rem 1rem;
}
.row::after {
content: '';
display: table;
clear: both;
}
.buttons {
white-space: nowrap;
}
.right {
align-self: center;
width: 10%;
}
[data-theme='light'] {
--ifm-background-color: #fff;
}
.browserWindowAddressBar {
flex: 1 0;
margin: 0 1rem 0 0.5rem;
border-radius: 12.5px;
background-color: var(--ifm-background-color);
color: var(--ifm-color-gray-800);
padding: 5px 15px;
font: 400 13px Arial, sans-serif;
user-select: none;
}
[data-theme='dark'] .browserWindowAddressBar {
color: var(--ifm-color-gray-300);
}
.dot {
margin-right: 6px;
margin-top: 4px;
height: 12px;
width: 12px;
background-color: #bbb;
border-radius: 50%;
display: inline-block;
}
.browserWindowMenuIcon {
margin-left: auto;
}
.bar {
width: 17px;
height: 3px;
background-color: #aaa;
margin: 3px 0;
display: block;
}
.browserWindowBody {
background-color: var(--ifm-background-color);
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
padding: 1rem;
}
.browserWindowBody > *:last-child {
margin-bottom: 0;
}
================================================
FILE: apps/docs/src/components/HomepageFeatures/index.tsx
================================================
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Heading from '@theme/Heading';
import styles from './styles.module.css';
type FeatureItem = {
title: string;
description: ReactNode;
};
const FeatureList: FeatureItem[] = [
{
title: '⚙️ Production-ready in 10 minutes',
description: (
<>
No database, no complex setup. Connect your cloud storage — AWS S3, Google Cloud Storage, or any S3-compatible provider — and you’re live. Handles release channels, branches, and runtime versions out of the box.
>
),
},
{
title: '🚀 One Command to Publish',
description: (
<>
The eoas CLI automates everything — run npx eoas init to configure your project, and npx eoas publish to push updates from your CI/CD pipeline. No extra scripts, no hassle.
>
),
},
{
title: '⚡ Fast Asset Delivery',
description: (
<>
Assets served at the edge. Deliver updates via CloudFront CDN or GCS signed URLs — your users get updates instantly, wherever they are. No public bucket access needed.
>
),
},
];
function Feature({title, description}: FeatureItem) {
return (
);
}
export default function HomepageFeatures(): ReactNode {
return (
{FeatureList.map((props, idx) => (
))}
);
}
================================================
FILE: apps/docs/src/components/HomepageFeatures/styles.module.css
================================================
.features {
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
}
.featureSvg {
height: 200px;
width: 200px;
}
================================================
FILE: apps/docs/src/css/custom.css
================================================
:root {
--ifm-color-primary: #9d4edd; /* Rich premium pink/purple */
--ifm-color-primary-dark: #3c096c; /* Slightly deeper purple */
--ifm-color-primary-darker: #240046; /* Even darker shade */
--ifm-color-primary-darkest: #10002b; /* Deep, elegant purple */
--ifm-color-primary-light: #9d4edd; /* Soft, vibrant purple-pink */
--ifm-color-primary-lighter: #9d4edd; /* Lighter and more luminous */
--ifm-color-primary-lightest: #c77dff; /* Pastel, premium feel */
--ifm-code-font-size: 95%;
----docusaurus-highlighted-code-line-bg: rgba(90, 24, 154, 0.15); /* Highlighted code line background */
}
[data-theme='dark'] {
--ifm-color-primary: #c77dff; /* Rich premium pink/purple */
--ifm-color-primary-light: #3c096c; /* Slightly deeper purple */
--ifm-color-primary-lighter: #240046; /* Even darker shade */
--ifm-color-primary-lightest: #10002b; /* Deep, elegant purple */
--ifm-color-primary-dark: #7b2cbf; /* Soft, vibrant purple-pink */
--ifm-color-primary-darker: #9d4edd; /* Lighter and more luminous */
--ifm-color-primary-dakest: #c77dff; /* Pastel, premium feel */
----docusaurus-highlighted-code-line-bg: rgba(90, 24, 154, 0.15); /* Highlighted code line background */
}
================================================
FILE: apps/docs/src/pages/index.module.css
================================================
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
.heroBanner {
padding: 4rem 0;
text-align: center;
position: relative;
overflow: hidden;
}
.docBtn {
background-color: var(--ifm-color-primary-darkest);
}
.buttons {
display: flex;
align-items: center;
gap: 20px;
}
.heroTopContainer {
display: flex;
flex-direction: row;
}
.heroTextContainer {
display: flex;
flex-direction: column;
margin-top: 5rem;
margin-left: 2rem;
margin-right: 2rem;
}
.imgHeader {
width: 700px;
height: auto;
max-width: 50%;
}
.heroTitle {
text-align: left !important;
}
.heroSubtitle {
text-align: left !important;
}
@media screen and (max-width: 996px) {
.heroTitle {
text-align: center !important;
}
.heroSubtitle {
text-align: center !important;
}
.heroTopContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.imgHeader {
width: 100%;
height: auto;
max-width: 500px;
}
.heroTextContainer {
margin: 0rem 2rem;
}
}
@media screen and (max-width: 300px) {
.imgHeader {
display: none !important;
}
}
================================================
FILE: apps/docs/src/pages/index.tsx
================================================
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import Heading from '@theme/Heading';
import styles from './index.module.css';
function HomepageHeader() {
const {siteConfig} = useDocusaurusContext();
return (
{siteConfig.title}
{siteConfig.tagline}
Get started
);
}
export default function Home(): ReactNode {
const { siteConfig } = useDocusaurusContext();
return (
);
}
================================================
FILE: apps/docs/src/pages/markdown-page.md
================================================
---
title: Markdown page example
---
# Markdown page example
You don't need React to write simple standalone pages.
================================================
FILE: apps/docs/static/.nojekyll
================================================
================================================
FILE: apps/docs/tsconfig.json
================================================
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
},
"exclude": [".docusaurus", "build"]
}
================================================
FILE: apps/eoas/.eslintignore
================================================
node_modules
================================================
FILE: apps/eoas/.eslintrc.js
================================================
module.exports = {
root: true,
extends: ['universe/node'],
plugins: ['node'],
ignorePatterns: ['bin/'],
rules: {
'no-console': 'warn',
'no-constant-condition': ['warn', { checkLoops: false }],
'sort-imports': [
'warn',
{
ignoreDeclarationSort: true,
},
],
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
],
'import/no-relative-packages': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
],
},
],
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'node/no-sync': 'error',
},
overrides: [
{
files: ['*.ts', '*.d.ts'],
parserOptions: {
project: './tsconfig.json',
},
rules: {
'@typescript-eslint/explicit-function-return-type': [
'warn',
{
allowExpressions: true,
},
],
'@typescript-eslint/prefer-nullish-coalescing': ['warn', { ignorePrimitives: true }],
'@typescript-eslint/no-confusing-void-expression': 'warn',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: false,
},
],
'@typescript-eslint/no-floating-promises': 'error',
'no-void': ['warn', { allowAsStatement: true }],
'no-return-await': 'off',
'@typescript-eslint/return-await': ['error', 'always'],
'@typescript-eslint/no-confusing-non-null-assertion': 'warn',
'@typescript-eslint/no-extra-non-null-assertion': 'warn',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/prefer-includes': 'warn',
'@typescript-eslint/prefer-readonly': 'warn',
'@typescript-eslint/prefer-string-starts-ends-with': 'warn',
'@typescript-eslint/prefer-ts-expect-error': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
},
},
],
};
================================================
FILE: apps/eoas/.gitignore
================================================
node_modules
*-debug.log
*-error.log
.DS_Store
.idea
.vscode
.history
coverage
dist/
================================================
FILE: apps/eoas/.prettierrc
================================================
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
}
================================================
FILE: apps/eoas/README.md
================================================
# EOAS (Expo Open Application Services)
EOAS ((Expo Open Application Services) is a powerful helper package designed to simplify the setup and update publication process for the [expo-open-ota](https://github.com/axelmarciano/expo-open-ota) project.
## Quick Start
To get started with EOAS, check out the official documentation:
[EOAS Official Documentation](https://axelmarciano.github.io/expo-open-ota/)
## Learn More
For detailed information and to explore the core functionalities of expo-open-ota, visit the main repository:
[expo-open-ota on GitHub](https://github.com/axelmarciano/expo-open-ota)
---
Feel free to contribute, raise issues, or share feedback to help us improve EOAS!
================================================
FILE: apps/eoas/package.json
================================================
{
"name": "eoas",
"version": "2.2.2",
"main": "index.js",
"scripts": {
"build": "tsc --project tsconfig.json",
"watch": "tsc --project tsconfig.json --watch",
"lint": "eslint ."
},
"engines": {
"node": ">=18.0.0"
},
"homepage": "https://github.com/axelmarciano/expo-open-ota/tree/main/eoas",
"keywords": [
"expo-open-ota",
"expo",
"eas",
"cli"
],
"author": "Axel Marciano",
"license": "MIT",
"description": "A CLI tool to manage publishing and OTA updates for expo-open-OTA self-hosted server. This is not an official tool from Expo but an open-source project (https://github.com/axelmarciano/expo-open-ota)",
"repository": "axelmarciano/expo-open-ota",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.5",
"@expo/config": "10.0.11",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "1.0.165",
"@expo/fingerprint": "^0.11.7",
"@expo/package-manager": "1.7.0",
"@expo/spawn-async": "1.7.2",
"@oclif/core": "^4.2.4",
"@types/node-fetch": "^2.6.12",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"better-opn": "3.0.2",
"chalk": "4.1.2",
"eslint": "^8.57.1",
"fast-glob": "3.3.2",
"fetch-retry": "^6.0.0",
"figures": "3.2.0",
"file-type": "^20.0.0",
"form-data": "^4.0.1",
"fs-extra": "11.2.0",
"getenv": "1.0.0",
"graphql": "16.8.1",
"graphql-tag": "^2.12.6",
"https-proxy-agent": "5.0.1",
"ignore": "5.3.0",
"joi": "17.11.0",
"jscodeshift": "^17.1.2",
"log-symbols": "^4.0.0",
"mime": "3.0.0",
"node-fetch": "^2.6.7",
"ora": "^5.1.0",
"prettier": "3.1.1",
"prompts": "^2.4.2",
"recast": "^0.23.9",
"resolve-from": "5.0.0",
"semver": "7.5.4",
"tar": "6.2.1",
"terminal-link": "2.1.1",
"uuid": "9.0.1"
},
"devDependencies": {
"@babel/parser": "^7.26.7",
"@babel/types": "^7.26.7",
"@tsconfig/node18": "^18.2.4",
"@types/fs-extra": "11.0.4",
"@types/getenv": "^1.0.0",
"@types/jscodeshift": "^0.12.0",
"@types/mime": "^3.0.4",
"@types/node": "^18.19.74",
"@types/prompts": "^2.4.9",
"@types/semver": "7.5.6",
"@types/tar": "6.1.10",
"@types/uuid": "9.0.7",
"eslint-config-universe": "^14.0.0",
"eslint-plugin-async-protect": "^3.1.0",
"eslint-plugin-node": "^11.1.0",
"ts-node": "10.9.2",
"typescript": "5.3.3"
},
"bin": {
"eoas": "./bin/run.js"
},
"oclif": {
"bin": "eoas",
"commands": "./dist/commands",
"dirname": "eoas",
"topicSeparator": ":"
},
"files": [
"/bin",
"/dist"
]
}
================================================
FILE: apps/eoas/src/commands/generate-certs.ts
================================================
import {
convertCertificateToCertificatePEM,
convertKeyPairToPEM,
generateKeyPair,
generateSelfSignedCodeSigningCertificate,
} from '@expo/code-signing-certificates';
import { Command } from '@oclif/core';
import { ensureDirSync, writeFile } from 'fs-extra';
import path from 'path';
import Log from '../lib/log';
import { promptAsync } from '../lib/prompts';
export default class GenerateCerts extends Command {
static override args = {};
static override description = 'Generate private & public certificates for code signing';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
public async run(): Promise {
const { certificateOutputDir } = await promptAsync({
message:
'In which directory would you like to store your code signing certificate (used by your expo app)?',
name: 'certificateOutputDir',
type: 'text',
initial: './certs',
validate: v => {
try {
// eslint-disable-next-line
ensureDirSync(path.join(process.cwd(), v));
return true;
} catch {
return false;
}
},
});
const { keyOutputDir } = await promptAsync({
message:
'In which directory would you like to store your key pair (used by your OTA Server) ?. ⚠️ Those certss are sensitive and should be kept private.',
name: 'keyOutputDir',
type: 'text',
initial: './certs',
validate: v => {
try {
// eslint-disable-next-line
ensureDirSync(path.join(process.cwd(), v));
return true;
} catch {
return false;
}
},
});
const { certificateCommonName } = await promptAsync({
message: 'Please enter your Organization name',
name: 'certificateCommonName',
type: 'text',
initial: 'Your Organization Name',
validate: v => {
return !!v;
},
});
const { certificateValidityDurationYears } = await promptAsync({
message: 'How many years should the certificate be valid for?',
name: 'certificateValidityDurationYears',
type: 'number',
initial: 10,
validate: v => {
return v > 0 && Number.isInteger(v);
},
});
const validityDurationYears = Math.floor(Number(certificateValidityDurationYears));
const certificateOutput = path.resolve(process.cwd(), certificateOutputDir);
const keyOutput = path.resolve(process.cwd(), keyOutputDir);
const validityNotBefore = new Date();
const validityNotAfter = new Date();
validityNotAfter.setFullYear(validityNotAfter.getFullYear() + validityDurationYears);
const keyPair = generateKeyPair();
const certificate = generateSelfSignedCodeSigningCertificate({
keyPair,
validityNotBefore,
validityNotAfter,
commonName: certificateCommonName,
});
const keyPairPEM = convertKeyPairToPEM(keyPair);
const certificatePEM = convertCertificateToCertificatePEM(certificate);
await Promise.all([
writeFile(path.join(keyOutput, 'public-key.pem'), keyPairPEM.publicKeyPEM),
writeFile(path.join(keyOutput, 'private-key.pem'), keyPairPEM.privateKeyPEM),
writeFile(path.join(certificateOutput, 'certificate.pem'), certificatePEM),
]);
Log.succeed(
`Generated public and private keys output in ${keyOutputDir}. Please follow the documentation to securely store them and do not commit them to your repository.`
);
Log.succeed(`Generated code signing certificate output in ${certificateOutputDir}.`);
}
}
================================================
FILE: apps/eoas/src/commands/init.ts
================================================
import { Command } from '@oclif/core';
import fs from 'fs-extra';
import path from 'path';
import {
createOrModifyExpoConfigAsync,
getExpoConfigUpdateUrl,
getPrivateExpoConfigAsync,
} from '../lib/expoConfig';
import Log from '../lib/log';
import { ora } from '../lib/ora';
import { isExpoInstalled } from '../lib/package';
import { confirmAsync, promptAsync } from '../lib/prompts';
import { isValidUpdateUrl } from '../lib/utils';
export default class Init extends Command {
static override args = {};
static override description = 'Configure your existing expo project with Expo Open OTA';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
public async run(): Promise {
const projectDir = process.cwd();
const hasExpo = isExpoInstalled(projectDir);
if (!hasExpo) {
Log.error('Expo is not installed in this project. Please install Expo first.');
return;
}
const config = await getPrivateExpoConfigAsync(projectDir);
if (!config) {
Log.error(
'Could not find Expo config in this project. Please make sure you have an Expo config.'
);
return;
}
const { updateUrl: promptedUrl } = await promptAsync({
message: 'Enter the URL of your update server (ex: https://customota.com)',
name: 'updateUrl',
type: 'text',
initial: getExpoConfigUpdateUrl(config),
validate: v => {
return !!v && isValidUpdateUrl(v);
},
});
let manifestEndpoint = `${promptedUrl}/manifest`;
const updateUrl = getExpoConfigUpdateUrl(config);
if (updateUrl && !updateUrl.includes('expo.dev')) {
const confirmed = await confirmAsync({
message: `Expo config already has an update URL set to ${updateUrl}. Do you want to replace it?`,
name: 'replace',
type: 'confirm',
});
if (!confirmed) {
manifestEndpoint = updateUrl;
}
}
const confirmed = await confirmAsync({
message: 'Do you have already generated your certificates for code signing?',
name: 'certificates',
type: 'confirm',
});
if (!confirmed) {
Log.fail('You need to generate your certificates first by using npx eoas generate-certs');
return;
}
const { codeSigningCertificatePath } = await promptAsync({
message: 'Enter the path to your code signing certificate (ex: ./certs/certificate.pem)',
name: 'codeSigningCertificatePath',
type: 'text',
initial: './certs/certificate.pem',
validate: v => {
try {
const fullPath = path.resolve(projectDir, v);
// eslint-disable-next-line
const fileExists = fs.existsSync(fullPath);
if (!fileExists) {
Log.newLine();
Log.error('File does not exist');
return false;
}
// eslint-disable-next-line
const key = fs.readFileSync(fullPath, 'utf8');
if (!key) {
Log.error('Empty key');
return false;
}
return true;
} catch {
return false;
}
},
});
const newUpdateConfig = {
url: manifestEndpoint,
codeSigningMetadata: {
keyid: 'main',
alg: 'rsa-v1_5-sha256' as const,
},
codeSigningCertificate: codeSigningCertificatePath,
enabled: true,
requestHeaders: {
'expo-channel-name': 'process.env.RELEASE_CHANNEL',
},
};
const updateConfigSpinner = ora('Updating Expo config').start();
try {
await createOrModifyExpoConfigAsync(projectDir, {
updates: newUpdateConfig,
});
updateConfigSpinner.succeed(
'Expo config successfully updated do not forget to format the file with prettier or eslint'
);
} catch (e) {
updateConfigSpinner.fail('Failed to update Expo config');
Log.error(e);
}
}
}
================================================
FILE: apps/eoas/src/commands/publish.ts
================================================
import { Env, Platform } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import { Command, Flags } from '@oclif/core';
import FormData from 'form-data';
import fs from 'fs-extra';
import mime from 'mime';
import path from 'path';
import { RequestUploadUrlItem, computeFilesRequests, requestUploadUrls } from '../lib/assets';
import { getAuthExpoHeaders, retrieveExpoCredentials } from '../lib/auth';
import {
RequestedPlatform,
getPrivateExpoConfigAsync,
getPublicExpoConfigAsync,
resolveServerUrl,
} from '../lib/expoConfig';
import { fetchWithRetries } from '../lib/fetch';
import Log from '../lib/log';
import { ora } from '../lib/ora';
import { isExpoInstalled } from '../lib/package';
import { resolvePackageRunner } from '../lib/packageRunner';
import { confirmAsync } from '../lib/prompts';
import { ensureRepoIsCleanAsync } from '../lib/repo';
import { resolveRuntimeVersionAsync } from '../lib/runtimeVersion';
import { resolveVcsClient } from '../lib/vcs';
import { resolveWorkflowAsync } from '../lib/workflow';
export default class Publish extends Command {
static override args = {};
static override description = 'Publish a new update to the self-hosted update server';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
platform: Flags.string({
type: 'option',
options: Object.values(RequestedPlatform),
default: RequestedPlatform.All,
required: false,
}),
channel: Flags.string({
description: 'Name of the channel to publish the update to',
required: false,
deprecated: {
message:
'Channel was initially used to provide RELEASE_CHANNEL in the environment when resolving the runtime version. It is no longer needed, you can use RELEASE_CHANNEL={channel} eoas publish --branch={branch} instead',
},
}),
disableRepositoryCheck: Flags.boolean({
description: 'Disable repository check (Useful for CI/CD)',
default: false,
hidden: true,
}),
branch: Flags.string({
description: 'Name of the branch to point to',
required: true,
}),
nonInteractive: Flags.boolean({
description: 'Run command in non-interactive mode',
default: false,
}),
outputDir: Flags.string({
description:
"Where to write build output. You can override the default dist output directory if it's being used by something else",
default: 'dist',
}),
packageRunner: Flags.string({
description:
'Package runner to use for spawning Expo CLI commands (e.g. npx, bunx, pnpx). Can also be set via EOAS_PACKAGE_RUNNER env var. Defaults to npx.',
required: false,
}),
message: Flags.string({
char: 'm',
description:
'A short message describing the update. Defaults to the latest git commit message.',
required: false,
}),
dumpSourcemap: Flags.boolean({
description:
'Emit Hermes source maps alongside the bundle so the published artifact can be symbolicated by tools like Sentry or PostHog.',
default: false,
}),
};
private sanitizeFlags(flags: any): {
platform: RequestedPlatform;
branch: string;
nonInteractive: boolean;
disableRepositoryCheck: boolean;
outputDir: string;
packageRunner: string;
providedDeprecatedChannel?: string;
message?: string;
dumpSourcemap: boolean;
} {
return {
disableRepositoryCheck: flags.disableRepositoryCheck,
platform: flags.platform,
branch: flags.branch,
nonInteractive: flags.nonInteractive,
outputDir: flags.outputDir,
packageRunner: resolvePackageRunner(flags.packageRunner, process.cwd()),
providedDeprecatedChannel: flags.channel,
message: flags.message,
dumpSourcemap: flags.dumpSourcemap,
};
}
public async run(): Promise {
const credentials = retrieveExpoCredentials();
if (!credentials.token && !credentials.sessionSecret) {
Log.error('You are not logged to eas, please run `eas login`');
process.exit(1);
}
const { flags } = await this.parse(Publish);
const {
platform,
nonInteractive,
branch,
outputDir,
packageRunner,
providedDeprecatedChannel,
disableRepositoryCheck,
message,
dumpSourcemap,
} = this.sanitizeFlags(flags);
if (!branch) {
Log.error('Branch name is required');
process.exit(1);
}
const projectDir = process.cwd();
const hasExpo = isExpoInstalled(projectDir);
if (!hasExpo) {
Log.error('Expo is not installed in this project. Please install Expo first.');
process.exit(1);
}
const vcsClient = resolveVcsClient(true);
if (!disableRepositoryCheck) {
await ensureRepoIsCleanAsync(vcsClient, nonInteractive);
}
const config = await getPrivateExpoConfigAsync(projectDir, {
env: {
...(process.env as Env),
...(providedDeprecatedChannel ? { RELEASE_CHANNEL: providedDeprecatedChannel } : {}),
},
packageRunner,
});
const serverUrl = await resolveServerUrl(config).catch(e => {
Log.error(e.message);
process.exit(1);
});
if (!nonInteractive) {
const confirmed = await confirmAsync({
message: `Is this the correct URL of your self-hosted update server? ${serverUrl}`,
name: 'export',
type: 'confirm',
});
if (!confirmed) {
Log.error('Please run `eoas init` to setup the correct update url');
process.exit(1);
}
}
const commitHash = await vcsClient.getCommitHashAsync();
let resolvedMessage = message;
if (!resolvedMessage && vcsClient.canGetLastCommitMessage()) {
resolvedMessage = (await vcsClient.getLastCommitMessageAsync()) ?? undefined;
}
const runtimeSpinner = ora('🔄 Resolving runtime version...').start();
const runtimeVersions = [
...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Ios
? [
{
runtimeVersion: (
await resolveRuntimeVersionAsync({
exp: config,
platform: 'ios',
workflow: await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient),
projectDir,
env: {
...(process.env as Env),
...(providedDeprecatedChannel
? { RELEASE_CHANNEL: providedDeprecatedChannel }
: {}),
},
})
)?.runtimeVersion,
platform: 'ios',
},
]
: []),
...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Android
? [
{
runtimeVersion: (
await resolveRuntimeVersionAsync({
exp: config,
platform: 'android',
workflow: await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient),
projectDir,
env: {
...(process.env as Env),
...(providedDeprecatedChannel
? { RELEASE_CHANNEL: providedDeprecatedChannel }
: {}),
},
})
)?.runtimeVersion,
platform: 'android',
},
]
: []),
].filter(({ runtimeVersion }) => !!runtimeVersion);
if (!runtimeVersions.length) {
runtimeSpinner.fail('Could not resolve runtime versions for the requested platforms');
Log.error('Could not resolve runtime versions for the requested platforms');
process.exit(1);
}
runtimeSpinner.succeed('✅ Runtime versions resolved');
const cleaningSpinner = ora(`🗑️ Cleaning up ${outputDir} directory...`).start();
try {
await fs.remove(path.join(projectDir, outputDir));
cleaningSpinner.succeed('✅ Cleanup completed');
} catch (e) {
cleaningSpinner.fail('❌ Failed to clean up the output directory');
Log.error(e);
process.exit(1);
}
const exportSpinner = ora('📦 Exporting project files...').start();
try {
const specifiedPlatform = platform === RequestedPlatform.All ? [] : ['--platform', platform];
const sourcemapArgs = dumpSourcemap ? ['--dump-sourcemap'] : [];
const { stdout } = await spawnAsync(packageRunner, ['expo', 'export', '--output-dir', outputDir, ...sourcemapArgs, ...specifiedPlatform], {
cwd: projectDir,
env: {
...process.env,
EXPO_NO_DOTENV: '1',
},
});
exportSpinner.succeed('🚀 Project exported successfully');
Log.withInfo(stdout);
} catch (e) {
exportSpinner.fail(`❌ Failed to export the project, ${e}`);
process.exit(1);
}
const publicConfig = await getPublicExpoConfigAsync(projectDir, {
skipSDKVersionRequirement: true,
packageRunner,
});
if (!publicConfig) {
Log.error(
'Could not find Expo config in this project. Please make sure you have an Expo config.'
);
process.exit(1);
}
// eslint-disable-next-line
fs.writeJsonSync(path.join(projectDir, outputDir, 'expoConfig.json'), publicConfig, {
spaces: 2,
});
Log.withInfo(`expoConfig.json file created in ${outputDir} directory`);
const uploadFilesSpinner = ora('📤 Uploading files...').start();
const files = computeFilesRequests(projectDir, outputDir, platform || RequestedPlatform.All);
if (!files.length) {
uploadFilesSpinner.fail('No files to upload');
process.exit(1);
}
let uploadUrls: {
uploadRequests: RequestUploadUrlItem[];
updateId: string;
platform: string;
runtimeVersion: string;
}[] = [];
try {
uploadUrls = await Promise.all(
runtimeVersions.map(async ({ runtimeVersion, platform }) => {
if (!runtimeVersion) {
throw new Error('Runtime version is not resolved');
}
return {
...(await requestUploadUrls({
body: {
fileNames: files.map(file => file.path),
},
requestUploadUrl: `${serverUrl}/requestUploadUrl/${branch}`,
auth: credentials,
runtimeVersion,
platform,
commitHash,
message: resolvedMessage,
})),
runtimeVersion,
platform,
};
})
);
const allItems = uploadUrls.flatMap(({ uploadRequests }) => uploadRequests);
await Promise.all(
allItems.map(async itm => {
const isLocalBucketFileUpload = itm.requestUploadUrl.startsWith(
`${serverUrl}/uploadLocalFile`
);
const formData = new FormData();
let file: fs.ReadStream;
try {
file = fs.createReadStream(path.join(projectDir, outputDir, itm.filePath));
} catch {
throw new Error(`Failed to read file ${itm.filePath}`);
}
formData.append(itm.fileName, file);
if (isLocalBucketFileUpload) {
const response = await fetchWithRetries(itm.requestUploadUrl, {
method: 'PUT',
headers: {
...formData.getHeaders(),
...getAuthExpoHeaders(credentials),
},
body: formData,
});
if (!response.ok) {
Log.error('Failed to upload file', await response.text());
throw new Error('Failed to upload file');
}
file.close();
return;
}
const findFile = files.find(f => f.path === itm.filePath || f.name === itm.fileName);
if (!findFile) {
Log.error(`File ${itm.filePath} not found`);
throw new Error(`File ${itm.filePath} not found`);
}
let contentType = mime.getType(findFile.ext);
if (!contentType) {
contentType = 'application/octet-stream';
}
const buffer = await fs.readFile(path.join(projectDir, outputDir, itm.filePath));
const response = await fetchWithRetries(itm.requestUploadUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Cache-Control': 'max-age=31556926',
},
body: buffer,
});
if (!response.ok) {
Log.error('❌ File upload failed', await response.text());
process.exit(1);
}
file.close();
})
);
uploadFilesSpinner.succeed('✅ Files uploaded successfully');
} catch (e) {
uploadFilesSpinner.fail('❌ Failed to upload static files');
Log.error(e);
process.exit(1);
}
const markAsFinishedSpinner = ora('🔗 Marking the updates as finished...').start();
const results = await Promise.all(
uploadUrls.map(async ({ updateId, platform, runtimeVersion }) => {
const markAsUploadedUrl = new URL(`${serverUrl}/markUpdateAsUploaded/${branch}`);
markAsUploadedUrl.searchParams.set('platform', platform);
markAsUploadedUrl.searchParams.set('updateId', updateId);
markAsUploadedUrl.searchParams.set('runtimeVersion', runtimeVersion);
const response = await fetchWithRetries(markAsUploadedUrl.toString(), {
method: 'POST',
headers: {
...getAuthExpoHeaders(credentials),
'Content-Type': 'application/json',
},
});
// If success and status code = 200
if (response.ok) {
Log.withInfo(`✅ Update ready for ${platform}`);
return 'deployed';
}
// If response.status === 406 duplicate update
if (response.status === 406) {
Log.withInfo(`⚠️ There is no change in the update for ${platform}, ignored...`);
return 'identical';
}
Log.error('❌ Failed to mark the update as finished for platform', platform);
Log.newLine();
Log.error(await response.text());
return 'error';
})
);
const erroredUpdates = results.filter(result => result === 'error');
const hasSuccess = results.some(result => result === 'deployed');
const allIdentical = results.every(result => result === 'identical');
if (allIdentical) {
markAsFinishedSpinner.warn('⚠️ No changes found in the update, nothing to deploy');
return;
}
if (erroredUpdates.length) {
markAsFinishedSpinner.fail('❌ Some errors occurred while marking updates as finished');
throw new Error();
} else {
markAsFinishedSpinner.succeed(
`\n✅ Your update has been successfully pushed to ${serverUrl}`
);
}
if (hasSuccess) {
Log.withInfo(`🌿 Branch: \`${branch}\``);
Log.withInfo(`⏳ Deployed at: \`${new Date().toUTCString()}\`\n`);
Log.withInfo('🔥 Your users will receive the latest update automatically!');
}
}
}
================================================
FILE: apps/eoas/src/commands/republish.ts
================================================
import { Env } from '@expo/eas-build-job';
import { Command, Flags } from '@oclif/core';
import ora from 'ora';
import { getAuthExpoHeaders, retrieveExpoCredentials } from '../lib/auth';
import { getExpoConfigUpdateUrl, getPrivateExpoConfigAsync } from '../lib/expoConfig';
import { fetchWithRetries } from '../lib/fetch';
import Log from '../lib/log';
import { isExpoInstalled } from '../lib/package';
import { promptAsync } from '../lib/prompts';
import { resolveVcsClient } from '../lib/vcs';
export default class Publish extends Command {
static override args = {};
static override description = 'Republish a previous update to a branch';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
branch: Flags.string({
description: 'Name of the branch to point to',
required: true,
}),
platform: Flags.string({
type: 'option',
options: ['ios', 'android', 'all'],
default: 'all',
required: true,
}),
};
private sanitizeFlags(flags: any): {
branch: string;
platform: string;
} {
return {
branch: flags.branch,
platform: flags.platform,
};
}
public async run(): Promise {
const credentials = retrieveExpoCredentials();
if (!credentials.token && !credentials.sessionSecret) {
Log.error('You are not logged to eas, please run `eas login`');
process.exit(1);
}
const { flags } = await this.parse(Publish);
const { branch, platform } = this.sanitizeFlags(flags);
if (!branch) {
Log.error('Branch name is required');
process.exit(1);
}
if (!platform) {
Log.error('Platform is required');
process.exit(1);
}
const vcsClient = resolveVcsClient(true);
await vcsClient.ensureRepoExistsAsync();
// const commitHash = await vcsClient.getCommitHashAsync();
const projectDir = process.cwd();
const hasExpo = isExpoInstalled(projectDir);
if (!hasExpo) {
Log.error('Expo is not installed in this project. Please install Expo first.');
process.exit(1);
}
const privateConfig = await getPrivateExpoConfigAsync(projectDir, {
env: process.env as Env,
});
const updateUrl = getExpoConfigUpdateUrl(privateConfig);
if (!updateUrl) {
Log.error(
"Update url is not setup in your config. Please run 'eoas init' to setup the update url"
);
process.exit(1);
}
let baseUrl: string;
try {
const parsedUrl = new URL(updateUrl);
baseUrl = parsedUrl.origin;
} catch (e) {
Log.error('Invalid URL', e);
process.exit(1);
}
const runtimeVersionsEndpoint = `${baseUrl}/api/branch/${branch}/runtimeVersions`;
const response = await fetchWithRetries(runtimeVersionsEndpoint, {
headers: { ...getAuthExpoHeaders(credentials), 'use-expo-auth': 'true' },
});
if (!response.ok) {
Log.error(`Failed to fetch runtime versions: ${await response.text()}`);
process.exit(1);
}
const runtimeVersions = (await response.json()) as {
runtimeVersion: string;
lastUpdatedAt: string;
createdAt: string;
numberOfUpdates: number;
}[];
const filteredRuntimeVersions = runtimeVersions.filter(
runtimeVersion => runtimeVersion.numberOfUpdates > 1
);
if (filteredRuntimeVersions.length === 0) {
Log.error('No runtime versions found');
process.exit(1);
}
// Ask the user to select a runtime version
const selectedRuntimeVersion = await promptAsync({
type: 'select',
name: 'runtimeVersion',
message: 'Select a runtime version',
choices: filteredRuntimeVersions.map(runtimeVersion => ({
title: runtimeVersion.runtimeVersion,
value: runtimeVersion.runtimeVersion,
})),
});
Log.log(`Selected runtime version: ${selectedRuntimeVersion.runtimeVersion}`);
const updatesEndpoint = `${baseUrl}/api/branch/${branch}/runtimeVersion/${selectedRuntimeVersion.runtimeVersion}/updates`;
const updatesResponse = await fetchWithRetries(updatesEndpoint, {
headers: { ...getAuthExpoHeaders(credentials), 'use-expo-auth': 'true' },
});
if (!updatesResponse.ok) {
Log.error(`Failed to fetch updates: ${await updatesResponse.text()}`);
process.exit(1);
}
const updates = (
(await updatesResponse.json()) as {
updateUUID: string;
createdAt: string;
updateId: string;
platform: string;
commitHash: string;
}[]
).filter(u => {
return (
u.updateUUID !== 'Rollback to embedded' && (platform === 'all' || u.platform === platform)
);
});
if (updates.length === 0) {
Log.error(
`No republishable updates found for runtime version ${selectedRuntimeVersion.runtimeVersion} on platform ${platform}.`
);
process.exit(1);
}
const selectedUpdated = await promptAsync({
type: 'select',
name: 'update',
message: 'Select an update to republish',
choices: updates.map(update => ({
title: update.updateUUID,
value: update,
description: `Created at: ${update.createdAt}, Platform: ${update.platform}, Commit hash: ${update.commitHash}`,
})),
});
Log.log(`Re-publishing update: ${selectedUpdated.update.updateUUID}`);
const republishUrl = new URL(`${baseUrl}/republish/${branch}`);
republishUrl.searchParams.set('platform', selectedUpdated.update.platform);
republishUrl.searchParams.set('runtimeVersion', selectedRuntimeVersion.runtimeVersion);
republishUrl.searchParams.set('updateId', selectedUpdated.update.updateId);
republishUrl.searchParams.set('commitHash', selectedUpdated.update.commitHash);
const republishSpinner = ora('🔄 Republishing update...').start();
const republishResponse = await fetchWithRetries(republishUrl.toString(), {
method: 'POST',
headers: {
...getAuthExpoHeaders(credentials),
'Content-Type': 'application/json',
},
});
if (!republishResponse.ok) {
republishSpinner.fail('❌ Republish failed');
Log.error(`Failed to republish update: ${await republishResponse.text()}`);
process.exit(1);
}
republishSpinner.succeed('✅ Republish successful');
}
}
================================================
FILE: apps/eoas/src/commands/rollback.ts
================================================
import { Env, Platform } from '@expo/eas-build-job';
import { Command, Flags } from '@oclif/core';
import { getAuthExpoHeaders, retrieveExpoCredentials } from '../lib/auth';
import {
RequestedPlatform,
getExpoConfigUpdateUrl,
getPrivateExpoConfigAsync,
} from '../lib/expoConfig';
import { fetchWithRetries } from '../lib/fetch';
import Log from '../lib/log';
import { ora } from '../lib/ora';
import { isExpoInstalled } from '../lib/package';
import { confirmAsync } from '../lib/prompts';
import { resolveRuntimeVersionAsync } from '../lib/runtimeVersion';
import { resolveVcsClient } from '../lib/vcs';
import { resolveWorkflowAsync } from '../lib/workflow';
export default class Publish extends Command {
static override args = {};
static override description = 'Publish a new rollback to the self-hosted update server';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
platform: Flags.string({
type: 'option',
options: Object.values(RequestedPlatform),
default: RequestedPlatform.All,
required: false,
}),
branch: Flags.string({
description: 'Name of the branch to point to',
required: true,
}),
};
private sanitizeFlags(flags: any): {
platform: RequestedPlatform;
branch: string;
} {
return {
platform: flags.platform,
branch: flags.branch,
};
}
public async run(): Promise {
const credentials = retrieveExpoCredentials();
if (!credentials.token && !credentials.sessionSecret) {
Log.error('You are not logged to eas, please run `eas login`');
process.exit(1);
}
const { flags } = await this.parse(Publish);
const { platform, branch } = this.sanitizeFlags(flags);
if (!branch) {
Log.error('Branch name is required');
process.exit(1);
}
const vcsClient = resolveVcsClient(true);
await vcsClient.ensureRepoExistsAsync();
const commitHash = await vcsClient.getCommitHashAsync();
const projectDir = process.cwd();
const hasExpo = isExpoInstalled(projectDir);
if (!hasExpo) {
Log.error('Expo is not installed in this project. Please install Expo first.');
process.exit(1);
}
const confirmed = await confirmAsync({
message: `Are you sure you want to publish a rollback to the branch ${branch} ?`,
name: 'export',
type: 'confirm',
});
if (!confirmed) {
Log.error('Operation cancelled');
process.exit(1);
}
const privateConfig = await getPrivateExpoConfigAsync(projectDir, {
env: process.env as Env,
});
if (privateConfig?.updates?.disableAntiBrickingMeasures) {
Log.error(
'When using disableAntiBrickingMeasures, expo-updates is ignoring the embeded update of the app, please use republish command instead'
);
process.exit(1);
}
const updateUrl = getExpoConfigUpdateUrl(privateConfig);
if (!updateUrl) {
Log.error(
"Update url is not setup in your config. Please run 'eoas init' to setup the update url"
);
process.exit(1);
}
let baseUrl: string;
try {
const parsedUrl = new URL(updateUrl);
baseUrl = parsedUrl.origin;
} catch (e) {
Log.error('Invalid URL', e);
process.exit(1);
}
const runtimeSpinner = ora('🔄 Resolving runtime version...').start();
const runtimeVersions = [
...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Ios
? [
{
runtimeVersion: (
await resolveRuntimeVersionAsync({
exp: privateConfig,
platform: 'ios',
workflow: await resolveWorkflowAsync(projectDir, Platform.IOS, vcsClient),
projectDir,
env: process.env as Env,
})
)?.runtimeVersion,
platform: 'ios',
},
]
: []),
...(!platform || platform === RequestedPlatform.All || platform === RequestedPlatform.Android
? [
{
runtimeVersion: (
await resolveRuntimeVersionAsync({
exp: privateConfig,
platform: 'android',
workflow: await resolveWorkflowAsync(projectDir, Platform.ANDROID, vcsClient),
projectDir,
env: process.env as Env,
})
)?.runtimeVersion,
platform: 'android',
},
]
: []),
].filter(({ runtimeVersion }) => !!runtimeVersion);
if (!runtimeVersions.length) {
runtimeSpinner.fail('Could not resolve runtime versions for the requested platforms');
Log.error('Could not resolve runtime versions for the requested platforms');
process.exit(1);
}
runtimeSpinner.succeed('✅ Runtime versions resolved');
const rollbackSpinner = ora('📦 Uploading rollback...').start();
const erroredPlatforms: { platform: string; reason: string }[] = [];
await Promise.all(
runtimeVersions.map(async ({ runtimeVersion, platform }) => {
const rollbackUrl = new URL(`${baseUrl}/rollback/${branch}`);
rollbackUrl.searchParams.set('commitHash', commitHash ?? '');
rollbackUrl.searchParams.set('platform', platform);
rollbackUrl.searchParams.set('runtimeVersion', runtimeVersion ?? '');
const response = await fetchWithRetries(rollbackUrl.toString(), {
method: 'POST',
headers: {
...getAuthExpoHeaders(credentials),
},
});
if (!response.ok) {
erroredPlatforms.push({
platform,
reason: await response.text(),
});
}
})
);
if (erroredPlatforms.length) {
rollbackSpinner.fail('❌ Rollback failed');
erroredPlatforms.forEach(({ platform, reason }) => {
Log.error(`Failed to publish rollback for ${platform}: ${reason}`);
});
process.exit(1);
} else {
rollbackSpinner.succeed('✅ Rollback published successfully');
}
}
}
================================================
FILE: apps/eoas/src/index.d.ts
================================================
declare module 'better-opn' {
function open(
target: string,
options?: any
): Promise;
export = open;
}
================================================
FILE: apps/eoas/src/lib/assets.ts
================================================
// This file is partially copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import { Platform } from '@expo/config';
import fs from 'fs-extra';
import Joi from 'joi';
import path from 'path';
import { ExpoCredentials, getAuthExpoHeaders } from './auth';
import { RequestedPlatform } from './expoConfig';
import { fetchWithRetries } from './fetch';
import Log from './log';
const fileMetadataJoi = Joi.object({
assets: Joi.array()
.required()
.items(Joi.object({ path: Joi.string().required(), ext: Joi.string().required() })),
bundle: Joi.string().required(),
}).optional();
export const MetadataJoi = Joi.object({
version: Joi.number().required(),
bundler: Joi.string().required(),
fileMetadata: Joi.object({
android: fileMetadataJoi,
ios: fileMetadataJoi,
web: fileMetadataJoi,
}).required(),
}).required();
type Metadata = {
version: number;
bundler: 'metro';
fileMetadata: {
[key in Platform]: { assets: { path: string; ext: string }[]; bundle: string };
};
};
interface AssetToUpload {
path: string;
name: string;
ext: string;
}
function loadMetadata(distRoot: string): Metadata {
// eslint-disable-next-line
const fileContent = fs.readFileSync(path.join(distRoot, 'metadata.json'), 'utf8');
let metadata: Metadata;
try {
metadata = JSON.parse(fileContent);
} catch (e: any) {
Log.error(`Failed to read metadata.json: ${e.message}`);
throw e;
}
const { error } = MetadataJoi.validate(metadata);
if (error) {
throw error;
}
// Check version and bundler by hand (instead of with Joi) so
// more informative error messages can be returned.
if (metadata.version !== 0) {
throw new Error('Only bundles with metadata version 0 are supported');
}
if (metadata.bundler !== 'metro') {
throw new Error('Only bundles created with Metro are currently supported');
}
const platforms = Object.keys(metadata.fileMetadata);
if (platforms.length === 0) {
Log.warn('No updates were exported for any platform');
}
Log.debug(`Loaded ${platforms.length} platform(s): ${platforms.join(', ')}`);
return metadata;
}
export function computeFilesRequests(
projectDir: string,
outputDir: string,
requestedPlatform: RequestedPlatform
): AssetToUpload[] {
const metadata = loadMetadata(path.join(projectDir, outputDir));
const assets: AssetToUpload[] = [
{ path: 'metadata.json', name: 'metadata.json', ext: 'json' },
{ path: 'expoConfig.json', name: 'expoConfig.json', ext: 'json' },
];
for (const platform of Object.keys(metadata.fileMetadata) as Platform[]) {
if (requestedPlatform !== RequestedPlatform.All && requestedPlatform !== platform) {
continue;
}
const bundle = metadata.fileMetadata[platform].bundle;
assets.push({ path: bundle, name: path.basename(bundle), ext: 'hbc' });
for (const asset of metadata.fileMetadata[platform].assets) {
assets.push({ path: asset.path, name: path.basename(asset.path), ext: asset.ext });
}
}
return assets;
}
export interface RequestUploadUrlItem {
requestUploadUrl: string;
fileName: string;
filePath: string;
}
export async function requestUploadUrls({
body,
requestUploadUrl,
auth,
runtimeVersion,
platform,
commitHash,
message,
}: {
body: { fileNames: string[] };
requestUploadUrl: string;
auth: ExpoCredentials;
runtimeVersion: string;
platform: string;
commitHash?: string;
message?: string;
}): Promise<{ uploadRequests: RequestUploadUrlItem[]; updateId: string }> {
const uploadUrl = new URL(requestUploadUrl);
uploadUrl.searchParams.set('runtimeVersion', runtimeVersion);
uploadUrl.searchParams.set('platform', platform);
uploadUrl.searchParams.set('commitHash', commitHash ?? '');
const requestBody: { fileNames: string[]; message?: string } = { ...body };
if (message) {
requestBody.message = message;
}
const response = await fetchWithRetries(uploadUrl.toString(), {
method: 'POST',
headers: {
...getAuthExpoHeaders(auth),
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Failed to request upload URL: ${text}`);
}
return await response.json();
}
================================================
FILE: apps/eoas/src/lib/auth.ts
================================================
import { homedir } from 'os';
import path from 'path';
export interface ExpoCredentials {
token?: string;
sessionSecret?: string;
}
type SessionData = {
sessionSecret: string;
userId: string;
username: string;
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
};
function dotExpoHomeDirectory(): string {
const home = homedir();
if (!home) {
throw new Error(
"Can't determine your home directory; make sure your $HOME environment variable is set."
);
}
let dirPath;
if (process.env.EXPO_STAGING) {
dirPath = path.join(home, '.expo-staging');
} else if (process.env.EXPO_LOCAL) {
dirPath = path.join(home, '.expo-local');
} else {
dirPath = path.join(home, '.expo');
}
return dirPath;
}
function getStateJsonPath(): string {
return path.join(dotExpoHomeDirectory(), 'state.json');
}
function getExpoSessionData(): SessionData | null {
try {
const stateJsonPath = getStateJsonPath();
const stateJson = require(stateJsonPath);
return stateJson['auth'] || null;
} catch {
return null;
}
}
export function retrieveExpoCredentials(): ExpoCredentials {
const token = process.env.EXPO_TOKEN;
const sessionData = getExpoSessionData();
const sessionSecret = sessionData?.sessionSecret;
return { token, sessionSecret };
}
export function getAuthExpoHeaders(credentials: ExpoCredentials): Record {
if (credentials.token) {
return {
Authorization: `Bearer ${credentials.token}`,
};
}
if (credentials.sessionSecret) {
return {
'expo-session': credentials.sessionSecret,
};
}
return {};
}
================================================
FILE: apps/eoas/src/lib/channel.ts
================================================
import { ExpoCredentials, getAuthExpoHeaders } from './auth';
import { fetchWithRetries } from './fetch';
export async function resolveReleaseChannelDynamicallyFromBranch(
baseUrl: string,
branch: string,
credentials: ExpoCredentials
): Promise {
const branchesEndpoint = `${baseUrl}/api/branches`;
const response = await fetchWithRetries(branchesEndpoint, {
headers: { ...getAuthExpoHeaders(credentials), 'use-expo-auth': 'true' },
});
if (!response.ok) {
throw new Error(`Failed to retrieve branches from server: ${await response.text()}`);
}
const branches = (await response.json()) as {
branchName: string;
releaseChannel?: string;
}[];
const branchInfo = branches.find(b => b.branchName === branch);
if (!branchInfo) {
throw new Error(`Branch ${branch} not found`);
}
if (!branchInfo.releaseChannel) {
throw new Error(`Branch ${branch} does not have a release channel linked`);
}
return branchInfo.releaseChannel;
}
================================================
FILE: apps/eoas/src/lib/expoConfig.ts
================================================
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import { ExpoConfig, getConfig, getConfigFilePaths } from '@expo/config';
import { Env } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import fs from 'fs-extra';
import Joi from 'joi';
import jscodeshift, { Collection } from 'jscodeshift';
import path from 'path';
import Log from './log';
import { isExpoInstalled } from './package';
import { resolvePackageRunner } from './packageRunner';
export enum RequestedPlatform {
Android = 'android',
Ios = 'ios',
All = 'all',
}
export type PublicExpoConfig = Omit<
ExpoConfig,
'_internal' | 'hooks' | 'ios' | 'android' | 'updates'
> & {
ios?: Omit;
android?: Omit;
updates?: Omit;
};
export interface ExpoConfigOptions {
env?: Env;
skipSDKVersionRequirement?: boolean;
skipPlugins?: boolean;
packageRunner?: string;
}
interface ExpoConfigOptionsInternal extends ExpoConfigOptions {
isPublicConfig?: boolean;
}
let wasExpoConfigWarnPrinted = false;
async function getExpoConfigInternalAsync(
projectDir: string,
opts: ExpoConfigOptionsInternal = {}
): Promise {
const originalProcessEnv: NodeJS.ProcessEnv = process.env;
try {
process.env = {
...process.env,
...opts.env,
};
let exp: ExpoConfig;
if (isExpoInstalled(projectDir)) {
const runner = resolvePackageRunner(opts.packageRunner, projectDir);
try {
const { stdout } = await spawnAsync(
runner,
['expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],
{
cwd: projectDir,
env: {
...process.env,
...opts.env,
EXPO_NO_DOTENV: '1',
},
}
);
exp = JSON.parse(stdout);
} catch (err: any) {
if (!wasExpoConfigWarnPrinted) {
Log.warn(
`Failed to read the app config from the project using "${runner} expo config" command: ${err.message}.`
);
Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.');
wasExpoConfigWarnPrinted = true;
}
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
...(opts.skipPlugins ? { skipPlugins: true } : {}),
}).exp;
}
} else {
exp = getConfig(projectDir, {
skipSDKVersionRequirement: true,
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
...(opts.skipPlugins ? { skipPlugins: true } : {}),
}).exp;
}
const { error } = MinimalAppConfigSchema.validate(exp, {
allowUnknown: true,
abortEarly: true,
});
if (error) {
throw new Error(`Invalid app config.\n${error.message}`);
}
return exp;
} finally {
process.env = originalProcessEnv;
}
}
const MinimalAppConfigSchema = Joi.object({
slug: Joi.string().required(),
name: Joi.string().required(),
version: Joi.string(),
android: Joi.object({
versionCode: Joi.number().integer(),
}),
ios: Joi.object({
buildNumber: Joi.string(),
}),
});
export async function getPrivateExpoConfigAsync(
projectDir: string,
opts: ExpoConfigOptions = {}
): Promise {
ensureExpoConfigExists(projectDir);
return await getExpoConfigInternalAsync(projectDir, { ...opts, isPublicConfig: false });
}
export function ensureExpoConfigExists(projectDir: string): void {
const paths = getConfigFilePaths(projectDir);
if (!paths?.staticConfigPath && !paths?.dynamicConfigPath) {
// eslint-disable-next-line node/no-sync
fs.writeFileSync(path.join(projectDir, 'app.json'), JSON.stringify({ expo: {} }, null, 2));
}
}
export function isUsingStaticExpoConfig(projectDir: string): boolean {
const paths = getConfigFilePaths(projectDir);
return !!(paths.staticConfigPath?.endsWith('app.json') && !paths.dynamicConfigPath);
}
export async function getPublicExpoConfigAsync(
projectDir: string,
opts: ExpoConfigOptions = {}
): Promise {
ensureExpoConfigExists(projectDir);
return await getExpoConfigInternalAsync(projectDir, { ...opts, isPublicConfig: true });
}
export function getExpoConfigUpdateUrl(config: ExpoConfig): string | undefined {
return config.updates?.url;
}
export async function createOrModifyExpoConfigAsync(
projectDir: string,
exp: Partial
): Promise {
try {
ensureExpoConfigExists(projectDir);
const configPathJS = path.join(projectDir, 'app.config.js');
const configPathTS = path.join(projectDir, 'app.config.ts');
// eslint-disable-next-line node/no-sync
const hasJsConfig = fs.existsSync(configPathJS);
if (isUsingStaticExpoConfig(projectDir)) {
Log.withInfo(
'You are using a static app config. We will create a dynamic config file for you.'
);
const newConfigContent = `export default ({ config }) => ({
...config,
...${stringifyWithEnv(exp)}
});`;
// eslint-disable-next-line node/no-sync
fs.writeFileSync(configPathJS, newConfigContent);
} else if (hasJsConfig) {
// eslint-disable-next-line node/no-sync
const existingCode = fs.readFileSync(configPathJS, 'utf8');
const j = jscodeshift;
const ast: Collection = j(existingCode);
ast.find(j.ArrowFunctionExpression).forEach(path => {
if (
path.value.body &&
j.BlockStatement.check(path.value.body) &&
path.value.body.body.length > 0
) {
const returnStatement = path.value.body.body.find(node => j.ReturnStatement.check(node));
if (
returnStatement &&
j.ReturnStatement.check(returnStatement) &&
returnStatement.argument
) {
const configObject = returnStatement.argument;
if (j.ObjectExpression.check(configObject)) {
updateObjectExpression(j, configObject, exp);
}
}
}
});
const updatedCode = ast.toSource({
quote: 'auto',
trailingComma: true,
reuseWhitespace: true,
});
// eslint-disable-next-line node/no-sync
fs.writeFileSync(configPathJS, updatedCode);
} else if (configPathTS) {
Log.warn('TypeScript support is not yet implemented.');
throw new Error('TypeScript support is not yet implemented.');
}
} catch (e) {
Log.withInfo('An error occurred while updating the Expo config. Please update it manually.');
Log.newLine();
Log.warn('Please modify your app.config.ts file manually by adding the following code:');
Log.newLine();
Log.withInfo(`${stringifyWithEnv(exp)}`);
Log.newLine();
throw e;
}
}
function updateObjectExpression(
j: typeof jscodeshift,
configObject: ReturnType,
updates: Record
): void {
Object.entries(updates).forEach(([key, value]) => {
const existingProperty = configObject.properties.find(prop => {
return (
prop.type === 'Property' &&
((prop.key.type === 'Identifier' && prop.key.name === key) ||
(prop.key.type === 'StringLiteral' && prop.key.value === key))
);
});
if (existingProperty) {
configObject.properties = configObject.properties.filter(prop => prop !== existingProperty);
}
const newProperty = j.objectProperty(j.identifier(key), createValueNode(j, value));
configObject.properties.push(newProperty);
});
}
function createValueNode(j: typeof jscodeshift, value: any): any {
if (typeof value === 'string' && value.startsWith('process.env.')) {
return j.memberExpression(
j.memberExpression(j.identifier('process'), j.identifier('env')),
j.identifier(value.split('.')[2])
);
}
if (typeof value === 'object' && value !== null) {
return j.objectExpression(
Object.entries(value).map(
([key, val]) => j.objectProperty(j.stringLiteral(key), createValueNode(j, val)) // Force stringLiteral pour garder les guillemets
)
);
}
return j.literal(value);
}
function stringifyWithEnv(obj: Record): string {
return JSON.stringify(obj, null, 2).replace(/"process\.env\.(\w+)"/g, 'process.env.$1');
}
export async function resolveServerUrl(config: ExpoConfig): Promise {
const updateUrl = config.updates?.url;
if (!updateUrl) {
throw new Error('No update URL found in the Expo config.');
}
let baseUrl: string;
try {
const parsedUrl = new URL(updateUrl);
baseUrl = parsedUrl.origin;
} catch (e) {
throw new Error('Invalid update URL.');
}
return baseUrl;
}
================================================
FILE: apps/eoas/src/lib/fetch.ts
================================================
import fetchRetry from 'fetch-retry';
import originalFetch, { RequestInit, Response } from 'node-fetch';
import Log from './log';
const fetch = fetchRetry(originalFetch);
export async function fetchWithRetries(url: string, options: RequestInit): Promise {
return await fetch(url, {
...options,
retryDelay(attempt) {
return Math.pow(2, attempt) * 500;
},
retryOn: (attempt, error) => {
if (attempt > 3) {
return false;
}
if (error) {
Log.warn(`Retry ${attempt} after network error:`, error.message);
return true;
}
return false;
},
});
}
================================================
FILE: apps/eoas/src/lib/log.ts
================================================
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import chalk from 'chalk';
import figures from 'figures';
import { boolish } from 'getenv';
import logSymbols from 'log-symbols';
import terminalLink from 'terminal-link';
type Color = (...text: string[]) => string;
export default class Log {
public static readonly isDebug = boolish('DEBUG', false);
public static log(...args: any[]): void {
Log.consoleLog(...args);
}
public static newLine(): void {
Log.consoleLog();
}
public static addNewLineIfNone(): void {
if (!Log.isLastLineNewLine) {
Log.newLine();
}
}
public static error(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.red));
}
public static warn(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.yellow));
}
public static debug(...args: any[]): void {
if (Log.isDebug) {
Log.consoleLog(...args);
}
}
public static gray(...args: any[]): void {
Log.consoleLog(...Log.withTextColor(args, chalk.gray));
}
public static warnDeprecatedFlag(flag: string, message: string): void {
Log.warn(`› ${chalk.bold('--' + flag)} flag is deprecated. ${message}`);
}
public static fail(message: string): void {
Log.log(`${chalk.red(logSymbols.error)} ${message}`);
}
public static succeed(message: string): void {
Log.log(`${chalk.green(logSymbols.success)} ${message}`);
}
public static withTick(...args: any[]): void {
Log.consoleLog(chalk.green(figures.tick), ...args);
}
public static withInfo(...args: any[]): void {
Log.consoleLog(chalk.green(figures.info), ...args);
}
private static consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
console.log(...args);
}
private static withTextColor(args: any[], chalkColor: Color): string[] {
return args.map(arg => chalkColor(arg));
}
private static isLastLineNewLine = false;
private static updateIsLastLineNewLine(args: any[]): void {
if (args.length === 0) {
Log.isLastLineNewLine = true;
} else {
const lastArg = args[args.length - 1];
if (typeof lastArg === 'string' && (lastArg === '' || lastArg.match(/[\r\n]$/))) {
Log.isLastLineNewLine = true;
} else {
Log.isLastLineNewLine = false;
}
}
}
}
/**
* Prints a link for given URL, using text if provided, otherwise text is just the URL.
* Format links as dim (unless disabled) and with an underline.
*
* @example https://expo.dev
*/
export function link(
url: string,
{ text = url, fallback, dim = true }: { text?: string; dim?: boolean; fallback?: string } = {}
): string {
// Links can be disabled via env variables https://github.com/jamestalmage/supports-hyperlinks/blob/master/index.js
const output = terminalLink(text, url, {
fallback: () =>
fallback ?? (text === url ? chalk.underline(url) : `${text}: ${chalk.underline(url)}`),
});
return dim ? chalk.dim(output) : output;
}
/**
* Provide a consistent "Learn more" link experience.
* Format links as dim (unless disabled) with an underline.
*
* @example Learn more: https://expo.dev
*/
export function learnMore(
url: string,
{
learnMoreMessage: maybeLearnMoreMessage,
dim = true,
}: { learnMoreMessage?: string; dim?: boolean } = {}
): string {
return link(url, { text: maybeLearnMoreMessage ?? 'Learn more', dim });
}
================================================
FILE: apps/eoas/src/lib/ora.ts
================================================
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import { boolish } from 'getenv';
// eslint-disable-next-line
import oraReal, { Options, Ora } from 'ora';
import Log from './log';
export { Ora, Options };
// eslint-disable-next-line no-console
const logReal = console.log;
// eslint-disable-next-line no-console
const infoReal = console.info;
// eslint-disable-next-line no-console
const warnReal = console.warn;
// eslint-disable-next-line no-console
const errorReal = console.error;
const isCi = boolish('CI', false);
/**
* A custom ora spinner that sends the stream to stdout in CI, or non-TTY, instead of stderr (the default).
*
* @param options
* @returns
*/
export function ora(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : options ?? {};
const disabled = Log.isDebug || !process.stdin.isTTY || isCi;
const spinner = oraReal({
// Ensure our non-interactive mode emulates CI mode.
isEnabled: !disabled,
// In non-interactive mode, send the stream to stdout so it prevents looking like an error.
stream: disabled ? process.stdout : process.stderr,
...inputOptions,
});
const oraStart = spinner.start.bind(spinner);
const oraStop = spinner.stop.bind(spinner);
const oraStopAndPersist = spinner.stopAndPersist.bind(spinner);
const logWrap = (method: any, args: any[]): void => {
oraStop();
method(...args);
spinner.start();
};
const wrapNativeLogs = (): void => {
// eslint-disable-next-line no-console
console.log = (...args: any) => {
logWrap(logReal, args);
};
// eslint-disable-next-line no-console
console.info = (...args: any) => {
logWrap(infoReal, args);
};
// eslint-disable-next-line no-console
console.warn = (...args: any) => {
logWrap(warnReal, args);
};
// eslint-disable-next-line no-console
console.error = (...args: any) => {
logWrap(errorReal, args);
};
};
const resetNativeLogs = (): void => {
// eslint-disable-next-line no-console
console.log = logReal;
// eslint-disable-next-line no-console
console.info = infoReal;
// eslint-disable-next-line no-console
console.warn = warnReal;
// eslint-disable-next-line no-console
console.error = errorReal;
};
spinner.start = (text): Ora => {
// wrapNativeLogs wraps calls to console so they always:
// 1. stop the spinner
// 2. log the message
// 3. start the spinner again
// Every restart of the spinner causes the spinner message to be logged again
// which makes logs look like
//
// - Exporting...
// [expo-cli] Starting Metro Bundler
// - Exporting...
// [expo-cli] Android Bundling complete 3492ms
// - Exporting...
//
// Skipping wrapping native logs removes the repeated interleaved "Exporting..." messages.
if (!disabled) {
wrapNativeLogs();
}
return oraStart(text);
};
spinner.stopAndPersist = (options): Ora => {
const result = oraStopAndPersist(options);
resetNativeLogs();
return result;
};
spinner.stop = (): Ora => {
const result = oraStop();
resetNativeLogs();
return result;
};
return spinner;
}
================================================
FILE: apps/eoas/src/lib/package.ts
================================================
import { getPackageJson } from '@expo/config';
export function isExpoInstalled(projectDir: string): boolean {
const packageJson = getPackageJson(projectDir);
return !!(packageJson.dependencies && 'expo' in packageJson.dependencies);
}
================================================
FILE: apps/eoas/src/lib/packageRunner.ts
================================================
import fs from 'fs-extra';
import path from 'path';
const DEFAULT_PACKAGE_RUNNER = 'npx';
const VALID_RUNNER_RE = /^[a-zA-Z0-9._-]+$/;
function assertValidRunner(value: string, source: string): void {
if (!VALID_RUNNER_RE.test(value)) {
throw new Error(
`Invalid package runner "${value}" (from ${source}). Expected a simple binary name like npx, bunx or pnpx.`
);
}
}
const PACKAGE_MANAGER_RUNNERS: Record = {
bun: 'bunx',
pnpm: 'pnpx',
yarn: 'npx',
npm: 'npx',
};
/**
* Resolves the package runner command to use for spawning Expo CLI commands.
*
* Priority:
* 1. Explicit value passed as argument (e.g. from --packageRunner CLI flag)
* 2. EOAS_PACKAGE_RUNNER environment variable
* 3. Inferred from packageManager field in package.json
* 4. Falls back to 'npx'
*
* Supported values: npx, bunx, pnpx, or any other package runner binary.
*/
export function resolvePackageRunner(explicit?: string, projectDir?: string): string {
if (explicit) {
assertValidRunner(explicit, '--packageRunner flag');
return explicit;
}
if (process.env.EOAS_PACKAGE_RUNNER) {
assertValidRunner(process.env.EOAS_PACKAGE_RUNNER, 'EOAS_PACKAGE_RUNNER environment variable');
return process.env.EOAS_PACKAGE_RUNNER;
}
if (projectDir) {
const detected = detectRunnerFromPackageJson(projectDir);
if (detected) return detected;
}
return DEFAULT_PACKAGE_RUNNER;
}
/**
* Walks up from projectDir to find a package.json with a packageManager field
* and maps it to the corresponding package runner binary.
*/
function detectRunnerFromPackageJson(startDir: string): string | undefined {
let dir = path.resolve(startDir);
const root = path.parse(dir).root;
while (dir !== root) {
const pkgPath = path.join(dir, 'package.json');
try {
if (fs.existsSync(pkgPath)) {
const pkg = fs.readJsonSync(pkgPath);
if (pkg.packageManager) {
const name = pkg.packageManager.split('@')[0];
return PACKAGE_MANAGER_RUNNERS[name];
}
}
} catch {
// Ignore read errors, keep walking up
}
dir = path.dirname(dir);
}
return undefined;
}
================================================
FILE: apps/eoas/src/lib/prompts.ts
================================================
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import { constants } from 'os';
import prompts, { Answers, Choice, Options } from 'prompts';
export interface ExpoChoice extends Choice {
value: T;
}
export async function promptAsync(
questions: prompts.PromptObject | prompts.PromptObject[],
options: Options = {}
): Promise> {
if (!process.stdin.isTTY) {
const message = Array.isArray(questions) ? questions[0]?.message : questions.message;
throw new Error(
`Input is required, but stdin is not readable. Failed to display prompt: ${message}`
);
}
return await prompts(questions, {
onCancel() {
process.exit(constants.signals.SIGINT + 128); // Exit code 130 used when process is interrupted with ctrl+c.
},
...options,
});
}
export async function confirmAsync(
question: prompts.PromptObject,
options?: Options
): Promise {
const { value } = await promptAsync(
{
initial: true,
...question,
name: 'value',
type: 'confirm',
},
options
);
return value;
}
export async function selectAsync(
message: string,
choices: ExpoChoice[],
config?: {
options?: Options;
initial?: T;
warningMessageForDisabledEntries?: string;
}
): Promise {
const initial = config?.initial ? choices.findIndex(({ value }) => value === config.initial) : 0;
const { value } = await promptAsync(
{
message,
choices,
initial,
name: 'value',
type: 'select',
warn: config?.warningMessageForDisabledEntries,
},
config?.options ?? {}
);
return value ?? null;
}
export async function toggleConfirmAsync(
questions: prompts.PromptObject,
options?: Options
): Promise {
const { value } = await promptAsync(
{
active: 'yes',
inactive: 'no',
...questions,
name: 'value',
type: 'toggle',
},
options
);
return value ?? null;
}
export async function pressAnyKeyToContinueAsync(): Promise {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
await new Promise(res => {
process.stdin.on('data', key => {
if (String(key) === '\u0003') {
process.exit(constants.signals.SIGINT + 128); // ctrl-c
}
res();
});
});
}
================================================
FILE: apps/eoas/src/lib/repo.ts
================================================
// This file is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience across the CLI.
import chalk from 'chalk';
import Log from './log';
import { confirmAsync, promptAsync } from './prompts';
import { Client } from './vcs/vcs';
export async function commitPromptAsync(
vcsClient: Client,
{
initialCommitMessage,
commitAllFiles,
}: {
initialCommitMessage?: string;
commitAllFiles?: boolean;
} = {}
): Promise {
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: initialCommitMessage,
validate: (input: string) => input !== '',
});
await vcsClient.commitAsync({
commitAllFiles,
commitMessage: message,
nonInteractive: false,
});
}
export async function ensureRepoIsCleanAsync(
vcsClient: Client,
nonInteractive = false
): Promise {
if (!(await vcsClient.isCommitRequiredAsync())) {
return;
}
Log.addNewLineIfNone();
Log.warn(`${chalk.bold('Warning!')} Your repository working tree is dirty.`);
Log.log(
`This operation needs to be run on a clean working tree. ${chalk.bold(
'Commit all your changes before proceeding'
)}.`
);
if (nonInteractive) {
Log.log('The following files need to be committed:');
await vcsClient.showChangedFilesAsync();
throw new Error('Commit all changes. Aborting...');
}
const answer = await confirmAsync({
message: `Commit changes to git?`,
type: 'confirm',
name: 'confirm git commit',
});
if (answer) {
await commitPromptAsync(vcsClient, { commitAllFiles: true });
} else {
throw new Error('Commit all changes. Aborting...');
}
}
================================================
FILE: apps/eoas/src/lib/runtimeVersion.ts
================================================
import { ExpoConfig } from '@expo/config';
import { Updates } from '@expo/config-plugins';
import { Env, Workflow } from '@expo/eas-build-job';
import spawnAsync from '@expo/spawn-async';
import fs from 'fs-extra';
import resolveFrom, { silent as silentResolveFrom } from 'resolve-from';
import semver from 'semver';
import Log, { link } from './log';
export class ExpoUpdatesCLIModuleNotFoundError extends Error {}
export class ExpoUpdatesCLIInvalidCommandError extends Error {}
export class ExpoUpdatesCLICommandFailedError extends Error {}
export async function expoUpdatesCommandAsync(
projectDir: string,
args: string[],
options: { env: Env | undefined; cwd?: string }
): Promise {
let expoUpdatesCli;
try {
expoUpdatesCli =
silentResolveFrom(projectDir, 'expo-updates/bin/cli') ??
resolveFrom(projectDir, 'expo-updates/bin/cli.js');
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new ExpoUpdatesCLIModuleNotFoundError(
`The \`expo-updates\` package was not found. Follow the installation directions at ${link(
'https://docs.expo.dev/bare/installing-expo-modules/'
)}`
);
}
throw e;
}
try {
return (
await spawnAsync(expoUpdatesCli, args, {
stdio: 'pipe',
env: { ...process.env, ...options.env },
cwd: options.cwd,
})
).stdout;
} catch (e: any) {
if (e.stderr && typeof e.stderr === 'string') {
if (e.stderr.includes('Invalid command')) {
throw new ExpoUpdatesCLIInvalidCommandError(
`The command specified by ${args} was not valid in the \`expo-updates\` CLI.`
);
} else {
throw new ExpoUpdatesCLICommandFailedError(e.stderr);
}
}
throw e;
}
}
async function getExpoUpdatesPackageVersionIfInstalledAsync(
projectDir: string
): Promise {
const maybePackageJson = resolveFrom.silent(projectDir, 'expo-updates/package.json');
if (!maybePackageJson) {
return null;
}
const { version } = await fs.readJson(maybePackageJson);
return version ?? null;
}
export async function isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync(
projectDir: string
): Promise {
const expoUpdatesPackageVersion = await getExpoUpdatesPackageVersionIfInstalledAsync(projectDir);
if (expoUpdatesPackageVersion === null) {
return false;
}
if (expoUpdatesPackageVersion.includes('canary')) {
return true;
}
// Anything SDK 51 or greater uses the expo-updates CLI
return semver.gte(expoUpdatesPackageVersion, '0.25.4');
}
export async function resolveRuntimeVersionUsingCLIAsync({
platform,
workflow,
projectDir,
env,
cwd,
}: {
platform: 'ios' | 'android';
workflow: Workflow;
projectDir: string;
env: Env | undefined;
cwd?: string;
}): Promise<{
runtimeVersion: string | null;
expoUpdatesRuntimeFingerprint: {
fingerprintSources: object[];
isDebugFingerprintSource: boolean;
} | null;
expoUpdatesRuntimeFingerprintHash: string | null;
}> {
Log.debug('Using expo-updates runtimeversion:resolve CLI for runtime version resolution');
const useDebugFingerprintSource = Log.isDebug;
const extraArgs = useDebugFingerprintSource ? ['--debug'] : [];
const resolvedRuntimeVersionJSONResult = await expoUpdatesCommandAsync(
projectDir,
['runtimeversion:resolve', '--platform', platform, '--workflow', workflow, ...extraArgs],
{ env, cwd }
);
const runtimeVersionResult = JSON.parse(resolvedRuntimeVersionJSONResult);
Log.debug('runtimeversion:resolve output:');
Log.debug(resolvedRuntimeVersionJSONResult);
return {
runtimeVersion: runtimeVersionResult.runtimeVersion ?? null,
expoUpdatesRuntimeFingerprint: runtimeVersionResult.fingerprintSources
? {
fingerprintSources: runtimeVersionResult.fingerprintSources,
isDebugFingerprintSource: useDebugFingerprintSource,
}
: null,
expoUpdatesRuntimeFingerprintHash: runtimeVersionResult.fingerprintSources
? runtimeVersionResult.runtimeVersion
: null,
};
}
export async function resolveRuntimeVersionAsync({
exp,
platform,
workflow,
projectDir,
env,
cwd,
}: {
exp: ExpoConfig;
platform: 'ios' | 'android';
workflow: Workflow;
projectDir: string;
env: Env | undefined;
cwd?: string;
}): Promise<{
runtimeVersion: string | null;
expoUpdatesRuntimeFingerprint: {
fingerprintSources: object[];
isDebugFingerprintSource: boolean;
} | null;
expoUpdatesRuntimeFingerprintHash: string | null;
} | null> {
if (!(await isModernExpoUpdatesCLIWithRuntimeVersionCommandSupportedAsync(projectDir))) {
// fall back to the previous behavior (using the @expo/config-plugins eas-cli dependency rather
// than the versioned @expo/config-plugins dependency in the project)
return {
runtimeVersion: await Updates.getRuntimeVersionNullableAsync(projectDir, exp, platform),
expoUpdatesRuntimeFingerprint: null,
expoUpdatesRuntimeFingerprintHash: null,
};
}
try {
return await resolveRuntimeVersionUsingCLIAsync({ platform, workflow, projectDir, env, cwd });
} catch (e: any) {
// if expo-updates is not installed, there's no need for a runtime version in the build
if (e instanceof ExpoUpdatesCLIModuleNotFoundError) {
return null;
}
throw e;
}
}
================================================
FILE: apps/eoas/src/lib/utils.ts
================================================
export function isValidUpdateUrl(updateUrl: string): boolean {
return updateUrl.match(/^https?:\/\/[^/]+$/) !== null;
}
================================================
FILE: apps/eoas/src/lib/vcs/README.md
================================================
This library is copied from eas-cli[https://github.com/expo/eas-cli] to ensure consistent user experience.
================================================
FILE: apps/eoas/src/lib/vcs/clients/git.ts
================================================
import * as PackageManagerUtils from '@expo/package-manager';
import spawnAsync from '@expo/spawn-async';
import { Errors } from '@oclif/core';
import chalk from 'chalk';
import path from 'path';
import Log, { learnMore } from '../../log';
import { ora } from '../../ora';
import { confirmAsync, promptAsync } from '../../prompts';
import {
doesGitRepoExistAsync,
getGitDiffOutputAsync,
gitDiffAsync,
gitStatusAsync,
isGitInstalledAsync,
} from '../git';
import { Client } from '../vcs';
export default class GitClient extends Client {
constructor(private readonly maybeCwdOverride?: string) {
super();
}
public override async ensureRepoExistsAsync(): Promise {
try {
if (!(await isGitInstalledAsync())) {
Log.error(
`${chalk.bold('git')} command not found. Install it before proceeding or set ${chalk.bold(
'EAS_NO_VCS=1'
)} to use EAS CLI without Git (or any other version control system).`
);
Log.error(learnMore('https://expo.fyi/eas-vcs-workflow'));
Errors.exit(1);
}
} catch (error: any) {
Log.error(
`${chalk.bold('git')} found, but ${chalk.bold(
'git --help'
)} exited with status ${error?.status}${error?.stderr ? `:` : '.'}`
);
if (error?.stderr) {
Log.error(error?.stderr);
}
Log.error(
`Repair your Git installation, or set ${chalk.bold(
'EAS_NO_VCS=1'
)} to use EAS CLI without Git (or any other version control system).`
);
Log.error(learnMore('https://expo.fyi/eas-vcs-workflow'));
Errors.exit(1);
}
if (await doesGitRepoExistAsync(this.maybeCwdOverride)) {
return;
}
Log.warn("It looks like you haven't initialized the git repository yet.");
Log.warn('EAS requires you to use a git repository for your project.');
const cwd = process.cwd();
const repoRoot = PackageManagerUtils.resolveWorkspaceRoot(cwd) ?? cwd;
const confirmInit = await confirmAsync({
message: `Would you like us to run 'git init' in ${
this.maybeCwdOverride ?? repoRoot
} for you?`,
type: 'confirm',
name: 'confirmInit',
});
if (!confirmInit) {
throw new Error(
'A git repository is required for building your project. Initialize it and run this command again.'
);
}
await spawnAsync('git', ['init'], { cwd: this.maybeCwdOverride ?? repoRoot });
Log.log("We're going to make an initial commit for your repository.");
const { message } = await promptAsync({
type: 'text',
name: 'message',
message: 'Commit message:',
initial: 'Initial commit',
validate: (input: string) => input !== '',
});
await this.commitAsync({ commitAllFiles: true, commitMessage: message, nonInteractive: false });
}
public override async commitAsync({
commitMessage,
commitAllFiles,
nonInteractive = false,
}: {
commitMessage: string;
commitAllFiles?: boolean;
nonInteractive: boolean;
}): Promise {
await ensureGitConfiguredAsync({ nonInteractive });
try {
if (commitAllFiles) {
await spawnAsync('git', ['add', '-A'], {
cwd: this.maybeCwdOverride,
});
}
await spawnAsync('git', ['add', '-u'], {
cwd: this.maybeCwdOverride,
});
await spawnAsync('git', ['commit', '-m', commitMessage], {
cwd: this.maybeCwdOverride,
});
} catch (err: any) {
if (err?.stdout) {
Log.error(err.stdout);
}
if (err?.stderr) {
Log.error(err.stderr);
}
throw err;
}
}
public override async isCommitRequiredAsync(): Promise {
return await this.hasUncommittedChangesAsync();
}
public override async showChangedFilesAsync(): Promise {
const gitStatusOutput = await gitStatusAsync({
showUntracked: true,
cwd: this.maybeCwdOverride,
});
Log.log(gitStatusOutput);
}
public override async hasUncommittedChangesAsync(): Promise {
const changes = await gitStatusAsync({ showUntracked: true, cwd: this.maybeCwdOverride });
return changes.length > 0;
}
public async getRootPathAsync(): Promise {
return (
await spawnAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: this.maybeCwdOverride,
})
).stdout.trim();
}
public async makeShallowCopyAsync(destinationPath: string): Promise {
if (await this.hasUncommittedChangesAsync()) {
// it should already be checked before this function is called, but in case it wasn't
// we want to ensure that any changes were introduced by call to `setGitCaseSensitivityAsync`
throw new Error('You have some uncommitted changes in your repository.');
}
let gitRepoUri;
if (process.platform === 'win32') {
// getRootDirectoryAsync() will return C:/path/to/repo on Windows and path
// prefix should be file:///
gitRepoUri = `file:///${await this.getRootPathAsync()}`;
} else {
// getRootDirectoryAsync() will /path/to/repo, and path prefix should be
// file:/// so only file:// needs to be prepended
gitRepoUri = `file://${await this.getRootPathAsync()}`;
}
const isCaseSensitive = await isGitCaseSensitiveAsync(this.maybeCwdOverride);
await setGitCaseSensitivityAsync(true, this.maybeCwdOverride);
try {
if (await this.hasUncommittedChangesAsync()) {
Log.error('Detected inconsistent filename casing between your local filesystem and git.');
Log.error('This will likely cause your build to fail. Impacted files:');
await spawnAsync('git', ['status', '--short'], {
stdio: 'inherit',
cwd: this.maybeCwdOverride,
});
Log.newLine();
Log.error(
`Error: Resolve filename casing inconsistencies before proceeding. ${learnMore(
'https://expo.fyi/macos-ignorecase'
)}`
);
throw new Error('You have some uncommitted changes in your repository.');
}
await spawnAsync(
'git',
['clone', '--no-hardlinks', '--depth', '1', gitRepoUri, destinationPath],
{
cwd: this.maybeCwdOverride,
}
);
} finally {
await setGitCaseSensitivityAsync(isCaseSensitive, this.maybeCwdOverride);
}
}
public override async getCommitHashAsync(): Promise {
try {
return (
await spawnAsync('git', ['rev-parse', 'HEAD'], {
cwd: this.maybeCwdOverride,
})
).stdout.trim();
} catch {
return undefined;
}
}
public override async trackFileAsync(file: string): Promise {
await spawnAsync('git', ['add', '--intent-to-add', file], {
cwd: this.maybeCwdOverride,
});
}
public override async getBranchNameAsync(): Promise {
try {
return (
await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: this.maybeCwdOverride,
})
).stdout.trim();
} catch {
return null;
}
}
public override async getLastCommitMessageAsync(): Promise {
try {
return (
await spawnAsync('git', ['--no-pager', 'log', '-1', '--pretty=%B'], {
cwd: this.maybeCwdOverride,
})
).stdout.trim();
} catch {
return null;
}
}
public override async showDiffAsync(): Promise {
const outputTooLarge =
(await getGitDiffOutputAsync(this.maybeCwdOverride)).split(/\r\n|\r|\n/).length > 100;
await gitDiffAsync({ withPager: outputTooLarge, cwd: this.maybeCwdOverride });
}
public async isFileUntrackedAsync(path: string): Promise {
const withUntrackedFiles = await gitStatusAsync({
showUntracked: true,
cwd: this.maybeCwdOverride,
});
const trackedFiles = await gitStatusAsync({ showUntracked: false, cwd: this.maybeCwdOverride });
const pathWithoutLeadingDot = path.replace(/^\.\//, ''); // remove leading './' from path
return (
withUntrackedFiles.includes(pathWithoutLeadingDot) &&
!trackedFiles.includes(pathWithoutLeadingDot)
);
}
public override async isFileIgnoredAsync(filePath: string): Promise {
try {
await spawnAsync('git', ['check-ignore', '-q', filePath], {
cwd: this.maybeCwdOverride ?? path.normalize(await this.getRootPathAsync()),
});
return true;
} catch {
return false;
}
}
public override canGetLastCommitMessage(): boolean {
return true;
}
}
async function ensureGitConfiguredAsync({
nonInteractive,
}: {
nonInteractive: boolean;
}): Promise {
let usernameConfigured = true;
let emailConfigured = true;
try {
await spawnAsync('git', ['config', '--get', 'user.name']);
} catch (err: any) {
Log.debug(err);
usernameConfigured = false;
}
try {
await spawnAsync('git', ['config', '--get', 'user.email']);
} catch (err: any) {
Log.debug(err);
emailConfigured = false;
}
if (usernameConfigured && emailConfigured) {
return;
}
Log.warn(
`You need to configure Git with your ${[
!usernameConfigured && 'username (user.name)',
!emailConfigured && 'email address (user.email)',
]
.filter(i => i)
.join(' and ')}`
);
if (nonInteractive) {
throw new Error('Git cannot be configured automatically in non-interactive mode');
}
if (!usernameConfigured) {
const { username } = await promptAsync({
type: 'text',
name: 'username',
message: 'Username:',
validate: (input: string) => input !== '',
});
const spinner = ora(
`Running ${chalk.bold(`git config --local user.name ${username}`)}`
).start();
try {
await spawnAsync('git', ['config', '--local', 'user.name', username]);
spinner.succeed();
} catch (err: any) {
spinner.fail();
throw err;
}
}
if (!emailConfigured) {
const { email } = await promptAsync({
type: 'text',
name: 'email',
message: 'Email address:',
validate: (input: string) => input !== '',
});
const spinner = ora(`Running ${chalk.bold(`git config --local user.email ${email}`)}`).start();
try {
await spawnAsync('git', ['config', '--local', 'user.email', email]);
spinner.succeed();
} catch (err: any) {
spinner.fail();
throw err;
}
}
}
/**
* Checks if git is configured to be case sensitive
* @returns {boolean | undefined}
* - boolean - is git case sensitive
* - undefined - case sensitivity is not configured and git is using default behavior
*/
export async function isGitCaseSensitiveAsync(
cwd: string | undefined
): Promise {
if (process.platform !== 'darwin') {
return undefined;
}
try {
const result = await spawnAsync('git', ['config', '--get', 'core.ignorecase'], {
cwd,
});
const isIgnoreCaseEnabled = result.stdout.trim();
if (isIgnoreCaseEnabled === '') {
return undefined;
} else if (isIgnoreCaseEnabled === 'true') {
return false;
} else {
return true;
}
} catch {
return undefined;
}
}
async function setGitCaseSensitivityAsync(
enable: boolean | undefined,
cwd: string | undefined
): Promise {
// we are assuming that if someone sets that on non-macos device then
// they know what they are doing
if (process.platform !== 'darwin') {
return;
}
if (enable === undefined) {
await spawnAsync('git', ['config', '--unset', 'core.ignorecase'], {
cwd,
});
} else {
await spawnAsync('git', ['config', 'core.ignorecase', String(!enable)], {
cwd,
});
}
}
================================================
FILE: apps/eoas/src/lib/vcs/clients/gitNoCommit.ts
================================================
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import path from 'path';
import GitClient from './git';
import Log from '../../log';
import { Ignore, makeShallowCopyAsync } from '../local';
export default class GitNoCommitClient extends GitClient {
public override async isCommitRequiredAsync(): Promise {
return false;
}
public override async getRootPathAsync(): Promise {
return (await spawnAsync('git', ['rev-parse', '--show-toplevel'])).stdout.trim();
}
public override async makeShallowCopyAsync(destinationPath: string): Promise {
// normalize converts C:/some/path to C:\some\path on windows
const srcPath = path.normalize(await this.getRootPathAsync());
await makeShallowCopyAsync(srcPath, destinationPath);
}
public override async isFileIgnoredAsync(filePath: string): Promise {
// normalize converts C:/some/path to C:\some\path on windows
const rootPath = path.normalize(await this.getRootPathAsync());
const ignore = new Ignore(rootPath);
await ignore.initIgnoreAsync();
return ignore.ignores(filePath);
}
public override async trackFileAsync(file: string): Promise {
try {
await super.trackFileAsync(file);
} catch {
// In the no commit workflow it doesn't matter if we fail to track changes,
// so we can ignore if this throws an exception
Log.warn(
`Unable to track ${chalk.bold(path.basename(file))} in Git. Proceeding without tracking.`
);
Log.warn(` Reason: the command ${chalk.bold(`"git add ${file}"`)} exited with an error.`);
Log.newLine();
}
}
}
================================================
FILE: apps/eoas/src/lib/vcs/clients/noVcs.ts
================================================
import { Ignore, getRootPath, makeShallowCopyAsync } from '../local';
import { Client } from '../vcs';
export default class NoVcsClient extends Client {
public async getRootPathAsync(): Promise {
return getRootPath();
}
public async makeShallowCopyAsync(destinationPath: string): Promise {
const srcPath = getRootPath();
await makeShallowCopyAsync(srcPath, destinationPath);
}
public override async isFileIgnoredAsync(filePath: string): Promise {
const ignore = new Ignore(getRootPath());
await ignore.initIgnoreAsync();
return ignore.ignores(filePath);
}
public override canGetLastCommitMessage(): boolean {
return false;
}
}
================================================
FILE: apps/eoas/src/lib/vcs/git.ts
================================================
import spawnAsync from '@expo/spawn-async';
export async function isGitInstalledAsync(): Promise {
try {
await spawnAsync('git', ['--help']);
} catch (error: any) {
if (error.code === 'ENOENT') {
return false;
}
throw error;
}
return true;
}
export async function doesGitRepoExistAsync(cwd: string | undefined): Promise {
try {
await spawnAsync('git', ['rev-parse', '--git-dir'], {
cwd,
});
return true;
} catch {
return false;
}
}
interface GitStatusOptions {
showUntracked: boolean;
cwd: string | undefined;
}
export async function gitStatusAsync({ showUntracked, cwd }: GitStatusOptions): Promise {
return (
await spawnAsync('git', ['status', '-s', showUntracked ? '-uall' : '-uno'], {
cwd,
})
).stdout;
}
export async function getGitDiffOutputAsync(cwd: string | undefined): Promise {
return (
await spawnAsync('git', ['--no-pager', 'diff'], {
cwd,
})
).stdout;
}
export async function gitDiffAsync({
withPager = false,
cwd,
}: {
withPager?: boolean;
cwd: string | undefined;
}): Promise {
const options = withPager ? [] : ['--no-pager'];
try {
await spawnAsync('git', [...options, 'diff'], {
stdio: ['ignore', 'inherit', 'inherit'],
cwd,
});
} catch (error: any) {
if (typeof error.message === 'string' && error.message.includes('SIGPIPE')) {
// This error is thrown when the user exits the pager with `q`.
// do nothing
return;
}
throw error;
}
}
================================================
FILE: apps/eoas/src/lib/vcs/index.ts
================================================
import chalk from 'chalk';
import GitClient from './clients/git';
import GitNoCommitClient from './clients/gitNoCommit';
import NoVcsClient from './clients/noVcs';
import { Client } from './vcs';
const NO_VCS_WARNING = `Using EAS CLI without version control system is not recommended, use this mode only if you know what you are doing.`;
export function resolveVcsClient(requireCommit: boolean = false): Client {
if (process.env.EAS_NO_VCS) {
if (process.env.NODE_ENV !== 'test') {
// This log might be printed before cli arguments are evaluated,
// so it needs to go to stderr in case command is run in JSON
// only mode.
// eslint-disable-next-line no-console
console.error(chalk.yellow(NO_VCS_WARNING));
}
return new NoVcsClient();
}
if (requireCommit) {
return new GitClient();
}
return new GitNoCommitClient();
}
================================================
FILE: apps/eoas/src/lib/vcs/local.ts
================================================
import fg from 'fast-glob';
import fs from 'fs-extra';
import createIgnore, { Ignore as SingleFileIgnore } from 'ignore';
import path from 'path';
const EASIGNORE_FILENAME = '.easignore';
const GITIGNORE_FILENAME = '.gitignore';
const DEFAULT_IGNORE = `
.git
node_modules
`;
export function getRootPath(): string {
const rootPath = process.env.EAS_PROJECT_ROOT ?? process.cwd();
if (!path.isAbsolute(rootPath)) {
return path.resolve(process.cwd(), rootPath);
}
return rootPath;
}
/**
* Ignore wraps the 'ignore' package to support multiple .gitignore files
* in subdirectories.
*
* Inconsistencies with git behavior:
* - if parent .gitignore has ignore rule and child has exception to that rule,
* file will still be ignored,
* - node_modules is always ignored,
* - if .easignore exists, .gitignore files are not used.
*/
export class Ignore {
private ignoreMapping: (readonly [string, SingleFileIgnore])[] = [];
constructor(private readonly rootDir: string) {}
public async initIgnoreAsync(): Promise {
const easIgnorePath = path.join(this.rootDir, EASIGNORE_FILENAME);
if (await fs.pathExists(easIgnorePath)) {
this.ignoreMapping = [
['', createIgnore().add(DEFAULT_IGNORE)],
['', createIgnore().add(await fs.readFile(easIgnorePath, 'utf-8'))],
];
return;
}
const ignoreFilePaths = (
await fg(`**/${GITIGNORE_FILENAME}`, {
cwd: this.rootDir,
ignore: ['node_modules'],
followSymbolicLinks: false,
})
)
// ensure that parent dir is before child directories
.sort((a, b) => a.length - b.length && a.localeCompare(b));
const ignoreMapping = await Promise.all(
ignoreFilePaths.map(async filePath => {
return [
filePath.slice(0, filePath.length - GITIGNORE_FILENAME.length),
createIgnore().add(await fs.readFile(path.join(this.rootDir, filePath), 'utf-8')),
] as const;
})
);
this.ignoreMapping = [['', createIgnore().add(DEFAULT_IGNORE)], ...ignoreMapping];
}
public ignores(relativePath: string): boolean {
for (const [prefix, ignore] of this.ignoreMapping) {
if (relativePath.startsWith(prefix) && ignore.ignores(relativePath.slice(prefix.length))) {
return true;
}
}
return false;
}
}
export async function makeShallowCopyAsync(src: string, dst: string): Promise {
const ignore = new Ignore(src);
await ignore.initIgnoreAsync();
await fs.copy(src, dst, {
filter: (srcFilePath: string) => {
if (srcFilePath === src) {
return true;
}
return !ignore.ignores(path.relative(src, srcFilePath));
},
});
}
================================================
FILE: apps/eoas/src/lib/vcs/vcs.ts
================================================
export abstract class Client {
// makeShallowCopyAsync should copy current project (result of getRootPathAsync()) to the specified
// destination, folder created this way will be uploaded "as is", so implementation should skip
// anything that is not committed to the repository. Most optimal solution is to create shallow clone
// using tooling provided by specific VCS, that respects all ignore rules
public abstract makeShallowCopyAsync(destinationPath: string): Promise;
// Find root of the repository.
//
// On windows path might look different depending on implementation
// - git based clients will return "C:/path/to/repo"
// - non-git clients will return "C:\path\to\repo"
public abstract getRootPathAsync(): Promise;
// (optional) ensureRepoExistsAsync should verify whether repository exists and tooling is installed
// it's not required for minimal support, but lack of validation might cause the failure at a later stage.
public async ensureRepoExistsAsync(): Promise {}
// (optional) checks whether commit is necessary before calling makeShallowCopyAsync
//
// If it's not implemented method `makeShallowCopyAsync` needs to be able to include uncommitted changes
// when creating copy
public async isCommitRequiredAsync(): Promise {
return false;
}
// (optional) hasUncommittedChangesAsync should check whether there are changes in local repository
public async hasUncommittedChangesAsync(): Promise {
return undefined;
}
// (optional) commitAsync commits changes
//
// - Should be implemented if hasUncommittedChangesAsync is implemented
// - If it's not implemented method `makeShallowCopyAsync` needs to be able to include uncommitted changes
// in project copy
public async commitAsync(_arg: {
commitMessage: string;
commitAllFiles?: boolean;
nonInteractive: boolean;
}): Promise {
// it should not be called unless hasUncommittedChangesAsync is implemented
throw new Error('commitAsync is not implemented');
}
// (optional) mark file as tracked, if this method is called on file, the next call to
// `commitAsync({ commitAllFiles: false })` should commit that file
public async trackFileAsync(_file: string): Promise {}
// (optional) print diff of the changes that will be commited in the next call to
// `commitAsync({ commitAllFiles: false })`
public async showDiffAsync(): Promise {}
/** (optional) print list of changed files */
public async showChangedFilesAsync(): Promise {}
// (optional) returns hash of the last commit
// used for metadata - implementation can be safely skipped
public async getCommitHashAsync(): Promise {
return undefined;
}
// (optional) returns name of the current branch
// used for EAS Update - implementation can be safely skipped
public async getBranchNameAsync(): Promise {
return null;
}
// (optional) returns message of the last commit
// used for EAS Update - implementation can be safely skipped
public async getLastCommitMessageAsync(): Promise {
return null;
}
// (optional) checks if the file is ignored, an implementation should ensure
// that if file exists and `isFileIgnoredAsync` returns true, then that file
// should not be included in the project tarball.
//
// @param filePath has to be a relative normalized path pointing to a file
// located under the root of the repository
public async isFileIgnoredAsync(_filePath: string): Promise {
return false;
}
/**
* Whether this VCS client can get the last commit message.
* Used for EAS Update - implementation can be false for noVcs client.
*/
public abstract canGetLastCommitMessage(): boolean;
}
================================================
FILE: apps/eoas/src/lib/workflow.ts
================================================
import { AndroidConfig, IOSConfig } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import { Client } from './vcs/vcs';
export async function resolveWorkflowAsync(
projectDir: string,
platform: Platform,
vcsClient: Client
): Promise |