Repository: axelmarciano/expo-open-ota Branch: main Commit: 2a506a8dd74f Files: 348 Total size: 9.9 MB Directory structure: gitextract_u7h5vna8/ ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── push.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── Dockerfile-ci ├── Dockerfile-dev ├── LICENSE.md ├── Makefile ├── README.md ├── apps/ │ ├── dashboard/ │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── components.json │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public/ │ │ │ └── env.js │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── components/ │ │ │ │ ├── APIError/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── Combobox/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── DataTable/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── UpdateDetailsSheet/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── app-sidebar.tsx │ │ │ │ └── ui/ │ │ │ │ ├── alert.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toaster.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── containers/ │ │ │ │ └── Layout/ │ │ │ │ └── index.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-mobile.tsx │ │ │ │ └── use-toast.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── api.ts │ │ │ │ ├── auth.ts │ │ │ │ └── utils.ts │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── Channels/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── SelectBranch/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── Login/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── Logout/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── Settings/ │ │ │ │ │ └── index.tsx │ │ │ │ └── Updates/ │ │ │ │ ├── components/ │ │ │ │ │ ├── BranchesTable/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── RuntimeVersionsTable/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── UpdatesTable/ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── docs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── docs/ │ │ │ ├── advanced/ │ │ │ │ ├── _category_.json │ │ │ │ └── prometheus.mdx │ │ │ ├── dashboard.mdx │ │ │ ├── deployment/ │ │ │ │ ├── _category_.json │ │ │ │ ├── custom.mdx │ │ │ │ ├── helm.mdx │ │ │ │ ├── railway.mdx │ │ │ │ └── testing.mdx │ │ │ ├── eoas/ │ │ │ │ ├── _category_.json │ │ │ │ ├── configure.mdx │ │ │ │ ├── intro.mdx │ │ │ │ ├── publish.mdx │ │ │ │ ├── republish.mdx │ │ │ │ └── rollback.mdx │ │ │ ├── getting-started/ │ │ │ │ ├── _category_.json │ │ │ │ ├── introduction.mdx │ │ │ │ ├── prerequisites.mdx │ │ │ │ └── quick-start.mdx │ │ │ ├── reference/ │ │ │ │ ├── _category_.json │ │ │ │ └── environment.mdx │ │ │ └── server-configuration/ │ │ │ ├── _category_.json │ │ │ ├── cache.mdx │ │ │ ├── cdn/ │ │ │ │ ├── _category_.json │ │ │ │ ├── cloudfront.mdx │ │ │ │ ├── generic.mdx │ │ │ │ └── intro.mdx │ │ │ ├── key-store.mdx │ │ │ └── storage.mdx │ │ ├── docusaurus.config.ts │ │ ├── package.json │ │ ├── sidebars.ts │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── BrowserWindow/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.module.css │ │ │ │ └── HomepageFeatures/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── css/ │ │ │ │ └── custom.css │ │ │ └── pages/ │ │ │ ├── index.module.css │ │ │ ├── index.tsx │ │ │ └── markdown-page.md │ │ ├── static/ │ │ │ └── .nojekyll │ │ └── tsconfig.json │ ├── eoas/ │ │ ├── .eslintignore │ │ ├── .eslintrc.js │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── generate-certs.ts │ │ │ │ ├── init.ts │ │ │ │ ├── publish.ts │ │ │ │ ├── republish.ts │ │ │ │ └── rollback.ts │ │ │ ├── index.d.ts │ │ │ └── lib/ │ │ │ ├── assets.ts │ │ │ ├── auth.ts │ │ │ ├── channel.ts │ │ │ ├── expoConfig.ts │ │ │ ├── fetch.ts │ │ │ ├── log.ts │ │ │ ├── ora.ts │ │ │ ├── package.ts │ │ │ ├── packageRunner.ts │ │ │ ├── prompts.ts │ │ │ ├── repo.ts │ │ │ ├── runtimeVersion.ts │ │ │ ├── utils.ts │ │ │ ├── vcs/ │ │ │ │ ├── README.md │ │ │ │ ├── clients/ │ │ │ │ │ ├── git.ts │ │ │ │ │ ├── gitNoCommit.ts │ │ │ │ │ └── noVcs.ts │ │ │ │ ├── git.ts │ │ │ │ ├── index.ts │ │ │ │ ├── local.ts │ │ │ │ └── vcs.ts │ │ │ └── workflow.ts │ │ └── tsconfig.json │ ├── example-app/ │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── .prettierignore │ │ ├── .prettierrc │ │ ├── README.md │ │ ├── app/ │ │ │ ├── +not-found.tsx │ │ │ ├── _layout.tsx │ │ │ └── index.tsx │ │ ├── app.config.ts │ │ ├── app.json │ │ ├── components/ │ │ │ ├── LogViewer.tsx │ │ │ ├── ThemedText.tsx │ │ │ ├── ThemedView.tsx │ │ │ ├── __tests__/ │ │ │ │ ├── ThemedText-test.tsx │ │ │ │ └── __snapshots__/ │ │ │ │ └── ThemedText-test.tsx.snap │ │ │ └── ui/ │ │ │ ├── IconSymbol.ios.tsx │ │ │ ├── IconSymbol.tsx │ │ │ ├── TabBarBackground.ios.tsx │ │ │ └── TabBarBackground.tsx │ │ ├── constants/ │ │ │ └── Colors.ts │ │ ├── hooks/ │ │ │ ├── useColorScheme.ts │ │ │ ├── useColorScheme.web.ts │ │ │ └── useThemeColor.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── network_security_config.xml │ │ │ ├── reset-project.js │ │ │ └── trust_local_certs.js │ │ └── tsconfig.json │ └── example-app-runtime-switch/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── README.md │ ├── app/ │ │ ├── +not-found.tsx │ │ ├── _layout.tsx │ │ └── index.tsx │ ├── app.config.ts │ ├── app.json │ ├── components/ │ │ ├── LogViewer.tsx │ │ ├── ThemedText.tsx │ │ ├── ThemedView.tsx │ │ ├── __tests__/ │ │ │ ├── ThemedText-test.tsx │ │ │ └── __snapshots__/ │ │ │ └── ThemedText-test.tsx.snap │ │ └── ui/ │ │ ├── IconSymbol.ios.tsx │ │ ├── IconSymbol.tsx │ │ ├── TabBarBackground.ios.tsx │ │ └── TabBarBackground.tsx │ ├── constants/ │ │ └── Colors.ts │ ├── hooks/ │ │ ├── useColorScheme.ts │ │ ├── useColorScheme.web.ts │ │ └── useThemeColor.ts │ ├── package.json │ ├── scripts/ │ │ ├── network_security_config.xml │ │ ├── reset-project.js │ │ └── trust_local_certs.js │ └── tsconfig.json ├── cmd/ │ └── api/ │ └── main.go ├── config/ │ ├── config.go │ └── config_test.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── grafana/ │ └── dashboard.json ├── grafana-dashboard.json ├── helm/ │ ├── .helmignore │ ├── Chart.yaml │ ├── helm_template_test.go │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── hpa.yaml │ │ ├── ingress.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tests/ │ │ └── test-connection.yaml │ └── values.yaml ├── internal/ │ ├── assets/ │ │ └── assets.go │ ├── auth/ │ │ └── auth.go │ ├── branch/ │ │ ├── branch.go │ │ └── branch_test.go │ ├── bucket/ │ │ ├── bucket.go │ │ ├── bucket_test.go │ │ ├── gcsBucket.go │ │ ├── localBucket.go │ │ ├── localBucket_test.go │ │ └── s3Bucket.go │ ├── cache/ │ │ ├── cache.go │ │ ├── cache_test.go │ │ ├── localCache.go │ │ └── redisCache.go │ ├── cdn/ │ │ ├── cdn.go │ │ ├── cdn_test.go │ │ ├── cloudfront.go │ │ ├── gcs_direct.go │ │ └── generic.go │ ├── compression/ │ │ └── compression.go │ ├── crypto/ │ │ ├── crypto.go │ │ └── crypto_test.go │ ├── dashboard/ │ │ └── dashboard.go │ ├── handlers/ │ │ ├── assets_handler.go │ │ ├── auth_handler.go │ │ ├── dashboard_handler.go │ │ ├── manifest_handler.go │ │ ├── republish_handler.go │ │ ├── rollback_handler.go │ │ └── upload_handler.go │ ├── helpers/ │ │ ├── auth.go │ │ ├── headers.go │ │ ├── string.go │ │ └── url.go │ ├── keyStore/ │ │ ├── awsSMKeyStorage.go │ │ ├── environmentKeyStorage.go │ │ ├── keyStore.go │ │ └── localKeyStorage.go │ ├── metrics/ │ │ ├── metrics.go │ │ └── metrics_test.go │ ├── middleware/ │ │ ├── auth_middleware.go │ │ ├── cors_middleware.go │ │ └── logging_middleware.go │ ├── migration/ │ │ ├── base.go │ │ ├── migration.go │ │ ├── registry.go │ │ └── runner.go │ ├── migrations/ │ │ ├── 20250417_persist_uuid/ │ │ │ └── 20250417_persist_uuid.go │ │ └── migrations.go │ ├── router/ │ │ └── router.go │ ├── services/ │ │ ├── aws.go │ │ ├── aws_test.go │ │ ├── expo.go │ │ ├── gcp.go │ │ └── jwt.go │ ├── types/ │ │ └── types.go │ ├── update/ │ │ ├── prewarm.go │ │ └── updates.go │ └── version/ │ └── version.go ├── prometheus.yml └── test/ ├── assets_test.go ├── channel_mapping_cache_test.go ├── dashboard_path_traversal_test.go ├── dashboard_test.go ├── expo_multipart_parser.go ├── helpers.go ├── manifest_test.go ├── migrations_test.go ├── republish_test.go ├── requestUpload_test.go ├── rollback_test.go ├── test-updates/ │ ├── branch-1/ │ │ └── 1/ │ │ └── 1674170951/ │ │ ├── .check │ │ ├── assets/ │ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ │ ├── bundles/ │ │ │ ├── android-82adadb1fb6e489d04ad95fd79670deb.js │ │ │ └── ios-9d01842d6ee1224f7188971c5d397115.js │ │ ├── expoConfig.json │ │ ├── metadata.json │ │ └── update-metadata.json │ ├── branch-2/ │ │ └── 1/ │ │ ├── 1666304169/ │ │ │ ├── .check │ │ │ ├── rollback │ │ │ └── update-metadata.json │ │ ├── 1666629107/ │ │ │ ├── .check │ │ │ ├── bundles/ │ │ │ │ ├── android-b00c4b050fca5b0ca395c7c183a2aed3.js │ │ │ │ └── ios-673cd0555c467df47093f49cc1b6d00f.js │ │ │ ├── metadata.json │ │ │ └── update-metadata.json │ │ ├── 1666629141/ │ │ │ ├── .check │ │ │ ├── rollback │ │ │ └── update-metadata.json │ │ ├── 1674170951/ │ │ │ ├── .check │ │ │ ├── assets/ │ │ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ │ │ ├── bundles/ │ │ │ │ ├── android-82adadb1fb6e489d04ad95fd79670deb.js │ │ │ │ └── ios-9d01842d6ee1224f7188971c5d397115.js │ │ │ ├── expoConfig.json │ │ │ ├── metadata.json │ │ │ └── update-metadata.json │ │ └── 1737455526/ │ │ ├── .check │ │ ├── _expo/ │ │ │ └── static/ │ │ │ └── js/ │ │ │ ├── android/ │ │ │ │ └── AppEntry-3aa3d3f85ad7a30a3c33dba2de772e4f.hbc │ │ │ └── ios/ │ │ │ └── AppEntry-546b83fc2035b34c5f2dbd9bb04a2478.hbc │ │ ├── assets/ │ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ │ ├── expoConfig.json │ │ ├── metadata.json │ │ └── update-metadata.json │ ├── branch-3/ │ │ └── 1/ │ │ ├── 1666304168/ │ │ │ ├── .check │ │ │ ├── assets/ │ │ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ │ │ ├── bundles/ │ │ │ │ ├── android-82adadb1fb6e489d04ad95fd79670deb.js │ │ │ │ └── ios-9d01842d6ee1224f7188971c5d397115.js │ │ │ ├── expoConfig.json │ │ │ ├── metadata.json │ │ │ └── update-metadata.json │ │ └── 1666304169/ │ │ ├── .check │ │ ├── rollback │ │ └── update-metadata.json │ └── branch-4/ │ └── 1/ │ ├── 1674170951/ │ │ ├── .check │ │ ├── assets/ │ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ │ ├── bundles/ │ │ │ ├── android-82adadb1fb6e489d04ad95fd79670deb.js │ │ │ └── ios-9d01842d6ee1224f7188971c5d397115.js │ │ ├── expoConfig.json │ │ ├── metadata.json │ │ └── update-metadata.json │ └── 1674170952/ │ ├── assets/ │ │ └── 4f1cb2cac2370cd5050681232e8575a8 │ ├── bundles/ │ │ └── android-82adadb1fb6e489d04ad95fd79670deb.js │ ├── expoConfig.json │ ├── metadata.json │ └── update-metadata.json └── url_encoding_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [https://github.com/sponsors/axelmarciano] ================================================ FILE: .github/workflows/push.yml ================================================ name: Push workflow on: push: branches: - '**' permissions: contents: write jobs: test: runs-on: ubuntu-latest container: image: ghcr.io/axelmarciano/expo-open-ota-ci:latest credentials: username: axelmarciano password: ${{ secrets.DOCKER_GITHUB_CONTAINER_REGISTRY_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: 1.24 - name: Check if .env exists or create it run: | if [ ! -f .env ]; then touch .env fi - name: Cache Go modules uses: actions/cache@v4 with: path: | ~/.cache/go-build ~/.go/pkg/mod key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- - name: Install Go dependencies run: | go mod tidy go mod download - name: Run tests run: make test_app html - name: Upload coverage artifact if: ${{ success() }} uses: actions/upload-artifact@v4 with: name: coverage path: coverage.html retention-days: 1 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release Workflow on: push: tags: - "v*" permissions: id-token: write contents: write packages: write jobs: docker: runs-on: ubuntu-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Build and push multi-platform Docker image run: | IMAGE_TAG="${GITHUB_REF#refs/tags/}" REPO="ghcr.io/${{ github.repository_owner }}/expo-open-ota" echo "Building Docker image with tag: $IMAGE_TAG" docker buildx build \ --platform linux/amd64,linux/arm64 \ -t "$REPO:$IMAGE_TAG" \ --push . - name: Check latest version and update latest tag env: IMAGE_TAG: ${{ github.ref_name }} run: | REPO="ghcr.io/${{ github.repository_owner }}/expo-open-ota" echo "Fetching the latest version tag from GHCR..." LATEST_TAG=$(gh api "https://api.github.com/users/${{ github.repository_owner }}/packages/container/expo-open-ota/versions?per_page=100" \ --jq '[.[].metadata.container.tags[]] | map(select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))) | sort_by(ltrimstr("v") | split(".") | map(tonumber)) | last' | tr -d '"') echo "Latest found version: $LATEST_TAG" echo "Current version: $IMAGE_TAG" if [ "$LATEST_TAG" = "$IMAGE_TAG" ]; then echo "$IMAGE_TAG is the latest version. Updating latest tag..." docker buildx imagetools create -t "$REPO:latest" "$REPO:$IMAGE_TAG" else echo "$IMAGE_TAG is not the latest version. Skipping latest tag update." fi helm: runs-on: ubuntu-latest needs: docker steps: - name: Checkout code uses: actions/checkout@v4 - name: Update Helm values.yaml run: | IMAGE_TAG="${GITHUB_REF#refs/tags/}" sed -i "s/tag: .*/tag: ${IMAGE_TAG}/" helm/values.yaml - name: Package Helm chart run: | IMAGE_TAG="${GITHUB_REF#refs/tags/}" helm package ./helm -d ./charts mv ./charts/expo-open-ota-*.tgz ./charts/expo-open-ota-helm-charts-${IMAGE_TAG}.tgz npm: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "24.x" registry-url: "https://registry.npmjs.org" - name: Publish NPM package run: | cd apps/eoas VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') npm ci npm run build npm version "$VERSION" --no-git-tag-version if echo "$VERSION" | grep -qE '\-(alpha|beta|rc)'; then NPM_TAG=$(echo "$VERSION" | grep -oE '(alpha|beta|rc)') echo "Pre-release detected, publishing with --tag $NPM_TAG" npm publish --access public --provenance --tag "$NPM_TAG" else npm publish --access public --provenance fi github-release: runs-on: ubuntu-latest needs: [helm, npm] steps: - name: Checkout code uses: actions/checkout@v4 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: ./charts/*.tgz prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} body: | ## Changes - Docker image: `ghcr.io/${{ github.repository_owner }}/expo-open-ota:${{ github.ref_name }}` - Helm chart version updated env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib *.test .idea/ keys/ # Output of the go coverage tool, specifically when used with LiteIDE *.out .env # Logs *.log # Temporary files *.tmp # Dependency directories vendor/ # Go modules # IDE/editor specific files .vscode/ .idea/ *.iml # GoLand plugin files *.local .claude # MacOS specific files .DS_Store # Node.js specific files (if the project includes frontend code) node_modules/ # Build directories bin/ build/ # Cover profile generated by 'go test -coverprofile' coverage.out coverage.html # Configuration files .env # Temporary backup files *~ updates/**/* test/keys/**/* ================================================ FILE: Dockerfile ================================================ FROM --platform=$BUILDPLATFORM node:24-alpine AS dashboard-builder WORKDIR /app/apps/dashboard COPY apps/dashboard/package.json apps/dashboard/package-lock.json ./ RUN npm ci COPY apps/dashboard ./ RUN npm run build FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder ARG TARGETARCH WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd COPY internal ./internal COPY keys ./keys COPY config ./config COPY updates ./updates RUN GOOS=linux GOARCH=${TARGETARCH} go build -o main ./cmd/api FROM alpine:latest RUN apk add --no-cache bash WORKDIR /app COPY --from=builder /app/main /app/main COPY --from=dashboard-builder /app/apps/dashboard/dist /app/apps/dashboard/dist EXPOSE 3000 CMD ["/app/main"] ================================================ FILE: Dockerfile-ci ================================================ FROM node:18-alpine AS dashboard-builder WORKDIR /app/apps/dashboard COPY apps/dashboard/package.json apps/dashboard/package-lock.json ./ RUN npm ci COPY apps/dashboard ./ RUN npm run build FROM golang:1.24-alpine RUN apk add --no-cache git bash curl unzip entr make tar RUN go install github.com/cespare/reflex@latest ENV PATH="/go/bin:${PATH}" COPY --from=dashboard-builder /app/apps/dashboard/dist /app/apps/dashboard/dist CMD ["bash"] ================================================ FILE: Dockerfile-dev ================================================ # Start with the official Golang base image FROM golang:1.24-alpine # Install necessary packages RUN apk add --no-cache git bash curl unzip entr # Install Reflex for hot reloading RUN go install github.com/cespare/reflex@latest # Ensure go binaries and AWS CLI are available in the PATH ENV PATH="/go/bin:${PATH}" # Set the Current Working Directory inside the container WORKDIR /app # Copy go mod and sum files COPY go.mod go.sum ./ # Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed RUN go mod download # Copy the source from the current directory to the Working Directory inside the container COPY cmd ./cmd COPY internal ./internal COPY keys ./keys COPY config ./config COPY updates ./updates COPY test ./test RUN if [ -f .env ]; then cp .env /app/.env; fi # Install dependencies RUN go get ./... # Command to run the application with Reflex CMD ["reflex", "-r", "\\.go", "-s", "--", "sh", "-c", "go run cmd/api/main.go"] ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) [2025] [Axel Marciano] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ DOCKER_FLAG := $(findstring docker, $(MAKECMDGOALS)) HTML_FLAG := $(findstring html, $(MAKECMDGOALS)) MAKEFLAGS += --silent build: ifeq ($(DOCKER_FLAG),docker) docker-compose build else go build ./... endif up: ifeq ($(DOCKER_FLAG),docker) docker-compose up -d else reflex -r '\.go$$' -s -- sh -c "go run cmd/api/main.go" endif down: ifeq ($(DOCKER_FLAG),docker) docker-compose down else echo "Not applicable locally. Stop the application manually." endif test_app: ifeq ($(DOCKER_FLAG),docker) docker-compose --profile test run --rm -e "" ota-server-test go test -v -coverprofile=coverage.out ./... else $(MAKE_COVERAGE_CMD) endif test_app_watch: find . -name '*.go' | entr -n -c $(MAKE) test_app $(DOCKER_FLAG) $(HTML_FLAG) define MAKE_COVERAGE_CMD go test -v -coverprofile=coverage.out ./... && \ $(call CLEAN_COVERAGE) && \ $(call GENERATE_HTML) endef define CLEAN_COVERAGE if [ "$(shell uname -s)" = "Darwin" ]; then \ sed -i '' -e '/test/d' -e '/cmd/d' coverage.out; \ else \ sed -i '/test/d;/cmd/d;' coverage.out; \ fi endef define GENERATE_HTML if [ "$(HTML_FLAG)" = "html" ]; then \ go tool cover -html=coverage.out -o coverage.html && \ echo 'Coverage report generated: coverage.html'; \ fi endef .PHONY: docker html ================================================ FILE: README.md ================================================

Expo Open OTA Expo Open OTA - Dashboard

Self-hosted OTA updates for Expo — multi-cloud, production-ready.

An open-source Go server implementing the Expo Updates protocol.
Deploy on AWS, GCP, or locally.

Documentation · Issues · Contact

--- ## Why Expo Open OTA? - **Cut costs** — Expo's OTA pricing scales with MAUs. Self-hosting gives you unlimited updates at infrastructure cost only. - **Own your infrastructure** — Store updates on your cloud, behind your VPN, with your security policies. - **No vendor lock-in** — Works with AWS, GCP, and any S3-compatible provider. Switch anytime. ## Features | Feature | Description | |---------|-------------| | **Multi-cloud storage** | AWS S3, Google Cloud Storage, S3-compatible (Cloudflare R2, MinIO, DigitalOcean Spaces), local file system | | **Fast asset delivery** | CloudFront CDN, GCS signed URLs, or direct serving — your choice | | **One-command publishing** | `npx eoas publish` from your CI/CD pipeline | | **Secure key management** | AWS Secrets Manager, environment variables, or local key files | | **Dashboard** | Built-in web UI for monitoring updates, branches, and runtime versions | | **Prometheus metrics** | Production observability out of the box | | **No database required** | Zero external dependencies beyond your storage provider | | **Helm chart** | Ready for Kubernetes deployments | ## Quick Start [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/MGW3k1?referralCode=OEHlEK&utm_medium=integration&utm_source=template&utm_campaign=generic) And follow the [Quick Start guide](https://axelmarciano.github.io/expo-open-ota/docs/getting-started/quick-start) to get up and running in minutes. ## Storage Options | Provider | Mode | Asset Delivery | |----------|------|----------------| | **Amazon S3** | `STORAGE_MODE=s3` | Direct or CloudFront CDN | | **Google Cloud Storage** | `STORAGE_MODE=gcs` | GCS signed URLs | | **S3-compatible** (R2, MinIO, etc.) | `STORAGE_MODE=s3` + `AWS_BASE_ENDPOINT` | Direct | | **Local file system** | `STORAGE_MODE=local` | Direct (dev only) | ## Disclaimer Expo Open OTA is **not officially supported or affiliated with [Expo](https://expo.dev/)**. This is an independent open-source project. ## License MIT — see [LICENSE](./LICENSE.md). ## Contact [expoopenota@gmail.com](mailto:expoopenota@gmail.com) ================================================ FILE: apps/dashboard/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: apps/dashboard/.prettierrc ================================================ { "printWidth": 100, "tabWidth": 2, "singleQuote": true, "bracketSameLine": true, "trailingComma": "es5", "arrowParens": "avoid", "endOfLine": "auto" } ================================================ FILE: apps/dashboard/README.md ================================================ # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh ## Expanding the ESLint configuration If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - Configure the top-level `parserOptions` property like this: ```js export default tseslint.config({ languageOptions: { // other options... parserOptions: { project: ['./tsconfig.node.json', './tsconfig.app.json'], tsconfigRootDir: import.meta.dirname, }, }, }) ``` - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` - Optionally add `...tseslint.configs.stylisticTypeChecked` - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: ```js // eslint.config.js import react from 'eslint-plugin-react' export default tseslint.config({ // Set the react version settings: { react: { version: '18.3' } }, plugins: { // Add the react plugin react, }, rules: { // other rules... // Enable its recommended rules ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, }, }) ``` ================================================ FILE: apps/dashboard/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/dashboard/eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, ) ================================================ FILE: apps/dashboard/index.html ================================================ Expo Open OTA
================================================ FILE: apps/dashboard/package.json ================================================ { "name": "dashboard", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "VITE_OTA_API_URL=http://localhost:3000 vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "@hookform/resolvers": "^3.10.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-query": "^5.66.0", "@tanstack/react-table": "^8.20.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "lucide-react": "^0.475.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", "react-router": "^7.1.5", "tailwind-merge": "^3.0.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1" }, "devDependencies": { "@eslint/js": "^9.19.0", "@types/node": "^22.13.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", "globals": "^15.14.0", "postcss": "^8.5.1", "prettier": "^3.1.1", "tailwindcss": "^3.4.17", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", "vite": "^6.1.0" } } ================================================ FILE: apps/dashboard/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: apps/dashboard/public/env.js ================================================ // Will be overwritten by the server ================================================ FILE: apps/dashboard/src/App.tsx ================================================ import { Layout } from '@/containers/Layout'; import { Route, Routes, useNavigate } from 'react-router'; import { isAuthenticated } from '@/lib/auth.ts'; import { useEffect, ReactNode } from 'react'; import { Login } from '@/pages/Login'; import { Toaster } from '@/components/ui/toaster.tsx'; import { Updates } from '@/pages/Updates'; import { Settings } from '@/pages/Settings'; import { Logout } from '@/pages/Logout'; import { Channels } from '@/pages/Channels'; function withLayout(children: ReactNode) { return {children}; } export const App = () => { const isLoggedIn = isAuthenticated(); const navigate = useNavigate(); useEffect(() => { if (!isLoggedIn) { navigate('/login'); } }, [isLoggedIn, navigate]); return ( <> } /> )} /> )} /> )} /> )} /> ); }; ================================================ FILE: apps/dashboard/src/components/APIError/index.tsx ================================================ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx'; import { AlertCircle } from 'lucide-react'; export const ApiError = ({ error }: { error: Error }) => { return ( An error occurred while fetching data {error.message} ); }; ================================================ FILE: apps/dashboard/src/components/Combobox/index.tsx ================================================ 'use client'; import * as React from 'react'; import { Check, ChevronsUpDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; interface ComboboxProps { options: { value: string; label: string }[]; value: string; onChange: (value: string) => void; loading?: boolean; label?: string; } export function Combobox(props: ComboboxProps) { const [open, setOpen] = React.useState(false); const { options, value, onChange, loading, label } = props; return ( No option found. {options.map(opt => ( { onChange(currentValue === value ? '' : currentValue); setOpen(false); }}> {opt.label} ))} {loading && Loading...} ); } ================================================ FILE: apps/dashboard/src/components/DataTable/index.tsx ================================================ import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton.tsx'; import { useState } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; interface DataTableProps { columns: ColumnDef[]; data: TData[]; loading?: boolean; onRowClick?: (row: TData) => void; defaultSorting?: SortingState; } export function DataTable({ columns, data, loading, onRowClick = (_row) => {}, defaultSorting = [], }: DataTableProps) { const [sorting, setSorting] = useState(defaultSorting); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, state: { sorting, }, }); return (
{table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => { return (
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && ( <> {header.column.getIsSorted() === 'asc' ? ( ) : header.column.getIsSorted() === 'desc' ? ( ) : ( )} )}
); })}
))}
{loading && Array.from({ length: 5 }).map(() => ( {columns.map((_, i) => ( ))} ))} {table.getRowModel().rows?.length ? table.getRowModel().rows.map(row => ( onRowClick(row.original)} > {row.getVisibleCells().map(cell => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) : null} {!loading && !table.getRowModel().rows?.length && ( No results. )}
); } ================================================ FILE: apps/dashboard/src/components/UpdateDetailsSheet/index.tsx ================================================ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet.tsx'; import { Label } from '@/components/ui/label.tsx'; import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api.ts'; import { Skeleton } from '@/components/ui/skeleton.tsx'; import { ApiError } from '@/components/APIError'; import { Badge } from '@/components/ui/badge.tsx'; interface Update { updateUUID: string; createdAt: string; updateId: string; platform: string; commitHash: string; } export type UpdateDetailsRef = { openSheet: (update: Update) => void; closeSheet: () => void; }; const UpdateDetails = ({ update, branch, runtimeVersion, }: { update: Update | null; branch: string; runtimeVersion: string; }) => { const { data, isLoading, error } = useQuery({ queryKey: [`update-details-${update?.updateUUID}`], enabled: !!update?.updateId, queryFn: () => api.getUpdateDetails(branch, runtimeVersion, update?.updateId as string), }); const updateDetails = data; if (!update) { return ( Update details ); } if (isLoading) { return ( Update details {update.updateId} ); } if (error) { return ( Update details {update.updateId}
); } if (!updateDetails) { return ( Update details {update.updateId} ); } return ( Update details {updateDetails.updateId}
{updateDetails.updateId}
{branch}
{runtimeVersion}
{new Date(updateDetails.createdAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', })}
{updateDetails.updateUUID}
{updateDetails.commitHash}
{updateDetails.message && (
{updateDetails.message}
)}
{updateDetails.platform}
{updateDetails.type === 0 ? 'Normal update' : 'Rollback'}
); }; type Props = { branch: string; runtimeVersion: string; }; export const UpdateDetailsSheet = forwardRef( ( { branch, runtimeVersion, }: { branch: string; runtimeVersion: string; }, ref ) => { const [currentUpdate, setCurrentUpdate] = useState(null); useImperativeHandle(ref, () => ({ openSheet: update => { setCurrentUpdate(update); }, closeSheet: () => { setCurrentUpdate(null); }, })); return ( { if (!o) { setCurrentUpdate(null); } }}> ); } ); ================================================ FILE: apps/dashboard/src/components/app-sidebar.tsx ================================================ import { Link, useLocation } from 'react-router'; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; import { Box, HardDriveDownload, PowerOff, Settings } from 'lucide-react'; import clsx from 'clsx'; const items = [ { title: 'Updates', url: '/', icon: HardDriveDownload, }, { title: 'Channels', url: '/channels', icon: Box, }, { title: 'Settings', url: '/settings', icon: Settings, }, { title: 'Logout', url: '/logout', icon: PowerOff, }, ]; export function AppSidebar() { const location = useLocation(); const currentPath = location.pathname; return (

Expo Open OTA

{items.map(item => { const isActive = currentPath === item.url; return ( { if (isActive) { e.preventDefault(); } }} className={clsx( 'flex items-center gap-2 px-4 py-2 rounded-lg transition', isActive ? 'bg-gray-200 text-black' : 'text-gray-500 hover:bg-gray-100' )}> {item.title} ); })}
); } ================================================ FILE: apps/dashboard/src/components/ui/alert.tsx ================================================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", { variants: { variant: { default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps >(({ className, variant, ...props }, ref) => (
)) Alert.displayName = "Alert" const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) AlertTitle.displayName = "AlertTitle" const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)) AlertDescription.displayName = "AlertDescription" export { Alert, AlertTitle, AlertDescription } ================================================ FILE: apps/dashboard/src/components/ui/badge.tsx ================================================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, } ) export interface BadgeProps extends React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return (
) } export { Badge, badgeVariants } ================================================ FILE: apps/dashboard/src/components/ui/breadcrumb.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { ChevronRight, MoreHorizontal } from "lucide-react" import { cn } from "@/lib/utils" const Breadcrumb = React.forwardRef< HTMLElement, React.ComponentPropsWithoutRef<"nav"> & { separator?: React.ReactNode } >(({ ...props }, ref) =>