diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..505834e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Keep local data, keys and build output out of the image's build context. +**/node_modules +**/dist +**/data +**/.env* +!**/.env.example +**/*.tsbuildinfo +server/src/generated +.git +.github +.claude +docs +client/.agents diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5fcfdfb --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Copy this file to .env (in the repo root, next to this file) and fill in +# the values below. Never commit your actual .env -- it's gitignored on +# purpose. +# +# One file for both setups: +# - `pnpm run dev`: the server loads it with `bun --env-file=../.env` +# (server/package.json), and the Prisma CLI through prisma7.config.ts. +# - `docker compose up`: passed to the container by compose.yaml. Only +# OPENAI_API_KEY and MAX_UPLOAD_MB apply there; the paths and port below +# are fixed inside the container. + +# Required for `pnpm run dev`. Used server-side only (transcription via +# Whisper and the AI buttons) -- never exposed to the browser. Optional +# with Docker, where the app asks for it on first launch instead. +OPENAI_API_KEY=sk-... + +# Optional. Largest accepted video upload, in MB (default 10240 = 10GB). +# Uploads stream straight to disk, so this is about disk space, not memory. +# MAX_UPLOAD_MB=10240 + +# --- Development only (the defaults below are what's used if unset) --- + +# Resolved relative to server/, where the backend runs from. +# DATABASE_URL="file:./data/app.db" + +# Root directory for all local project data (uploads, transcripts, renders). +# DATA_DIR="./data" + +# Port the Hono/Bun server listens on. The Vite client's dev server proxies +# /api/* requests here (see client/vite.config.mts). +# PORT=3001 + +# Origin allowed to make cross-origin requests in dev (the Vite dev server). +# CORS_ORIGIN="http://localhost:5173" + +# Overrides the `ffmpeg` binary used for rendering. Only needed if your +# default `ffmpeg` lacks libass (subtitle burn-in support) -- on Homebrew, +# `brew install ffmpeg-full` gets you one that has it, and this points at +# it instead of the default `ffmpeg` on PATH. Without this, "Burn in +# captions" will fail with "No such filter: 'subtitles'" if your default +# ffmpeg build doesn't include libass. FFPROBE_PATH does the same for +# ffprobe. +# FFMPEG_PATH="/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06a390b..4f742c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,29 +7,26 @@ issues for bugs and ideas. ## Running it locally -On macOS, `./scripts/setup-mac.sh` handles all of this for you (Node/pnpm/ -Bun/FFmpeg via Homebrew, `pnpm install`, `prisma generate`, and `.env`) -- -see the README's "macOS quick start". Otherwise, manually: +Just want to use the app? `docker compose up --build` runs it without +any of the tools below; see the README. For working on the code: + ```bash -pnpm install # installs deps for client + server -cd server -cp .env.example .env # then fill in OPENAI_API_KEY -bunx prisma generate -cd .. -pnpm run dev # runs the Vite client and Bun backend together +pnpm install # installs deps for client + server +cp .env.example .env # then fill in OPENAI_API_KEY +pnpm --filter server db:generate # generates the Prisma client +pnpm run dev # runs the Vite client and Bun backend together ``` Open [http://localhost:5173](http://localhost:5173). See -[`server/.env.example`](./server/.env.example) for what each variable +[`.env.example`](./.env.example) for what each variable does. You'll also need [FFmpeg](https://ffmpeg.org/download.html) and [Bun](https://bun.sh) (the backend's runtime) on your machine. If burning in captions fails with `No such filter: 'subtitles'`, your FFmpeg build -doesn't include libass — see the `FFMPEG_PATH` note in `.env.example` (or -just use `scripts/setup-mac.sh`, which installs and points at a build -that has it). +doesn't include libass — see the `FFMPEG_PATH` note in `.env.example` +(on macOS, Homebrew's `ffmpeg-full` has it). ## Before submitting a PR diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbbe1f7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# Runs the app as one container: the Bun server serves both the built +# client and /api/* on port 3001, with all project data under /data. +# See compose.yaml for how to run it, and claude.md section 35. + +# --- build: install the workspace, generate Prisma, build the client ---- +FROM node:22-bookworm-slim AS build +RUN corepack enable && corepack prepare pnpm@10.29.2 --activate +WORKDIR /src + +# Manifests first, so the dependency layer is cached across source edits. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY client/package.json client/ +COPY server/package.json server/ +RUN pnpm install --frozen-lockfile + +COPY client client +COPY server server +RUN pnpm --filter server db:generate \ + && pnpm --filter client build \ + && pnpm --filter server deploy --prod --legacy /out/server + +# --- runtime ------------------------------------------------------------ +FROM oven/bun:1.4.2-debian + +# Fonts for burned-in captions and title cards: Liberation stands in for +# Arial/Helvetica/Courier New (metric-compatible, aliased by fontconfig), +# DejaVu is the fallback for the rest of CAPTION_FONTS. +RUN apt-get update \ + && apt-get install -y --no-install-recommends fontconfig fonts-liberation2 fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Static ffmpeg/ffprobe with libass, pinned to the version the xfade +# workarounds in lib/ffmpeg/plan.ts were measured on. +COPY --from=mwader/static-ffmpeg:9.0.2 /ffmpeg /ffprobe /usr/local/bin/ + +WORKDIR /app +COPY --from=build /out/server ./ +# Gitignored, so `pnpm deploy` leaves it out. +COPY --from=build /src/server/src/generated ./src/generated +COPY --from=build /src/client/dist ./client-dist + +ENV NODE_ENV=production \ + PORT=3001 \ + DATA_DIR=/data \ + DATABASE_URL=file:/data/app.db \ + CLIENT_DIST_DIR=/app/client-dist + +RUN mkdir -p /data && chown bun:bun /data +USER bun +VOLUME /data +EXPOSE 3001 + +CMD ["bun", "src/index.ts"] diff --git a/README.md b/README.md index 910ebc8..47b0a85 100644 --- a/README.md +++ b/README.md @@ -124,40 +124,50 @@ pnpm run dev ## Running the app -Requires [FFmpeg](https://ffmpeg.org/download.html) (with `ffprobe`) and -[Bun](https://bun.sh) (the backend's runtime) on your machine, plus an -OpenAI API key (used server-side only, for transcription and the AI -buttons). +The easiest way to run it is with [Docker](https://docs.docker.com/get-docker/). +The image includes Bun, FFmpeg (with subtitle burn-in support) and the +fonts captions need. You only bring an OpenAI API key, used server-side +for transcription and the AI buttons. -### macOS quick start - -[`scripts/setup-mac.sh`](./scripts/setup-mac.sh) installs Node/pnpm/Bun/ -FFmpeg (with subtitle burn-in support) via Homebrew if you don't already -have them, installs dependencies for the whole workspace, and walks you -through `.env`: +### With Docker ```bash git clone https://github.com/amide-init/transcriptcut.git cd transcriptcut -./scripts/setup-mac.sh +docker compose up --build ``` -Then `pnpm run dev` from the repo root. Safe to re-run -- it only -installs what's missing and never overwrites an existing `.env`. +Open [http://localhost:3001](http://localhost:3001). On first launch the +app asks for your OpenAI API key. To skip that, run `cp .env.example .env` +and set `OPENAI_API_KEY` before starting. It's the same root `.env` the +development setup uses. + +Projects, uploads and renders live in the `transcriptcut-data` Docker +volume, so they survive restarts and rebuilds. To update, run +`git pull && docker compose up --build`. + +The app is published on `127.0.0.1` only, since it has no login (see +`claude.md` section 18). Caption fonts use free Liberation and DejaVu equivalents of Arial, +Helvetica, Verdana and the rest, so burned-in captions can look slightly +different from the same export on a Mac. -### Manual setup (any platform) +### Development setup + +For working on the code, with hot reload. Requires +[FFmpeg](https://ffmpeg.org/download.html) (with `ffprobe`) and +[Bun](https://bun.sh) on your machine: ```bash -pnpm install # installs deps for client + server -cd server -cp .env.example .env # then fill in OPENAI_API_KEY -bunx prisma generate -cd .. -pnpm run dev # runs the Vite client and Bun backend together +git clone https://github.com/amide-init/transcriptcut.git +cd transcriptcut +pnpm install # installs deps for client + server +cp .env.example .env # then fill in OPENAI_API_KEY +pnpm --filter server db:generate # generates the Prisma client +pnpm run dev # runs the Vite client and Bun backend together ``` Open [http://localhost:5173](http://localhost:5173). See -[`server/.env.example`](./server/.env.example) for what each variable +[`.env.example`](./.env.example) for what each variable does, and [`CONTRIBUTING.md`](./CONTRIBUTING.md) for more. One thing to know during development: diff --git a/claude.md b/claude.md index 80b353c..dae5e75 100644 --- a/claude.md +++ b/claude.md @@ -1476,8 +1476,8 @@ with the same care as the code itself. # 35. Web Only (no native app) -The app ships as the web setup only: `pnpm run dev` locally, or the Bun -server serving the built client in production. A Tauri-wrapped macOS +The app ships as the web setup only: `docker compose up` to run it, or +`pnpm run dev` to develop it. A Tauri-wrapped macOS `.app` (and its CI release workflow) existed earlier and was deliberately removed to keep the project to one supported setup; don't re-add native packaging speculatively. @@ -1491,4 +1491,30 @@ any deployment: * The OpenAI API key can be entered on first run through `client/src/routes/SetupRoute.tsx`, stored in `DATA_DIR/settings.json` (`server/src/lib/settings.ts`), which is checked before falling back to - `server/.env`'s `OPENAI_API_KEY`. + the root `.env`'s `OPENAI_API_KEY`. + +## Docker + +The run path is one container (`Dockerfile`, `compose.yaml` at the repo +root): a Node build stage installs the workspace, runs `prisma generate`, +builds the client and `pnpm deploy`s a production-only server; the +runtime is `oven/bun` running `src/index.ts`, serving the client from +`CLIENT_DIST_DIR` and keeping everything under `DATA_DIR=/data` (a named +volume). A fresh volume works because startup migrations build the +schema. Choices worth knowing before changing them: + +* FFmpeg is the pinned static build `mwader/static-ffmpeg:9.0.2`, not + Debian's (5.1): it has libass, and 9.0.2 is the version the xfade + workarounds in section 12 were measured on. +* Caption and card fonts are Liberation + DejaVu, not Microsoft's core + fonts (a build-time SourceForge download with licensing caveats), so + burned-in text can differ slightly from a Mac. +* compose publishes on `127.0.0.1` only, since there's no auth + (section 18). +* There's one `.env`, at the repo root (`.env.example` documents it), + shared by both setups: the dev server loads it with + `bun --env-file=../.env` (Bun's own `.env` loading would look in + `server/`), the Prisma CLI through `prisma7.config.ts`, and compose + through `env_file`. compose pins the paths, port and FFmpeg binaries + over it, since dev values (relative paths, a Homebrew `FFMPEG_PATH`) + don't exist in the container. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..bf97f4e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +# docker compose up --build, then open http://localhost:3001 +name: transcriptcut + +services: + app: + build: . + # Bound to this machine only: there's no login (claude.md section 18), + # so don't publish it to a shared network without adding auth first. + ports: + - "127.0.0.1:3001:3001" + volumes: + - transcriptcut-data:/data + # Optional: OPENAI_API_KEY (and MAX_UPLOAD_MB) from the same root .env + # the dev server uses. Without one, the app asks for the key on first + # launch. + env_file: + - path: .env + required: false + # Pinned over anything in .env, whose dev values (relative paths, a + # Homebrew FFMPEG_PATH) don't exist inside the container. + environment: + PORT: "3001" + DATA_DIR: /data + DATABASE_URL: file:/data/app.db + CLIENT_DIST_DIR: /app/client-dist + FFMPEG_PATH: /usr/local/bin/ffmpeg + FFPROBE_PATH: /usr/local/bin/ffprobe + restart: unless-stopped + +volumes: + transcriptcut-data: + # Fixed name, so it doesn't depend on the folder the repo is cloned into. + name: transcriptcut-data diff --git a/docs/community.md b/docs/community.md index 19c7b16..390e6b5 100644 --- a/docs/community.md +++ b/docs/community.md @@ -26,8 +26,8 @@ OS/Node/FFmpeg versions if it's a rendering or transcription issue. and [`claude.md`](https://github.com/amide-init/transcriptcut/blob/main/claude.md) (the full product spec — read it before proposing anything that changes how the app is structured). -2. Set up locally with `./scripts/setup-mac.sh` (macOS) or the manual - steps in [Getting Started](/guide/getting-started). +2. Set up locally with the steps in + [Getting Started](/guide/getting-started). 3. Before opening a PR, all of these must pass (CI runs the same three commands on every PR): diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index fe98aea..fcfefe1 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,54 +1,62 @@ # Getting Started -transcriptcut is local-first: everything runs on your own machine with -`pnpm install && pnpm run dev`. The only external dependency is an -OpenAI API key, used for transcription and the AI buttons (filler words, -speakers, chapters, show notes, highlights), never for storage. +transcriptcut is local-first: everything runs on your own machine. The +only external dependency is an OpenAI API key, used for transcription and +the AI buttons (filler words, speakers, chapters, show notes, +highlights), never for storage. -## Requirements +## Run with Docker -- [Node.js](https://nodejs.org/) 20+ -- [pnpm](https://pnpm.io/) 10+ -- [Bun](https://bun.sh) (the backend's runtime) -- [FFmpeg](https://ffmpeg.org/download.html) and `ffprobe` on your - `PATH` (used for audio extraction, cuts, audio cleanup and export) -- An OpenAI API key (used server-side only) - -## macOS quick start - -[`scripts/setup-mac.sh`](https://github.com/amide-init/transcriptcut/blob/main/scripts/setup-mac.sh) -installs Node/pnpm/Bun/FFmpeg (with subtitle burn-in support) via -Homebrew if you don't already have them, installs dependencies for the -whole workspace, and walks you through `.env`: +The easiest way to run it. All you need is +[Docker](https://docs.docker.com/get-docker/); the image includes Bun, +FFmpeg (with subtitle burn-in support) and the fonts captions need. ```bash git clone https://github.com/amide-init/transcriptcut.git cd transcriptcut -./scripts/setup-mac.sh +docker compose up --build ``` -Then, from the repo root: +Open `http://localhost:3001`. On first launch the app asks +for your OpenAI API key. To skip that, run `cp .env.example .env` and set +`OPENAI_API_KEY` before starting. It's the same root `.env` the +development setup uses. -```bash -pnpm run dev -``` +Projects, uploads and renders live in the `transcriptcut-data` Docker +volume, so they survive restarts and rebuilds. To update, run +`git pull && docker compose up --build`. + +The app is published on `127.0.0.1` only, since it has no login (see +[Security & Self-Hosting](/guide/security)). Caption fonts use free Liberation and DejaVu equivalents of Arial, +Helvetica, Verdana and the rest, so burned-in captions can look slightly +different from the same export on a Mac. -Safe to re-run — it only installs what's missing and never overwrites an -existing `.env`. +## Development setup -## Manual setup (any platform) +For working on the code, with hot reload. + +### Requirements + +- [Node.js](https://nodejs.org/) 20+ +- [pnpm](https://pnpm.io/) 10+ +- [Bun](https://bun.sh) (the backend's runtime) +- [FFmpeg](https://ffmpeg.org/download.html) and `ffprobe` on your + `PATH` (used for audio extraction, cuts, audio cleanup and export) +- An OpenAI API key (used server-side only) + +### Setup ```bash -pnpm install # installs deps for client + server -cd server -cp .env.example .env # then fill in OPENAI_API_KEY -bunx prisma generate -cd .. -pnpm run dev # runs the Vite client and Bun backend together +git clone https://github.com/amide-init/transcriptcut.git +cd transcriptcut +pnpm install # installs deps for client + server +cp .env.example .env # then fill in OPENAI_API_KEY +pnpm --filter server db:generate # generates the Prisma client +pnpm run dev # runs the Vite client and Bun backend together ``` Open `http://localhost:5173`. See -[`server/.env.example`](https://github.com/amide-init/transcriptcut/blob/main/server/.env.example) +[`.env.example`](https://github.com/amide-init/transcriptcut/blob/main/.env.example) for what each variable does. `MAX_UPLOAD_MB` raises or lowers the upload limit, which defaults to 10 GB. diff --git a/docs/guide/podcasting.md b/docs/guide/podcasting.md index 02035ea..b6eaec0 100644 --- a/docs/guide/podcasting.md +++ b/docs/guide/podcasting.md @@ -14,7 +14,7 @@ own, and AI output never edits your project without you reviewing it Upload the recording from **New project**. Long episodes are fine: - **Uploads up to 10 GB** by default (set `MAX_UPLOAD_MB` in - `server/.env` to change it). The file streams straight to disk, so size + `.env` to change it). The file streams straight to disk, so size is limited by free disk space, not memory. The upload button shows progress. - **Transcription runs in the background, in chunks.** The server diff --git a/docs/guide/security.md b/docs/guide/security.md index b49a60c..2809305 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -13,6 +13,11 @@ create projects, upload video, trigger renders, read/delete anything. network without adding real authentication first.** Running it on `localhost` on your own machine is the intended v1 use case. +The Docker setup follows this: `compose.yaml` publishes the app on +`127.0.0.1:3001`, so other machines on your network can't reach it. +Changing that to `3001:3001` (or `0.0.0.0`) exposes it to everyone on +the network, so only do that behind real authentication. + ## What the app validates, and why it matters more locally Even without accounts, this is an app that runs FFmpeg and touches the diff --git a/package.json b/package.json index 8567a06..b7fb75b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "pnpm": { "onlyBuiltDependencies": [ "@prisma/engines", - "better-sqlite3", "prisma", "protobufjs", "unrs-resolver" diff --git a/scripts/setup-mac.sh b/scripts/setup-mac.sh deleted file mode 100755 index 8a7b66a..0000000 --- a/scripts/setup-mac.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# -# One-shot setup for running this project on macOS: installs Node/pnpm/ -# Bun/ffmpeg-full via Homebrew (Bun via its own official installer, see -# below) if missing, installs JS deps for the whole workspace, generates -# the server's Prisma client, and creates server/.env (with FFMPEG_PATH -# pre-filled and an interactive OPENAI_API_KEY prompt) if one doesn't -# already exist. -# -# Safe to re-run -- every step checks before it acts, and an existing -# server/.env is never overwritten. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -SERVER_DIR="$REPO_ROOT/server" - -step() { printf '\n==> %s\n' "$1"; } - -# Replaces the (whole-line) $2 with $3 in file $1, as an exact literal -# line match via awk -- not sed (would need regex escaping for the `/` and -# `"` in a path/key) and not bash's ${var/pattern/replacement} (tried -# first, but macOS ships bash 3.2 -- Apple has never upgraded it, over -# GPLv3 licensing -- and its parameter-substitution pattern matching has -# real quoting/anchoring quirks: quoting $pattern/$replacement inserted -# literal quote characters into the result, and unquoting that broke -# matching for any pattern starting with '#', both verified reproducing -# on this exact bash 3.2 and not on bash 4+). awk's -v passes strings -# through literally, with no glob/regex reinterpretation either way. -set_env_var() { - local file="$1" pattern="$2" replacement="$3" - awk -v pat="$pattern" -v rep="$replacement" ' - $0 == pat { print rep; next } - { print } - ' "$file" > "$file.tmp" && mv "$file.tmp" "$file" -} - -step "Checking you're on macOS" -if [[ "$(uname)" != "Darwin" ]]; then - echo "This script is macOS-only. See CONTRIBUTING.md for the manual setup steps on other platforms." - exit 1 -fi - -step "Checking for Homebrew" -if ! command -v brew &>/dev/null; then - cat <<'EOF' -Homebrew isn't installed. Install it first (see https://brew.sh), then re-run this script: - - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -EOF - exit 1 -fi -echo "Found Homebrew." - -step "Checking for Node 20+" -if command -v node &>/dev/null; then - node_major="$(node -v | sed 's/^v//' | cut -d. -f1)" - if [ "$node_major" -lt 20 ]; then - echo "Found Node $(node -v), but 20+ is required. Upgrade it (e.g. 'brew upgrade node') and re-run this script." - exit 1 - fi - echo "Found Node $(node -v)." -else - echo "Installing Node via Homebrew..." - brew install node -fi - -step "Checking for pnpm" -if command -v pnpm &>/dev/null; then - echo "Found pnpm $(pnpm -v)." -else - echo "Installing pnpm via Homebrew..." - brew install pnpm -fi - -step "Checking for Bun" -if command -v bun &>/dev/null; then - echo "Found Bun $(bun -v)." -else - # Bun's own installer, not Homebrew: the Homebrew formula requires - # compiling a native dependency (better-sqlite3, transitively) against - # Xcode Command Line Tools, and fails on a CLT version that's gone - # stale relative to the current macOS/Xcode release -- a common state - # on a machine that hasn't run a manual CLT update in a while. Bun's - # installer just downloads a prebuilt binary, sidestepping that - # entirely (see https://bun.sh). - echo "Installing Bun via its official installer..." - curl -fsSL https://bun.sh/install | bash - export PATH="$HOME/.bun/bin:$PATH" - echo "Installed Bun $(bun -v). Open a new terminal (or re-source your shell rc) to get 'bun' on PATH permanently." -fi - -step "Checking ffmpeg has subtitle burn-in (libass) support" -ffmpeg_path_override="" -if command -v ffmpeg &>/dev/null && ffmpeg -filters 2>/dev/null | grep -qw subtitles; then - echo "Default ffmpeg already supports subtitles -- nothing to do." -else - echo "Default ffmpeg is missing or lacks libass support (needed for 'Burn in captions')." - echo "Installing ffmpeg-full via Homebrew..." - brew install ffmpeg-full - # ffmpeg-full is keg-only (its ffmpeg/ffprobe binaries conflict with the - # plain ffmpeg formula's), so it's never on PATH -- the app needs the - # explicit path via FFMPEG_PATH, filled into .env below. brew --prefix - # resolves correctly on both Apple Silicon (/opt/homebrew) and Intel - # (/usr/local), unlike a hardcoded path. - ffmpeg_path_override="$(brew --prefix ffmpeg-full)/bin/ffmpeg" -fi - -step "Installing JS dependencies (client + server workspace)" -(cd "$REPO_ROOT" && pnpm install) - -step "Generating the Prisma client" -(cd "$SERVER_DIR" && bunx prisma generate) - -step "Setting up server/.env" -env_file="$SERVER_DIR/.env" -if [ -f "$env_file" ]; then - echo "server/.env already exists -- leaving it as-is." -else - cp "$SERVER_DIR/.env.example" "$env_file" - echo "Created server/.env from .env.example." - - if [ -n "$ffmpeg_path_override" ]; then - set_env_var "$env_file" \ - '# FFMPEG_PATH="/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg"' \ - "FFMPEG_PATH=\"$ffmpeg_path_override\"" - echo "Set FFMPEG_PATH to the Homebrew ffmpeg-full binary (fixes caption burn-in)." - fi - - openai_key_input="" - read -rsp "Paste your OpenAI API key (used server-side only; Enter to skip and edit server/.env by hand later): " openai_key_input || true - echo - if [ -n "$openai_key_input" ]; then - set_env_var "$env_file" "OPENAI_API_KEY=sk-..." "OPENAI_API_KEY=$openai_key_input" - echo "Set OPENAI_API_KEY in server/.env." - else - echo "Skipped -- transcription and AI features won't work until you set OPENAI_API_KEY in server/.env." - fi -fi - -step "Done" -cat <