#!/usr/bin/env bash # # Arkdev local-development CLI installer. # # Usage: # curl -fsSL https://start.arkstone.dev | bash # # Pin a specific version: # curl -fsSL https://start.arkstone.dev \ # | ARKSTONE_VERSION=v0.1.0 bash # # Skip the shell-profile PATH modification: # curl -fsSL https://start.arkstone.dev \ # | ARKSTONE_NO_PATH_MODIFY=1 bash # # Verbose composer output (-vvv) — useful for debugging install hangs: # curl -fsSL https://start.arkstone.dev \ # | ARKSTONE_VERBOSE=1 bash # # Skip the guided GitHub/Composer authentication flow (advanced automation): # curl -fsSL https://start.arkstone.dev \ # | ARKSTONE_NO_AUTO_AUTH=1 bash # # Foundation tier (Lefthook, Solo, OrbStack, Docker, ...) checking # moved out of Arkstart in v0.4 — Arkdev itself owns the # list (config/arkstone.php) and enforces it at `arkdev init` and # `arkdev sync`. Run `arkdev foundation:check` after Arkstart # completes for a focused preview, or `arkdev doctor` for the # full diagnostic. # set -euo pipefail # --- Preflight: required tools -------------------------------------------- command -v php >/dev/null 2>&1 || { echo "error: php 8.4+ is required"; exit 1; } command -v composer >/dev/null 2>&1 || { echo "error: composer 2.x is required"; exit 1; } command -v git >/dev/null 2>&1 || { echo "error: git is required"; exit 1; } command -v curl >/dev/null 2>&1 || { echo "error: curl is required"; exit 1; } php -r 'exit(PHP_VERSION_ID < 80400 ? 1 : 0);' || { echo "error: php 8.4+ required (found $(php -r 'echo PHP_VERSION;'))" exit 1 } # --- Preflight: one GitHub identity -------------------------------------- # # `curl | bash` consumes standard input, so interactive GitHub commands must # read from the controlling terminal explicitly. The GitHub CLI stores the # credential in the system credential store. Arkdev then reuses that same # credential for private repository access, package-read permission, and # Composer — no second PAT flow. TTY_PATH="${ARKSTONE_TTY_PATH:-/dev/tty}" has_controlling_tty() { if [ "${ARKSTONE_TEST_ASSUME_TTY:-}" = "1" ]; then [ -r "${TTY_PATH}" ] && [ -w "${TTY_PATH}" ] return fi # The redirection must be wrapped in a brace group. Bash applies an # `exec`'s redirections left to right and reports a failure itself, so a # trailing `2>/dev/null` on the exec is applied too late to suppress # "/dev/tty: Device not configured". The group redirects stderr first and # still opens the descriptor in this shell, so detached runs stay quiet # and print only our actionable message. if ! { exec 9<> "${TTY_PATH}"; } 2>/dev/null; then return 1 fi if [ -t 9 ]; then exec 9>&- 9<&- return 0 fi exec 9>&- 9<&- return 1 } run_with_tty() { if ! has_controlling_tty; then echo "error: GitHub authentication needs an interactive terminal." echo " Re-run the installer from Terminal.app (not from a detached job)." return 1 fi # GitHub CLI writes its one-time code and interactive prompt to stderr. # Keep stdin on the controlling terminal, but merge stderr into the # already-visible stdout stream. Reopening /dev/tty for output can leak # terminal-query responses as visible escape sequences. "$@" < "${TTY_PATH}" 2>&1 } confirm_from_tty() { local prompt="$1" local answer="" if ! has_controlling_tty; then return 1 fi printf '%s [Y/n] ' "${prompt}" > "${TTY_PATH}" if [ "${ARKSTONE_TEST_ASSUME_TTY:-}" = "1" ] && [ -n "${ARKSTONE_TEST_CONFIRM_RESPONSE+x}" ]; then answer="${ARKSTONE_TEST_CONFIRM_RESPONSE}" elif ! IFS= read -r answer < "${TTY_PATH}"; then return 1 fi case "${answer}" in n|N|no|NO|No) return 1 ;; *) return 0 ;; esac } print_install_success() { local message="The Arkdev installation was successful." if [ -t 1 ] && [ -n "${TERM:-}" ] && [ "${TERM}" != "dumb" ]; then printf '\033[1;32m%s\033[0m\n' "${message}" return fi printf '%s\n' "${message}" } clear_controlling_terminal() { if ! has_controlling_tty || [ -z "${TERM:-}" ] || [ "${TERM}" = "dumb" ]; then return fi # Clear only the terminal where the engineer explicitly accepted the # init handoff. Detached output and forced automation stay byte-clean. printf '\033[2J\033[H' > "${TTY_PATH}" } ensure_github_cli() { if command -v gh >/dev/null 2>&1; then return 0 fi if ! command -v brew >/dev/null 2>&1; then echo "error: GitHub CLI is required. Install it from https://cli.github.com and re-run this installer." return 1 fi if ! has_controlling_tty; then echo "error: GitHub CLI installation needs an interactive terminal." echo " Re-run the installer from Terminal.app (not from a detached job)." return 1 fi if ! confirm_from_tty "GitHub CLI is required. Install it with Homebrew now?"; then echo "error: GitHub CLI installation was declined." return 1 fi brew install gh } github_scopes() { if gh auth status --active --hostname github.com --json hosts \ --jq '.hosts["github.com"][] | select(.active == true and .state == "success") | .scopes' \ 2>/dev/null \ | tr ',' '\n' \ | sed "s/^[[:space:]'\"]*//; s/[[:space:]'\"]*$//"; then return fi gh auth status --active --hostname github.com 2>&1 \ | sed -n 's/.*Token scopes: *//p' \ | tr ',' '\n' \ | sed "s/^[[:space:]'\"]*//; s/[[:space:]'\"]*$//" } configure_composer_auth_from_github() { local composer_home="" composer_home="$(composer config --global home --absolute 2>/dev/null)" if [ -z "${composer_home}" ]; then echo "error: could not locate Composer's global configuration directory." return 1 fi mkdir -p "${composer_home}" # The token travels only over stdin into a short-lived PHP process. It is # never printed and never appears in a process argument. The writer merges # the GitHub credential into Composer's existing auth.json atomically and # enforces owner-only permissions. # PHP variables in the inline program must not expand in Bash. # shellcheck disable=SC2016 gh auth token --hostname github.com \ | COMPOSER_AUTH_PATH="${composer_home}/auth.json" php -r ' $path = getenv("COMPOSER_AUTH_PATH"); $token = trim(stream_get_contents(STDIN)); if (!is_string($path) || $path === "" || $token === "") { exit(2); } $data = []; if (is_file($path)) { $decoded = json_decode((string) file_get_contents($path), true); if (!is_array($decoded)) { exit(3); } $data = $decoded; } if (!isset($data["github-oauth"]) || !is_array($data["github-oauth"])) { $data["github-oauth"] = []; } $data["github-oauth"]["github.com"] = $token; // Composer initializes these provider maps as empty JSON arrays in // a new global home. They are schema-defined maps, so preserve // populated values but encode empty maps as JSON objects. foreach (["bitbucket-oauth", "github-oauth", "gitlab-oauth", "gitlab-token", "http-basic", "bearer", "forgejo-token"] as $provider) { if (isset($data[$provider]) && is_array($data[$provider]) && $data[$provider] === []) { $data[$provider] = new stdClass; } } $tmp = tempnam(dirname($path), "arkstart-auth-"); if ($tmp === false) { exit(4); } $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false || file_put_contents($tmp, $json . PHP_EOL, LOCK_EX) === false) { @unlink($tmp); exit(5); } chmod($tmp, 0600); if (!rename($tmp, $path)) { @unlink($tmp); exit(6); } ' echo "Composer is configured to reuse your GitHub CLI identity." } ensure_github_identity() { if [ -n "${ARKSTONE_NO_AUTO_AUTH:-}" ]; then echo "Skipping guided GitHub authentication (ARKSTONE_NO_AUTO_AUTH is set)." return 0 fi ensure_github_cli if ! gh auth status --active --hostname github.com >/dev/null 2>&1; then echo "GitHub authentication is required. Opening the browser login flow..." run_with_tty gh auth login \ --hostname github.com \ --git-protocol https \ --web \ --scopes read:packages fi if ! github_scopes | grep -Fxq 'read:packages'; then echo "Adding GitHub package-read permission..." run_with_tty gh auth refresh --hostname github.com --scopes read:packages fi # Reuse the same GitHub CLI credential for private HTTPS clones. This # works for both new and already-authenticated users and avoids requiring # a separately provisioned SSH key. if ! gh auth setup-git --hostname github.com; then echo "error: could not configure Git to reuse the GitHub CLI credential." return 1 fi if ! gh repo view arkstone/arkdev --json name --jq .name >/dev/null 2>&1; then echo "error: this GitHub account cannot read arkstone/arkdev." echo " Confirm Arkstone organization membership and SSO authorization, then retry." return 1 fi if ! gh api -H "Accept: application/vnd.github+json" \ "/orgs/arkstone/packages/container/dev-api/versions?per_page=1" >/dev/null 2>&1; then echo "error: this GitHub account cannot read the arkstone/dev-api package." echo " Confirm package access and organization SSO authorization, then retry." return 1 fi configure_composer_auth_from_github echo "GitHub identity verified for private repositories and packages." } ensure_github_identity # --- Install --------------------------------------------------------------- VERSION="${ARKSTONE_VERSION:-@stable}" COMPOSER_VERBOSITY="" if [ -n "${ARKSTONE_VERBOSE:-}" ]; then COMPOSER_VERBOSITY="-vvv" fi echo "Installing arkstone/arkdev:${VERSION}..." composer ${COMPOSER_VERBOSITY} config --global repositories.arkstone vcs https://github.com/arkstone/arkdev.git composer ${COMPOSER_VERBOSITY} global require --with-all-dependencies "arkstone/arkdev:${VERSION}" # --- PATH setup ------------------------------------------------------------ # # If composer's global bin directory isn't on PATH, append an export line to # the user's shell profile. Idempotent across re-runs. Opt out by setting # ARKSTONE_NO_PATH_MODIFY=1 — we'll print the line for you to add manually. # # We can't automate `source` for you — subshell changes can't propagate up to # the parent shell — but we print a copy-pasteable command so it's one step. ensure_on_path() { local bin="$1" local line="export PATH=\"${bin}:\$PATH\"" local marker="# Added by Arkstart installer" local profile="" # Already on PATH? Nothing to do. case ":${PATH}:" in *":${bin}:"*) return 0 ;; esac # Explicit opt-out. if [ -n "${ARKSTONE_NO_PATH_MODIFY:-}" ]; then echo echo "Composer's global bin directory isn't on your PATH. Add this to your shell profile: ${line}" return 0 fi # Pick a profile file based on $SHELL. case "${SHELL:-}" in */zsh) profile="${HOME}/.zshrc" ;; */bash) if [ "$(uname -s)" = "Darwin" ]; then profile="${HOME}/.bash_profile" else profile="${HOME}/.bashrc" fi ;; esac if [ -z "${profile}" ]; then echo echo "Couldn't detect your shell profile (SHELL=${SHELL:-unset}). Add this to your shell's rc file: ${line}" return 0 fi # Idempotent: already in the profile. if [ -f "${profile}" ] && grep -qF "${line}" "${profile}"; then echo echo "${profile} already has the PATH entry." echo echo "Open a new shell or activate it in this shell: source ${profile}" return 0 fi # Append with a marker comment so future maintenance can find/remove it. { echo "" echo "${marker}" echo "${line}" } >> "${profile}" echo echo "Added Composer's global bin directory to ${profile}." echo echo "Open a new shell or activate it in this shell: source ${profile}" } BIN="$(composer config --global bin-dir --absolute 2>/dev/null || true)" echo print_install_success if [ -n "${BIN}" ]; then ensure_on_path "${BIN}" fi echo run_init=0 clear_before_init=0 if [ -n "${ARKSTONE_RUN_INIT:-}" ]; then run_init=1 elif [ -z "${ARKSTONE_NO_INIT:-}" ] && confirm_from_tty "Run arkdev init now?"; then run_init=1 clear_before_init=1 fi if [ "${run_init}" -eq 1 ]; then ARKDEV_BIN="" if [ -n "${BIN}" ] && [ -x "${BIN}/arkdev" ]; then ARKDEV_BIN="${BIN}/arkdev" elif command -v arkdev >/dev/null 2>&1; then ARKDEV_BIN="$(command -v arkdev)" fi if [ -z "${ARKDEV_BIN}" ]; then echo "warning: Arkdev was installed but is not visible in this shell yet." echo " Activate the PATH line above, then run: arkdev init" elif has_controlling_tty; then if [ "${clear_before_init}" -eq 1 ]; then clear_controlling_terminal fi "${ARKDEV_BIN}" init < "${TTY_PATH}" else echo "warning: no interactive terminal is available; run \`arkdev init\` from your terminal." fi fi