feat: interactive CF Apps install menu with oem/collaborator/orchard profiles

Adds a manifest-driven "Install CircuitForge Apps" menu to install.sh,
alongside the existing extras-menu pattern.

- cf-apps/*.manifest: one file per product (circuitforge-core, peregrine,
  kiwi, snipe, turnstone, pagepiper, linnet - the beta/alpha menagerie
  products), declaring repo URLs, supported install types, conda env,
  .env template, and per-install-type setup hooks. Hooks prefer shelling
  out to each product's own install.sh/Makefile/docker-compose rather
  than reimplementing their setup logic.
- lib/cf-apps.functions: registry loader, provisioning-profile prompt
  (oem/collaborator/orchard), app multiselect menu, and the install
  dispatcher (clone, .env bootstrap, install-type selection, hook
  dispatch). circuitforge-core installs automatically as a dependency
  for apps that declare app_needs_core=true.

Provisioning profiles gate both which apps are offered and which remote
credentials are used:
- oem: public CircuitForgeLLC GitHub mirrors only, no Forgejo access,
  no circuitforge-orch (product install menu only)
- collaborator: private Circuit-Forge Forgejo, same product menu
- orchard: narrow flow, not the product menu - clones circuitforge-orch
  (Forgejo-only, no public mirror exists) and hands off interactively to
  its own install.sh, which gathers agent/coordinator topology itself
  (it has no --topology/--coordinator-url flags to script around).
  NOTE: circuitforge-orch has no model-sync/cache-sync mechanism today
  (checked its install.sh and README); this wrapper doesn't invent one.

Design rationale and survey of each product's real install story:
circuitforge-plans/cf-node-bootstrap/superpowers/plans/2026-07-17-cf-apps-install-menu.md
This commit is contained in:
pyr0ball 2026-07-17 20:42:21 -07:00
parent d72d8fb89c
commit 2a325b55bd
10 changed files with 468 additions and 0 deletions

39
cf-apps/README.md Normal file
View file

@ -0,0 +1,39 @@
# CF Apps manifests
One `.manifest` file per CircuitForge product, sourced by
`lib/cf-apps.functions`. Each is a plain bash file defining these
variables (see any existing manifest for a concrete example):
```bash
app_name="" # matches the filename minus .manifest
app_desc="" # one line, shown in the selection menu
app_repo_url_github="" # public CircuitForgeLLC mirror, used for the oem profile
app_repo_url_forgejo="" # private Circuit-Forge Forgejo, used for the collaborator profile
app_available_profiles=() # which of: oem collaborator
app_install_types=() # which of: bare-metal docker podman, in menu display order
app_conda_env="" # empty string if not applicable
app_env_template="" # path relative to repo root, empty if none
app_needs_core=false # true if circuitforge-core must be installed first
```
Optionally define hook functions for whichever install types the app
actually supports (skip the ones it doesn't):
```bash
app_setup_bare_metal() { local dir="$1"; ... }
app_setup_docker() { local dir="$1"; ... }
app_setup_podman() { local dir="$1"; ... }
```
`$1` is the app's clone directory. Prefer shelling out to the app's own
`install.sh`/`Makefile`/`docker compose` rather than reimplementing its
setup steps here — most CF products already have one.
If a hook is left undefined for an install type the app declares in
`app_install_types`, the dispatcher clones the repo and prints a warning
telling the user to finish setup manually, rather than silently doing
nothing.
See `circuitforge-plans/cf-node-bootstrap/superpowers/plans/2026-07-17-cf-apps-install-menu.md`
for the full design rationale, including the OEM/collaborator/orchard
provisioning-profile split.

View file

@ -0,0 +1,20 @@
app_name="circuitforge-core"
app_desc="Shared scaffold (db/llm/tiers/config) used by other CF products"
app_repo_url_github="https://github.com/CircuitForgeLLC/circuitforge-core.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git"
# Not directly user-selectable in the app menu (installed automatically as a
# dependency by install-cf-core-dependency), but still needs a profile list
# so its own clone step honors the oem/collaborator remote split.
app_available_profiles=(oem collaborator)
app_install_types=(bare-metal)
app_conda_env="cf"
app_env_template=""
app_needs_core=false
app_setup_bare_metal() {
local dir="$1"
run "conda run -n ${app_conda_env} pip install -e \"${dir}\""
if [[ $dry_run != true ]]; then
conda run -n "${app_conda_env}" pip install -e "${dir}"
fi
}

32
cf-apps/kiwi.manifest Normal file
View file

@ -0,0 +1,32 @@
app_name="kiwi"
app_desc="Pantry tracker + leftover recipe suggestions (beta)"
app_repo_url_github="https://github.com/CircuitForgeLLC/kiwi.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi.git"
app_available_profiles=(oem collaborator)
app_install_types=(docker bare-metal)
app_conda_env="kiwi"
app_env_template=".env.example"
app_needs_core=true
# kiwi has a single Dockerfile (no compose), listening on 8512. No
# persistent-storage volume is assumed here since that isn't documented;
# follow up manually if kiwi needs one for your data to survive a restart.
app_setup_docker() {
local dir="$1"
boxline "Building and running ${app_name} (port 8512)..."
(cd "$dir" && docker build -t "${app_name}" .)
(cd "$dir" && docker run -d --name "${app_name}" --env-file .env -p 8512:8512 "${app_name}")
}
# kiwi's bare-metal path is documented (docs/getting-started/installation.md,
# llm-setup.md - BYOK: Ollama/vLLM/OpenAI/Anthropic) but not scripted end to
# end. This creates the conda env from environment.yml; point the user at
# the docs for the LLM backend choice rather than guessing one.
app_setup_bare_metal() {
local dir="$1"
run "conda env create -f \"${dir}/environment.yml\" -n \"${app_conda_env}\""
if [[ $dry_run != true ]]; then
conda env create -f "${dir}/environment.yml" -n "${app_conda_env}"
fi
boxline "${app_name}: conda env '${app_conda_env}' created. See docs/getting-started/installation.md and llm-setup.md for the remaining BYOK LLM backend setup (Ollama/vLLM/OpenAI/Anthropic)."
}

17
cf-apps/linnet.manifest Normal file
View file

@ -0,0 +1,17 @@
app_name="linnet"
app_desc="Real-time tone annotation product (alpha)"
app_repo_url_github="https://github.com/CircuitForgeLLC/linnet.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/linnet.git"
app_available_profiles=(oem collaborator)
app_install_types=(docker)
app_conda_env=""
app_env_template=".env.example"
app_needs_core=true
# linnet also has compose.cloud.yml/compose.demo.yml/compose.test.yml;
# plain compose.yml is the fresh-node default.
app_setup_docker() {
local dir="$1"
boxline "Running docker compose up for ${app_name}..."
(cd "$dir" && docker compose -f compose.yml up -d)
}

View file

@ -0,0 +1,17 @@
app_name="pagepiper"
app_desc="Document/content pipeline tool (beta)"
app_repo_url_github="https://github.com/CircuitForgeLLC/pagepiper.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/pagepiper.git"
app_available_profiles=(oem collaborator)
app_install_types=(docker)
app_conda_env="pagepiper"
app_env_template=".env.example"
app_needs_core=true
# pagepiper also has compose.cloud.yml/compose.override.yml(.example) for
# cloud/dev variants; plain compose.yml is the fresh-node default.
app_setup_docker() {
local dir="$1"
boxline "Running docker compose up for ${app_name}..."
(cd "$dir" && docker compose -f compose.yml up -d)
}

View file

@ -0,0 +1,29 @@
app_name="peregrine"
app_desc="LLM-powered job discovery and application pipeline (beta)"
app_repo_url_github="https://github.com/CircuitForgeLLC/peregrine.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine.git"
app_available_profiles=(oem collaborator)
app_install_types=(bare-metal docker podman)
app_conda_env="cf"
app_env_template=".env.example"
app_needs_core=true
# peregrine ships its own install.sh (installs Docker/Podman + NVIDIA
# toolkit per-distro) and a Makefile (setup -> preflight -> start, with
# PROFILE=remote|cpu|single-gpu|dual-gpu). Shell out rather than
# reimplementing that logic here.
app_setup_bare_metal() {
local dir="$1"
boxline "Handing off to ${app_name}'s own install.sh..."
(cd "$dir" && ./install.sh)
}
app_setup_docker() {
local dir="$1"
boxline "Handing off to ${app_name}'s own install.sh (choose a docker/podman GPU profile when prompted)..."
(cd "$dir" && ./install.sh)
}
app_setup_podman() {
app_setup_docker "$1"
}

15
cf-apps/snipe.manifest Normal file
View file

@ -0,0 +1,15 @@
app_name="snipe"
app_desc="eBay trust-scoring tool (beta)"
app_repo_url_github="https://github.com/CircuitForgeLLC/snipe.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git"
app_available_profiles=(oem collaborator)
app_install_types=(docker)
app_conda_env=""
app_env_template=".env.example"
app_needs_core=true
app_setup_docker() {
local dir="$1"
boxline "Handing off to ${app_name}'s own install.sh..."
(cd "$dir" && ./install.sh)
}

View file

@ -0,0 +1,30 @@
app_name="turnstone"
app_desc="Log diagnostics tool (beta)"
app_repo_url_github="https://github.com/CircuitForgeLLC/turnstone.git"
app_repo_url_forgejo="https://git.opensourcesolarpunk.com/Circuit-Forge/turnstone.git"
app_available_profiles=(oem collaborator)
app_install_types=(bare-metal docker)
app_conda_env="cf"
app_env_template=""
app_needs_core=true
# turnstone's own install.sh finds/installs conda and sets up the "cf" env
# + pip deps; shell out rather than reimplementing that.
app_setup_bare_metal() {
local dir="$1"
boxline "Handing off to ${app_name}'s own install.sh..."
(cd "$dir" && ./install.sh)
}
# turnstone ships docker-standalone.sh alongside docker-compose.yml for a
# single-container run; prefer that as the simpler default entry point.
app_setup_docker() {
local dir="$1"
if [ -x "${dir}/docker-standalone.sh" ]; then
boxline "Running ${app_name}'s docker-standalone.sh..."
(cd "$dir" && ./docker-standalone.sh)
else
boxline "Running docker compose up for ${app_name}..."
(cd "$dir" && docker compose -f docker-compose.yml up -d)
fi
}

View file

@ -32,6 +32,11 @@ else
fi
fi
# Source CF Apps registry/installer (see cf-apps/README.md)
if [ -f "${rundir}/lib/cf-apps.functions" ] ; then
source "${rundir}/lib/cf-apps.functions"
fi
rundir_absolute=$(pushd $rundir ; pwd ; popd)
escape_dir=$(printf %q "${rundir_absolute}")
logfile="${rundir}/${pretty_date}_${scriptname}.log"
@ -416,6 +421,11 @@ userinstall(){
# launch extra installs
extras-menu
# Optionally provision this node with CircuitForge apps
if declare -F cf-provision-menu >/dev/null; then
cf-provision-menu
fi
if [[ $dry_run != true ]] ; then
boxborder "${grn}Please be sure to run ${lyl}sensors-detect --auto${grn} after installation completes${dfl}"
fi
@ -484,6 +494,11 @@ globalinstall(){
# Download and install any other extras
extras-menu
# Optionally provision this node with CircuitForge apps
if declare -F cf-provision-menu >/dev/null; then
cf-provision-menu
fi
#clear
}

254
lib/cf-apps.functions Normal file
View file

@ -0,0 +1,254 @@
#!/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}"
# 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
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)"
else
boxline "Cloning ${app_name} from ${repo_url}"
run "git clone --recurse-submodules \"${repo_url}\" \"${clone_dir}\""
if [[ $dry_run != true ]]; then
mkdir -p "$apps_base_dir"
git clone --recurse-submodules "$repo_url" "$clone_dir"
fi
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
}
# 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)"
else
boxline "Cloning circuitforge-orch from Forgejo"
run "git clone \"${repo_url}\" \"${clone_dir}\""
if [[ $dry_run != true ]]; then
mkdir -p "$apps_base_dir"
git clone "$repo_url" "$clone_dir"
fi
fi
boxline "Handing off to circuitforge-orch's own install.sh..."
(cd "$clone_dir" && ./install.sh)
}