run() re-splits its argument as a bare string with no eval, so any call like run "git clone \"$url\" \"$dir\"" corrupts the quoted arguments (embedded escaped quotes survive as literal characters, e.g. git sees the protocol as '"https). Each of the 4 affected call sites papered over this by following run() with a second, correctly quoted manual execution of the same command - meaning every real run double-executed (git clone twice, pip install -e twice, conda env create twice), with the first, corrupted attempt's failure masked by the working second attempt. Fixed by dropping run() at these sites entirely and gating on $dry_run directly with a single, properly quoted real execution path (matches the pattern already used in the new model-cache-redirect extra): - lib/cf-apps.functions: install-cf-app's clone, provision-orchard-node's clone - cf-apps/circuitforge-core.manifest: pip install -e setup hook - cf-apps/kiwi.manifest: conda env create setup hook Verified on sif via a scratch-dir harness (install-cf-app circuitforge-core): first run now clones cleanly with no mangled-URL error, second run correctly skips the clone and re-runs the idempotent pip install; state.conf stays single-entry across both.
292 lines
11 KiB
Bash
292 lines
11 KiB
Bash
#!/bin/bash
|
|
# CF Apps registry + interactive install dispatcher for cf-node-bootstrap.
|
|
#
|
|
# Manifests live in cf-apps/*.manifest (see cf-apps/README.md for the
|
|
# schema). Design rationale, including the oem/collaborator/orchard
|
|
# provisioning-profile split, is in:
|
|
# circuitforge-plans/cf-node-bootstrap/superpowers/plans/2026-07-17-cf-apps-install-menu.md
|
|
|
|
cf_apps_manifest_dir="${cf_apps_manifest_dir:-${rundir}/cf-apps}"
|
|
|
|
# State file recording what this installer has set up on this node, so
|
|
# lib/skel/.bashrc.d/11-quickinfo.bashrc can report on it. Plain
|
|
# sourceable key=value pairs (values %q-escaped), one write-or-replace
|
|
# per key - not a general config format, just enough for quickinfo to read.
|
|
cf_node_state_file="${cf_node_state_file:-${HOME}/.config/cf-node-bootstrap/state.conf}"
|
|
|
|
record-cf-node-state() {
|
|
local key="$1" value="$2"
|
|
mkdir -p "$(dirname "$cf_node_state_file")"
|
|
touch "$cf_node_state_file"
|
|
if [ -s "$cf_node_state_file" ]; then
|
|
grep -v "^${key}=" "$cf_node_state_file" > "${cf_node_state_file}.tmp"
|
|
mv "${cf_node_state_file}.tmp" "$cf_node_state_file"
|
|
fi
|
|
printf '%s=%q\n' "$key" "$value" >> "$cf_node_state_file"
|
|
}
|
|
|
|
# Resets the app_* manifest variables/functions so sourcing one manifest
|
|
# can't leak stale values into the next.
|
|
_cf-apps-reset-manifest-vars() {
|
|
unset app_name app_desc app_repo_url_github app_repo_url_forgejo \
|
|
app_available_profiles app_install_types app_conda_env \
|
|
app_env_template app_needs_core
|
|
unset -f app_setup_bare_metal app_setup_docker app_setup_podman 2>/dev/null
|
|
}
|
|
|
|
# Prompts for a base directory to clone CF app repos into; sets $cf_apps_dir.
|
|
prompt-cf-apps-dir() {
|
|
local default_dir="${HOME}/CircuitForge"
|
|
boxborder "${lyl}Where should CircuitForge app repos be cloned?${dfl} ${gry}(default: ${default_dir})${dfl}"
|
|
local input_dir
|
|
read -r input_dir
|
|
cf_apps_dir="${input_dir:-$default_dir}"
|
|
}
|
|
|
|
# Yes/no gate + entry point, mirroring install.sh's extras-menu pattern.
|
|
# Call this from install.sh's userinstall/globalinstall alongside extras-menu.
|
|
cf-provision-menu() {
|
|
boxborder "${lyl}Set up this node for CircuitForge apps?${dfl} ${gry}(product installs or joining an orchard)${dfl}"
|
|
local cfapps_menu=(
|
|
"$(boxline "${green_check} Yes")"
|
|
"$(boxline "${red_x} No")"
|
|
)
|
|
case `select_opt "${cfapps_menu[@]}"` in
|
|
0) prompt-cf-apps-dir
|
|
cf-provision-node
|
|
;;
|
|
1) boxline "Skipping CircuitForge app provisioning" ;;
|
|
esac
|
|
}
|
|
|
|
# Populates parallel arrays describing every available app manifest.
|
|
# Call before cf-apps-menu.
|
|
load-cf-apps() {
|
|
cf_app_names=()
|
|
cf_app_descs=()
|
|
cf_app_profiles=()
|
|
local manifest
|
|
for manifest in "${cf_apps_manifest_dir}"/*.manifest; do
|
|
[ -f "$manifest" ] || continue
|
|
_cf-apps-reset-manifest-vars
|
|
source "$manifest"
|
|
cf_app_names+=("$app_name")
|
|
cf_app_descs+=("$app_desc")
|
|
cf_app_profiles+=("${app_available_profiles[*]}")
|
|
done
|
|
}
|
|
|
|
# Returns success if $1 (a space-joined profile list) contains $2
|
|
_cf-apps-profile-allows() {
|
|
local profiles=" $1 " want="$2"
|
|
[[ "$profiles" == *" $want "* ]]
|
|
}
|
|
|
|
# Prompts for what kind of node this is; sets $cf_provision_profile to
|
|
# oem|collaborator|orchard. Gates both which apps are offered and which
|
|
# remote (public GitHub vs private Forgejo) they're cloned from.
|
|
select-provision-profile() {
|
|
boxborder "${lyl}${unl}What kind of node is this?${dfl}"
|
|
profile_menu=(
|
|
"$(boxline "OEM / customer node ${gry}(public GitHub only, licensed products)${dfl}")"
|
|
"$(boxline "Collaborator node ${gry}(private Forgejo, full app list)${dfl}")"
|
|
"$(boxline "Orchard-join node ${gry}(cf-orch agent only)${dfl}")"
|
|
)
|
|
case `select_opt "${profile_menu[@]}"` in
|
|
0) cf_provision_profile="oem" ;;
|
|
1) cf_provision_profile="collaborator" ;;
|
|
2) cf_provision_profile="orchard" ;;
|
|
esac
|
|
}
|
|
|
|
# Top-level entry point: prompts for a profile (unless already set), then
|
|
# either runs the orchard-join flow or offers the app multiselect menu.
|
|
cf-provision-node() {
|
|
if [ -z "$cf_provision_profile" ]; then
|
|
select-provision-profile
|
|
fi
|
|
record-cf-node-state "cf_node_profile" "$cf_provision_profile"
|
|
|
|
if [[ "$cf_provision_profile" == "orchard" ]]; then
|
|
provision-orchard-node
|
|
return
|
|
fi
|
|
|
|
cf-apps-menu
|
|
}
|
|
|
|
cf-apps-menu() {
|
|
load-cf-apps
|
|
|
|
local menu_display=() menu_app_idx=() preselect=()
|
|
local i
|
|
for ((i=0; i<${#cf_app_names[@]}; i++)); do
|
|
if _cf-apps-profile-allows "${cf_app_profiles[$i]}" "$cf_provision_profile"; then
|
|
menu_display+=("${cf_app_names[$i]} ${gry}- ${cf_app_descs[$i]}${dfl}")
|
|
menu_app_idx+=("$i")
|
|
preselect+=("false")
|
|
fi
|
|
done
|
|
|
|
if [ "${#menu_display[@]}" -eq 0 ]; then
|
|
warn "No CF apps are available for the '${cf_provision_profile}' profile."
|
|
return
|
|
fi
|
|
|
|
boxborder "${lyl}${unl}Select CircuitForge apps to install:${dfl}"
|
|
multiselect result menu_display preselect
|
|
|
|
local idx=0 selected_i
|
|
for selected_i in "${menu_app_idx[@]}"; do
|
|
if [[ "${result[idx]}" == "true" ]]; then
|
|
install-cf-app "${cf_app_names[$selected_i]}"
|
|
fi
|
|
((idx++))
|
|
done
|
|
}
|
|
|
|
# Installs circuitforge-core as an editable dependency, once per run.
|
|
install-cf-core-dependency() {
|
|
if [[ "$cf_core_installed" == true ]]; then
|
|
return
|
|
fi
|
|
install-cf-app "circuitforge-core"
|
|
cf_core_installed=true
|
|
}
|
|
|
|
# Clones (if needed) and runs setup for a single app by name, honoring
|
|
# $cf_provision_profile for which remote to clone from.
|
|
install-cf-app() {
|
|
local name="$1"
|
|
local manifest="${cf_apps_manifest_dir}/${name}.manifest"
|
|
if [ ! -f "$manifest" ]; then
|
|
warn "No manifest found for '$name'"
|
|
return 1
|
|
fi
|
|
_cf-apps-reset-manifest-vars
|
|
source "$manifest"
|
|
|
|
if [[ "$app_needs_core" == true ]]; then
|
|
install-cf-core-dependency
|
|
fi
|
|
|
|
local repo_url
|
|
if [[ "$cf_provision_profile" == "oem" ]]; then
|
|
repo_url="$app_repo_url_github"
|
|
else
|
|
repo_url="$app_repo_url_forgejo"
|
|
fi
|
|
|
|
local apps_base_dir="${cf_apps_dir:?cf_apps_dir must be set before calling install-cf-app}"
|
|
local clone_dir="${apps_base_dir}/${app_name}"
|
|
if [ -d "$clone_dir" ]; then
|
|
warn "${clone_dir} already exists, skipping clone (will not overwrite)"
|
|
elif [[ $dry_run == true ]]; then
|
|
boxline "DryRun: git clone --recurse-submodules ${repo_url} ${clone_dir}"
|
|
else
|
|
# run() re-splits its argument as a bare string with no eval, which
|
|
# mangles anything with embedded quotes (URLs/paths) - execute directly
|
|
# instead, gated on $dry_run above rather than double-executing via run().
|
|
boxline "Cloning ${app_name} from ${repo_url}"
|
|
mkdir -p "$apps_base_dir"
|
|
git clone --recurse-submodules "$repo_url" "$clone_dir"
|
|
fi
|
|
|
|
if [ -n "$app_env_template" ] && [ -f "${clone_dir}/${app_env_template}" ] && [ ! -f "${clone_dir}/.env" ]; then
|
|
cp "${clone_dir}/${app_env_template}" "${clone_dir}/.env"
|
|
boxline "Created ${app_name}/.env from ${app_env_template}"
|
|
fi
|
|
|
|
local install_type
|
|
if [ "${#app_install_types[@]}" -gt 1 ]; then
|
|
boxborder "${lyl}Install type for ${app_name}?${dfl}"
|
|
local type_menu=() t
|
|
for t in "${app_install_types[@]}"; do
|
|
type_menu+=("$(boxline "$t")")
|
|
done
|
|
install_type="${app_install_types[$(select_opt "${type_menu[@]}")]}"
|
|
else
|
|
install_type="${app_install_types[0]}"
|
|
fi
|
|
|
|
case "$install_type" in
|
|
bare-metal)
|
|
if declare -F app_setup_bare_metal >/dev/null; then
|
|
app_setup_bare_metal "$clone_dir"
|
|
else
|
|
warn "${app_name}: no bare-metal setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
|
|
fi
|
|
;;
|
|
docker)
|
|
if declare -F app_setup_docker >/dev/null; then
|
|
app_setup_docker "$clone_dir"
|
|
else
|
|
warn "${app_name}: no docker setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
|
|
fi
|
|
;;
|
|
podman)
|
|
if declare -F app_setup_podman >/dev/null; then
|
|
app_setup_podman "$clone_dir"
|
|
else
|
|
warn "${app_name}: no podman setup hook defined yet; clone is at ${clone_dir}, follow its README manually"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
record-cf-node-state "cf_app_${app_name//-/_}" "${app_name}:${install_type}:${clone_dir}"
|
|
}
|
|
|
|
# Narrow, single-purpose flow for adding a GPU worker node to an existing
|
|
# cf-orch coordinator. circuitforge-orch has no public GitHub mirror (it's
|
|
# CircuitForge's internal coordinator/agent product), so this always clones
|
|
# from Forgejo regardless of provisioning profile.
|
|
#
|
|
# circuitforge-orch's own install.sh has no --topology/--coordinator-url
|
|
# flags - topology and coordinator URL are gathered via its own interactive
|
|
# prompts (read -rp). So this hands off to it interactively rather than
|
|
# trying to script around those prompts.
|
|
#
|
|
# NOTE: circuitforge-orch's installer and README have no model-sync/
|
|
# model-cache-sync mechanism today (checked both, no hits). This wrapper
|
|
# does not invent one - if a node needs that, raise it with circuitforge-orch
|
|
# directly rather than expecting it here.
|
|
provision-orchard-node() {
|
|
boxborder "${lyl}${unl}Join an existing CircuitForge orchard${dfl}"
|
|
boxline "This will clone circuitforge-orch and hand off to its own"
|
|
boxline "installer. When prompted for topology, choose 'agent' and have"
|
|
boxline "your coordinator's URL ready."
|
|
|
|
local apps_base_dir="${cf_apps_dir:?cf_apps_dir must be set before calling provision-orchard-node}"
|
|
local clone_dir="${apps_base_dir}/circuitforge-orch"
|
|
local repo_url="https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-orch.git"
|
|
|
|
if [ -d "$clone_dir" ]; then
|
|
warn "${clone_dir} already exists, skipping clone (will not overwrite)"
|
|
elif [[ $dry_run == true ]]; then
|
|
boxline "DryRun: git clone ${repo_url} ${clone_dir}"
|
|
else
|
|
# See install-cf-app for why this doesn't route through run().
|
|
boxline "Cloning circuitforge-orch from Forgejo"
|
|
mkdir -p "$apps_base_dir"
|
|
git clone "$repo_url" "$clone_dir"
|
|
fi
|
|
|
|
boxline "Handing off to circuitforge-orch's own install.sh..."
|
|
(cd "$clone_dir" && ./install.sh)
|
|
|
|
# circuitforge-orch's installer writes its own resulting config to
|
|
# ~/.config/circuitforge/cf-orch.env (CFORCH_MODE, CFORCH_COORDINATOR_URL);
|
|
# read that back rather than trying to track which topology the user
|
|
# picked inside its interactive prompts.
|
|
local orch_env_file="${HOME}/.config/circuitforge/cf-orch.env"
|
|
if [ -f "$orch_env_file" ]; then
|
|
local cforch_mode cforch_coordinator_url
|
|
cforch_mode=$(grep -m1 '^CFORCH_MODE=' "$orch_env_file" | cut -d= -f2-)
|
|
cforch_coordinator_url=$(grep -m1 '^CFORCH_COORDINATOR_URL=' "$orch_env_file" | cut -d= -f2-)
|
|
record-cf-node-state "cf_orchard_role" "${cforch_mode:-unknown}"
|
|
record-cf-node-state "cf_orchard_coordinator_url" "$cforch_coordinator_url"
|
|
record-cf-node-state "cf_orchard_dir" "$clone_dir"
|
|
else
|
|
warn "Couldn't find ${orch_env_file} after install - orchard role won't show in quickinfo until it exists"
|
|
fi
|
|
}
|