diff --git a/.dockerignore b/.dockerignore index 3bc6603..e4788ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,6 @@ CONTRIBUTING.md *~ .DS_Store .devcontainer +docker-compose.yml +.env +.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a0d3d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and adjust — consumed by docker-compose.yml. +# cp .env.example .env + +# Image tag to run. Pin a release (e.g. v1.0.0) for reproducibility, or a +# pre-release for testing (e.g. v1.0.0-rc1). `latest` tracks newest stable. +SQUAREBOX_TAG=latest + +# Host user/group that should own files squarebox writes to the workspace mount. +# Unraid / most NAS shares: 99 / 100. A typical single-user Linux host: 1000 / 1000. +PUID=1000 +PGID=1000 + +# Host path mounted as /workspace (your code). Relative paths are resolved next +# to docker-compose.yml. On Unraid, point this at an array/share path, e.g. +# /mnt/user/appdata/squarebox/workspace or /mnt/user/code. +SQUAREBOX_WORKSPACE=./workspace diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d822e5..5e7848a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,3 +86,20 @@ jobs: run: | docker rm -f squarebox-test 2>/dev/null || true docker rm -f sqrbx-persist 2>/dev/null || true + + # arm64 build smoke — the published image is multi-arch, so catch arm64-only + # Dockerfile / tool-asset breakage at PR time (via QEMU) rather than only on + # the release tag. Build-only; behavioural arm64 tests run in e2e on tags. + build-arm64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/arm64 + push: false + cache-from: type=gha,scope=arm64 + cache-to: type=gha,mode=max,scope=arm64 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 388c0a1..a59674c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -365,3 +365,92 @@ jobs: name: e2e-report path: e2e-report.md retention-days: 90 + + # ── Publish (gated) ────────────────────────────────────────────────────── + # Pushes the multi-arch image to GHCR and cuts the GitHub release ONLY after + # every test job above has passed, and ONLY for version tags. `needs` without + # `if: always()` means a single failed test job skips this job — so a tag can + # never publish a broken image or advertise a release for one. Runs of the + # E2E suite via workflow_dispatch (no tag) skip it via the ref guard. + publish: + needs: + - build-amd64 + - build-arm64 + - host-install + - tools-verification + - shell-environment + - setup-noninteractive + - setup-editors-sdks + - container-lifecycle + - sqrbx-update + - devcontainer + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + permissions: + contents: write # create the GitHub release + packages: write # push to GHCR + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Resolve image ref and tags + id: meta + run: | + set -euo pipefail + # GHCR requires a lowercase path; the org is mixed-case. + image="ghcr.io/$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" + ref="${GITHUB_REF_NAME}" # e.g. v1.0.0-rc1 + ver="${ref#v}" # e.g. 1.0.0-rc1 + tags="${image}:${ref},${image}:${ver}" + # Prerelease tags (-rc, -beta, …) must not move the :latest pointer — + # `latest` always tracks the newest stable release. + case "$ref" in + *-*) prerelease=true ;; + *) prerelease=false; tags="${tags},${image}:latest" ;; + esac + { + echo "tags=${tags}" + echo "prerelease=${prerelease}" + } >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + # Reuse the layer caches the build-amd64 / build-arm64 jobs populated. + cache-from: | + type=gha + type=gha,scope=arm64 + cache-to: type=gha,mode=max + + - name: Create or update the GitHub release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ steps.meta.outputs.prerelease }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + prerelease_flag="" + [ "$PRERELEASE" = "true" ] && prerelease_flag="--prerelease" + # Idempotent: re-running the tag refreshes assets but preserves any + # human-edited title/notes from the first run. + if gh release view "$tag" >/dev/null 2>&1; then + gh release upload "$tag" install.sh install.ps1 uninstall.sh --clobber + else + gh release create "$tag" \ + --title "$tag" \ + --generate-notes \ + $prerelease_flag \ + install.sh install.ps1 uninstall.sh + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c79c6c4..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Create release with install scripts as assets - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="${GITHUB_REF_NAME}" - - # Pre-release tags carry an `-rc`, `-beta`, etc. suffix. - prerelease_flag="" - case "$tag" in - *-*) prerelease_flag="--prerelease" ;; - esac - - # Idempotent: if the workflow re-runs on the same tag, replace assets. - # Re-runs only refresh assets — title and release notes are preserved - # from the first run so any human edits aren't clobbered. If you need - # to regenerate notes from scratch, delete the release first and - # re-push the tag. - if gh release view "$tag" >/dev/null 2>&1; then - gh release upload "$tag" install.sh install.ps1 --clobber - else - gh release create "$tag" \ - --title "$tag" \ - --generate-notes \ - $prerelease_flag \ - install.sh install.ps1 - fi diff --git a/.gitignore b/.gitignore index d558d85..1fe3d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # Environment and secrets .env .env.* +!.env.example credentials.json # Docker build artifacts diff --git a/Dockerfile b/Dockerfile index 469ecfd..38bc37a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -132,21 +132,27 @@ COPY setup.sh /usr/local/lib/squarebox/setup.sh COPY scripts/squarebox-update.sh /usr/local/bin/sqrbx-update COPY scripts/squarebox-setup.sh /usr/local/bin/sqrbx-setup COPY scripts/sqrbx-learn /usr/local/bin/sqrbx-learn +COPY scripts/squarebox-entrypoint.sh /usr/local/bin/squarebox-entrypoint COPY scripts/lib/tools.yaml /usr/local/lib/squarebox/tools.yaml COPY scripts/lib/tool-lib.sh /usr/local/lib/squarebox/tool-lib.sh RUN chmod +x /usr/local/lib/squarebox/setup.sh \ /usr/local/lib/squarebox/motd.sh \ /usr/local/bin/sqrbx-update \ /usr/local/bin/sqrbx-setup \ - /usr/local/bin/sqrbx-learn + /usr/local/bin/sqrbx-learn \ + /usr/local/bin/squarebox-entrypoint RUN chown -R dev:dev /home/dev/.config /home/dev/.claude \ && mkdir -p /workspace && chown dev:dev /workspace -USER dev - +# The container starts as root so the entrypoint can honour PUID/PGID, then +# drops to `dev` via setpriv. With the default 1000:1000 this is a no-op and +# the running process is `dev` — identical to a plain `USER dev` image. PUID/ +# PGID are declared here so docker-compose / Unraid template UIs surface them. ENV HOME=/home/dev ENV SQUAREBOX=1 +ENV PUID=1000 +ENV PGID=1000 # 7. Shell Config # The .bashrc lives in dotfiles/ on the host so install.sh can bind-mount it @@ -157,4 +163,5 @@ ENV SQUAREBOX=1 COPY --chown=dev:dev dotfiles/bashrc /home/dev/.bashrc WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/squarebox-entrypoint"] CMD ["/bin/bash"] diff --git a/README.md b/README.md index 2d5f217..29645dd 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,13 @@ running before you continue. Install ------- -These commands clone the repo, build the container image, and drop you into the -container (if possible). On first login, a setup script runs automatically to -configure git (pulling your name and email from the host's global git config -if available), optionally sign in to GitHub CLI, your choice of AI coding -assistant, and language SDKs. +These commands install squarebox and drop you into the container (if possible). +By default they **pull a prebuilt image** from GHCR — no local Docker build, no +build toolchain — then clone the repo into `~/squarebox` for the config files +and the `sqrbx` helper commands. On first login, a setup script runs +automatically to configure git (pulling your name and email from the host's +global git config if available), optionally sign in to GitHub CLI, your choice +of AI coding assistant, and language SDKs. **Stable** @@ -70,9 +72,51 @@ assistant, and language SDKs. curl -fsSL https://github.com/SquareWaveSystems/squarebox/releases/latest/download/install.sh | bash -s -- --edge -Stable installs the latest tagged release (pre-release tags like `-rc` are skipped). Edge uses the latest commit on main. The install script itself is published as a release asset, so the URL is pinned to a tagged version of the script — pushes to `main` won't break new installs until a release is cut. +Stable pulls the prebuilt image for the latest tagged release (pre-release tags +like `-rc` are skipped). Edge builds from the latest commit on `main` — no image +is published for unreleased commits, so edge always builds from source. To build +the released version from source instead of pulling, pass `--build`. The install +script itself is published as a release asset, so the URL is pinned to a tagged +version of the script — pushes to `main` won't break new installs until a +release is cut. -If the install fails or you want to see the full docker build and git output, re-run with `--verbose`. +If the install fails or you want to see the full build/pull and git output, +re-run with `--verbose`. + +
+Advanced install options (flags & environment variables) + +Flags: `--build` (build from source instead of pulling), `--edge` (latest +`main`), `--verbose`. + +| Variable | Default | Purpose | +|----------|---------|---------| +| `SQUAREBOX_DIR` | `~/squarebox` | Install location (repo + workspace). Point at durable storage on hosts where `$HOME` is volatile — e.g. Unraid `/mnt/user/appdata/squarebox`. | +| `SQUAREBOX_WORKSPACE` | `$SQUAREBOX_DIR/workspace` | Host path mounted as `/workspace`. | +| `SQUAREBOX_TAG` | matched release / `latest` | Image tag to pull (e.g. `v1.0.0-rc1` to test a pre-release). | +| `SQUAREBOX_IMAGE` | `ghcr.io/squarewavesystems/squarebox` | Image repository to pull from. | +| `SQUAREBOX_BUILD` | `0` | `1` is equivalent to `--build`. | +| `PUID` / `PGID` | `1000` / `1000` | Host uid/gid that should own bind-mounted files. Unraid/NAS: `99` / `100`. | +| `SQUAREBOX_RUNTIME` | auto | Force `docker` or `podman`. | +| `SQUAREBOX_HOME_VOLUME` | `squarebox-home` | Name of the named volume backing `/home/dev`. | +| `SQUAREBOX_EDGE` | `0` | `1` is equivalent to `--edge`. | + +**Non-interactive provisioning** — set any of these to a comma-separated list to +pre-select a toolset and install it without prompts (handy for servers and +scripted installs). Values use the same keys as `sqrbx-setup`: + +| Variable | Selects | +|----------|---------| +| `SQUAREBOX_AI` | AI assistants (`claude,copilot,gemini,codex,opencode,pi`) | +| `SQUAREBOX_SDKS` | language SDKs (`node,python,go,dotnet,rust`) | +| `SQUAREBOX_EDITORS` | editors (`micro,edit,fresh,nvim`) | +| `SQUAREBOX_TUIS` | TUI tools (`lazygit,gh-dash,yazi`) | +| `SQUAREBOX_MULTIPLEXERS` | multiplexers (`tmux,zellij`) | +| `SQUAREBOX_GIT_NAME` / `SQUAREBOX_GIT_EMAIL` | git identity (when no host gitconfig) | + +Example: `SQUAREBOX_AI=claude SQUAREBOX_SDKS=node,python curl -fsSL …/install.sh | bash` + +
**Windows (PowerShell 7+)** @@ -109,6 +153,35 @@ lives in a named Docker volume (`squarebox-home`) that survives container recreation. Image-managed config like `.bashrc` is bind-mounted from the repo so updates flow through to the running container. +Run as a long-lived server (Unraid / NAS / VPS) +----------------------------------------------- + +The `curl | bash` installer is built around an interactive desktop shell. To run +squarebox as a persistent server container you attach into on demand — on +Unraid, a NAS, or a VPS — use the prebuilt image directly with the bundled +`docker-compose.yml`: + + cp .env.example .env # set PUID/PGID, the workspace path, and a tag + docker compose up -d + docker compose exec -u dev squarebox bash + +Set `PUID`/`PGID` in `.env` to match your host so files squarebox writes to the +workspace mount are owned correctly — on Unraid that's `99` / `100`. The `-u dev` +on `exec` is needed because the container starts as root (to apply PUID/PGID) +then drops to the `dev` user; `exec` bypasses that, so `-u dev` lands you where +you want to be. + +Per-user state (shell history, gh auth, mise toolchains, AI-assistant state) +lives in the `squarebox-home` named volume and survives image updates; your code +lives on the host at the workspace path. To update, pull a newer tag and +`docker compose up -d`. The published image is multi-arch (amd64 + arm64), so it +also runs on ARM NAS/VPS hosts. + +> **Unraid note:** the host's `/root` is tmpfs and wiped on reboot, so a raw +> `curl | bash` install there won't persist. Either use compose (above) with the +> workspace path under `/mnt/user/appdata`, or run the installer with +> `SQUAREBOX_DIR` and `SQUAREBOX_WORKSPACE` pointed at appdata. + What's included --------------- @@ -139,8 +212,10 @@ What's optional ---------------- Selected during first-run setup. Choose any combination, all, or none. -Selections are saved to the workspace volume and reused automatically on -container rebuilds. +Selections are saved under `/workspace/.squarebox` (on the host workspace bind +mount) and reused automatically on container rebuilds. They can also be +pre-selected non-interactively via the `SQUAREBOX_AI`/`SQUAREBOX_SDKS`/… env vars +(see *Advanced install options* above). ### AI Coding Assistants @@ -259,6 +334,15 @@ to scale both the examples and the agent's coaching; you can change it later from the menu. Lessons render through `glow` when available so the markdown bodies display properly. +Reconfiguring +------------- + +Re-run the first-run wizard at any time from inside the container with +`sqrbx-setup`. With no arguments it walks every section; pass one or more +section names to reconfigure just those: `git`, `github`, `ai`, `editors`, +`tuis`, `multiplexers`, `sdks`, `shell`, `learn`. `sqrbx-setup --list` shows your +current selections and `sqrbx-setup --help` the usage. + Aliases ------- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..060e194 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +# docker-compose.yml — run squarebox as a long-lived container on a server / NAS. +# +# Quick start: +# cp .env.example .env # then edit PUID/PGID/paths/tag as needed +# docker compose up -d +# docker compose exec -u dev squarebox bash # drop into your dev shell +# +# Why `-u dev`: the container starts as root so its entrypoint can apply +# PUID/PGID, then drops to the `dev` user. `docker exec` bypasses the entrypoint +# and would otherwise land you as root — `-u dev` puts you where you want to be. +# +# Unraid / NAS: set PUID/PGID in .env to match your shares (Unraid: 99 / 100) so +# files squarebox writes to ./workspace are owned correctly on the host. + +services: + squarebox: + image: ghcr.io/squarewavesystems/squarebox:${SQUAREBOX_TAG:-latest} + # Bleeding edge / local source instead of the published image: comment the + # `image:` line above and uncomment the build block below. + # build: + # context: . + container_name: squarebox + restart: unless-stopped + + # squarebox's default command is an interactive shell. Allocate a TTY and + # keep stdin open so that shell stays alive as PID 1 (otherwise it exits + # immediately and the container stops); you then attach with `exec`. + stdin_open: true + tty: true + + environment: + # Host uid/gid to own bind-mounted files. Unraid: 99 / 100. + PUID: ${PUID:-1000} + PGID: ${PGID:-1000} + + volumes: + # Persistent per-user state: shell history, gh auth, mise toolchains, + # AI-assistant state. Survives image updates and container recreation. + - squarebox-home:/home/dev + # Your code. Defaults to ./workspace beside this file; override in .env. + - ${SQUAREBOX_WORKSPACE:-./workspace}:/workspace + - /etc/localtime:/etc/localtime:ro + + # Mirror install.sh's hardened capability set: drop everything, then re-add + # only what scoped sudo and the PUID/PGID entrypoint remap actually need + # (CHOWN/FOWNER/DAC_OVERRIDE to chown + edit /etc/passwd; SETUID/SETGID for + # setpriv to drop privileges to dev). + cap_drop: + - ALL + cap_add: + - CHOWN + - DAC_OVERRIDE + - FOWNER + - SETUID + - SETGID + - KILL + +volumes: + squarebox-home: diff --git a/install.sh b/install.sh index cc3f680..4daeb94 100755 --- a/install.sh +++ b/install.sh @@ -49,15 +49,34 @@ if [ -n "${USERPROFILE:-}" ]; then else USER_HOME="${HOME}" fi -INSTALL_DIR="${USER_HOME}/squarebox" +# Install dir + workspace are overridable so the install can live somewhere +# durable. On Unraid `$HOME` (=/root) is tmpfs-backed and wiped on reboot — point +# SQUAREBOX_DIR at /mnt/user/appdata/squarebox instead. SQUAREBOX_WORKSPACE +# relocates just the code mount (defaults to a subdir of the install dir). +INSTALL_DIR="${SQUAREBOX_DIR:-${USER_HOME}/squarebox}" +WORKSPACE_DIR="${SQUAREBOX_WORKSPACE:-${INSTALL_DIR}/workspace}" IMAGE_NAME="squarebox" CONTAINER_NAME="squarebox" +# Prebuilt multi-arch image published by the release pipeline (see +# .github/workflows/e2e.yml → publish). SQUAREBOX_IMAGE/SQUAREBOX_TAG override. +SQUAREBOX_IMAGE="${SQUAREBOX_IMAGE:-ghcr.io/squarewavesystems/squarebox}" EDGE="${SQUAREBOX_EDGE:-0}" +# Default installs PULL the prebuilt image — no local Docker build, no build +# toolchain. --build (or SQUAREBOX_BUILD=1) builds from the cloned source; +# --edge implies a build from latest main (no image is published for unreleased +# commits). +BUILD="${SQUAREBOX_BUILD:-0}" +# Container user mapping for bind-mount ownership parity (honoured by the image +# entrypoint). Hosts that aren't uid 1000 — Unraid 99:100, root installs — set +# these so files squarebox writes to the host are owned correctly. +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" VERBOSE=0 for arg in "$@"; do case "$arg" in --edge) EDGE=1 ;; + --build) BUILD=1 ;; --verbose) VERBOSE=1 ;; esac done @@ -138,24 +157,49 @@ if ! rt_cmd info &>/dev/null; then exit 1 fi -# Build +# Acquire the image. Default: pull the prebuilt multi-arch image (fast, no build +# toolchain). --build / --edge: build from the cloned source. Either path leaves +# the image tagged locally as $IMAGE_NAME so the rest of the script is identical. _build_log="$(mktemp)" _rc_tmp="" _create_log="" trap 'rm -f "$_build_log" "$_rc_tmp" "$_create_log" 2>/dev/null || true' EXIT -if [ "$VERBOSE" = 1 ]; then - echo "Building image..." - rt_cmd build -t "$IMAGE_NAME" "$INSTALL_DIR" +if [ "$EDGE" = 1 ] || [ "$BUILD" = 1 ]; then + if [ "$VERBOSE" = 1 ]; then + echo "Building image..." + rt_cmd build -t "$IMAGE_NAME" "$INSTALL_DIR" + else + printf "Building image... " + if rt_cmd build -t "$IMAGE_NAME" "$INSTALL_DIR" > "$_build_log" 2>&1; then + echo "done" + else + echo "FAILED" >&2 + echo "Build output:" >&2 + cat "$_build_log" >&2 + exit 1 + fi + fi else - printf "Building image... " - if rt_cmd build -t "$IMAGE_NAME" "$INSTALL_DIR" > "$_build_log" 2>&1; then - echo "done" + # Pull the image tag matching the checked-out release. SQUAREBOX_TAG overrides + # (e.g. test a pre-release with SQUAREBOX_TAG=v1.0.0-rc1); falls back to + # :latest when the checkout carries no release tag. + _pull_ref="${SQUAREBOX_IMAGE}:${SQUAREBOX_TAG:-${LATEST_TAG:-latest}}" + if [ "$VERBOSE" = 1 ]; then + echo "Pulling ${_pull_ref}..." + rt_cmd pull "$_pull_ref" else - echo "FAILED" >&2 - echo "Build output:" >&2 - cat "$_build_log" >&2 - exit 1 + printf "Pulling %s... " "$_pull_ref" + if rt_cmd pull "$_pull_ref" > "$_build_log" 2>&1; then + echo "done" + else + echo "FAILED" >&2 + cat "$_build_log" >&2 + echo "" >&2 + echo "Could not pull the prebuilt image. To build from source instead, re-run with --build." >&2 + exit 1 + fi fi + rt_cmd tag "$_pull_ref" "$IMAGE_NAME" fi # Remove old container if it exists @@ -298,7 +342,7 @@ if [ -n "${MSYSTEM:-}" ] || [ -n "${USERPROFILE:-}" ]; then fi # Prepare host directories -mkdir -p "${USER_HOME}/.config/git" "${INSTALL_DIR}/workspace" "${INSTALL_DIR}/.config/lazygit" +mkdir -p "${USER_HOME}/.config/git" "${WORKSPACE_DIR}" "${INSTALL_DIR}/.config/lazygit" # On Linux where host uid != 1000, a previous install may have chowned # ${USER_HOME}/.config/git to 1000:1000 (for the container's `dev` user), so @@ -311,7 +355,7 @@ mkdir -p "${USER_HOME}/.config/git" "${INSTALL_DIR}/workspace" "${INSTALL_DIR}/. # Podman: rootless Podman maps UIDs via user namespaces, so this is unnecessary. # Rootful Podman behaves like Docker — chown is still needed. _needs_chown=0 -if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -ne 1000 ]; then +if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -ne "$PUID" ]; then if [ "$RUNTIME" = "docker" ]; then _needs_chown=1 elif [ "$RUNTIME" = "podman" ] && podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null | grep -qi false; then @@ -352,6 +396,11 @@ if [ -z "$_host_name" ] && [ -n "${USERPROFILE:-}" ]; then fi fi +# Explicit overrides win — lets a headless/server install set git identity +# without a host gitconfig (SQUAREBOX_GIT_NAME / SQUAREBOX_GIT_EMAIL). +[ -n "${SQUAREBOX_GIT_NAME:-}" ] && _host_name="$SQUAREBOX_GIT_NAME" +[ -n "${SQUAREBOX_GIT_EMAIL:-}" ] && _host_email="$SQUAREBOX_GIT_EMAIL" + [ -n "$_host_name" ] && git config --file "$_git_cfg" user.name "$_host_name" [ -n "$_host_email" ] && git config --file "$_git_cfg" user.email "$_host_email" @@ -363,6 +412,26 @@ if [ ! -f "${INSTALL_DIR}/.config/lazygit/config.yml" ]; then printf 'git:\n paging:\n colorArg: always\n pager: delta --dark --paging=never\n' > "${INSTALL_DIR}/.config/lazygit/config.yml" fi +# Non-interactive provisioning: when SQUAREBOX_AI/_SDKS/_EDITORS/_TUIS are set, +# pre-seed the selection files the container's first-run setup.sh reads (the same +# /workspace/.squarebox/* contract that devcontainer-postcreate.sh uses). Only +# seed a file that's absent, so a returning user's prior choices win. Sections +# with a value are queued for a one-off provisioning run after create. +_seed_dir="${WORKSPACE_DIR}/.squarebox" +_seed_sections="" +_seed() { + local file="$1" value="$2" section="$3" + [ -n "$value" ] || return 0 + mkdir -p "$_seed_dir" + [ -f "${_seed_dir}/${file}" ] || printf '%s\n' "$value" > "${_seed_dir}/${file}" + _seed_sections="${_seed_sections:+$_seed_sections }$section" +} +_seed ai-tool "${SQUAREBOX_AI:-}" ai +_seed sdks "${SQUAREBOX_SDKS:-}" sdks +_seed editors "${SQUAREBOX_EDITORS:-}" editors +_seed tuis "${SQUAREBOX_TUIS:-}" tuis +_seed multiplexer "${SQUAREBOX_MULTIPLEXERS:-}" multiplexers + # The container's `dev` user is uid 1000. On native Linux, bind mounts preserve # host ownership, so when the host user's uid differs (e.g. installing as root # on DietPi), `dev` can't write to the mounted dirs and setup.sh fails with @@ -376,22 +445,22 @@ fi if [ "$_needs_chown" = 1 ]; then _chown_paths=( "${USER_HOME}/.config/git" - "${INSTALL_DIR}/workspace" + "${WORKSPACE_DIR}" "${INSTALL_DIR}/.config" ) if [ "$(id -u)" -eq 0 ]; then _chown=(chown) elif command -v sudo &>/dev/null; then - echo "Host uid $(id -u) differs from container 'dev' uid (1000); using sudo to chown mount dirs..." + echo "Host uid $(id -u) differs from container 'dev' uid (${PUID}); using sudo to chown mount dirs..." _chown=(sudo chown) else - echo "Warning: host uid $(id -u) differs from container 'dev' uid (1000) and sudo is unavailable." >&2 + echo "Warning: host uid $(id -u) differs from container 'dev' uid (${PUID}) and sudo is unavailable." >&2 echo " You may see permission errors in the container. Manually run:" >&2 - echo " chown -R 1000:1000 ${_chown_paths[*]}" >&2 + echo " chown -R ${PUID}:${PGID} ${_chown_paths[*]}" >&2 _chown=() fi if [ ${#_chown[@]} -gt 0 ]; then - "${_chown[@]}" -R 1000:1000 "${_chown_paths[@]}" + "${_chown[@]}" -R "${PUID}:${PGID}" "${_chown_paths[@]}" fi fi @@ -416,7 +485,7 @@ if [ ! -f "${INSTALL_DIR}/dotfiles/bashrc" ]; then fi RT_VOLUMES=( - -v "${INSTALL_DIR}/workspace:/workspace" + -v "${WORKSPACE_DIR}:/workspace" -v "${HOME_VOLUME}:/home/dev" -v "${INSTALL_DIR}/dotfiles/bashrc:/home/dev/.bashrc:ro" -v "${USER_HOME}/.config/git:/home/dev/.config/git" @@ -438,9 +507,14 @@ elif [ -d "${USER_HOME}/.ssh" ]; then RT_VOLUMES+=(-v "${USER_HOME}/.ssh:/home/dev/.ssh:ro") fi -# Drop all Linux capabilities except those needed for scoped sudo +# Drop all Linux capabilities except those needed for scoped sudo and the +# entrypoint's PUID/PGID remap (CHOWN/FOWNER/DAC_OVERRIDE to chown + edit +# /etc/passwd, SETUID/SETGID for setpriv to drop privileges). RT_OPTS+=(--cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=SETUID --cap-add=SETGID --cap-add=KILL) +# Container user mapping for bind-mount ownership parity (see image entrypoint). +RT_OPTS+=(-e "PUID=${PUID}" -e "PGID=${PGID}") + _create_log="$(mktemp)" if ! rt_cmd create -it --name "$CONTAINER_NAME" \ "${RT_OPTS[@]}" \ @@ -451,6 +525,19 @@ if ! rt_cmd create -it --name "$CONTAINER_NAME" \ exit 1 fi +# Provision any requested default toolset now, in a transient container sharing +# the same home volume + workspace, so the tools are present before first use +# (mirrors devcontainer-postcreate.sh for the CLI path). Best-effort: a failure +# here never blocks the install — the seeds remain for a later sqrbx-setup. +if [ -n "$_seed_sections" ]; then + echo "Provisioning default toolset (${_seed_sections})... (first run may take a few minutes)" + # shellcheck disable=SC2086 — $_seed_sections is an intentional word list + if ! rt_cmd run --rm "${RT_OPTS[@]}" "${RT_VOLUMES[@]}" "$IMAGE_NAME" \ + /usr/local/lib/squarebox/setup.sh --rerun $_seed_sections; then + echo "Warning: toolset provisioning did not complete — run 'sqrbx-setup' inside the container to finish." >&2 + fi +fi + if [ -t 0 ]; then rt_interactive start -ai "$CONTAINER_NAME" else diff --git a/scripts/squarebox-entrypoint.sh b/scripts/squarebox-entrypoint.sh new file mode 100755 index 0000000..5ee6463 --- /dev/null +++ b/scripts/squarebox-entrypoint.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# squarebox-entrypoint — optional PUID/PGID remap for bind-mount ownership parity. +# +# Why: when squarebox runs as a long-lived server container (docker-compose, +# Unraid, a NAS), files it writes to bind-mounted host paths inherit the +# container user's uid/gid. Hosts rarely use 1000:1000 — Unraid shares are +# 99:100, other setups vary — so without a remap the host sees files owned by a +# phantom uid and other services can't touch them. linuxserver.io solved this +# with PUID/PGID; we mirror that convention. +# +# How: when started as root, remap the image's `dev` user to the requested +# PUID/PGID, fix ownership of the paths dev must write, then drop privileges to +# it via setpriv (util-linux — always present on Ubuntu, no gosu dependency). +# When already unprivileged — rootless Podman maps the container user to the +# host user, or the operator passed `--user` — there is nothing to remap, so we +# just exec the command as-is. +# +# Defaults are 1000:1000, i.e. exactly the image's baked `dev` user, so the +# common desktop install path is a no-op: the remap and chown are skipped +# entirely and behaviour is identical to a plain `USER dev` image. +set -euo pipefail + +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" + +if [ "$(id -u)" = "0" ]; then + cur_uid="$(id -u dev)" + cur_gid="$(id -g dev)" + + # -o permits a non-unique id (e.g. a PUID that collides with an existing + # account). groupmod before usermod so the gid exists when usermod runs. + if [ "$PGID" != "$cur_gid" ]; then + groupmod -o -g "$PGID" dev + fi + if [ "$PUID" != "$cur_uid" ]; then + usermod -o -u "$PUID" dev + fi + + # Re-own the paths dev owns only when the ids actually changed. /etc/passwd + # is in the container's writable layer, so on a create-once-start-many + # container (squarebox's model) this fires only on the first start after a + # change — subsequent starts see cur == requested and skip the costly chown. + if [ "$PUID" != "$cur_uid" ] || [ "$PGID" != "$cur_gid" ]; then + # Best-effort: /home/dev may be a large named volume; never fail boot on it. + chown -R "$PUID:$PGID" /home/dev 2>/dev/null || true + chown "$PUID:$PGID" /workspace 2>/dev/null || true + fi + + # Drop to dev. --init-groups picks up dev's supplementary groups; numeric + # ids resolve back to the (now-remapped) dev passwd entry. + exec setpriv --reuid "$PUID" --regid "$PGID" --init-groups -- "$@" +fi + +# Already unprivileged (rootless Podman, or --user override): run as-is. +exec "$@"