Files
logos-workspace/scripts/ws
T

2977 lines
98 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"
# shellcheck source=scripts/_standalone-app
source "$WORKSPACE_ROOT/scripts/_standalone-app"
# 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" == "--" ]] && return 1; [[ "$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=(
# Shared Nix infrastructure
"logos-nix|logos-nix|https://github.com/logos-co/logos-nix.git|yes"
# 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-basecamp|logos-basecamp|https://github.com/logos-co/logos-basecamp.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"
# Blockchain / Execution zone (LEX)
"logos-blockchain-module|logos-blockchain-module|https://github.com/logos-blockchain/logos-blockchain-module.git|yes"
"logos-blockchain-ui|logos-blockchain-ui|https://github.com/logos-blockchain/logos-blockchain-ui.git|yes"
"logos-execution-zone-wallet-ui|logos-execution-zone-wallet-ui|https://github.com/logos-blockchain/logos-execution-zone-wallet-ui.git|yes"
"logos-execution-zone-module|logos-execution-zone-module|https://github.com/logos-blockchain/logos-execution-zone-module.git|yes"
# Other modules
"logos-libp2p-module|logos-libp2p-module|https://github.com/logos-co/logos-libp2p-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"
# Testing
"logos-test-modules|logos-test-modules|https://github.com/logos-co/logos-test-modules.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-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"
)
# ── Repo groups ──────────────────────────────────────────────────────────────
# Format: "group_name|member1,member2,..."
# Members can be repo names or other group names (resolved recursively).
REPO_GROUPS=(
"core|logos-cpp-sdk,logos-module,logos-liblogos,logos-module-builder,logos-capability-module,logos-package-manager-module,logos-test-modules"
"chat|core,logos-chat-module,logos-chatsdk-module,logos-waku-module,logos-irc-module"
"package-manager|logos-package,logos-package-manager-module,logos-package-manager-ui,nix-bundle-lgx"
"app|core,logos-basecamp,logos-package-manager-ui"
)
# ── 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"
}
# Check if a name is a defined group
is_group() {
local name="$1"
for entry in "${REPO_GROUPS[@]}"; do
if [[ "$(echo "$entry" | cut -d'|' -f1)" == "$name" ]]; then
return 0
fi
done
return 1
}
# Expand a group name into a newline-separated list of repo names.
# Recursively resolves nested group references; deduplicates results.
expand_group() {
local name="$1"
local _visited="${2:-}"
# Cycle detection
if echo "$_visited" | grep -qw "$name" 2>/dev/null; then
return
fi
_visited="$_visited $name"
local found=false
for entry in "${REPO_GROUPS[@]}"; do
local gname members
gname=$(echo "$entry" | cut -d'|' -f1)
if [[ "$gname" == "$name" ]]; then
members=$(echo "$entry" | cut -d'|' -f2)
found=true
IFS=',' read -ra tokens <<< "$members"
for token in "${tokens[@]}"; do
if is_group "$token"; then
expand_group "$token" "$_visited"
else
echo "$token"
fi
done
break
fi
done
if ! $found; then
echo -e "${RED}Error:${NC} unknown group '$name'" >&2
echo "Available groups: $(printf '%s\n' "${REPO_GROUPS[@]}" | cut -d'|' -f1 | tr '\n' ' ')" >&2
exit 1
fi
}
# 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[@]+"${dirty[@]}"}
}
# Get all cloned repos that have flakes (for --workspace override)
get_all_flake_repos() {
local repos=()
for entry in "${REPOS[@]}"; do
local dir_name has_flake
dir_name=$(get_field "$entry" 2)
has_flake=$(get_field "$entry" 4)
if [[ "$has_flake" == "yes" ]] && [[ -d "$REPOS_DIR/$dir_name" ]]; then
repos+=("$dir_name")
fi
done
echo "${repos[@]}"
}
# 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[#output]...|--group name> [options]
Build one or more repos via nix.
Use repo#output to build a specific flake output instead of the default.
For example: ws build logos-basecamp#portable
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
--workspace, -w Override all deps with local workspace repos
Examples:
ws build logos-basecamp
ws build logos-basecamp#portable
ws build logos-basecamp logos-liblogos
ws build --group core
ws build logos-basecamp --auto-local
ws build logos-basecamp --local logos-cpp-sdk logos-liblogos
ws build logos-basecamp#bin-bundle-dir --local nix-bundle-lgx logos-liblogos
ws build logos-basecamp --workspace"
local repos=()
local auto_local=false
local use_workspace=false
local local_overrides=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
--auto-local|--autolocal|-a)
auto_local=true; shift ;;
--workspace|-w)
use_workspace=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*)
extra_args+=("$1"); shift ;;
*)
repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Error:${NC} no repo specified" >&2
echo "Usage: ws build <repo...> [options]" >&2
exit 1
fi
# Override all workspace repos
if $use_workspace; then
for dir in $(get_all_flake_repos); do
local_overrides+=("$dir")
done
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
if [[ ${#repos[@]} -eq 1 ]]; then
local repo_spec="${repos[0]}"
local repo_name="${repo_spec%%#*}"
local output_suffix=""
[[ "$repo_spec" == *"#"* ]] && output_suffix="${repo_spec#*#}"
local input_name
input_name=$(get_input_name "$repo_name")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '$repo_name' has no flake input (non-flake repo)" >&2
exit 1
fi
# All repo outputs are exposed through the workspace flake:
# default → .#<input_name>, non-default → .#<input_name>--<output>
local nix_ref
if [[ -n "$output_suffix" ]]; then
nix_ref="$WORKSPACE_ROOT#$input_name--$output_suffix"
else
nix_ref="$WORKSPACE_ROOT#$input_name"
fi
local display_name="$input_name"
[[ -n "$output_suffix" ]] && display_name="$input_name#$output_suffix"
log "${BOLD}Building ${CYAN}$display_name${NC}${BOLD}...${NC}"
exec nix build \
--extra-experimental-features "nix-command flakes" \
"$nix_ref" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
fi
local failed=0
for repo_spec in "${repos[@]}"; do
local repo_name="${repo_spec%%#*}"
local output_suffix=""
[[ "$repo_spec" == *"#"* ]] && output_suffix="${repo_spec#*#}"
local input_name
input_name=$(get_input_name "$repo_name")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '$repo_name' has no flake input (non-flake repo)" >&2
failed=$((failed + 1))
continue
fi
local nix_ref
if [[ -n "$output_suffix" ]]; then
nix_ref="$WORKSPACE_ROOT#$input_name--$output_suffix"
else
nix_ref="$WORKSPACE_ROOT#$input_name"
fi
local display_name="$input_name"
[[ -n "$output_suffix" ]] && display_name="$input_name#$output_suffix"
log "${BOLD}Building ${CYAN}$display_name${NC}${BOLD}...${NC}"
if ! nix build \
--extra-experimental-features "nix-command flakes" \
"$nix_ref" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}; then
echo -e "${RED}FAIL${NC} $display_name"
failed=$((failed + 1))
else
echo -e "${GREEN}OK${NC} $display_name"
fi
log ""
done
[[ $failed -eq 0 ]] || exit 1
}
cmd_bundle() {
wants_help "$@" && show_help "Usage: ws bundle <repo...|--group name> [options]
Bundle one or more repos into .lgx packages using nix-bundle-lgx.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
--workspace, -w Override all deps with local workspace repos
--mode, -m <dev|portable|dual> Bundle mode (default: dev)
-o, --out-dir <path> Output directory for multi-repo bundles (default: result-bundle)
Examples:
ws bundle logos-chat-module
ws bundle logos-chat-module --mode portable
ws bundle logos-chat-module logos-waku-module --auto-local
ws bundle --group core --mode dual"
local repos=()
local auto_local=false
local use_workspace=false
local local_overrides=()
local extra_args=()
local bundle_mode="dev"
local out_dir="result-bundle"
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
--auto-local|--autolocal|-a)
auto_local=true; shift ;;
--workspace|-w)
use_workspace=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
--mode|-m)
shift
if [[ $# -eq 0 ]]; then
echo -e "${RED}Error:${NC} --mode requires an argument (dev, portable, dual)" >&2
exit 1
fi
bundle_mode="$1"
if [[ "$bundle_mode" != "dev" && "$bundle_mode" != "portable" && "$bundle_mode" != "dual" ]]; then
echo -e "${RED}Error:${NC} invalid mode '$bundle_mode' (must be dev, portable, or dual)" >&2
exit 1
fi
shift ;;
-o|--out-dir)
shift
if [[ $# -eq 0 ]]; then
echo -e "${RED}Error:${NC} --out-dir requires an argument" >&2
exit 1
fi
out_dir="$1"; shift ;;
-*)
extra_args+=("$1"); shift ;;
*)
repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Error:${NC} no repo specified" >&2
echo "Usage: ws bundle <repo...> [options]" >&2
exit 1
fi
# Resolve bundler variant
local bundler_ref="path:$REPOS_DIR/nix-bundle-lgx"
case "$bundle_mode" in
dev) ;; # default bundler
portable) bundler_ref="$bundler_ref#portable" ;;
dual) bundler_ref="$bundler_ref#dual" ;;
esac
# Override all workspace repos
if $use_workspace; then
for dir in $(get_all_flake_repos); do
local_overrides+=("$dir")
done
fi
# Auto-detect dirty repos
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
# 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
# Deduplicate repos
repos=($(printf '%s\n' "${repos[@]}" | awk '!seen[$0]++'))
# Resolve bundle target: prefer the --lib output (has drv.src with metadata.json
# needed by nix-bundle-lgx), fall back to the default package.
_bundle_target() {
local name="$1"
# Check if the --lib package exists in the workspace flake
if nix eval --extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT#${name}--lib" --apply 'x: true' &>/dev/null; then
echo "${name}--lib"
else
echo "$name"
fi
}
if [[ ${#repos[@]} -eq 1 ]]; then
local input_name
input_name=$(get_input_name "${repos[0]}")
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} repo '${repos[0]}' has no flake input (non-flake repo)" >&2
exit 1
fi
local target
target=$(_bundle_target "$input_name")
log "${BOLD}Bundling ${CYAN}$input_name${NC}${BOLD} (mode: $bundle_mode)...${NC}"
exec nix bundle \
--extra-experimental-features "nix-command flakes" \
--bundler "$bundler_ref" \
-o result \
"$WORKSPACE_ROOT#$target" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
fi
local failed=0
for repo in "${repos[@]}"; do
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
failed=$((failed + 1))
continue
fi
local target
target=$(_bundle_target "$input_name")
log "${BOLD}Bundling ${CYAN}$input_name${NC}${BOLD} (mode: $bundle_mode)...${NC}"
if ! nix bundle \
--extra-experimental-features "nix-command flakes" \
--bundler "$bundler_ref" \
-o "$out_dir/$input_name" \
"$WORKSPACE_ROOT#$target" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}; then
echo -e "${RED}FAIL${NC} $input_name"
failed=$((failed + 1))
else
echo -e "${GREEN}OK${NC} $input_name$out_dir/$input_name/"
fi
log ""
done
if [[ $failed -eq 0 ]]; then
log "${GREEN}All ${#repos[@]} repos bundled successfully.${NC}"
log "Output: $out_dir/"
fi
[[ $failed -eq 0 ]] || exit 1
}
cmd_run() {
wants_help "$@" && show_help "Usage: ws run <repo|--group name> [options] [-- <app-args>]
Build and run a repo via nix. Only one repo at a time.
Options:
--group, -g <name> Use a named repo group (see 'ws groups')
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
logos-standalone-app extras (passed after --):
<ui-plugin-repo> Repo name to bundle via nix-bundle-lgx and install
into ./plugins, or an existing .dylib / plugin dir path.
--modules, -M <repo>[,...] Backend module repo(s) to bundle and install into
./modules. logos-capability-module is always auto-included.
Examples:
ws run logos-basecamp
ws run logos-basecamp --auto-local
ws run logos-basecamp --local logos-cpp-sdk
ws run logos-standalone-app -- logos-accounts-ui --modules logos-accounts-module
ws run logos-standalone-app -- ./repos/logos-accounts-ui/result/lib/accounts_ui.dylib"
local repos=()
local auto_local=false
local local_overrides=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--)
shift; extra_args+=("$@"); break ;;
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
--auto-local|--autolocal|-a) auto_local=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*)
extra_args+=("$1"); shift ;;
*)
repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Error:${NC} no repo specified" >&2
echo "Usage: ws run <repo> [options]" >&2
exit 1
fi
if [[ ${#repos[@]} -gt 1 ]]; then
echo -e "${RED}Error:${NC} ws run only supports a single repo (got: ${repos[*]})" >&2
exit 1
fi
local repo="${repos[0]}"
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
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-standalone-app)
cmd_run_standalone_app "$input_name"
;;
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/LogosBasecampNix"
else
cache_dir="${XDG_DATA_HOME:-$HOME/.local/share}/LogosBasecampNix"
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.
# Always pass -- before extra_args so app flags (e.g. --help, --plugin)
# are not consumed by nix run itself.
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...|--group name] [options]
Enter a nix devShell. Without a repo, enters the workspace shell (zsh + tmux + tools).
With one or more repos, enters each repo's own devShell sequentially.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--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
ws develop --group core Enter each core repo's devShell sequentially
ws develop logos-cpp-sdk logos-liblogos Enter each devShell sequentially"
local repos=()
local auto_local=false
local fresh=false
local local_overrides=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
--auto-local|--autolocal|-a) auto_local=true; shift ;;
--fresh) fresh=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*) shift ;;
*) repos+=("$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 [[ ${#repos[@]} -eq 0 ]]; 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[@]}"}
elif [[ ${#repos[@]} -eq 1 ]]; then
local repo_path="$REPOS_DIR/${repos[0]}"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '${repos[0]}' not found at $repo_path" >&2
exit 1
fi
exec nix develop \
--extra-experimental-features "nix-command flakes" \
"path:$repo_path" \
${flags[@]+"${flags[@]}"}
else
for repo in "${repos[@]}"; do
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found at $repo_path" >&2
continue
fi
log "${BOLD}Entering devShell for ${CYAN}$repo${NC}${BOLD}...${NC}"
nix develop \
--extra-experimental-features "nix-command flakes" \
"path:$repo_path" \
${flags[@]+"${flags[@]}"}
done
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 override_flags="${4:-}"
local use_workspace_flake="${5:-false}"
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 (always from repo flake for discovery)
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/')
# Build the flake ref for checks and packages.
# When overrides are active (--local, --auto-local, --workspace), build from the
# workspace flake so that follows declarations propagate overrides to transitive deps.
# Without overrides, build from the individual repo flake (matches standalone nix flake check).
local check_ref pkg_ref
if [[ "$use_workspace_flake" == "true" ]]; then
# Workspace flake prefixes check names: checks.<system>.<input_name>--<check>
check_ref="$WORKSPACE_ROOT#checks.$system.${input_name}--"
pkg_ref="$WORKSPACE_ROOT#${input_name}--"
else
check_ref="path:$repo_path#checks.$system."
pkg_ref="path:$repo_path#"
fi
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"
# shellcheck disable=SC2086
if nix build --extra-experimental-features "nix-command flakes" \
"${check_ref}tests" $override_flags -L; then
echo -e " ${GREEN}PASS${NC} ${dir_name} tests"
else
echo -e " ${RED}FAIL${NC} ${dir_name} 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"
# shellcheck disable=SC2086
if nix build --extra-experimental-features "nix-command flakes" \
"${check_ref}${check}" $override_flags -L; then
echo -e " ${GREEN}PASS${NC} ${dir_name} $check"
else
echo -e " ${RED}FAIL${NC} ${dir_name} $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)"
# shellcheck disable=SC2086
if nix build --extra-experimental-features "nix-command flakes" \
"${pkg_ref}${pkg}" $override_flags -L; then
echo -e " ${GREEN}PASS${NC} ${dir_name} $pkg"
else
echo -e " ${RED}FAIL${NC} ${dir_name} $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...|--group name] [options]
Run checks/tests for one or more repos, or all repos.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--all Test all repos (default if no repo given)
--type, -t <type> Filter by project type: cpp, rust, nim, js, qml
--auto-local, -a Auto-detect dirty repos and use as local overrides
--local, -l <repo1> <repo2> Use specific repos as local overrides
--workspace, -w Override all deps with local workspace repos
Examples:
ws test logos-module Test a single repo
ws test logos-module logos-liblogos Test multiple repos
ws test --group core Test all repos in the core group
ws test --all Test all repos
ws test --type cpp Test only C++ repos
ws test logos-module --type cpp
ws test logos-test-modules --auto-local
ws test logos-test-modules --local logos-liblogos
ws test --all --workspace Test all repos using workspace dep versions"
local targets=()
local test_all=false
local test_type=""
local auto_local=false
local use_workspace=false
local local_overrides=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && targets+=("$r"); done <<< "$_gexp"
shift
done ;;
--type|-t) test_type="$2"; shift 2 ;;
--all) test_all=true; shift ;;
--auto-local|--autolocal|-a) auto_local=true; shift ;;
--workspace|-w) use_workspace=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*) echo -e "${YELLOW}WARN${NC} Unknown flag: $1" >&2; shift ;;
*)
targets+=("$1"); shift ;;
esac
done
# Override all workspace repos
if $use_workspace; then
for dir in $(get_all_flake_repos); do
local_overrides+=("$dir")
done
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 override_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 ""
override_flags=$(build_override_flags "${unique_overrides[@]}")
fi
# Use workspace flake when overrides are active so follows propagate to transitive deps
local use_workspace_flake=false
if [[ -n "$override_flags" ]]; then
use_workspace_flake=true
fi
local system
system=$(_detect_system)
if $test_all || [[ ${#targets[@]} -eq 0 ]]; then
log "${BOLD}Running tests across all repos (system: $system)${NC}"
log ""
local passed=0 failed=0 skipped=0
local passed_repos="" failed_repos=""
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" "$override_flags" "$use_workspace_flake" || rc=$?
if [[ $rc -eq 0 ]]; then
passed=$((passed + 1))
passed_repos="$passed_repos $dir_name"
elif [[ $rc -eq 2 ]]; then
skipped=$((skipped + 1))
else
failed=$((failed + 1))
failed_repos="$failed_repos $dir_name"
fi
log ""
done
echo ""
echo -e "${BOLD}Results:${NC} ${GREEN}$passed passed${NC} ${RED}$failed failed${NC} ${YELLOW}$skipped skipped${NC}"
if [[ -n "$passed_repos" ]]; then
echo -e " ${GREEN}Passed:${NC}$passed_repos"
fi
if [[ -n "$failed_repos" ]]; then
echo -e " ${RED}Failed:${NC}$failed_repos"
fi
[[ $failed -eq 0 ]] || exit 1
elif [[ ${#targets[@]} -eq 1 ]]; then
local input_name dir_name
input_name=$(get_input_name "${targets[0]}")
dir_name="${targets[0]}"
if [[ -z "$input_name" ]]; then
echo -e "${RED}Error:${NC} '${targets[0]}' has no flake input" >&2
exit 1
fi
local rc=0
_test_one_repo "$input_name" "$dir_name" "$system" "$override_flags" "$use_workspace_flake" || rc=$?
if [[ $rc -eq 1 ]]; then exit 1; fi
else
# Multiple repos
local passed=0 failed=0 skipped=0
local passed_repos="" failed_repos=""
for target in "${targets[@]}"; do
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
failed=$((failed + 1))
failed_repos="$failed_repos $target"
continue
fi
local rc=0
_test_one_repo "$input_name" "$dir_name" "$system" "$override_flags" "$use_workspace_flake" || rc=$?
if [[ $rc -eq 0 ]]; then
passed=$((passed + 1))
passed_repos="$passed_repos $dir_name"
elif [[ $rc -eq 2 ]]; then
skipped=$((skipped + 1))
else
failed=$((failed + 1))
failed_repos="$failed_repos $dir_name"
fi
log ""
done
echo ""
echo -e "${BOLD}Results:${NC} ${GREEN}$passed passed${NC} ${RED}$failed failed${NC} ${YELLOW}$skipped skipped${NC}"
if [[ -n "$passed_repos" ]]; then
echo -e " ${GREEN}Passed:${NC}$passed_repos"
fi
if [[ -n "$failed_repos" ]]; then
echo -e " ${RED}Failed:${NC}$failed_repos"
fi
[[ $failed -eq 0 ]] || exit 1
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...|--group name]
Show the dependency graph. With repos, shows deps and dependents for each.
Without a repo, outputs the full graph in DOT format.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
Examples:
ws graph logos-basecamp Show deps and dependents of a repo
ws graph logos-basecamp logos-liblogos Show deps for multiple repos
ws graph --group core Show deps for all core repos
ws graph Output full DOT graph
ws graph | dot -Tsvg > g.svg Render as SVG (requires graphviz)"
local targets=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && targets+=("$r"); done <<< "$_gexp"
shift
done ;;
-*) shift ;;
*) targets+=("$1"); shift ;;
esac
done
if [[ ! -f "$DEP_GRAPH" ]]; then
echo -e "${RED}Error:${NC} dep-graph.nix not found" >&2
exit 1
fi
if [[ ${#targets[@]} -gt 0 ]]; then
for target in "${targets[@]}"; do
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
if [[ ${#targets[@]} -gt 1 ]]; then
log ""
fi
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...|--group name> [options]
Print the nix --override-input flags for building repos with local overrides.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--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-basecamp --auto-local
ws override-inputs --group core --auto-local
ws override-inputs logos-basecamp logos-liblogos --auto-local
ws override-inputs logos-basecamp --local logos-cpp-sdk"
local repos=()
local auto_local=false
local local_overrides=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
--auto-local|--autolocal|-a) auto_local=true; shift ;;
--local|-l)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local_overrides+=("$1"); shift
done ;;
-*) shift ;;
*) repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Error:${NC} no repo specified" >&2
echo "Usage: ws override-inputs <repo...> [options]" >&2
exit 1
fi
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))
for repo in "${repos[@]}"; do
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 ""
if [[ ${#repos[@]} -gt 1 ]]; then
log ""
fi
done
}
cmd_loc() {
wants_help "$@" && show_help "Usage: ws loc [repo...|--group name] [options]
Count lines of code using tokei. Without a repo, counts the whole workspace.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--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 --group core Count code in all core repos
ws loc logos-cpp-sdk logos-module Count code in multiple repos
ws loc --all Per-repo breakdown
ws loc --all --no-nix Per-repo breakdown, code only"
local targets=()
local exclude_nix=false
local show_all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && targets+=("$r"); done <<< "$_gexp"
shift
done ;;
--no-nix|--code-only) exclude_nix=true; shift ;;
--all) show_all=true; shift ;;
-*) shift ;;
*) targets+=("$1"); shift ;;
esac
done
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 $show_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 [[ ${#targets[@]} -gt 0 ]]; then
for target in "${targets[@]}"; do
local repo_path="$REPOS_DIR/$target"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$target' not found" >&2
continue
fi
if [[ ${#targets[@]} -gt 1 ]]; then
log "${BOLD}── $target ──${NC}"
fi
tokei "$repo_path" ${tokei_args[@]+"${tokei_args[@]}"}
if [[ ${#targets[@]} -gt 1 ]]; then
log ""
fi
done
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...|--group name> [opts] Re-run tests when repo files change
build <repo...|--group name> [opts] Re-build when repo files change
run <cmd> [-w repo ...] Run arbitrary command on file changes
Options (test/build):
--group, -g <name...> Use a named repo group (see 'ws groups')
Examples:
ws watch test logos-module
ws watch test --group core --auto-local
ws watch test logos-module logos-liblogos --auto-local
ws watch build logos-basecamp --auto-local
ws watch build --group core
ws watch build logos-basecamp logos-liblogos
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 repos=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
-*) extra_args+=("$1"); shift ;;
*) repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Usage:${NC} ws watch test <repo...|--group name> [ws-test-opts]" >&2
exit 1
fi
local watch_dirs=()
for repo in "${repos[@]}"; do
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found" >&2
exit 1
fi
watch_dirs+=(-w "$repo_path")
done
log "${BOLD}Watching ${CYAN}${repos[*]}${NC}${BOLD} for changes — running tests on save${NC}"
exec watchexec "${watch_dirs[@]}" --restart -- \
"$WORKSPACE_ROOT/scripts/ws" test "${repos[@]}" "${extra_args[@]}"
;;
build)
local repos=()
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && repos+=("$r"); done <<< "$_gexp"
shift
done ;;
-*) extra_args+=("$1"); shift ;;
*) repos+=("$1"); shift ;;
esac
done
if [[ ${#repos[@]} -eq 0 ]]; then
echo -e "${RED}Usage:${NC} ws watch build <repo...|--group name> [ws-build-opts]" >&2
exit 1
fi
local watch_dirs=()
for repo in "${repos[@]}"; do
local repo_path="$REPOS_DIR/$repo"
if [[ ! -d "$repo_path" ]]; then
echo -e "${RED}Error:${NC} repo '$repo' not found" >&2
exit 1
fi
watch_dirs+=(-w "$repo_path")
done
log "${BOLD}Watching ${CYAN}${repos[*]}${NC}${BOLD} for changes — building on save${NC}"
exec watchexec "${watch_dirs[@]}" --restart -- \
"$WORKSPACE_ROOT/scripts/ws" build "${repos[@]}" "${extra_args[@]}"
;;
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 test logos-module logos-liblogos --auto-local" >&2
echo " ws watch build logos-basecamp --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...|--group name] [--deep]
Update flake.lock inputs. Without a repo, updates all inputs.
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--deep Also update in every sub-repo that depends on the target
Without --deep, only the workspace flake.lock is updated.
With --deep, every repo that lists the target as a direct flake input
gets its own flake.lock updated too. This ensures consistent versions
across the entire dependency tree.
Examples:
ws update Update all flake inputs
ws update logos-cpp-sdk Update a single input in workspace flake.lock
ws update --deep logos-cpp-sdk Update logos-cpp-sdk everywhere (workspace + all sub-repos)
ws update --group core Update all core group inputs
ws update logos-cpp-sdk logos-module Update multiple inputs"
local targets=()
local deep=false
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && targets+=("$r"); done <<< "$_gexp"
shift
done ;;
--all) targets=(); break ;;
--deep) deep=true; shift ;;
-*) shift ;;
*) targets+=("$1"); shift ;;
esac
done
if [[ ${#targets[@]} -eq 0 ]]; then
log "${BOLD}Updating all flake inputs...${NC}"
nix flake update \
--extra-experimental-features "nix-command flakes" \
"$WORKSPACE_ROOT"
else
for target in "${targets[@]}"; do
local input_name
input_name=$(get_input_name "$target")
log "${BOLD}Updating ${CYAN}$input_name${NC}${BOLD} in workspace...${NC}"
nix flake lock \
--extra-experimental-features "nix-command flakes" \
--update-input "$input_name" \
"$WORKSPACE_ROOT"
if $deep; then
# Find all repos that have this target as a direct dependency
local dependents
dependents=$(get_dependents "$input_name")
if [[ -n "$dependents" ]]; then
while IFS= read -r dep_input; do
[[ -z "$dep_input" ]] && continue
# Map input name back to directory name
local dep_entry dep_dir
dep_entry=$(find_repo_entry "$dep_input") || continue
dep_dir=$(get_field "$dep_entry" 2)
local dep_path="$REPOS_DIR/$dep_dir"
if [[ ! -d "$dep_path" ]] || [[ ! -f "$dep_path/flake.nix" ]]; then
continue
fi
log " ${YELLOW}*${NC} Updating ${CYAN}$input_name${NC} in ${BOLD}$dep_dir${NC}/flake.lock"
nix flake lock \
--extra-experimental-features "nix-command flakes" \
--update-input "$input_name" \
"path:$dep_path" || {
echo -e " ${RED}WARN${NC} Failed to update $input_name in $dep_dir"
}
done <<< "$dependents"
else
log " No sub-repos depend on ${CYAN}$input_name${NC}"
fi
fi
done
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_nix_diagnose() {
wants_help "$@" && show_help "Usage: ws nix-diagnose <repo>
Diagnose dependency issues in a repo's flake.
Checks:
1. Circular dependencies in the workspace dependency graph
2. Duplicate dependencies — same repo appearing at different versions
in the flake.lock (e.g. two copies of logos-nix at different revs)
Examples:
ws nix-diagnose logos-basecamp
ws nix-diagnose logos-liblogos"
local repo=""
while [[ $# -gt 0 ]]; do
case "$1" in
-*) shift ;;
*) repo="$1"; shift ;;
esac
done
if [[ -z "$repo" ]]; then
echo -e "${RED}Error:${NC} repo name required" >&2
exit 1
fi
local entry
entry=$(find_repo_entry "$repo") || { echo -e "${RED}Unknown repo:${NC} $repo" >&2; exit 1; }
local input_name dir_name
input_name=$(get_field "$entry" 1)
dir_name=$(get_field "$entry" 2)
# ── 1. Circular dependency detection (from flake.lock) ─────────────────────
log "${BOLD}Circular Dependencies${NC}"
local lock_file="$REPOS_DIR/$dir_name/flake.lock"
if [[ ! -f "$lock_file" ]]; then
echo -e " ${YELLOW}No flake.lock found, skipping${NC}"
elif ! command -v jq &>/dev/null; then
echo -e " ${RED}jq is required but not found${NC}" >&2
else
# Build a repo-level dependency graph from the flake.lock.
# Each lock node maps to a repo identity (original.repo or the node key
# with _N suffixes stripped). Edges are the direct (non-follows) inputs.
# Output: "source_repo target_repo" lines (unique).
local repo_edges
repo_edges=$(jq -r '
.nodes as $nodes |
[ $nodes | to_entries[] |
select(.value.inputs != null) |
.key as $parent |
( $nodes[$parent].original.repo // ($parent | sub("_[0-9]+$"; "")) ) as $src |
.value.inputs | to_entries[] |
select(.value | type == "string") |
.value as $child |
( $nodes[$child].original.repo // ($child | sub("_[0-9]+$"; "")) ) as $dst |
select($src != $dst) |
$src + " " + $dst
] | unique[]
' "$lock_file" 2>/dev/null || true)
if [[ -z "$repo_edges" ]]; then
echo -e " ${GREEN}No circular dependencies found${NC}"
else
# Build adjacency list as "repo|dep1,dep2,..." lines
local graph_data
graph_data=$(echo "$repo_edges" | awk '{
deps[$1] = deps[$1] ? deps[$1] "," $2 : $2
} END {
for (n in deps) print n "|" deps[n]
}')
# DFS cycle detection using temp files (bash 3.2 compatible)
local _dfs_state_file _dfs_path_file
_dfs_state_file=$(mktemp)
_dfs_path_file=$(mktemp)
local found_cycle=false
_dfs_cycle() {
local node="$1"
echo "$node visiting" >> "$_dfs_state_file"
echo "$node" >> "$_dfs_path_file"
local deps_str
deps_str=$(echo "$graph_data" | grep "^${node}|" | head -1 | cut -d'|' -f2 || true)
local dep
for dep in ${deps_str//,/ }; do
[[ -z "$dep" ]] && continue
local state
state=$(grep "^${dep} " "$_dfs_state_file" 2>/dev/null | tail -1 | cut -d' ' -f2 || true)
if [[ "$state" == "visiting" ]]; then
found_cycle=true
local cycle="" in_cycle=false
while IFS= read -r p; do
[[ "$p" == "$dep" ]] && in_cycle=true
if $in_cycle; then
[[ -n "$cycle" ]] && cycle+=" -> "
cycle+="$p"
fi
done < "$_dfs_path_file"
echo -e " ${RED}Cycle:${NC} $cycle -> $dep"
elif [[ -z "$state" ]]; then
_dfs_cycle "$dep"
fi
done
# Pop path (GNU sed uses -i, BSD sed requires -i '')
if sed -i '$ d' "$_dfs_path_file" 2>/dev/null; then :; else sed -i '' '$ d' "$_dfs_path_file"; fi
echo "$node done" >> "$_dfs_state_file"
}
# Start DFS from the repo's own identity
local start_repo
start_repo=$(jq -r '
.nodes.root.inputs | to_entries[] |
select(.value | type == "string") | .key
' "$lock_file" 2>/dev/null | head -1 || true)
# Visit all repos reachable from root inputs
local root_inputs
root_inputs=$(jq -r '
.nodes.root.inputs | to_entries[] |
select(.value | type == "string") | .value
' "$lock_file" 2>/dev/null || true)
local repo_name
for repo_name in $root_inputs; do
local identity
identity=$(jq -r --arg n "$repo_name" '
.nodes[$n].original.repo // ($n | sub("_[0-9]+$"; ""))
' "$lock_file" 2>/dev/null || true)
[[ -z "$identity" ]] && continue
local state
state=$(grep "^${identity} " "$_dfs_state_file" 2>/dev/null | tail -1 | cut -d' ' -f2 || true)
[[ -z "$state" ]] && _dfs_cycle "$identity"
done
rm -f "$_dfs_state_file" "$_dfs_path_file"
if ! $found_cycle; then
echo -e " ${GREEN}No circular dependencies found${NC}"
fi
fi
fi
# ── 2. Dependency version conflicts ─────────────────────────────────────────
log ""
log "${BOLD}Dependency Version Conflicts${NC}"
local lock_file="$REPOS_DIR/$dir_name/flake.lock"
if [[ ! -f "$lock_file" ]]; then
echo -e " ${YELLOW}No flake.lock found${NC}"
return
fi
if ! command -v jq &>/dev/null; then
echo -e " ${RED}jq is required but not found${NC}" >&2
return 1
fi
# Find groups of nodes that map to the same repo but different revs
local conflicts_json
conflicts_json=$(jq '
.nodes as $nodes |
[
$nodes | to_entries[] |
select(.key != "root") |
select(.value.locked != null) |
select(.value.original.type == "github") |
{
key: .key,
repo_id: (.value.original.owner + "/" + .value.original.repo),
rev: .value.locked.rev
}
] | group_by(.repo_id) |
map(select([.[].rev] | unique | length > 1))
' "$lock_file" 2>/dev/null)
local conflict_count
conflict_count=$(echo "$conflicts_json" | jq 'length')
if [[ "$conflict_count" == "0" ]]; then
echo -e " ${GREEN}No version conflicts found${NC}"
return
fi
# Build adjacency list (one jq call): "parent child input_name" per line
local adj_lines
adj_lines=$(jq -r '
.nodes | to_entries[] |
.key as $parent |
select(.value.inputs != null) |
.value.inputs | to_entries[] |
select(.value | type == "string") |
$parent + " " + .value + " " + .key
' "$lock_file" 2>/dev/null)
# BFS to find shortest path from root to a target node (bash 3.2 compatible)
_find_path() {
local target="$1"
[[ "$target" == "root" ]] && { echo "root"; return; }
local _bfs_queue_file _bfs_seen_file
_bfs_queue_file=$(mktemp)
_bfs_seen_file=$(mktemp)
echo "root|root" > "$_bfs_queue_file"
local result="(unreachable)"
while [[ -s "$_bfs_queue_file" ]]; do
local item
item=$(head -1 "$_bfs_queue_file")
if sed -i '1d' "$_bfs_queue_file" 2>/dev/null; then :; else sed -i '' '1d' "$_bfs_queue_file"; fi
local node="${item%%|*}"
local path="${item#*|}"
grep -qx "$node" "$_bfs_seen_file" 2>/dev/null && continue
echo "$node" >> "$_bfs_seen_file"
if [[ "$node" == "$target" ]]; then
result="$path"
break
fi
while IFS=' ' read -r p c i; do
if [[ "$p" == "$node" ]] && ! grep -qx "$c" "$_bfs_seen_file" 2>/dev/null; then
echo "$c|$path -> $i" >> "$_bfs_queue_file"
fi
done <<< "$adj_lines"
done
rm -f "$_bfs_queue_file" "$_bfs_seen_file"
echo "$result"
}
echo -e " ${YELLOW}Found $conflict_count conflict(s):${NC}"
echo ""
local i=0
while [[ $i -lt $conflict_count ]]; do
local repo_id
repo_id=$(echo "$conflicts_json" | jq -r ".[$i][0].repo_id")
echo -e " ${BOLD}$repo_id${NC}:"
# Group by rev within this conflict, output "short_rev|full_rev|key1,key2,..."
echo "$conflicts_json" | jq -r --argjson idx "$i" '
.[$idx] | group_by(.rev) | .[] |
.[0].rev[0:7] + "|" + .[0].rev + "|" + ([.[].key] | join(","))
' | while IFS='|' read -r short_rev _full_rev node_keys; do
echo -e " rev ${CYAN}$short_rev${NC}:"
IFS=',' read -ra keys <<< "$node_keys"
for key in "${keys[@]}"; do
local path
path=$(_find_path "$key")
echo " $path"
done
done
echo ""
((i++))
done
}
cmd_repo_upgrade() {
wants_help "$@" && show_help "Usage: ws repo-upgrade [repo...|--group name|--all]
Pull the latest master/main for repo submodules.
Without arguments, upgrades ALL repos. With repo names, upgrades only those.
Fetches from origin and checks out the latest commit on the default branch
(master or main). Retries on failure (20s, then 40s).
Options:
--group, -g <name...> Use a named repo group (see 'ws groups')
--all Upgrade all repos (default when no args given)
Examples:
ws repo-upgrade Upgrade all repos
ws repo-upgrade logos-cpp-sdk Upgrade a single repo
ws repo-upgrade logos-cpp-sdk logos-module Upgrade multiple repos
ws repo-upgrade --group core Upgrade all core repos"
local targets=()
local all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--group|-g)
shift
while [[ $# -gt 0 && "$1" != -* ]]; do
local _gexp; _gexp=$(expand_group "$1") || exit 1
while IFS= read -r r; do [[ -n "$r" ]] && targets+=("$r"); done <<< "$_gexp"
shift
done ;;
--all) all=true; shift ;;
-*) shift ;;
*) targets+=("$1"); shift ;;
esac
done
# Default to all repos when no targets specified
if [[ ${#targets[@]} -eq 0 ]]; then
all=true
fi
local entries=()
if $all; then
entries=("${REPOS[@]}")
else
for t in "${targets[@]}"; do
local entry
entry=$(find_repo_entry "$t") || { echo -e "${RED}Unknown repo:${NC} $t" >&2; continue; }
entries+=("$entry")
done
fi
local failed=()
local total=${#entries[@]}
local i=0
for entry in "${entries[@]}"; do
local dir_name
dir_name=$(get_field "$entry" 2)
local repo_dir="$REPOS_DIR/$dir_name"
((i++))
if [[ ! -e "$repo_dir/.git" ]]; then
log " ${YELLOW}[$i/$total] $dir_name — not cloned, skipping${NC}"
continue
fi
local default_branch
default_branch=$(git -C "$repo_dir" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@refs/remotes/origin/@@' || echo "master")
local fetched=false
for wait in 0 20 40; do
[[ "$wait" -gt 0 ]] && log " ${YELLOW}retrying in ${wait}s...${NC}" && sleep "$wait"
if git -C "$repo_dir" fetch origin "$default_branch" 2>/dev/null; then
fetched=true
break
fi
done
if $fetched; then
git -C "$repo_dir" checkout "origin/$default_branch" --detach 2>/dev/null
local short_rev
short_rev=$(git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null)
log " ${GREEN}[$i/$total] $dir_name${NC} -> ${CYAN}$default_branch${NC} ($short_rev)"
else
log " ${RED}[$i/$total] $dir_name — fetch failed${NC}"
failed+=("$dir_name")
fi
sleep $((RANDOM % 2 + 2))
done
echo ""
if [[ ${#failed[@]} -gt 0 ]]; then
echo -e "${RED}Failed repos:${NC}"
printf " %s\n" "${failed[@]}"
else
log "${GREEN}All ${total} repos upgraded.${NC}"
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-basecamp
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/LogosBasecampNix/modules")
cache_dirs+=("$HOME/Library/Application Support/LogosBasecampNix/plugins")
cache_dirs+=("$HOME/Library/Application Support/LogosBasecamp/modules")
cache_dirs+=("$HOME/Library/Application Support/LogosBasecamp/plugins")
else
local xdg="${XDG_DATA_HOME:-$HOME/.local/share}"
cache_dirs+=("$xdg/LogosBasecampNix/modules" "$xdg/LogosBasecampNix/plugins")
cache_dirs+=("$xdg/LogosBasecamp/modules" "$xdg/LogosBasecamp/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/LogosBasecampNix${NC}"
else
echo -e " ${BOLD}rm -rf \${XDG_DATA_HOME:-~/.local/share}/LogosBasecampNix${NC}"
fi
exit 1
fi
if ! $stale_found; then
echo -e "${GREEN}OK:${NC} No stale modules in local cache"
fi
fi
}
cmd_groups() {
wants_help "$@" && show_help "Usage: ws groups
List all defined repo groups and their members.
Examples:
ws groups"
for entry in "${REPO_GROUPS[@]}"; do
local gname members
gname=$(echo "$entry" | cut -d'|' -f1)
members=$(echo "$entry" | cut -d'|' -f2)
echo -e "${BOLD}${CYAN}$gname${NC}"
local expanded
expanded=$(expand_group "$gname" | sort -u)
while IFS= read -r repo; do
[[ -n "$repo" ]] && echo -e " $repo"
done <<< "$expanded"
echo ""
done
}
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
groups List all defined repo groups
build <repo...|--group G> [opts] Build one or more repos via nix
--group, -g <name...> Use a named repo group (see 'ws groups')
--auto-local, -a Auto-detect dirty repos and use as overrides
--local, -l <r1> <r2> ... Use specific local repos as overrides
bundle <repo...|--group G> [opts] Bundle repos into .lgx packages
--group, -g <name...> Use a named repo group (see 'ws groups')
--auto-local, -a Auto-detect dirty repos and use as overrides
--local, -l <r1> <r2> ... Use specific local repos as overrides
--mode, -m <dev|portable|dual> Bundle mode (default: dev)
-o, --out-dir <path> Output dir for multi-repo (default: result-bundle)
run <repo> [opts] Build and run a repo (single repo only)
run <repo> [opts] [-- <args>] Build and run a repo (single repo only)
--auto-local, -a Auto-detect dirty repos and use as overrides
--local, -l <r1> <r2> ... Use specific local repos as overrides
logos-standalone-app only (after --):
<ui-plugin-repo> Bundle repo → install to ./plugins; or pass existing path
--modules, -M <repo>[,...] Bundle backend module(s) → install to ./modules
develop [repo...|--group G] [opts] Enter a nix devShell
--group, -g <name...> Use a named repo group
--auto-local, -a Include overrides for dirty repos
--local, -l <r1> <r2> ... Include specific local overrides
--fresh Force rebuild (ignore cache)
test [repo...|--group G|--all] [--type T] Run checks/tests
--group, -g <name...> Use a named repo group
--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...|--group G] Show dependency graph (DOT format, or repo details)
override-inputs <repo...|--group G> [opts] Show the nix --override-input flags
update [repo...|--group G|--all] Update flake.lock inputs
loc [repo...|--group G|--all] [--no-nix] Count lines of code (uses tokei)
watch test <repo...|--group G> Re-run tests on file changes
watch build <repo...|--group G> 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
repo-upgrade [repo...|--group G|--all] Pull latest master/main for submodules
sync-graph Regenerate nix/dep-graph.nix from repo flake.nix files
nix-diagnose <repo> Detect circular deps and version conflicts in flake.lock
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
REPO GROUPS:
Most commands accept --group/-g to operate on a named set of repos.
Groups are defined in the ws script and can reference other groups.
Use 'ws groups' to see all available groups and their members.
EXAMPLES:
# Set up the workspace
ws init
# Build a single repo
ws build logos-basecamp --auto-local
# Build all repos in a group
ws build --group core
# Build multiple repos
ws build logos-module logos-liblogos --auto-local
# Explicitly override specific deps
ws build logos-basecamp --local logos-cpp-sdk logos-liblogos
# Bundle a module into an .lgx package
ws bundle logos-chat-module
# Bundle with portable mode (self-contained, no nix store deps)
ws bundle logos-chat-module --mode portable
# Bundle multiple repos (results in result-bundle/<repo>/)
ws bundle logos-chat-module logos-waku-module --auto-local
# Run all tests
ws test --all
# Test a group of repos
ws test --group core --auto-local
# Run only C++ repo tests
ws test --all --type cpp
# Test specific repos
ws test logos-cpp-sdk logos-module
# See what your dirty repos affect
ws dirty
# List available groups
ws groups
# Enter logos-basecamp's devShell with local logos-cpp-sdk
ws develop logos-basecamp --local logos-cpp-sdk
# Visualize the dependency graph
ws graph | dot -Tsvg > graph.svg
# See deps of specific repos
ws graph logos-basecamp logos-liblogos
# Run git pull in every repo
ws foreach git pull
# Show override flags without executing
ws override-inputs logos-basecamp --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 group
ws loc --group core
# Lines of code for specific repos
ws loc logos-cpp-sdk logos-module
# Auto-run tests when files change
ws watch test logos-module
# Watch and test a group
ws watch test --group core --auto-local
# Watch and test multiple repos
ws watch test logos-module logos-liblogos --auto-local
# Auto-build on changes with local overrides
ws watch build logos-basecamp --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 "$@" ;;
bundle) shift; cmd_bundle "$@" ;;
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 "$@" ;;
nix-diagnose) shift; cmd_nix_diagnose "$@" ;;
repo-upgrade) shift; cmd_repo_upgrade "$@" ;;
list|ls) shift; cmd_list "$@" ;;
groups) shift; cmd_groups "$@" ;;
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