Skip to content

Instantly share code, notes, and snippets.

@birgersp
Last active September 7, 2026 08:59
Show Gist options
  • Select an option

  • Save birgersp/c7d84daca89d68daa9d68a65ec67e0f0 to your computer and use it in GitHub Desktop.

Select an option

Save birgersp/c7d84daca89d68daa9d68a65ec67e0f0 to your computer and use it in GitHub Desktop.
fedora-silverblue-setup.py
# NOTE: this utility script is a) very hastily put togheter and b) under constant modification.
from os import environ, path
import os
import shutil
import subprocess
import sys
from typing import Callable, Dict, List
FEDORA_VERSION = 44
DOCKER_DESKTOP_RPM_URL = "https://desktop.docker.com/linux/main/amd64/docker-desktop-x86_64.rpm"
EXTERNAL_RPM_PACKAGES: Dict[str, str] = {
"google-chrome-stable": "https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm",
"chatgpt": "https://persistent.oaistatic.com/codex-app-prod/linux/rpm/latest/chatgpt.x86_64.rpm",
"docker-desktop": DOCKER_DESKTOP_RPM_URL,
}
RPM_PACKAGES = [
"akmods",
"code",
"dbus-tools",
"git",
"insync",
"mosh",
"pipx",
"postgresql",
"rpmdevtools",
"sqlite3",
"yaru-theme",
"zsh",
f"https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-{FEDORA_VERSION}.noarch.rpm",
f"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-{FEDORA_VERSION}.noarch.rpm",
] + list(EXTERNAL_RPM_PACKAGES.values())
FLATPAK_APPS = [
"com.github.git_cola.git-cola",
"org.pgadmin.pgadmin4",
"com.spotify.Client",
"com.discordapp.Discord",
"org.signal.Signal",
"io.github.Faugus.faugus-launcher",
"org.sqlitebrowser.sqlitebrowser",
"com.valvesoftware.Steam",
"page.tesk.Refine",
"com.mattjakeman.ExtensionManager",
"io.github.kolunmi.Bazaar",
"io.github.pwr_solaar.solaar",
]
GNOME_EXTENSIONS = [
"quicksettings-audio-devices-hider@marcinjahn.com",
"appindicatorsupport@rgcjonas.gmail.com",
"clipboard-indicator@tudmotu.com",
"dash-to-panel@jderose9.github.com",
"emoji-copy@felipeftn",
"gnome-extensions-cli install",
"switcher@landau.fi",
"system-monitor@gnome-shell-extensions.gcampax.github.com",
"window-calls@domandoman.xyz",
]
HOME = os.environ["HOME"]
CHROME_PROFILE_DIRECTORY = "Default"
CHROME_DESKTOP_ENTRY_FILES = [
"google-chrome.desktop",
"com.google.Chrome.desktop",
]
SIGNAL_FLATPAK_APP_ID = "org.signal.Signal"
STEAM_FLATPAK_APP_ID = "com.valvesoftware.Steam"
#
#
#
# repo files, installation urls, etc
DOCKER_REPO_PATH = "/etc/yum.repos.d/docker-ce.repo"
DOCKER_REPO_URL = "https://download.docker.com/linux/fedora/docker-ce.repo"
VSCODE_REPO_PATH = "/etc/yum.repos.d/vscode.repo"
VSCODE_REPO_FILE = """[vscode-yum]
name=vscode-yum
baseurl=https://packages.microsoft.com/yumrepos/vscode/
repo_gpgcheck=0
gpgcheck=0
enabled=1
gpgkey=https://packages.microsoft.com/yumrepos/vscode/repodata/repomd.xml.key
"""
NAUTILUS_TYPEAHEAD_REPO_PATH = f"/etc/yum.repos.d/nelsonaloysio-nautilus-typeahead-fedora-{FEDORA_VERSION}.repo"
NAUTILUS_TYPEAHEAD_REPO_URL = f"https://copr.fedorainfracloud.org/coprs/nelsonaloysio/nautilus-typeahead/repo/fedora-44/nelsonaloysio-nautilus-typeahead-fedora-{FEDORA_VERSION}.repo"
INSYNC_REPO_PATH = "/etc/yum.repos.d/insync.repo"
INSYNC_REPO_FILE = """[insync]
name=insync repo
baseurl=http://yum.insync.io/fedora/$releasever/
gpgcheck=1
gpgkey=https://d2t3ff60b2tol4.cloudfront.net/repomd.xml.key
enabled=1
metadata_expire=120m
"""
NVM_INSTALL_URL = "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh"
#
#
#
# main
def main() -> None:
actions: Dict[str, Callable[[], None]] = {
"all": doAllActions,
"configure gnome": configureGnome,
"configure chrome": configureChromeDesktopEntries,
"configure steam autostart": configureSteamAutostart,
"install flatpaks": installFlatpaks,
"install nvm": installNvm,
"install deno": installDeno,
"install all system packages": installSystemPackages,
"reinstall (upgrade) url-package": upgradeExternal,
}
args = sys.argv[2:]
if len(sys.argv) > 1:
action = sys.argv[1]
else:
action = promptChooseItem(list(actions.keys()))
callback = actions.get(action)
if callback is None:
print(f"Unrecognized option: {action}")
print("Pick one of these:")
for action in list(actions.keys()):
print(f" {action}")
print("Or none to open the selector")
exit(1)
callback()
def promptChooseItem(items: List[str]) -> str:
print("Which one?")
for number, action in enumerate(items, start=1):
print(f" {number}. {action}")
while True:
try:
selection = input(f"Choose [1-{len(items)}]: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.")
raise SystemExit(0)
if selection.isdigit():
selectionNumber = int(selection)
if 1 <= selectionNumber <= len(items):
return items[selectionNumber - 1]
print(f"Please enter a number from 1 to {len(items)}.")
def doAllActions() -> None:
appState = AppState()
if appState.state == "":
appState.setState("1")
if appState.state == "1":
installSystemPackages()
installNautilusTypeahead()
appState.setState("2")
print("you'll need to reboot to proceed. reboot and re-run this script")
exit(0)
if appState.state == "2":
installFlatpaks()
installGnomeExtensions()
configureGnome()
configureChatgptLogoutKill()
configureChromeDesktopEntries()
configureSteamAutostart()
setDefaultShellToZsh()
appState.setState("3")
print("you'll need to log out")
exit(0)
#
#
#
# helpers
def assertCommandExists(command: str) -> None:
if not checkCommandExists(command):
print(f"command \"{command}\" not found. perhaps you need to reboot?")
exit(1)
def writeStateFile(fileName: str, content: str) -> None:
stateFilePath = getStateFilePath(fileName)
stateFileDir = os.path.dirname(stateFilePath)
if not os.path.exists(stateFileDir):
os.mkdir(stateFileDir)
with open(stateFilePath, 'w', encoding='utf-8') as file:
file.write(content)
def readStateFile(fileName: str) -> str:
try:
with open(getStateFilePath(fileName)) as file:
return file.read().strip()
except:
return ""
def optionalBool() -> bool | None:
return None
def executeReturnOutput(command: str) -> str:
print(f"> {command}")
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if process.stdout is None:
raise Exception()
buffer = []
for line in process.stdout:
buffer.append(line)
process.wait()
output = "".join(buffer).strip()
if process.returncode != 0:
raise subprocess.CalledProcessError(returncode=process.returncode, cmd=command, output=output)
return output
def executePrintOutput(command: str) -> None:
print(f"> {command}")
if subprocess.run(command, shell=True).returncode != 0:
print("Command failed")
exit(1)
def checkInstallVscodeExtension(id: str) -> None:
targetFolder = id.lower().replace("@", "-")
for file in os.listdir(path.join(environ["HOME"], ".vscode", "extensions")):
if file.startswith(targetFolder):
# target folder already present
return
executePrintOutput(f"code --install-extension {id}")
def checkCommandExists(cmd: str) -> bool:
return shutil.which(cmd) != None
def getStateFilePath(fileName: str) -> str:
return path.join(environ["HOME"], ".local", "state", "birgersp-setup", fileName)
def checkSystemIsFedora() -> bool:
return executeReturnOutput("grep '^ID=fedora' /etc/os-release") == "ID=fedora"
def writeFileAsSudo(filePath: str, content: str) -> None:
if not content.endswith("\n"):
content = content + "\n"
subprocess.run(["sudo", "mkdir", "-p", os.path.dirname(filePath)])
subprocess.run(["sudo", "tee", filePath], input=content, text=True, capture_output=True)
def checkFileExists(filePath: str) -> bool:
return os.path.exists(filePath)
class AppState:
state = ""
def __init__(self):
self.state = readStateFile("state")
def setState(self, newState: str) -> None:
writeStateFile("state", newState)
self.state = newState
#
#
#
# setup action functions
def configureGnome() -> None:
installGnomeExtensions()
setGnomeSettings()
configureChatgptLogoutKill()
configureChromeDesktopEntries()
configureSteamAutostart()
def installSystemPackages() -> None:
print("Installing system packages...")
if not checkFileExists(VSCODE_REPO_PATH):
writeFileAsSudo(VSCODE_REPO_PATH, VSCODE_REPO_FILE)
if not checkFileExists(INSYNC_REPO_PATH):
writeFileAsSudo(INSYNC_REPO_PATH, INSYNC_REPO_FILE)
configureDockerRepo()
rpmPackagesStr = " ".join(RPM_PACKAGES)
executePrintOutput(f"sudo rpm-ostree install --idempotent -y {rpmPackagesStr}")
def configureDockerRepo() -> None:
if not checkFileExists(DOCKER_REPO_PATH):
executePrintOutput(f"sudo wget \"{DOCKER_REPO_URL}\" -O \"{DOCKER_REPO_PATH}\"")
def installDockerDesktop() -> None:
print("Installing Docker Desktop...")
configureDockerRepo()
executePrintOutput(f"sudo rpm-ostree install -y {DOCKER_DESKTOP_RPM_URL}")
def installNautilusTypeahead() -> None:
if not checkFileExists(NAUTILUS_TYPEAHEAD_REPO_PATH):
executePrintOutput(f"sudo wget \"{NAUTILUS_TYPEAHEAD_REPO_URL}\" -O \"{NAUTILUS_TYPEAHEAD_REPO_PATH}\"")
executePrintOutput(f"sudo rpm-ostree override remove nautilus nautilus-extensions papers-nautilus --install nautilus-typeahead")
def installFlatpaks() -> None:
executePrintOutput("flatpak remote-add --system --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo")
flatpaksStr = " ".join(FLATPAK_APPS)
executePrintOutput(f"flatpak install --system -y flathub {flatpaksStr}")
configureSignalFlatpak()
def configureSignalFlatpak() -> None:
executePrintOutput(f"flatpak override --user --env=SIGNAL_PASSWORD_STORE=gnome-libsecret {SIGNAL_FLATPAK_APP_ID}")
def installNvm() -> None:
if checkFileExists(path.join(HOME, ".nvm")):
return
executePrintOutput(f"wget -qO- {NVM_INSTALL_URL} | bash")
def installDeno() -> None:
if checkCommandExists("deno"):
return
executePrintOutput("curl -fsSL https://deno.land/install.sh | sh")
def installGnomeExtensions() -> None:
executePrintOutput("pipx install gnome-extensions-cli")
executePrintOutput("pipx ensurepath")
if not checkCommandExists("gnome-extensions-cli"):
print("Command \"install gnome-extensions-cli\" not found. Try re-running in a new shell (where PATH is loaded)")
exit(1)
extensionsStr = " ".join(GNOME_EXTENSIONS)
executePrintOutput(f"gnome-extensions-cli install {extensionsStr}")
def setGnomeSettings() -> None:
commands = [
"dconf write \"/org/gnome/desktop/interface/icon-theme\" \"'Yaru-blue'\"",
"dconf write \"/org/gnome/desktop/wm/keybindings/switch-applications-backward\" \"@as []\"",
"dconf write \"/org/gnome/desktop/wm/keybindings/switch-applications\" \"@as []\"",
"dconf write \"/org/gnome/desktop/wm/keybindings/switch-windows-backward\" \"['<Shift><Alt>Tab']\"",
"dconf write \"/org/gnome/desktop/wm/keybindings/switch-windows\" \"['<Alt>Tab']\"",
"dconf write \"/org/gnome/desktop/wm/preferences/button-layout\" \"':minimize,maximize,close'\"",
"dconf write \"/org/gnome/desktop/wm/preferences/resize-with-right-button\" \"true\"",
"gsettings set org.gnome.desktop.wm.keybindings close \"['<Alt>F4']\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/binding\" \"'<Control><Alt>t'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/command\" \"'bash /var/home/birger/repo/tools/linux/hotkeys.sh --ctrl-alt-t'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/name\" \"'ctrl+alt+t'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/binding\" \"'<Super>m'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/command\" \"'bash /var/home/birger/repo/tools/linux/hotkeys.sh --super-m'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/name\" \"'super+m'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom2/binding\" \"'<Super>g'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom2/command\" \"'bash /var/home/birger/repo/tools/linux/hotkeys.sh --super-g'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom2/name\" \"'super+g'\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings\" \"['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0/', '/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom1/', '/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom2/']\"",
"dconf write \"/org/gnome/settings-daemon/plugins/media-keys/terminal\" \"@as []\"",
"dconf write \"/org/gnome/shell/extensions/clipboard-indicator/cache-size\" \"20\"",
"dconf write \"/org/gnome/shell/extensions/clipboard-indicator/history-size\" \"100\"",
"dconf write \"/org/gnome/shell/extensions/clipboard-indicator/toggle-menu\" \"['<Control><Alt>h']\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/dot-size\" \"0\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/group-apps\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/trans-bg-color\" \"'#3d3846'\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/trans-panel-opacity\" \"1.0\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/trans-use-custom-bg\" \"true\"",
"dconf write \"/org/gnome/shell/extensions/dash-to-panel/trans-use-custom-opacity\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/emoji-copy/always-show\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/emoji-copy/emoji-keybind\" \"['<Ctrl><Alt>period']\"",
"dconf write \"/org/gnome/shell/extensions/switcher/fade-enable\" \"true\"",
"dconf write \"/org/gnome/shell/extensions/switcher/font-size\" \"uint32 24\"",
"dconf write \"/org/gnome/shell/extensions/switcher/max-width-percentage\" \"uint32 50\"",
"dconf write \"/org/gnome/shell/extensions/system-monitor/show-cpu\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/system-monitor/show-download\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/system-monitor/show-swap\" \"false\"",
"dconf write \"/org/gnome/shell/extensions/system-monitor/show-upload\" \"false\"",
"dconf write \"/org/gnome/shell/keybindings/toggle-message-tray\" \"@as []\"",
]
for command in commands:
executePrintOutput(command)
def configureChatgptLogoutKill() -> None:
localBinDir = path.join(HOME, ".local", "bin")
systemdUserDir = path.join(HOME, ".config", "systemd", "user")
wantsDir = path.join(systemdUserDir, "graphical-session.target.wants")
os.makedirs(localBinDir, exist_ok=True)
os.makedirs(wantsDir, exist_ok=True)
killScriptPath = path.join(localBinDir, "kill-chatgpt-app-now")
servicePath = path.join(systemdUserDir, "kill-chatgpt-app-on-logout.service")
serviceLinkPath = path.join(wantsDir, "kill-chatgpt-app-on-logout.service")
oldKillScriptPath = path.join(localBinDir, "kill-codex-desktop-now")
oldServicePath = path.join(systemdUserDir, "kill-codex-desktop-on-logout.service")
oldServiceLinkPath = path.join(wantsDir, "kill-codex-desktop-on-logout.service")
killScript = """#!/usr/bin/env bash
set -u
legacy_app_id="codex-desktop"
legacy_state_dir="${XDG_STATE_HOME:-${HOME}/.local/state}/${legacy_app_id}"
log_dir="${XDG_STATE_HOME:-${HOME}/.local/state}/chatgpt-app-logout-kill"
log_file="${log_dir}/events.log"
mkdir -p "${log_dir}"
patterns=(
"${HOME}/.local/share/codex-app/electron"
"${HOME}/.local/share/codex-app/start.sh"
"${HOME}/.local/share/codex-app/.codex-linux/webview-server.py"
"--user-data-dir=${legacy_state_dir}/electron-user-data"
"CODEX_LINUX_APP_ID=${legacy_app_id}"
"/usr/lib/chatgpt/ChatGPT"
"/usr/lib/chatgpt/resources/codex"
"/usr/lib/chatgpt/resources/cua_node/bin/node"
"/usr/lib/chatgpt/resources/cua_node/bin/node_repl"
"--user-data-dir=${HOME}/.config/Codex"
"--database=${HOME}/.config/Codex/Crashpad"
)
log_pattern="codex-app/electron|codex-app/start.sh|codex-linux/webview-server.py|--user-data-dir=.*codex-desktop|/usr/lib/chatgpt/ChatGPT|/usr/lib/chatgpt/resources/codex|/usr/lib/chatgpt/resources/cua_node/bin/node|--user-data-dir=${HOME}/.config/Codex|--database=${HOME}/.config/Codex/Crashpad"
log_matches() {
pgrep -a -u "${USER}" -f "${log_pattern}" || true
}
read_pid_files() {
find "${legacy_state_dir}" -type f \\( -name 'app.pid' -o -name 'webview.pid' \\) -print 2>/dev/null |
while IFS= read -r pid_file; do
sed -n '1p' "${pid_file}" 2>/dev/null
done |
awk '/^[0-9]+$/ { print }'
}
signal_pid_files() {
local signal="$1"
local pid
while IFS= read -r pid; do
kill "-${signal}" "${pid}" 2>/dev/null || true
done < <(read_pid_files)
}
{
printf '\\n[%s] killing ChatGPT app processes for logout\\n' "$(date --iso-8601=seconds)"
log_matches
} >> "${log_file}" 2>&1
signal_pid_files TERM
for pattern in "${patterns[@]}"; do
pkill -TERM -u "${USER}" -f "${pattern}" 2>/dev/null || true
done
sleep 1
signal_pid_files KILL
for pattern in "${patterns[@]}"; do
pkill -KILL -u "${USER}" -f "${pattern}" 2>/dev/null || true
done
{
printf '[%s] remaining ChatGPT app matches after kill attempt\\n' "$(date --iso-8601=seconds)"
log_matches
} >> "${log_file}" 2>&1
"""
service = """[Unit]
Description=Kill ChatGPT app when the graphical session ends
Documentation=man:systemd.special(7)
PartOf=graphical-session.target
After=graphical-session.target
Before=shutdown.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/true
ExecStop=%h/.local/bin/kill-chatgpt-app-now
TimeoutStopSec=3s
[Install]
WantedBy=graphical-session.target
"""
for oldPath in [oldServiceLinkPath, oldServicePath, oldKillScriptPath]:
if os.path.lexists(oldPath):
os.remove(oldPath)
with open(killScriptPath, "w", encoding="utf-8") as file:
file.write(killScript)
os.chmod(killScriptPath, 0o755)
with open(servicePath, "w", encoding="utf-8") as file:
file.write(service)
if os.path.lexists(serviceLinkPath):
os.remove(serviceLinkPath)
os.symlink("../kill-chatgpt-app-on-logout.service", serviceLinkPath)
executePrintOutput("systemctl --user daemon-reload")
executePrintOutput("systemctl --user start kill-chatgpt-app-on-logout.service")
def configureChromeDesktopEntries() -> None:
applicationsDir = path.join(HOME, ".local", "share", "applications")
os.makedirs(applicationsDir, exist_ok=True)
for desktopEntryFile in CHROME_DESKTOP_ENTRY_FILES:
srcPath = path.join("/usr/share/applications", desktopEntryFile)
destPath = path.join(applicationsDir, desktopEntryFile)
if not checkFileExists(srcPath):
print(f"Skipping {desktopEntryFile}: {srcPath} does not exist")
continue
shutil.copyfile(srcPath, destPath)
with open(destPath, encoding="utf-8") as file:
lines = file.read().splitlines()
replacements = {
"Exec=/usr/bin/google-chrome-stable %U": f"Exec=/usr/bin/google-chrome-stable --profile-directory={CHROME_PROFILE_DIRECTORY} %U",
"Exec=/usr/bin/google-chrome-stable": f"Exec=/usr/bin/google-chrome-stable --profile-directory={CHROME_PROFILE_DIRECTORY}",
"Exec=/usr/bin/google-chrome-stable --incognito": f"Exec=/usr/bin/google-chrome-stable --profile-directory={CHROME_PROFILE_DIRECTORY} --incognito",
}
with open(destPath, "w", encoding="utf-8") as file:
file.write("\n".join([replacements.get(line, line) for line in lines]) + "\n")
if checkCommandExists("update-desktop-database"):
executePrintOutput(f"update-desktop-database \"{applicationsDir}\"")
def configureSteamAutostart() -> None:
autostartDir = path.join(HOME, ".config", "autostart")
os.makedirs(autostartDir, exist_ok=True)
if checkCommandExists("steam"):
execCommand = "steam -silent"
tryExec = "steam"
else:
execCommand = f"flatpak run {STEAM_FLATPAK_APP_ID} -silent"
tryExec = "flatpak"
content = f"""[Desktop Entry]
Type=Application
Name=Steam
Comment=Start Steam automatically on login
Exec={execCommand}
TryExec={tryExec}
Icon=steam
Terminal=false
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Delay=10
"""
with open(path.join(autostartDir, "steam.desktop"), "w", encoding="utf-8") as file:
file.write(content)
def setDefaultShellToZsh() -> None:
zshPath = shutil.which("zsh")
if zshPath is None:
print("zsh is not found(?). you might wanna reboot or something")
exit(1)
if os.environ["SHELL"] == zshPath:
# shell is zsh
return
executePrintOutput(f"chsh -s {zshPath}")
def upgradeExternal():
packages = list(EXTERNAL_RPM_PACKAGES.keys())
item = promptChooseItem(packages + ["all"])
if item == "all":
command = "sudo rpm-ostree install " + " ".join(list(EXTERNAL_RPM_PACKAGES.values()))
for package in packages:
command += f" --uninstall {package}"
executePrintOutput(command)
else:
url = EXTERNAL_RPM_PACKAGES[item]
executePrintOutput(f"sudo rpm-ostree install {url} --uninstall {item}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment