Files
logos-workspace/scripts/ws
T

1819 lines
59 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ── Logos Workspace CLI ──────────────────────────────────────────────────────
# Unified interface for building, testing, and managing the multi-repo workspace.
WORKSPACE_ROOT="${LOGOS_WORKSPACE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
REPOS_DIR="$WORKSPACE_ROOT/repos"
DEP_GRAPH="$WORKSPACE_ROOT/nix/dep-graph.nix"
# Show help text and exit. Usage: show_help "text"
show_help() { echo -e "$1"; exit 0; }
# Check if any argument is --help or -h
wants_help() { for a in "$@"; do [[ "$a" == "--help" || "$a" == "-h" ]] && return 0; done; return 1; }
# Global quiet flag — suppresses informational output
QUIET=false
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Logging: log() is suppressed in quiet mode; errors always print to stderr
log() { $QUIET || echo -e "$@"; }
# ── Repo registry ───────────────────────────────────────────────────────────
# Format: "input_name|directory_name|git_url|has_flake"
# input_name: name in flake.nix inputs (empty string for non-flake repos)
# directory_name: submodule dir under repos/
# git_url: clone URL
# has_flake: "yes" or "no"
REPOS=(
# Foundation
"logos-cpp-sdk|logos-cpp-sdk|https://github.com/logos-co/logos-cpp-sdk.git|yes"
"logos-module|logos-module|https://github.com/logos-co/logos-module.git|yes"
"logos-liblogos|logos-liblogos|https://github.com/logos-co/logos-liblogos.git|yes"
"logos-capability-module|logos-capability-module|https://github.com/logos-co/logos-capability-module.git|yes"
"logos-package|logos-package|https://github.com/logos-co/logos-package.git|yes"
"logos-module-builder|logos-module-builder|https://github.com/logos-co/logos-module-builder.git|yes"
# Packaging / Bundling
"nix-bundle-dir|nix-bundle-dir|https://github.com/logos-co/nix-bundle-dir.git|yes"
"nix-bundle-lgx|nix-bundle-lgx|https://github.com/logos-co/nix-bundle-lgx.git|yes"
# App
"logos-app-poc|logos-app-poc|https://github.com/logos-co/logos-app-poc.git|yes"
# Accounts
"logos-accounts-module|logos-accounts-module|https://github.com/logos-co/logos-accounts-module.git|yes"
"logos-accounts-ui|logos-accounts-ui|https://github.com/logos-co/logos-accounts-ui.git|yes"
# Chat & Messaging
"logos-chat-module|logos-chat-module|https://github.com/logos-co/logos-chat-module.git|yes"
"logos-chat-legacy-module|logos-chat-legacy-module|https://github.com/logos-co/logos-chat-legacy-module.git|yes"
"logos-chat-tui|logos-chat-tui|https://github.com/logos-co/logos-chat-tui.git|yes"
"logos-chat-ui|logos-chat-ui|https://github.com/logos-co/logos-chat-ui.git|yes"
"logos-chatsdk-module|logos-chatsdk-module|https://github.com/logos-co/logos-chatsdk-module.git|yes"
"logos-chatsdk-ui|logos-chatsdk-ui|https://github.com/logos-co/logos-chatsdk-ui.git|yes"
"logos-irc-module|logos-irc-module|https://github.com/logos-co/logos-irc-module.git|yes"
# Waku
"logos-waku-module|logos-waku-module|https://github.com/logos-co/logos-waku-module.git|yes"
"logos-waku-ui|logos-waku-ui|https://github.com/logos-co/logos-waku-ui.git|yes"
# Package Manager
"logos-package-manager-module|logos-package-manager-module|https://github.com/logos-co/logos-package-manager-module.git|yes"
"logos-package-manager-ui|logos-package-manager-ui|https://github.com/logos-co/logos-package-manager-ui.git|yes"
# Storage
"logos-storage-module|logos-storage-module|https://github.com/logos-co/logos-storage-module.git|yes"
"logos-storage-ui|logos-storage-ui|https://github.com/logos-co/logos-storage-ui.git|yes"
# Wallet
"logos-wallet-module|logos-wallet-module|https://github.com/logos-co/logos-wallet-module.git|yes"
"logos-wallet-ui|logos-wallet-ui|https://github.com/logos-co/logos-wallet-ui.git|yes"
# Other modules
"logos-libp2p-module|logos-libp2p-module|https://github.com/logos-co/logos-libp2p-module.git|yes"
"logos-simple-module|logos-simple-module|https://github.com/logos-co/logos-simple-module.git|yes"
"logos-webview-app|logos-webview-app|https://github.com/logos-co/logos-webview-app.git|yes"
"counter_qml|counter_qml|https://github.com/logos-co/counter_qml.git|yes"
"counter|counter|https://github.com/logos-co/counter.git|yes"
# UI
"logos-design-system|logos-design-system|https://github.com/logos-co/logos-design-system.git|yes"
# SDKs
"logos-js-sdk|logos-js-sdk|https://github.com/logos-co/logos-js-sdk.git|yes"
"logos-nim-sdk|logos-nim-sdk|https://github.com/logos-co/logos-nim-sdk.git|yes"
"logos-rust-sdk|logos-rust-sdk|https://github.com/logos-co/logos-rust-sdk.git|yes"
# Other
"logos-modules|logos-modules|https://github.com/logos-co/logos-modules.git|yes"
"logos-module-viewer|logos-module-viewer|https://github.com/logos-co/logos-module-viewer.git|yes"
# Non-flake repos (submodules only)
"|logos-blockchain-module|https://github.com/logos-blockchain/logos-blockchain-module.git|no"
"|logos-template-module|https://github.com/logos-co/logos-template-module.git|no"
"|logos-docs|https://github.com/logos-co/logos-docs.git|no"
"|logos-website|https://github.com/logos-co/logos-website.git|no"
"|logos-tutorial|https://github.com/logos-co/logos-tutorial.git|no"
"|node-configs|https://github.com/logos-co/node-configs.git|no"
)
# ── Helper functions ─────────────────────────────────────────────────────────
get_field() {
echo "$1" | cut -d'|' -f"$2"
}
find_repo_entry() {
local query="$1"
for entry in "${REPOS[@]}"; do
local input_name dir_name
input_name=$(get_field "$entry" 1)
dir_name=$(get_field "$entry" 2)
if [[ "$dir_name" == "$query" || "$input_name" == "$query" ]]; then
echo "$entry"
return 0
fi
done
return 1
}
get_input_name() {
local dir_name="$1"
for entry in "${REPOS[@]}"; do
if [[ "$(get_field "$entry" 2)" == "$dir_name" ]]; then
echo "$(get_field "$entry" 1)"
return 0
fi
done
echo "$dir_name"
}
# Get repos that are dirty (have local uncommitted changes)
get_dirty_repos() {
local dirty=()
for entry in "${REPOS[@]}"; do
local dir_name
dir_name=$(get_field "$entry" 2)
local repo_path="$REPOS_DIR/$dir_name"
if [[ -d "$repo_path/.git" ]] || [[ -f "$repo_path/.git" ]]; then
if [[ -n "$(git -C "$repo_path" status --porcelain 2>/dev/null)" ]] || \
[[ -n "$(git -C "$repo_path" log '@{upstream}..HEAD' --oneline 2>/dev/null)" ]]; then
dirty+=("$dir_name")
fi
fi
done
echo "${dirty[@]}"
}
# Build --override-input flags for dirty repos
build_override_flags() {
local flags=()
local explicit_overrides=("$@")
for dir_name in "${explicit_overrides[@]}"; do
local input_name
input_name=$(get_input_name "$dir_name")
if [[ -n "$input_name" ]]; then
flags+=("--override-input" "$input_name" "path:$REPOS_DIR/$dir_name")
fi
done
echo "${flags[@]}"
}
# Check if a repo has tests according to dep-graph metadata
has_tests() {
local target="$1"
if [[ ! -f "$DEP_GRAPH" ]]; then
return 0 # assume yes if no metadata
fi
local result
result=$(nix eval --extra-experimental-features "nix-command flakes" \
--impure --raw --expr '
let graph = import '"$DEP_GRAPH"';
in if (graph.${"'"$target"'"} or null) == null then "unknown"
else if graph.${"'"$target"'"}.hasTests then "true"
else "false"
' 2>/dev/null)
[[ "$result" != "false" ]]
}
# Get dependents of a repo (repos that depend on it)
get_dependents() {
local target="$1"
if [[ ! -f "$DEP_GRAPH" ]]; then
return
fi
nix eval --extra-experimental-features "nix-command flakes" \
--impure --raw --expr '
let
graph = import '"$DEP_GRAPH"';
names = builtins.attrNames graph;
dependents = builtins.filter (n: builtins.elem "'"$target"'" graph.${n}.deps) names;
in builtins.concatStringsSep "\n" dependents
' 2>/dev/null
}
# ── Commands ─────────────────────────────────────────────────────────────────
cmd_init() {
wants_help "$@" && show_help "Usage: ws init [jobs]
Clone all submodules under repos/.
Arguments:
jobs Number of parallel clone jobs (default: 4)"
log "${BOLD}Initializing workspace submodules...${NC}"
local jobs="${1:-4}"
# Add submodules that don't exist yet
local count=0
local total=${#REPOS[@]}
for entry in "${REPOS[@]}"; do
local dir_name git_url
dir_name=$(get_field "$entry" 2)
git_url=$(get_field "$entry" 3)
count=$((count + 1))
if [[ -d "$REPOS_DIR/$dir_name/.git" ]] || [[ -f "$REPOS_DIR/$dir_name/.git" ]]; then
log " [${count}/${total}] ${GREEN}exists${NC} $dir_name"
continue
fi
log " [${count}/${total}] ${YELLOW}cloning${NC} $dir_name ..."
git -C "$WORKSPACE_ROOT" submodule add --depth 1 "$git_url" "repos/$dir_name" 2>/dev/null || \
echo -e " ${RED}failed${NC} $dir_name (may already be in .gitmodules)"
done
log ""
log "${BOLD}Initializing and updating submodules...${NC}"
git -C "$WORKSPACE_ROOT" submodule update --init --depth 1 --jobs "$jobs"
log ""
log "${GREEN}Done.${NC} $(ls -d "$REPOS_DIR"/*/ 2>/dev/null | wc -l) repos under repos/"
}
cmd_build() {
wants_help "$@" && show_help "Usage: ws build <repo> [options]
Build a repo via nix.
Options:
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
Examples:
ws build logos-app-poc
ws build logos-app-poc --auto-local
ws build logos-app-poc --local logos-cpp-sdk logos-liblogos"
local repo="$1"; shift
local auto_local=false
local local_overrides=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--auto-local|-a)
auto_local=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
*)
extra_args+=("$1"); shift ;;
esac
done
# Resolve input name
local input_name
input_name=$(get_input_name "$repo")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' has no flake input (non-flake repo)" >&2
exit 1
fi
# Auto-detect dirty repos
if $auto_local; then
for dir in $(get_dirty_repos); do
local name
name=$(get_input_name "$dir")
if [[ -n "$name" ]]; then
local_overrides+=("$dir")
fi
done
fi
# Build override flags
local flags=()
if [[ ${#local_overrides[@]} -gt 0 ]]; then
local unique_overrides
unique_overrides=($(printf '%s\n' "${local_overrides[@]}" | sort -u))
log "${CYAN}Local overrides:${NC}"
for dir in "${unique_overrides[@]}"; do
log " ${YELLOW}*${NC} $dir → path:$REPOS_DIR/$dir"
done
log ""
flags=($(build_override_flags "${unique_overrides[@]}"))
fi
log "${BOLD}Building ${CYAN}$input_name${NC}${BOLD}...${NC}"
exec nix build \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#$input_name" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
}
cmd_run() {
wants_help "$@" && show_help "Usage: ws run <repo> [options]
Build and run a repo via nix.
Options:
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
Examples:
ws run logos-app-poc
ws run logos-app-poc --auto-local
ws run logos-app-poc --local logos-cpp-sdk"
local repo="$1"; shift
local input_name
input_name=$(get_input_name "$repo")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' has no flake input" >&2
exit 1
fi
local auto_local=false
local local_overrides=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--auto-local|-a) auto_local=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
*)
extra_args+=("$1"); shift ;;
esac
done
if $auto_local; then
for dir in $(get_dirty_repos); do
local name
name=$(get_input_name "$dir")
[[ -n "$name" ]] && local_overrides+=("$dir")
done
fi
local flags=()
if [[ ${#local_overrides[@]} -gt 0 ]]; then
local unique=($(printf '%s\n' "${local_overrides[@]}" | sort -u))
log "${CYAN}Local overrides:${NC}"
for dir in "${unique[@]}"; do
log " ${YELLOW}*${NC} $dir"
done
log ""
flags=($(build_override_flags "${unique[@]}"))
fi
log "${BOLD}Running ${CYAN}$input_name${NC}${BOLD}...${NC}"
# For app repos, build first and check for Qt conflicts before launching.
case "$input_name" in
logos-app-poc|logos-app|logos-basecamp)
local store_path
store_path=$(nix build --extra-experimental-features "nix-command flakes" \
--no-link --print-out-paths "$WORKSPACE_ROOT#$input_name" \
${flags[@]+"${flags[@]}"}) || exit $?
# Check closure for multiple Qt versions
local qt_versions
qt_versions=$(nix-store -qR "$store_path" 2>/dev/null | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+' | sort -u || true)
local qt_count
qt_count=$(echo "$qt_versions" | grep -c . 2>/dev/null || true)
if [[ "$qt_count" -gt 1 ]]; then
echo -e "${YELLOW}WARNING: Multiple Qt versions found in closure:${NC}" >&2
echo "$qt_versions" | while read -r v; do echo -e " ${YELLOW}*${NC} $v" >&2; done
echo -e "${YELLOW}If the app crashes, run: ${BOLD}ws check-qt $repo${NC}" >&2
fi
# Check for stale local module cache
local app_qt
app_qt=$(nix-store -qR "$store_path" 2>/dev/null | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+' | sort -u | tail -1 || true)
if [[ -n "$app_qt" ]]; then
local cache_dir
if [[ "$(uname)" == "Darwin" ]]; then
cache_dir="$HOME/Library/Application Support/LogosAppNix"
else
cache_dir="${XDG_DATA_HOME:-$HOME/.local/share}/LogosAppNix"
fi
if [[ -d "$cache_dir" ]]; then
local cached_qt
cached_qt=$(find "$cache_dir" -type f \( -name "*.dylib" -o -name "*.so" \) -print0 2>/dev/null \
| xargs -0 strings 2>/dev/null | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+' | sort -u || true)
if [[ -n "$cached_qt" && "$cached_qt" != *"$app_qt"* ]]; then
echo -e "${RED}STALE MODULE CACHE: Local modules use $cached_qt but build uses $app_qt${NC}" >&2
echo -e "${YELLOW}Fix: rm -rf $(printf '%q' "$cache_dir")${NC}" >&2
echo -e "${YELLOW}Then run again.${NC}" >&2
exit 1
fi
fi
fi
# Run the already-built output directly from the store
exec nix run --extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#$input_name" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
;;
*)
exec nix run \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#$input_name" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
;;
esac
}
cmd_develop() {
wants_help "$@" && show_help "Usage: ws develop [repo] [options]
Enter a nix devShell. Without a repo, enters the workspace shell (zsh + tmux + tools).
With a repo, enters that repo's own devShell.
Options:
--fresh Force rebuild (clear cached shell environment)
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
Examples:
ws develop Enter the workspace devShell
ws develop --fresh Rebuild the workspace devShell from scratch
ws develop logos-cpp-sdk Enter logos-cpp-sdk's own devShell"
local repo=""
local auto_local=false
local fresh=false
local local_overrides=()
while [[ $# -gt 0 ]]; do
case "$1" in
--auto-local|-a) auto_local=true; shift ;;
--fresh) fresh=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*) shift ;;
*) [[ -z "$repo" ]] && repo="$1"; shift ;;
esac
done
if $auto_local; then
for dir in $(get_dirty_repos); do
local name
name=$(get_input_name "$dir")
[[ -n "$name" ]] && local_overrides+=("$dir")
done
fi
local flags=()
if [[ ${#local_overrides[@]} -gt 0 ]]; then
local unique=($(printf '%s\n' "${local_overrides[@]}" | sort -u))
flags=($(build_override_flags "${unique[@]}"))
fi
if [[ -z "$repo" ]]; then
# Workspace devShell
# Use cached dev environment to skip slow nix flake evaluation.
# Cache is invalidated when devshell.nix, flake.nix, or flake.lock change.
# Use --fresh to force rebuild.
local cache_dir="$WORKSPACE_ROOT/.dev-shell-cache"
local cache_env="$cache_dir/dev-env.sh"
local cache_hash_file="$cache_dir/hash"
if $fresh; then
rm -rf "$cache_dir"
fi
local flake_hash=""
if command -v shasum &>/dev/null; then
flake_hash=$(cat "$WORKSPACE_ROOT/nix/devshell.nix" "$WORKSPACE_ROOT/flake.nix" "$WORKSPACE_ROOT/flake.lock" 2>/dev/null | shasum | cut -d' ' -f1)
fi
# Check cache validity
local use_cache=false
if [[ ${#flags[@]} -eq 0 && -n "$flake_hash" && -f "$cache_hash_file" && -f "$cache_env" ]]; then
local cached_hash
cached_hash=$(cat "$cache_hash_file" 2>/dev/null)
[[ "$cached_hash" == "$flake_hash" ]] && use_cache=true
fi
if $use_cache; then
# Fast path: source cached env + run shellHook (sub-second)
set +eu
source "$cache_env"
eval "$shellHook"
return 0
fi
# Slow path: full nix evaluation
# Cache the result in background for next time
if [[ ${#flags[@]} -eq 0 && -n "$flake_hash" ]]; then
mkdir -p "$cache_dir"
echo ".dev-shell-cache/" >> "$WORKSPACE_ROOT/.gitignore" 2>/dev/null || true
nix print-dev-env \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT" > "$cache_env.tmp" 2>/dev/null && \
mv "$cache_env.tmp" "$cache_env" && \
echo "$flake_hash" > "$cache_hash_file" &
fi
exec nix develop \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT" \
${flags[@]+"${flags[@]}"}
else
# Sub-repo devShell — use the repo's own flake directly
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found at $repo_path" >&2
exit 1
fi
exec nix develop \
--extra-experimental-features "nix-command flakes" \
"path:$repo_path" \
${flags[@]+"${flags[@]}"}
fi
}
_detect_system() {
nix eval --extra-experimental-features "nix-command flakes" \
--impure --raw --expr builtins.currentSystem 2>/dev/null || echo "x86_64-linux"
}
# Parse a JSON array string like ["a","b","c"] into newline-separated values (no jq needed)
_json_array_to_lines() {
echo "$1" | tr -d '[]"' | tr ',' '\n' | sed '/^$/d'
}
# Discover checks and test-like packages in a single nix eval call
_discover_test_outputs() {
local repo_path="$1"
local system="$2"
# Single eval: get both checks and test-like packages at once
nix eval --extra-experimental-features "nix-command flakes" \
--impure --raw --expr '
let
flake = builtins.getFlake "path:'"$repo_path"'";
checks = builtins.attrNames (flake.checks.'"$system"' or {});
packages = builtins.attrNames (flake.packages.'"$system"' or {});
testPkgs = builtins.filter (n: builtins.match ".*[Tt]est.*" n != null) packages;
in builtins.toJSON { inherit checks testPkgs; }
' 2>/dev/null || echo '{"checks":[],"testPkgs":[]}'
}
# Run test for a single repo.
# Priority:
# 1. checks.<system>.tests (convention: runs tests)
# 2. Other checks.<system>.* (also run tests)
# 3. packages.<system>.*test* (test packages — build only)
# 4. Skip
_test_one_repo() {
local input_name="$1"
local dir_name="$2"
local system="$3"
local repo_path="$REPOS_DIR/$dir_name"
log "${BOLD}Testing ${CYAN}$dir_name${NC}${BOLD}...${NC}"
if [[ ! -d "$repo_path" ]]; then
echo -e " ${RED}not cloned${NC}"
return 1
fi
# Fast path: skip repos known to have no tests
local skip_known=false
has_tests "$input_name" || skip_known=true
if $skip_known; then
log " ${YELLOW}SKIP${NC} no tests (per nix/dep-graph.nix)"
return 2
fi
# Discover checks + test packages in one eval
log " ${BLUE}Discovering test outputs...${NC}"
local outputs
outputs=$(_discover_test_outputs "$repo_path" "$system")
local checks_json test_pkgs_json
checks_json=$(echo "$outputs" | sed 's/.*"checks":\([^,}]*\).*/\1/')
test_pkgs_json=$(echo "$outputs" | sed 's/.*"testPkgs":\([^}]*\).*/\1/')
local ran_something=false
local all_pass=true
# 1. Run checks (convention: checks.*.tests first, then others)
local checks
checks=$(_json_array_to_lines "$checks_json")
if [[ -n "$checks" ]]; then
# If "tests" exists, run it first (convention)
if echo "$checks" | grep -qx "tests"; then
ran_something=true
log " ${YELLOW}check${NC} tests"
if nix build --extra-experimental-features "nix-command flakes" \
"path:$repo_path#checks.$system.tests" -L; then
echo -e " ${GREEN}PASS${NC} tests"
else
echo -e " ${RED}FAIL${NC} tests"
all_pass=false
fi
fi
# Run any other checks
while IFS= read -r check; do
[[ -z "$check" || "$check" == "tests" ]] && continue
ran_something=true
log " ${YELLOW}check${NC} $check"
if nix build --extra-experimental-features "nix-command flakes" \
"path:$repo_path#checks.$system.$check" -L; then
echo -e " ${GREEN}PASS${NC} $check"
else
echo -e " ${RED}FAIL${NC} $check"
all_pass=false
fi
done <<< "$checks"
fi
# 2. If no checks, try test-like packages (build only)
if ! $ran_something; then
while IFS= read -r pkg; do
[[ -z "$pkg" ]] && continue
ran_something=true
log " ${YELLOW}package${NC} $pkg (build only — add checks.*.tests to run)"
if nix build --extra-experimental-features "nix-command flakes" \
"path:$repo_path#$pkg" -L; then
echo -e " ${GREEN}PASS${NC} $pkg"
else
echo -e " ${RED}FAIL${NC} $pkg"
all_pass=false
fi
done <<< "$(_json_array_to_lines "$test_pkgs_json")"
fi
# 3. Nothing found despite hasTests=true — metadata may be stale
if ! $ran_something; then
log " ${YELLOW}SKIP${NC} hasTests=true in dep-graph.nix but no checks/test packages found"
return 2
fi
$all_pass
}
cmd_test() {
wants_help "$@" && show_help "Usage: ws test [repo] [options]
Run checks/tests for a repo or all repos.
Options:
--all Test all repos (default if no repo given)
--type, -t <type> Filter by project type: cpp, rust, nim, js, qml
Examples:
ws test logos-module Test a single repo
ws test --all Test all repos
ws test --type cpp Test only C++ repos
ws test logos-module --type cpp"
local target=""
local test_type=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type|-t) test_type="$2"; shift 2 ;;
--all) target="--all"; shift ;;
-*) shift ;; # skip unknown flags
*)
# First non-flag arg is the repo name
[[ -z "$target" ]] && target="$1"
shift ;;
esac
done
local system
system=$(_detect_system)
if [[ "$target" == "--all" || -z "$target" ]]; then
log "${BOLD}Running tests across all repos (system: $system)${NC}"
log ""
local passed=0 failed=0 skipped=0
for entry in "${REPOS[@]}"; do
local input_name dir_name has_flake
input_name=$(get_field "$entry" 1)
dir_name=$(get_field "$entry" 2)
has_flake=$(get_field "$entry" 4)
if [[ "$has_flake" != "yes" || -z "$input_name" ]]; then
skipped=$((skipped + 1))
continue
fi
# Filter by type if specified
if [[ -n "$test_type" ]]; then
local repo_path="$REPOS_DIR/$dir_name"
local matches=false
case "$test_type" in
cpp|cmake)
[[ -f "$repo_path/CMakeLists.txt" ]] && matches=true ;;
rust|cargo)
[[ -f "$repo_path/Cargo.toml" ]] && matches=true ;;
nim|nimble)
ls "$repo_path"/*.nimble >/dev/null 2>&1 && matches=true ;;
js|node|npm)
[[ -f "$repo_path/package.json" ]] && matches=true ;;
qml)
ls "$repo_path"/*.qml >/dev/null 2>&1 && matches=true
[[ -f "$repo_path/CMakeLists.txt" ]] && grep -q "qml" "$repo_path/CMakeLists.txt" 2>/dev/null && matches=true ;;
*)
echo -e "${RED}Unknown type: $test_type${NC}" >&2
echo "Available types: cpp, rust, nim, js, qml" >&2
exit 1 ;;
esac
if ! $matches; then
skipped=$((skipped + 1))
continue
fi
fi
local rc=0
_test_one_repo "$input_name" "$dir_name" "$system" || rc=$?
if [[ $rc -eq 0 ]]; then
passed=$((passed + 1))
elif [[ $rc -eq 2 ]]; then
skipped=$((skipped + 1))
else
failed=$((failed + 1))
fi
log ""
done
echo -e "${BOLD}Results:${NC} ${GREEN}$passed passed${NC} ${RED}$failed failed${NC} ${YELLOW}$skipped skipped${NC}"
[[ $failed -eq 0 ]] || exit 1
else
# Single repo
local input_name dir_name
input_name=$(get_input_name "$target")
dir_name="$target"
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} '$target' has no flake input" >&2
exit 1
fi
local rc=0
_test_one_repo "$input_name" "$dir_name" "$system" || rc=$?
if [[ $rc -eq 1 ]]; then exit 1; fi
fi
}
cmd_status() {
wants_help "$@" && show_help "Usage: ws status
Show git status across all repos — branch, dirty/clean, ahead/behind.
Examples:
ws status"
log "${BOLD}Workspace status${NC}"
log ""
local dirty=0 clean=0 missing=0
for entry in "${REPOS[@]}"; do
local dir_name has_flake
dir_name=$(get_field "$entry" 2)
has_flake=$(get_field "$entry" 4)
local repo_path="$REPOS_DIR/$dir_name"
if [[ ! -d "$repo_path" ]]; then
echo -e " ${RED}missing${NC} $dir_name"
missing=$((missing + 1))
continue
fi
if [[ ! -d "$repo_path/.git" && ! -f "$repo_path/.git" ]]; then
echo -e " ${RED}no-git${NC} $dir_name"
missing=$((missing + 1))
continue
fi
local status
status=$(git -C "$repo_path" status --porcelain 2>/dev/null)
local branch
branch=$(git -C "$repo_path" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "???")
local ahead_behind
ahead_behind=$(git -C "$repo_path" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || echo "")
if [[ -n "$status" ]]; then
local modified added untracked
modified=$(echo "$status" | grep -c '^ M\| ^M' 2>/dev/null || echo 0)
added=$(echo "$status" | grep -c '^A ' 2>/dev/null || echo 0)
untracked=$(echo "$status" | grep -c '^??' 2>/dev/null || echo 0)
echo -e " ${YELLOW}dirty${NC} $dir_name ${BLUE}($branch)${NC} [M:$modified A:$added ?:$untracked]"
dirty=$((dirty + 1))
else
local marker="${GREEN}clean${NC}"
if [[ -n "$ahead_behind" ]]; then
local behind ahead
behind=$(echo "$ahead_behind" | awk '{print $1}')
ahead=$(echo "$ahead_behind" | awk '{print $2}')
if [[ "$ahead" -gt 0 ]]; then
marker="${GREEN}clean${NC} ${CYAN}+${ahead}${NC}"
fi
fi
echo -e " $marker $dir_name ${BLUE}($branch)${NC}"
clean=$((clean + 1))
fi
done
echo ""
echo -e " ${GREEN}$clean clean${NC} ${YELLOW}$dirty dirty${NC} ${RED}$missing missing${NC}"
}
cmd_dirty() {
wants_help "$@" && show_help "Usage: ws dirty
Show repos with uncommitted changes and what downstream repos they affect.
Examples:
ws dirty"
log "${BOLD}Repos with local changes:${NC}"
log ""
local dirty_repos
dirty_repos=($(get_dirty_repos))
if [[ ${#dirty_repos[@]} -eq 0 ]]; then
echo -e " ${GREEN}All repos are clean.${NC}"
return
fi
for dir_name in "${dirty_repos[@]}"; do
local input_name
input_name=$(get_input_name "$dir_name")
echo -e " ${YELLOW}*${NC} ${BOLD}$dir_name${NC}"
# Show what would be affected
local dependents
dependents=$(get_dependents "${input_name:-$dir_name}")
if [[ -n "$dependents" ]]; then
echo -e " affects: $(echo "$dependents" | tr '\n' ' ')"
fi
done
log ""
log "Use ${CYAN}ws build <repo> --auto-local${NC} to build with these overrides."
}
cmd_graph() {
wants_help "$@" && show_help "Usage: ws graph [repo]
Show the dependency graph. With a repo, shows its deps and dependents.
Without a repo, outputs the full graph in DOT format.
Examples:
ws graph logos-app-poc Show deps and dependents of a repo
ws graph Output full DOT graph
ws graph | dot -Tsvg > g.svg Render as SVG (requires graphviz)"
local target="${1:-}"
if [[ ! -f "$DEP_GRAPH" ]]; then
echo -e "${RED}Error:${NC} dep-graph.nix not found" >&2
exit 1
fi
if [[ -n "$target" ]]; then
log "${BOLD}Dependencies of ${CYAN}$target${NC}${BOLD}:${NC}"
nix eval --extra-experimental-features "nix-command flakes" --impure --raw --expr '
let graph = import '"$DEP_GRAPH"';
in builtins.concatStringsSep "\n" ((graph.${"'"$target"'"} or { deps = []; }).deps or [])
' 2>/dev/null | while IFS= read -r dep || [[ -n "$dep" ]]; do
[[ -n "$dep" ]] && echo -e " -> $dep"
done
log ""
log "${BOLD}Dependents (repos that depend on ${CYAN}$target${NC}${BOLD}):${NC}"
nix eval --extra-experimental-features "nix-command flakes" --impure --raw --expr '
let
graph = import '"$DEP_GRAPH"';
names = builtins.attrNames graph;
dependents = builtins.filter (n: builtins.elem "'"$target"'" graph.${n}.deps) names;
in builtins.concatStringsSep "\n" dependents
' 2>/dev/null | while IFS= read -r dep || [[ -n "$dep" ]]; do
[[ -n "$dep" ]] && echo -e " <- $dep"
done
else
# Full graph in DOT format
nix eval --extra-experimental-features "nix-command flakes" --impure --raw --expr '
let
graph = import '"$DEP_GRAPH"';
names = builtins.attrNames graph;
edges = builtins.concatMap (k:
map (dep: " \"" + dep + "\" -> \"" + k + "\";") graph.${k}.deps
) names;
in "digraph logos {\n rankdir=BT;\n node [shape=box, fontsize=10];\n\n"
+ builtins.concatStringsSep "\n" edges
+ "\n}"
' 2>/dev/null
echo ""
$QUIET || echo -e "${CYAN}Pipe to 'dot -Tsvg > graph.svg' for visualization${NC}" >&2
fi
}
cmd_override_inputs() {
wants_help "$@" && show_help "Usage: ws override-inputs <repo> [options]
Print the nix --override-input flags for building a repo with local overrides.
Options:
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
Examples:
ws override-inputs logos-app-poc --auto-local
ws override-inputs logos-app-poc --local logos-cpp-sdk"
local repo="$1"; shift || true
local auto_local=false
local local_overrides=()
while [[ $# -gt 0 ]]; do
case "$1" in
--auto-local|-a) auto_local=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
*) shift ;;
esac
done
if $auto_local; then
for dir in $(get_dirty_repos); do
local name
name=$(get_input_name "$dir")
[[ -n "$name" ]] && local_overrides+=("$dir")
done
fi
if [[ ${#local_overrides[@]} -eq 0 ]]; then
echo -e "${YELLOW}No local overrides specified.${NC}"
echo "Use --auto-local or --local <repo1> <repo2> ..."
return
fi
local unique=($(printf '%s\n' "${local_overrides[@]}" | sort -u))
log "${BOLD}Override flags for building ${CYAN}$repo${NC}${BOLD}:${NC}"
log ""
echo -n "nix build .#$repo"
for dir in "${unique[@]}"; do
local input_name
input_name=$(get_input_name "$dir")
echo -n " \\"
echo ""
echo -n " --override-input $input_name path:./repos/$dir"
done
echo ""
}
cmd_loc() {
wants_help "$@" && show_help "Usage: ws loc [repo] [options]
Count lines of code using tokei. Without a repo, counts the whole workspace.
Options:
--all Per-repo breakdown sorted by LOC
--no-nix, --code-only Exclude .nix, .lock, .json, .md files
Examples:
ws loc Count all code in workspace
ws loc logos-cpp-sdk Count code in a single repo
ws loc --all Per-repo breakdown
ws loc --all --no-nix Per-repo breakdown, code only"
local target="${1:-}"
shift || true
local exclude_nix=false
local repos_list=()
while [[ $# -gt 0 ]]; do
case "$1" in
--no-nix) exclude_nix=true; shift ;;
--code-only) exclude_nix=true; shift ;;
*) repos_list+=("$1"); shift ;;
esac
done
# Also check if first arg was a flag
case "$target" in
--no-nix|--code-only) exclude_nix=true; target="" ;;
--all) target="--all" ;;
esac
if ! command -v tokei &>/dev/null; then
echo -e "${YELLOW}tokei not found, installing...${NC}" >&2
nix profile install --extra-experimental-features "nix-command flakes" nixpkgs#tokei 2>/dev/null || {
echo -e "${RED}Error:${NC} could not install tokei. Run 'ws develop' or install tokei manually." >&2
exit 1
}
fi
local tokei_args=()
if $exclude_nix; then
tokei_args+=(-e '*.nix' -e '*.lock' -e 'flake.lock' -e '*.json' -e '*.md')
fi
if [[ "$target" == "--all" ]]; then
# Per-repo breakdown
local total_header_shown=false
for entry in "${REPOS[@]}"; do
local dir_name
dir_name=$(get_field "$entry" 2)
local repo_path="$REPOS_DIR/$dir_name"
[[ -d "$repo_path" ]] || continue
local count
count=$(tokei "$repo_path" ${tokei_args[@]+"${tokei_args[@]}"} --output json 2>/dev/null | \
sed -n 's/.*"Total":{[^}]*"code":\([0-9]*\).*/\1/p' 2>/dev/null || echo "0")
[[ -z "$count" ]] && count=0
if [[ "$count" -gt 0 ]]; then
printf " ${CYAN}%7s${NC} %s\n" "$count" "$dir_name"
fi
done | sort -rn -k1
echo ""
log "${BOLD}Total across all repos:${NC}"
tokei "$REPOS_DIR" ${tokei_args[@]+"${tokei_args[@]}"}
elif [[ -n "$target" ]]; then
# Single repo
local repo_path="$REPOS_DIR/$target"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$target' not found" >&2
exit 1
fi
tokei "$repo_path" ${tokei_args[@]+"${tokei_args[@]}"}
else
# Whole workspace
tokei "$REPOS_DIR" ${tokei_args[@]+"${tokei_args[@]}"}
fi
}
cmd_watch() {
wants_help "$@" && show_help "Usage: ws watch <test|build|run> [args]
Auto-run commands on file changes using watchexec.
Subcommands:
test <repo> [opts] Re-run tests when repo files change
build <repo> [opts] Re-build when repo files change
run <cmd> [-w repo ...] Run arbitrary command on file changes
Examples:
ws watch test logos-module
ws watch build logos-app-poc --auto-local
ws watch run 'ws test logos-module' -w logos-module -w logos-cpp-sdk"
local subcmd="${1:-}"
shift || true
if ! command -v watchexec &>/dev/null; then
echo -e "${YELLOW}watchexec not found, installing...${NC}" >&2
nix profile install --extra-experimental-features "nix-command flakes" nixpkgs#watchexec 2>/dev/null || {
echo -e "${RED}Error:${NC} could not install watchexec. Run 'ws develop' or install watchexec manually." >&2
exit 1
}
fi
case "$subcmd" in
test)
local repo="${1:-}"
if [[ -z "$repo" ]]; then
echo -e "${RED}Usage:${NC} ws watch test <repo> [ws-test-opts]" >&2
exit 1
fi
shift
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found" >&2
exit 1
fi
log "${BOLD}Watching ${CYAN}$repo${NC}${BOLD} for changes — running tests on save${NC}"
exec watchexec -w "$repo_path" --restart -- \
"$WORKSPACE_ROOT/scripts/ws" test "$repo" "$@"
;;
build)
local repo="${1:-}"
if [[ -z "$repo" ]]; then
echo -e "${RED}Usage:${NC} ws watch build <repo> [ws-build-opts]" >&2
exit 1
fi
shift
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found" >&2
exit 1
fi
log "${BOLD}Watching ${CYAN}$repo${NC}${BOLD} for changes — building on save${NC}"
exec watchexec -w "$repo_path" --restart -- \
"$WORKSPACE_ROOT/scripts/ws" build "$repo" "$@"
;;
run)
local cmd="${1:-}"
if [[ -z "$cmd" ]]; then
echo -e "${RED}Usage:${NC} ws watch run <command> [-w repo1 -w repo2 ...]" >&2
echo " Runs <command> whenever watched files change." >&2
echo "" >&2
echo "Examples:" >&2
echo " ws watch run 'ws test logos-module --auto-local' -w logos-module -w logos-cpp-sdk" >&2
echo " ws watch run 'echo changed' -w logos-liblogos" >&2
exit 1
fi
shift
# Collect -w flags and convert repo names to paths
local watch_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
-w)
local repo_name="$2"
local rpath="$REPOS_DIR/$repo_name"
if [[ -d "$rpath" ]]; then
watch_args+=(-w "$rpath")
else
watch_args+=(-w "$repo_name")
fi
shift 2
;;
*) shift ;;
esac
done
if [[ ${#watch_args[@]} -eq 0 ]]; then
watch_args=(-w "$REPOS_DIR")
fi
log "${BOLD}Watching for changes...${NC}"
exec watchexec "${watch_args[@]}" --restart -- bash -c "$cmd"
;;
*)
echo -e "${RED}Usage:${NC} ws watch <test|build|run>" >&2
echo "" >&2
echo " test <repo> [opts] Re-run tests on file changes" >&2
echo " build <repo> [opts] Re-build on file changes" >&2
echo " run <cmd> [-w repo...] Run arbitrary command on file changes" >&2
echo "" >&2
echo "Examples:" >&2
echo " ws watch test logos-module" >&2
echo " ws watch build logos-app-poc --auto-local" >&2
echo " ws watch run 'ws test logos-module --auto-local' -w logos-module -w logos-cpp-sdk" >&2
exit 1
;;
esac
}
cmd_foreach() {
wants_help "$@" && show_help "Usage: ws foreach <command>
Run a command in every cloned repo.
When the command is a single quoted string with spaces, it runs via bash -c
(so shell operators like && and || work).
Examples:
ws foreach git status -s
ws foreach 'git add . && git commit -m \"update\"'
ws foreach pwd"
local cmd=("$@")
if [[ ${#cmd[@]} -eq 0 ]]; then
echo -e "${RED}Usage:${NC} ws foreach <command>" >&2
exit 1
fi
for entry in "${REPOS[@]}"; do
local dir_name
dir_name=$(get_field "$entry" 2)
local repo_path="$REPOS_DIR/$dir_name"
if [[ ! -d "$repo_path" ]]; then
continue
fi
log "${BOLD}── $dir_name ──${NC}"
(cd "$repo_path" && if [[ ${#cmd[@]} -eq 1 && "${cmd[0]}" == *" "* ]]; then bash -c "${cmd[0]}"; else "${cmd[@]}"; fi) || true
log ""
done
}
cmd_worktree() {
wants_help "$@" && show_help "Usage: ws worktree <add|list|remove>
Manage workspace worktrees for isolated multi-repo feature branches.
Subcommands:
add <name> [-b branch] Create a worktree with submodules and ws/<branch> branches
list List all worktrees
remove <name> Remove a worktree
Examples:
ws worktree add my-feature
ws worktree add my-feature -b custom-branch
ws worktree list
ws worktree remove my-feature"
local subcmd="${1:-}"
shift || true
case "$subcmd" in
add)
local name=""
local branch=""
while [[ $# -gt 0 ]]; do
case "$1" in
-b) branch="$2"; shift 2 ;;
*) name="$1"; shift ;;
esac
done
if [[ -z "$name" ]]; then
echo -e "${RED}Usage:${NC} ws worktree add <name> [-b branch]" >&2
echo " Creates a workspace worktree with submodules initialized." >&2
echo " Each subrepo gets a ws/<branch> branch." >&2
exit 1
fi
branch="${branch:-$name}"
local wt_path="$WORKSPACE_ROOT/../logos-workspace--$name"
if [[ -d "$wt_path" ]]; then
echo -e "${RED}Worktree already exists:${NC} $wt_path" >&2
exit 1
fi
log "${BOLD}Creating workspace worktree...${NC}"
git -C "$WORKSPACE_ROOT" worktree add "$wt_path" -b "$branch"
log "${BOLD}Initializing submodules...${NC}"
(cd "$wt_path" && git submodule update --init --depth 1 --jobs 4)
local repo_branch="ws/$branch"
log "${BOLD}Creating branch ${CYAN}$repo_branch${NC}${BOLD} in all repos...${NC}"
for entry in "${REPOS[@]}"; do
local dir_name
dir_name=$(get_field "$entry" 2)
local repo_path="$wt_path/repos/$dir_name"
if [[ -d "$repo_path/.git" ]] || [[ -f "$repo_path/.git" ]]; then
(cd "$repo_path" && git checkout -b "$repo_branch" 2>/dev/null && \
log " ${GREEN}${NC} $dir_name") || \
log " ${YELLOW}skip${NC} $dir_name (branch may already exist)"
fi
done
log ""
echo -e "${GREEN}Worktree ready:${NC} $wt_path"
echo -e " Workspace branch: ${CYAN}$branch${NC}"
echo -e " Subrepo branches: ${CYAN}$repo_branch${NC}"
;;
list)
git -C "$WORKSPACE_ROOT" worktree list
;;
remove)
local name="${1:-}"
if [[ -z "$name" ]]; then
echo -e "${RED}Usage:${NC} ws worktree remove <name>" >&2
exit 1
fi
local wt_path="$WORKSPACE_ROOT/../logos-workspace--$name"
if [[ ! -d "$wt_path" ]]; then
echo -e "${RED}Worktree not found:${NC} $wt_path" >&2
exit 1
fi
log "${BOLD}Removing worktree...${NC}"
git -C "$WORKSPACE_ROOT" worktree remove "$wt_path" --force
echo -e "${GREEN}Removed:${NC} $wt_path"
;;
*)
echo -e "${RED}Usage:${NC} ws worktree <add|list|remove>" >&2
echo " add <name> [-b branch] Create a worktree with submodules and branches" >&2
echo " list List all worktrees" >&2
echo " remove <name> Remove a worktree" >&2
exit 1
;;
esac
}
cmd_update() {
wants_help "$@" && show_help "Usage: ws update [repo]
Update flake.lock inputs. Without a repo, updates all inputs.
Examples:
ws update Update all flake inputs
ws update logos-cpp-sdk Update a single input"
local target="${1:-}"
if [[ "$target" == "--all" || -z "$target" ]]; then
log "${BOLD}Updating all flake inputs...${NC}"
nix flake update \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT"
else
local input_name
input_name=$(get_input_name "$target")
log "${BOLD}Updating ${CYAN}$input_name${NC}${BOLD}...${NC}"
nix flake lock \
--extra-experimental-features "nix-command flakes" \
--update-input "$input_name" \
"$WORKSPACE_ROOT"
fi
}
cmd_list() {
wants_help "$@" && show_help "Usage: ws list
List all repos in the workspace with their flake and clone status.
Examples:
ws list"
log "${BOLD}Workspace repos (${#REPOS[@]} total)${NC}"
log ""
local current_section=""
for entry in "${REPOS[@]}"; do
local input_name dir_name has_flake
input_name=$(get_field "$entry" 1)
dir_name=$(get_field "$entry" 2)
has_flake=$(get_field "$entry" 4)
local flake_marker=""
if [[ "$has_flake" == "yes" ]]; then
flake_marker="${GREEN}flake${NC}"
else
flake_marker="${YELLOW}no-flake${NC}"
fi
local exists_marker=""
if [[ -d "$REPOS_DIR/$dir_name/.git" ]] || [[ -f "$REPOS_DIR/$dir_name/.git" ]]; then
exists_marker="${GREEN}cloned${NC}"
else
exists_marker="${RED}not-cloned${NC}"
fi
printf " %-35s %-15b %b\n" "$dir_name" "$flake_marker" "$exists_marker"
done
}
cmd_sync_graph() {
wants_help "$@" && show_help "Usage: ws sync-graph
Regenerate nix/dep-graph.nix by scanning each repo's flake.nix for
workspace dependencies and test outputs.
Examples:
ws sync-graph"
log "${BOLD}Syncing nix/dep-graph.nix from repo flake.nix files...${NC}"
log ""
# Build list of workspace input names
local ws_names=()
for entry in "${REPOS[@]}"; do
local iname
iname=$(get_field "$entry" 1)
[[ -n "$iname" ]] && ws_names+=("$iname")
done
# Find max name length for alignment
local max_len=0
for name in "${ws_names[@]}"; do
[[ ${#name} -gt $max_len ]] && max_len=${#name}
done
local tmpfile
tmpfile=$(mktemp)
cat > "$tmpfile" << 'HEADER'
# Dependency graph: flake input name → { deps, hasTests }
#
# deps: list of workspace input names this repo depends on
# (only logos-co deps; external deps like go-wallet-sdk are omitted)
# hasTests: whether this repo exposes checks or test packages in its flake
# Used by `ws test` to skip expensive nix eval for repos without tests.
#
# AUTO-GENERATED by `ws sync-graph`. To update: ws sync-graph
#
# If you add a `checks` output to a repo's flake.nix, re-run this command
# so that `ws test --all` picks it up.
{
HEADER
for entry in "${REPOS[@]}"; do
local input_name dir_name has_flake
input_name=$(get_field "$entry" 1)
dir_name=$(get_field "$entry" 2)
has_flake=$(get_field "$entry" 4)
[[ "$has_flake" != "yes" || -z "$input_name" ]] && continue
local flake_file="$REPOS_DIR/$dir_name/flake.nix"
if [[ ! -f "$flake_file" ]]; then
log " ${YELLOW}skip${NC} $dir_name (not cloned)"
continue
fi
# Find deps: which workspace inputs does this flake reference?
local deps=()
for ws_name in "${ws_names[@]}"; do
[[ "$ws_name" == "$input_name" ]] && continue # skip self
# Match by input name (name.url, name.follows, name = {)
# OR by URL referencing the repo (github:logos-co/<name>)
if grep -qE "^[[:space:]]+${ws_name}(\.|[[:space:]]*=)" "$flake_file" 2>/dev/null || \
grep -qE "logos-co/${ws_name}(\.git)?\"" "$flake_file" 2>/dev/null; then
deps+=("$ws_name")
fi
done
# Check hasTests: does the flake declare a checks output?
local has_tests="false"
if grep -qE '^[[:space:]]+checks[[:space:]]*=' "$flake_file" 2>/dev/null; then
has_tests="true"
fi
# Format deps
local deps_str="[]"
if [[ ${#deps[@]} -gt 0 ]]; then
deps_str="[ $(printf '"%s" ' "${deps[@]}")]"
fi
# Write entry with padding
local pad
pad=$(printf "%-${max_len}s" "$input_name")
echo " ${pad} = { deps = ${deps_str}; hasTests = ${has_tests}; };" >> "$tmpfile"
# Console status
local marker="${GREEN}ok${NC}"
[[ "$has_tests" == "true" ]] && marker="${GREEN}ok${NC} ${CYAN}(tests)${NC}"
log " $marker $input_name (${#deps[@]} deps)"
done
echo "}" >> "$tmpfile"
log ""
# Compare and update
if [[ -f "$DEP_GRAPH" ]] && diff -q "$DEP_GRAPH" "$tmpfile" >/dev/null 2>&1; then
echo -e "${GREEN}No changes${NC} — nix/dep-graph.nix is already up to date."
rm "$tmpfile"
else
if [[ -f "$DEP_GRAPH" ]]; then
echo -e "${BOLD}Changes:${NC}"
diff --color=auto -u "$DEP_GRAPH" "$tmpfile" || true
echo ""
fi
mv "$tmpfile" "$DEP_GRAPH"
echo -e "${GREEN}Updated${NC} nix/dep-graph.nix"
fi
}
cmd_check_qt() {
wants_help "$@" && show_help "Usage: ws check-qt <repo>
Check a repo's build for Qt version conflicts that cause runtime crashes
('Class QT_ROOT_LEVEL_POOL ... is implemented in both ...').
Checks two things:
1. Whether the repo's nix closure has a DIRECT dependency on multiple Qt
versions (broken follows chain — nix-level fix needed).
2. Whether the user's local module cache contains modules built against a
different Qt than the current build (stale cache — clear the data dir).
Examples:
ws check-qt logos-app-poc
ws check-qt logos-liblogos"
local repo="${1:?Usage: ws check-qt <repo>}"
local input_name
input_name=$(get_input_name "$repo")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' has no flake input" >&2
exit 1
fi
log "${BOLD}Checking Qt versions for ${CYAN}$input_name${NC}${BOLD}...${NC}"
local store_path
store_path=$(nix build --extra-experimental-features "nix-command flakes" \
--no-link --print-out-paths "$WORKSPACE_ROOT#$input_name" 2>/dev/null)
if [[ -z "$store_path" ]]; then
echo -e "${RED}Error:${NC} failed to build $input_name" >&2
exit 1
fi
# Find all qtbase store paths in the transitive closure
local all_qt_paths
all_qt_paths=$(nix-store -qR "$store_path" 2>/dev/null | grep 'qtbase-[0-9]' || true)
if [[ -z "$all_qt_paths" ]]; then
echo -e "${GREEN}OK:${NC} No Qt found in closure (non-Qt repo)"
return 0
fi
# Use nix why-depends to check which Qt versions the build DIRECTLY depends on.
# External deps (logos-chat, logos-delivery, etc.) may bring their own nixpkgs
# with a different qtbase transitively, but that's harmless if it's not loaded
# by the app at runtime.
local direct_qt=()
while IFS= read -r qt_path; do
if nix why-depends --extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#$input_name" "$qt_path" >/dev/null 2>&1; then
local ver
ver=$(echo "$qt_path" | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+')
direct_qt+=("$ver")
fi
done <<< "$all_qt_paths"
# Deduplicate
local unique_qt
unique_qt=$(printf '%s\n' "${direct_qt[@]}" | sort -u)
local qt_count
qt_count=$(echo "$unique_qt" | grep -c . 2>/dev/null || true)
if [[ "$qt_count" -gt 1 ]]; then
echo -e "${RED}CONFLICT:${NC} Build directly depends on multiple Qt versions:"
echo "$unique_qt" | while read -r v; do echo -e " ${YELLOW}*${NC} $v"; done
echo ""
echo -e "This ${BOLD}will crash${NC} at runtime. Fix the follows chain in flake.nix."
echo -e "Trace each version with:"
while IFS= read -r qt_path; do
local ver
ver=$(echo "$qt_path" | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+')
if nix why-depends --extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#$input_name" "$qt_path" >/dev/null 2>&1; then
echo -e " ${BOLD}nix why-depends .#$input_name $qt_path${NC}"
fi
done <<< "$all_qt_paths"
exit 1
elif [[ "$qt_count" -eq 1 ]]; then
echo -e "${GREEN}OK:${NC} Single Qt version in direct deps: $unique_qt"
fi
# Note transitive Qt versions (informational, not a build error)
local all_qt_versions
all_qt_versions=$(echo "$all_qt_paths" | grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
local all_qt_count
all_qt_count=$(echo "$all_qt_versions" | grep -c . 2>/dev/null || true)
if [[ "$all_qt_count" -gt 1 ]]; then
log ""
log "${YELLOW}Note:${NC} Transitive closure contains other Qt versions (from external deps):"
echo "$all_qt_versions" | while read -r v; do
if [[ "$unique_qt" != *"$v"* ]]; then
log " ${YELLOW}*${NC} $v (indirect — not loaded by $input_name)"
fi
done
log "This is harmless unless modules built against these versions are loaded at runtime."
fi
# Check local module cache for stale Qt
local app_qt
app_qt=$(echo "$unique_qt" | head -1)
if [[ -n "$app_qt" ]]; then
local cache_dirs=()
if [[ "$(uname)" == "Darwin" ]]; then
cache_dirs+=("$HOME/Library/Application Support/LogosAppNix/modules")
cache_dirs+=("$HOME/Library/Application Support/LogosAppNix/plugins")
cache_dirs+=("$HOME/Library/Application Support/LogosApp/modules")
cache_dirs+=("$HOME/Library/Application Support/LogosApp/plugins")
else
local xdg="${XDG_DATA_HOME:-$HOME/.local/share}"
cache_dirs+=("$xdg/LogosAppNix/modules" "$xdg/LogosAppNix/plugins")
cache_dirs+=("$xdg/LogosApp/modules" "$xdg/LogosApp/plugins")
fi
local stale_found=false
for dir in "${cache_dirs[@]}"; do
[[ -d "$dir" ]] || continue
local cached_qt
cached_qt=$(find "$dir" -type f \( -name "*.dylib" -o -name "*.so" \) -exec strings {} + 2>/dev/null \
| grep -oE 'qtbase-[0-9]+\.[0-9]+\.[0-9]+' | sort -u || true)
if [[ -n "$cached_qt" && "$cached_qt" != *"$app_qt"* ]]; then
if ! $stale_found; then
echo ""
echo -e "${RED}STALE MODULE CACHE:${NC} Local modules reference a different Qt:"
stale_found=true
fi
echo -e " ${YELLOW}$dir${NC}$cached_qt (build uses $app_qt)"
fi
done
if $stale_found; then
echo ""
echo -e "${YELLOW}Fix:${NC} Clear the stale cache:"
if [[ "$(uname)" == "Darwin" ]]; then
echo -e " ${BOLD}rm -rf ~/Library/Application\\ Support/LogosAppNix${NC}"
else
echo -e " ${BOLD}rm -rf \${XDG_DATA_HOME:-~/.local/share}/LogosAppNix${NC}"
fi
exit 1
fi
if ! $stale_found; then
echo -e "${GREEN}OK:${NC} No stale modules in local cache"
fi
fi
}
cmd_help() {
cat <<'EOF'
Logos Workspace CLI
USAGE:
ws [--quiet] <command> [args]
GLOBAL OPTIONS:
--quiet, -q Suppress informational output (for CI/scripting)
COMMANDS:
init [jobs] Clone all submodules (default: 4 parallel jobs)
list List all repos and their status
build <repo> [opts] Build a repo via nix
--auto-local, -a Auto-detect dirty repos and use as overrides
--local, -l <r1> <r2> ... Use specific local repos as overrides
run <repo> [opts] Build and run a repo (same options as build)
develop [repo] [opts] Enter a nix devShell (workspace shell if no repo)
--auto-local, -a Include overrides for dirty repos
--local, -l <r1> <r2> ... Include specific local overrides
--fresh Force rebuild (ignore cache)
test [repo|--all] [--type T] Run checks/tests
--type, -t <type> Filter by: cpp, rust, nim, js, qml
status Git status across all repos
dirty Show dirty repos and what they affect
graph [repo] Show dependency graph (DOT format, or repo details)
override-inputs <repo> [opts] Show the nix --override-input flags that would be used
update [repo|--all] Update flake.lock inputs
loc [repo|--all] [--no-nix] Count lines of code (uses tokei)
watch test <repo> Re-run tests on file changes
watch build <repo> Re-build on file changes
watch run <cmd> [-w repo...] Run command on file changes
foreach <command> Run a command in every repo directory
worktree add <name> [-b br] Create a worktree with submodules and ws/<branch> branches
worktree list List all worktrees
worktree remove <name> Remove a worktree
sync-graph Regenerate nix/dep-graph.nix from repo flake.nix files
check-qt <repo> Check for Qt version conflicts in a repo's nix closure
lm <args> Module inspector (auto-builds logos-module)
logoscore <args> Headless runtime (auto-builds logos-liblogos)
lgx <args> Package tool (auto-builds logos-package)
lgpm <args> Package manager (auto-builds logos-package-manager-module)
logos-cpp-generator <args> SDK code generator (auto-builds logos-cpp-sdk)
help Show this help
EXAMPLES:
# Set up the workspace
ws init
# Edit logos-cpp-sdk, then build logos-app-poc using your local changes
cd repos/logos-cpp-sdk && vim src/something.cpp
ws build logos-app-poc --auto-local
# Explicitly override specific deps
ws build logos-app-poc --local logos-cpp-sdk logos-liblogos
# Run all tests
ws test --all
# Run only C++ repo tests
ws test --all --type cpp
# Test a specific repo
ws test logos-cpp-sdk
# See what your dirty repos affect
ws dirty
# Enter logos-app-poc's devShell with local logos-cpp-sdk
ws develop logos-app-poc --local logos-cpp-sdk
# Visualize the dependency graph
ws graph | dot -Tsvg > graph.svg
# See deps of a specific repo
ws graph logos-app-poc
# Run git pull in every repo
ws foreach git pull
# Show override flags without executing
ws override-inputs logos-app-poc --auto-local
# Create an isolated worktree for a feature branch
ws worktree add my-feature -b my-feature-branch
# Result: ../logos-workspace--my-feature/ with all repos on ws/my-feature-branch
# Remove a worktree when done
ws worktree remove my-feature
# Lines of code across all repos
ws loc --all --no-nix
# Lines of code for a single repo
ws loc logos-cpp-sdk
# Auto-run tests when files change
ws watch test logos-module
# Auto-build on changes with local overrides
ws watch build logos-app-poc --auto-local
# Watch specific repos and run a custom command
ws watch run 'ws test logos-module --auto-local' -w logos-module -w logos-cpp-sdk
# Regenerate dep-graph.nix after adding tests to a repo
ws sync-graph
EOF
}
# ── Parse global flags ────────────────────────────────────────────────────────
_args=()
for _arg in "$@"; do
case "$_arg" in
--quiet|-q) QUIET=true ;;
*) _args+=("$_arg") ;;
esac
done
set -- "${_args[@]}"
if $QUIET; then
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' NC=''
fi
# ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-help}" in
init) shift; cmd_init "$@" ;;
build) shift; cmd_build "$@" ;;
run) shift; cmd_run "$@" ;;
develop|dev) shift; cmd_develop "$@" ;;
test) shift; cmd_test "$@" ;;
status|st) shift; cmd_status "$@" ;;
dirty) shift; cmd_dirty "$@" ;;
graph) shift; cmd_graph "$@" ;;
override-inputs|oi) shift; cmd_override_inputs "$@" ;;
update) shift; cmd_update "$@" ;;
loc) shift; cmd_loc "$@" ;;
watch) shift; cmd_watch "$@" ;;
foreach) shift; cmd_foreach "$@" ;;
worktree|wt) shift; cmd_worktree "$@" ;;
sync-graph) shift; cmd_sync_graph "$@" ;;
check-qt) shift; cmd_check_qt "$@" ;;
list|ls) shift; cmd_list "$@" ;;
help|--help|-h) cmd_help ;;
# CLI tool wrappers — delegate to scripts/
lm|logoscore|lgx|lgpm|logos-cpp-generator)
exec "$WORKSPACE_ROOT/scripts/$1" "${@:2}" ;;
*)
echo -e "${RED}Unknown command:${NC} $1" >&2
echo "Run 'ws help' for usage." >&2
exit 1
;;
esac