Two unrelated scripts that share a name because they share a theme: getting a Linux box to behave like a Chromebook.
stage— bring a fresh ChromeOS Linux (Crostini) container up from nothing, when the things you want to install live in a private Cloud Storage bucket.flex— turn a stock Debian desktop into a ChromeOS Flex lookalike: shelf, web apps, keybindings, wallpaper. No bucket, no gcloud, nothing private — this one is standalone.
That is a chicken-and-egg problem: reading the bucket needs gcloud, and a
bare container has no gcloud. This script is the one piece that has to be
fetchable without credentials, so it lives here instead of in the bucket.
bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)No git required — curl is enough, so there is nothing to apt-get install
first.
Process substitution rather than a pipe is deliberate. curl ... | bash hands
the script to bash on stdin, which is the same stdin gcloud auth login
needs to read your answers from; the login then fails or silently eats the rest
of the script. bash <(curl ...) passes it as a file descriptor instead and
leaves stdin attached to your terminal.
If your shell has no process substitution, download and run in two steps:
curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage -o /tmp/stage
bash /tmp/stagestage leaves you with a container that can read the bucket. Four commands
take it the rest of the way:
bash <(curl -sSL https://raw.githubusercontent.com/xbill9/chromeos-boot/main/stage)
exec bash -l
bootstrap
bootstrap codestage— gcloud, login, then~/binand the dotfiles out of the bucket.exec bash -l— load-bearing, and easy to skip.bootstrapis a shell function defined in the.bashrcthatstagehas just fetched, so it does not exist until a new login shell reads it.bootstrap— apt packages, node, python, rust, go, docker, aws and the agent CLIs. Every stage is idempotent, so re-running is how you repair one that failed;bootstrap <stage>runs a single one andbootstrap -llists them. The python stage compiles CPython and is slow.bootstrap code— clones the repos. Kept out of the default set because it takes a while.
Two things then need the steps above to have finished:
- Log out and back in. The docker stage adds you to the
dockergroup, which a session that is already running will not pick up. - Re-run
nnn. A couple of the scripts in~/binare symlinks into a cloned repo rather than copies from the bucket, so they cannot be linked untilbootstrap codehas cloned it.nnnwarns and skips them until then.
- Installs the Google Cloud CLI from the tarball into
$HOME— no sudo, no apt, no keyring setup. - Logs you in, opening a browser tab. Crostini hands the URL to the ChromeOS browser you are already signed into.
- Copies
nnnout of the bucket into~/binand runs it, which fetches everything else. - Replaces the tarball with the apt-managed
google-cloud-cliin/usr/bin, then deletes~/google-cloud-sdk.
It is idempotent: existing gcloud and an active login are detected and
skipped, so re-run it to repair a half-finished container.
The tarball is the only kind of gcloud a bare container can install: no sudo,
no keyring, no apt repo to add — and adding one needs gnupg, which may not be
there yet either. It exists to read the bucket once, and is deliberately never
added to PATH.
It is not the copy you want to keep. Step 4 adds Google's apt repo and installs
google-cloud-cli into /usr/bin, which is what everything downstream expects
and what gets updated along with the rest of the machine, and then removes
~/google-cloud-sdk. This used to be left to the bootstrap function in the
fetched .bashrc; stage now does it, so bootstrap's gcloud stage finds
the CLI already in place and does nothing.
Step 4 is non-fatal. Without sudo, or with apt unreachable, it warns, keeps
the tarball and leaves the job to bootstrap gcloud.
Credentials live in ~/.config/gcloud, a separate directory shared by both
copies, so you log in exactly once and removing the tarball does not log you
out.
Defaults to the stage bucket; pass another as the first argument, or set
BUCKET. The name is not sensitive - the bucket is private and IAM gates every
object in it, so knowing the name gets you nothing without an authorised
account. Step 2 is what establishes that.
Turn a stock Debian 13 (trixie) / GNOME 48 desktop into a ChromeOS Flex lookalike: a bottom shelf, the Google web apps as first-class icons, ChromeOS keybindings, a pruned app grid, and a matching pair of wallpapers. Written from — and matches — the customisation actually run on this Yoga 9 install.
Unlike stage, this one is self-contained: no bucket, no gcloud, nothing
private. Everything it touches lives under $HOME except the pkgs stage
(apt, and non-fatal without sudo) and boot-splash.sh, a script it installs
but never runs, since that one edits GRUB.
Install GNOME with gdm3 — the Debian installer's default desktop task
gives you that; take it and change nothing. flex never touches the display
manager, so this is not about anything it configures, it is about the session
it lands you in, and the script only works in one: it dies without
gsettings, warns unless XDG_CURRENT_DESKTOP is GNOME, and installs a
gnome-shell extension for the shelf, reading gnome-shell --version to pick
the build.
lightdm is the wrong choice here. Debian's greeter only lists
/usr/share/xsessions, so GNOME starts on Xorg rather than the Wayland
session everything below was measured on, and it doesn't take the handoff
from Plymouth cleanly, which is most of the point of boot-splash.sh. If a
machine somehow ends up with both, sudo dpkg-reconfigure gdm3 chooses.
bash flex # everything, dark mode
bash flex --light # everything, light mode
bash flex -l # list the stages
bash flex shelf webapps # just those two
bash flex revert # undo itEvery stage is idempotent, so re-running is how you repair one that failed,
and naming a stage is how you repair it without redoing the rest. icons
(a ~60MB icon theme) and wallpaper (~9s per render) are the slow ones.
Wayland cannot restart gnome-shell in place, so the shelf, the GTK theme
and the app grid only take effect after a full log out and back in; the
wallpaper and the keybindings are live immediately.
pkgs— Roboto, gnome-tweaks, unzip, and Chrome if no Chromium-family browser is already installed.theme— adw-gtk3, light and dark, from the upstream release tarball (trixie has no package for it), so GTK3 apps match the libadwaita GTK4 ones.icons— Papirus and Papirus-Dark, user-level.shelf— installs dash-to-panel in place of the packaged dash-to-dock: only dash-to-panel merges the taskbar and system tray into one bar, which is what the ChromeOS shelf is. Bottom, 56px, 75% opacity, Google-Blue running-app dots, Alt+1-9 launches the nth pinned app. App icons are centred on the monitor with the launcher hard left and the clock and tray hard right, as on ChromeOS. The per-monitor layout is resolved through Mutter'sDisplayConfigat run time rather than a hardcoded panel ID, so it isn't tied to one laptop's monitor — but a monitor this stage has never seen falls back to dash-to-panel's own left-stacked default, so plug the second screen in first and re-runbash flex shelf.webapps— the nine Google apps (Gmail, Calendar, Drive, Docs, Sheets, Keep, Photos, Maps, YouTube) as windowless--app=launchers with their own shelf icons, icons fetched from gstatic with a favicon-service fallback.appgrid— hides the apps ChromeOS doesn't have (LibreOffice, xterm, Disk Utility, and 30-odd others) by shadowing each system.desktopwith a copy carryingNoDisplay=true— a copy, not a stub, so MIME associations and "Open with" still work. Nothing is uninstalled. GNOME Text Editor is renamed to "Text".look— Roboto as the UI/document/titlebar font, blue accent, no hot corners, time-only clock, one workspace, shelf favourites.keys— Caps Lock becomes Super (the ChromeOS Launcher key); Alt for window minimise/maximise/tile; Alt+Tab cycles windows (not app groups, which move to Super+Tab); Super+[ / Super+] switch workspaces; Ctrl+F5 overview; Ctrl+Shift+F5 screenshot; Ctrl+Shift+Q sign out; Ctrl+Alt+T terminal.helpers— installsset-mode.sh,gen_wallpaper.pyandboot-splash.shunder~/.local/share/chromeos-flex/.wallpaper— renders both wallpaper variants (pure-Python PNG encoder, no Pillow needed) and applies the requested mode.
~/.local/share/chromeos-flex/set-mode.sh light # flip the whole look
~/.local/share/chromeos-flex/set-mode.sh # report the current modeTwo things it can't do for you, both a couple of clicks in Settings: mounting Drive in the Files app (Settings → Online Accounts → Google), and Chrome reopening its tabs on launch (Chrome Settings → On startup). The latter has a managed-policy route, deliberately not taken here — it stamps "Managed by your organization" onto the Chrome menu permanently in exchange for skipping one five-second click.
boot-splash.sh, also under ~/.local/share/chromeos-flex/, is installed
but never run automatically: it edits /etc/default/grub to drop the GRUB
menu and enable the Plymouth splash that ships already installed. Needs
root; read it before running it.
bash flex revert resets every setting this script touched back to the
GNOME defaults (not to whatever they were before — this doesn't snapshot
prior values) and removes the launchers, icons, wallpapers and helper
scripts it created. adw-gtk3, Papirus, dash-to-panel and any apt packages
are left in place, since removing them isn't really an "undo" either.