From 2a325b55bd17260814c041d426080093900ae8bf Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 17 Jul 2026 20:42:21 -0700 Subject: [PATCH] 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 --- cf-apps/README.md | 39 +++++ cf-apps/circuitforge-core.manifest | 20 +++ cf-apps/kiwi.manifest | 32 ++++ cf-apps/linnet.manifest | 17 ++ cf-apps/pagepiper.manifest | 17 ++ cf-apps/peregrine.manifest | 29 ++++ cf-apps/snipe.manifest | 15 ++ cf-apps/turnstone.manifest | 30 ++++ install.sh | 15 ++ lib/cf-apps.functions | 254 +++++++++++++++++++++++++++++ 10 files changed, 468 insertions(+) create mode 100644 cf-apps/README.md create mode 100644 cf-apps/circuitforge-core.manifest create mode 100644 cf-apps/kiwi.manifest create mode 100644 cf-apps/linnet.manifest create mode 100644 cf-apps/pagepiper.manifest create mode 100644 cf-apps/peregrine.manifest create mode 100644 cf-apps/snipe.manifest create mode 100644 cf-apps/turnstone.manifest create mode 100644 lib/cf-apps.functions diff --git a/cf-apps/README.md b/cf-apps/README.md new file mode 100644 index 0000000..f520351 --- /dev/null +++ b/cf-apps/README.md @@ -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. diff --git a/cf-apps/circuitforge-core.manifest b/cf-apps/circuitforge-core.manifest new file mode 100644 index 0000000..a7d2862 --- /dev/null +++ b/cf-apps/circuitforge-core.manifest @@ -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 +} diff --git a/cf-apps/kiwi.manifest b/cf-apps/kiwi.manifest new file mode 100644 index 0000000..6198010 --- /dev/null +++ b/cf-apps/kiwi.manifest @@ -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)." +} diff --git a/cf-apps/linnet.manifest b/cf-apps/linnet.manifest new file mode 100644 index 0000000..d071248 --- /dev/null +++ b/cf-apps/linnet.manifest @@ -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) +} diff --git a/cf-apps/pagepiper.manifest b/cf-apps/pagepiper.manifest new file mode 100644 index 0000000..ad34722 --- /dev/null +++ b/cf-apps/pagepiper.manifest @@ -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) +} diff --git a/cf-apps/peregrine.manifest b/cf-apps/peregrine.manifest new file mode 100644 index 0000000..dc17518 --- /dev/null +++ b/cf-apps/peregrine.manifest @@ -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" +} diff --git a/cf-apps/snipe.manifest b/cf-apps/snipe.manifest new file mode 100644 index 0000000..f7255df --- /dev/null +++ b/cf-apps/snipe.manifest @@ -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) +} diff --git a/cf-apps/turnstone.manifest b/cf-apps/turnstone.manifest new file mode 100644 index 0000000..31cd330 --- /dev/null +++ b/cf-apps/turnstone.manifest @@ -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 +} diff --git a/install.sh b/install.sh index 0104385..8d51565 100755 --- a/install.sh +++ b/install.sh @@ -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 } diff --git a/lib/cf-apps.functions b/lib/cf-apps.functions new file mode 100644 index 0000000..d374ada --- /dev/null +++ b/lib/cf-apps.functions @@ -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) +}