Files
logos-workspace/scripts/ws
T
Dario LipicarandClaude Opus 4.8 952e339b33 fix: pin logos-libp2p-module's libp2p input to a SHA (deleted upstream branch) (#94)
* fix: pin logos-libp2p-module's libp2p input to a SHA (deleted upstream branch)

The nightly bump's "Update flake.lock" step failed:

  error: unable to download
  'https://api.github.com/repos/vacp2p/nim-libp2p/commits/feat/cbind/xpr-decode':
  HTTP error 422

logos-libp2p-module pins its `libp2p` input to the upstream branch
`vacp2p/nim-libp2p/feat/cbind/xpr-decode`, which was deleted. Re-locking
that input (nix flake lock --update-input logos-libp2p-module) re-resolves
the branch ref and 422s, so the candidate is never staged.

Override `libp2p` at the workspace level to the exact SHA the branch last
pointed at (7c73484c — what the lock already resolved to; never merged to
master, reachable only by SHA). Behavior-preserving.

Because the nightly runs `ws sync-graph` BEFORE the lock update — which
regenerates the auto-inputs block of flake.nix — a hand-edited override
would be wiped every run. So the override is emitted by sync-graph itself
via a new `get_manual_url_overrides` helper (mirrors `get_manual_follows`),
and also applied to the committed flake.nix/flake.lock so master is
self-consistent now.

Stopgap until logos-libp2p-module SHA-pins libp2p in its own flake.nix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ws): filter and sort transitive URL overrides in sync-graph

Address review feedback on get_manual_url_overrides emission:

- Filter each override iname against the downstream flake's declared_set
  (same guard the follows path uses) so a renamed/removed upstream input
  can't leave a stale override that adds a spurious lock node; warn on skip.
- Sort the kept overrides for deterministic, idempotent flake.nix output
  when a repo ever declares more than one override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:59:32 -03:00

3828 lines
132 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
# Global verbose flag — shows full nix output (default: capture and only show on error)
VERBOSE=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 "$@"; }
# Run a nix command, capturing output unless --verbose is set.
# On failure, captured output is dumped to stderr.
# Usage: run_nix <nix-args...>
run_nix() {
if $VERBOSE; then
nix "$@"
else
local _nix_output _nix_rc=0
_nix_output=$(nix "$@" 2>&1) || _nix_rc=$?
if [[ $_nix_rc -ne 0 ]]; then
echo "$_nix_output" >&2
return $_nix_rc
fi
return 0
fi
}
# Run a nix command for tests — stderr (warnings) is suppressed unless --verbose,
# but stdout (test output with -L) always flows through. On failure, captured stderr is shown.
run_nix_test() {
if $VERBOSE; then
nix "$@"
else
local _nix_stderr_file _nix_rc=0
_nix_stderr_file=$(mktemp)
nix "$@" 2>"$_nix_stderr_file" || _nix_rc=$?
if [[ $_nix_rc -ne 0 ]]; then
cat "$_nix_stderr_file" >&2
fi
rm -f "$_nix_stderr_file"
return $_nix_rc
fi
}
# ── 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-protocol|logos-protocol|https://github.com/logos-co/logos-protocol.git|yes"
"logos-qt-sdk|logos-qt-sdk|https://github.com/logos-co/logos-qt-sdk.git|yes"
"logos-cpp-sdk|logos-cpp-sdk|https://github.com/logos-co/logos-cpp-sdk.git|yes"
"logos-view-module-runtime|logos-view-module-runtime|https://github.com/logos-co/logos-view-module-runtime.git|yes"
"logos-module-client|logos-module-client|https://github.com/logos-co/logos-module-client.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-logoscore-cli|logos-logoscore-cli|https://github.com/logos-co/logos-logoscore-cli.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-plugin-qt|logos-plugin-qt|https://github.com/logos-co/logos-plugin-qt.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"
"nix-bundle-appimage|nix-bundle-appimage|https://github.com/logos-co/nix-bundle-appimage.git|yes"
"nix-bundle-macos-app|nix-bundle-macos-app|https://github.com/logos-co/nix-bundle-macos-app.git|yes"
# App
"logos-standalone-app|logos-standalone-app|https://github.com/logos-co/logos-standalone-app.git|yes"
"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-ui|logos-chat-ui|https://github.com/logos-co/logos-chat-ui.git|yes"
# Package Manager
"logos-package-manager|logos-package-manager|https://github.com/logos-co/logos-package-manager.git|yes"
"logos-package-downloader|logos-package-downloader|https://github.com/logos-co/logos-package-downloader.git|yes"
"logos-package-manager-module|logos-package-manager-module|https://github.com/logos-co/logos-package-manager-module.git|yes"
"logos-package-downloader-module|logos-package-downloader-module|https://github.com/logos-co/logos-package-downloader-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"
# Delivery
"logos-delivery-module|logos-delivery-module|https://github.com/logos-co/logos-delivery-module.git|yes"
"logos-delivery-demo|logos-delivery-demo|https://github.com/logos-co/logos-delivery-demo.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"
"logos-logoscore-py|logos-logoscore-py|https://github.com/logos-co/logos-logoscore-py.git|yes"
# Testing
"logos-test-modules|logos-test-modules|https://github.com/logos-co/logos-test-modules.git|yes"
# Other
"process-stats|process-stats|https://github.com/logos-co/process-stats.git|yes"
"logos-module-viewer|logos-module-viewer|https://github.com/logos-co/logos-module-viewer.git|yes"
# EVM wallet (logos-evm-* modules + the fail-closed net-proxy crate).
# input_name == dir == GitHub slug (the doctest matrix in logos-doctest-hub
# intersects repos.json names against submodule dir names, so they must match).
"logos-evm-net-proxy|logos-evm-net-proxy|https://github.com/logos-co/logos-evm-net-proxy.git|yes"
"logos-evm-keystore-module|logos-evm-keystore-module|https://github.com/logos-co/logos-evm-keystore-module.git|yes"
"logos-evm-eth-rpc-module|logos-evm-eth-rpc-module|https://github.com/logos-co/logos-evm-eth-rpc-module.git|yes"
"logos-evm-token-list-module|logos-evm-token-list-module|https://github.com/logos-co/logos-evm-token-list-module.git|yes"
"logos-evm-uniswap-module|logos-evm-uniswap-module|https://github.com/logos-co/logos-evm-uniswap-module.git|yes"
"logos-evm-railgun-module|logos-evm-railgun-module|https://github.com/logos-co/logos-evm-railgun-module.git|yes"
"logos-evm-wallet-backend-module|logos-evm-wallet-backend-module|https://github.com/logos-co/logos-evm-wallet-backend-module.git|yes"
"logos-evm-wallet-ui|logos-evm-wallet-ui|https://github.com/logos-co/logos-evm-wallet-ui.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-protocol,logos-qt-sdk,logos-cpp-sdk,logos-module,logos-liblogos,logos-logoscore-cli,logos-module-builder,logos-capability-module,logos-package-manager-module,logos-test-modules"
"chat|core,logos-chat-module"
"package-manager|logos-package,logos-package-manager,logos-package-downloader,logos-package-manager-module,logos-package-downloader-module,logos-package-manager-ui,nix-bundle-lgx"
"app|core,logos-basecamp,logos-package-manager-ui"
"evm-wallet|logos-evm-net-proxy,logos-evm-keystore-module,logos-evm-eth-rpc-module,logos-evm-token-list-module,logos-evm-uniswap-module,logos-evm-railgun-module,logos-evm-wallet-backend-module,logos-evm-wallet-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 query="$1"
for entry in "${REPOS[@]}"; do
if [[ "$(get_field "$entry" 2)" == "$query" || "$(get_field "$entry" 1)" == "$query" ]]; then
echo "$(get_field "$entry" 1)"
return 0
fi
done
echo "$query"
}
get_repo_path() {
local query="$1"
for entry in "${REPOS[@]}"; do
if [[ "$(get_field "$entry" 2)" == "$query" || "$(get_field "$entry" 1)" == "$query" ]]; then
echo "$(get_field "$entry" 2)"
return 0
fi
done
echo "$query"
}
# 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 repo_path
input_name=$(get_input_name "$dir_name")
repo_path=$(get_repo_path "$dir_name")
if [[ -n "$input_name" ]]; then
flags+=("--override-input" "$input_name" "path:$REPOS_DIR/$repo_path")
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
}
# Map a "owner/repo" GitHub identifier to the workspace flake input name.
_github_repo_to_input_name() {
local gh_repo="$1"
for entry in "${REPOS[@]}"; do
local input url
input=$(get_field "$entry" 1)
url=$(get_field "$entry" 3)
[[ -z "$input" ]] && continue
if [[ "$url" == "https://github.com/${gh_repo}.git" || \
"$url" == "https://github.com/${gh_repo}" ]]; then
echo "$input"
return 0
fi
done
}
# Compute topological propagation levels starting from a given dependency.
# Uses level-based relaxation: each level is a batch of repos that can be
# updated in parallel, but all repos in a level must be committed and pushed
# to GitHub before the next level can run nix flake lock (nix fetches from
# remote URLs, not local paths).
#
# Usage: get_topo_levels <start_dep_input_name> [--for <target_input_name>]
#
# Output (one line per affected repo, sorted by level then name):
# LEVEL|REPO_INPUT_NAME|CHANGED_DEP1,CHANGED_DEP2,...
#
# --for <target>: only include repos on paths from start_dep to target
get_topo_levels() {
local start_dep="$1"
local for_target=""
shift
while [[ $# -gt 0 ]]; do
case "$1" in
--for) for_target="$2"; shift 2 ;;
*) shift ;;
esac
done
[[ ! -f "$DEP_GRAPH" ]] && return
local graph_json
graph_json=$(nix eval --extra-experimental-features "nix-command flakes" \
--impure --json --expr 'import '"$DEP_GRAPH"'' 2>/dev/null) || return
echo "$graph_json" | jq -r --arg start "$start_dep" --arg target "$for_target" '
. as $g |
# Compute propagation levels via Gauss-Seidel relaxation.
# Level 0 = start_dep (seed). Level n = repos whose deepest upstream
# changed dep is at level n-1. Uses max-of-paths for diamond deps.
( $g | keys_unsorted | map({key: ., value: -1}) | from_entries | .[$start] = 0 ) |
reduce range(60) as $_ (
.;
. as $acc |
reduce ($g | keys_unsorted[] | select(. != $start)) as $repo (
$acc;
. as $cur |
( [($g[$repo].deps // [])[] | $cur[.] // -1] |
if length > 0 then max else -1 end
) as $max_dep |
if $max_dep >= 0 and ($max_dep + 1) > $cur[$repo]
then .[$repo] = ($max_dep + 1)
else .
end
)
) | . as $levels |
# If --for given, compute the set of transitive deps of target.
# Output is restricted to repos that are in both the forward propagation
# from start_dep AND the ancestor set of target (i.e. on a path between them).
( if $target != "" then
( $g | keys_unsorted | map({key: ., value: false}) | from_entries |
.[$target] = true ) |
reduce range(60) as $_ (
.;
. as $anc |
reduce ($g | keys_unsorted[]) as $repo (
$anc;
if .[$repo] then
reduce ($g[$repo].deps // [])[] as $dep (.; .[$dep] = true)
else .
end
)
)
else null
end
) as $ancestors |
# Output: repos with level > 0, filtered, sorted by level then name
[ $levels | to_entries[] |
select(.value > 0) |
select( if $ancestors != null then ($ancestors[.key] // false) else true end ) |
.key as $repo | .value as $lvl |
[ ($g[$repo].deps // [])[] |
select(. == $start or (($levels[.] // -1) > 0))
] as $changed_deps |
"\($lvl)|\($repo)|\($changed_deps | join(","))"
] | sort[]
'
}
# ── 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}"
if $VERBOSE; then
exec nix build \
--extra-experimental-features "nix-command flakes" \
"$nix_ref" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
else
if run_nix build \
--extra-experimental-features "nix-command flakes" \
"$nix_ref" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}; then
echo -e "${GREEN}OK${NC} $display_name"
else
echo -e "${RED}FAIL${NC} $display_name"
exit 1
fi
exit 0
fi
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 ! run_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-storage-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}"
if $VERBOSE; then
exec nix bundle \
--extra-experimental-features "nix-command flakes" \
--bundler "$bundler_ref" \
-o result \
"$WORKSPACE_ROOT#$target" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}
else
if run_nix bundle \
--extra-experimental-features "nix-command flakes" \
--bundler "$bundler_ref" \
-o result \
"$WORKSPACE_ROOT#$target" \
${flags[@]+"${flags[@]}"} \
${extra_args[@]+"${extra_args[@]}"}; then
echo -e "${GREEN}OK${NC} $input_name → result/"
else
echo -e "${RED}FAIL${NC} $input_name"
exit 1
fi
exit 0
fi
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 ! run_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 via nix eval.
# When override flags are present, uses installable syntax (flake ref + attribute)
# so --override-input flags are respected (builtins.getFlake ignores them).
_discover_test_outputs() {
local repo_path="$1"
local system="$2"
local override_flags="${3:-}"
if [[ -n "$override_flags" ]]; then
# Two evals with explicit attribute paths — --override-input applies to the flake ref
local checks_json test_pkgs_json
# shellcheck disable=SC2086
checks_json=$(nix eval --extra-experimental-features "nix-command flakes" \
--json "path:${repo_path}#checks.${system}" \
--apply 'builtins.attrNames' \
$override_flags 2>/dev/null) || checks_json='[]'
# shellcheck disable=SC2086
test_pkgs_json=$(nix eval --extra-experimental-features "nix-command flakes" \
--json "path:${repo_path}#packages.${system}" \
--apply 'ps: builtins.filter (n: builtins.match ".*[Tt]est.*" n != null) (builtins.attrNames ps)' \
$override_flags 2>/dev/null) || test_pkgs_json='[]'
echo "{\"checks\":${checks_json},\"testPkgs\":${test_pkgs_json}}"
else
# No overrides: single eval via builtins.getFlake (faster, no extra resolution)
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":[]}'
fi
}
# 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
log " ${BLUE}Discovering test outputs...${NC}"
local outputs
outputs=$(_discover_test_outputs "$repo_path" "$system" "$override_flags")
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 run_nix_test 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 run_nix_test 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 run_nix_test 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
}
# ── URL overrides for repos not at github:logos-co/<name> ──────────────────
# Used by `ws sync-graph` when generating flake.nix's auto-inputs section.
# Base URL (org/repo) — override for repos outside logos-co or with ?dir=.
get_base_repo_url() {
case "$1" in
logos-blockchain-module) echo "github:logos-blockchain/logos-blockchain-module" ;;
logos-blockchain-ui) echo "github:logos-blockchain/logos-blockchain-ui" ;;
logos-execution-zone-module) echo "github:logos-blockchain/logos-execution-zone-module" ;;
logos-execution-zone-wallet-ui) echo "github:logos-blockchain/logos-execution-zone-wallet-ui" ;;
*) echo "github:logos-co/$1" ;;
esac
}
# Returns the flake URL for a repo, always pinning to the workspace submodule
# commit so flake.nix tracks the exact same revision as the submodule pointer.
get_repo_url() {
local input_name="$1"
local base_url
base_url=$(get_base_repo_url "$input_name")
# Find the repo's directory from the REPOS table
local entry dir_name repo_dir
entry=$(find_repo_entry "$input_name") || { echo "$base_url"; return; }
dir_name=$(get_field "$entry" 2)
repo_dir="$REPOS_DIR/$dir_name"
[[ -d "$repo_dir" ]] || { echo "$base_url"; return; }
# Get the submodule commit that the workspace has checked out.
# Read from the submodule's own HEAD (not the superproject's committed
# gitlink) so we pick up bumps that haven't been committed yet — e.g. the
# nightly workflow runs `ws sync-graph` between `git submodule update
# --remote` and the eventual commit.
local sub_rev
sub_rev=$(git -C "$repo_dir" rev-parse HEAD 2>/dev/null)
[[ -n "$sub_rev" ]] || { echo "$base_url"; return; }
# Always pin to the submodule rev — append /rev for simple URLs,
# or &rev=... for URLs with ?dir= query params.
case "$base_url" in
*\?*) echo "${base_url}&rev=${sub_rev}" ;;
*) echo "${base_url}/${sub_rev}" ;;
esac
}
# ── Repos that must build from their OWN locked inputs even under
# --workspace, i.e. sync-graph emits NO workspace `follows` for them.
# Each entry is a deliberate divergence and must say why.
follows_exempt() {
case "$1" in
# logos-rust-sdk's C-FFI codegen needs logos-cpp-sdk's `c_ffi` branch:
# the `support c-ffi` commit (d98e2a9) that emits the provider
# inter-module API header was never merged to cpp-sdk master. Following
# the workspace (master) cpp-sdk breaks the caller module's generated
# code (`fatal error: sdk_test_provider_module_api.h: No such file`), so
# keep it on its own c_ffi pins. Revisit once c-ffi lands on cpp-sdk master.
logos-rust-sdk) return 0 ;;
*) return 1 ;;
esac
}
# ── Workspace-level follows that override what each repo's lock would
# otherwise produce. Every entry is a deliberate divergence from upstream
# and should have a comment explaining why. Returns tab-separated
# `input_name<TAB>target` lines per repo.
get_manual_follows() {
case "$1" in
# These three repos declare an input named `logos-package-manager` that
# actually points at `logos-package-manager-module` upstream. The repos
# consume `<input>.packages.${system}.cli`, which only exists on the
# real `logos-package-manager` library — so the workspace retargets it.
logos-accounts-ui|logos-execution-zone-wallet-ui|logos-module-viewer)
printf 'logos-package-manager\tlogos-package-manager\n' ;;
# logos-blockchain-module's upstream lock currently resolves
# logos-blockchain as github:logos-blockchain/logos-blockchain/0.2.1
# which Nix interprets as a commit-ish and fails to fetch.
# Force it to follow the workspace-level pinned input instead.
logos-blockchain-module)
printf 'logos-blockchain\tlogos-blockchain\n' ;;
# keystore + token-list ship no committed flake.lock, so sync-graph can't
# auto-derive their module-builder dep — declare it manually so the
# workspace overrides it (version consistency with the rest of the tree).
logos-evm-keystore-module|logos-evm-token-list-module)
printf 'logos-module-builder\tlogos-module-builder\n' ;;
esac
}
# ── Workspace-level URL overrides for a downstream repo's TRANSITIVE inputs.
# Unlike follows (which retarget a transitive input to a workspace input),
# these pin a transitive input to an explicit flake ref. Use when upstream
# pins a ref that has become unresolvable (e.g. a deleted branch), which would
# otherwise make `nix flake lock --update-input <repo>` (the nightly bump) fail
# to resolve that ref. Returns tab-separated `input_name<TAB>flake_ref` lines
# per repo. Emitted into the flake.nix input block after the follows lines.
get_manual_url_overrides() {
case "$1" in
# logos-libp2p-module pins its `libp2p` input to the upstream
# `feat/cbind/xpr-decode` branch, which was deleted — so re-locking it
# 422s on branch resolution. Pin to the exact SHA that branch last pointed
# at (never merged to master) until the module pins the SHA itself.
logos-libp2p-module)
printf 'libp2p\tgithub:vacp2p/nim-libp2p/7c73484cc0c57a5f649dd3d277cef1c7c4de28f0\n' ;;
esac
}
# Parse a flake.nix and emit its declared root input names, one per line.
# Handles both styles:
# inputs.foo.url = "...";
# inputs = { foo.url = "..."; bar = { ... }; };
get_declared_input_names() {
local flake_file="$1"
awk '
BEGIN { in_inputs = 0; depth = 0 }
{
line = $0
if (!in_inputs) {
# Top-level attrpath form: `inputs.<name>.<...> = ...;`
if (match(line, /^[[:space:]]*inputs\.[^[:space:].={]+/)) {
key = substr(line, RSTART, RLENGTH)
sub(/^[[:space:]]*inputs\./, "", key)
if (length(key) > 0) print key
}
# Attrset block form: `inputs = { ... };`
if (match(line, /^[[:space:]]*inputs[[:space:]]*=[[:space:]]*\{/)) {
in_inputs = 1
depth = 1
}
next
}
# Capture names assigned at the top level of inputs attrset.
if (depth == 1 && match(line, /^[[:space:]]*[^[:space:]=]+[[:space:]]*=/)) {
key = substr(line, RSTART, RLENGTH)
sub(/^[[:space:]]*/, "", key)
sub(/[[:space:]]*=.*/, "", key)
sub(/\..*$/, "", key)
gsub(/^"/, "", key)
gsub(/"$/, "", key)
if (length(key) > 0) print key
}
opens = gsub(/\{/, "{", line)
closes = gsub(/\}/, "}", line)
depth += opens - closes
if (depth <= 0) {
in_inputs = 0
depth = 0
}
}
' "$flake_file" | sort -u
}
cmd_sync_graph() {
wants_help "$@" && show_help "Usage: ws sync-graph
Regenerate nix/dep-graph.nix and the auto-inputs section of flake.nix by
scanning each repo's flake.lock for workspace dependencies and follows
mappings, plus its flake.nix for test outputs.
Nix's flake parser requires \`inputs\` to be a literal attrset (no \`let\`,
no \`//\`, no \`import\`), so the per-repo inputs in flake.nix must be
generated by this command rather than computed at evaluation time.
Examples:
ws sync-graph"
log "${BOLD}Syncing nix/dep-graph.nix and flake.nix inputs from repo flake.lock files...${NC}"
log ""
# Build list of workspace input names (in REPOS order), as a space-padded
# string for fast membership testing in bash 3.2 (no associative arrays).
# `ws_names_ordered` preserves REPOS order; `ws_names_set` is padded with
# leading/trailing spaces so `case "$ws_names_set" in *" $name "*)` matches.
# `ws_names_set` also includes synthetic workspace-level inputs that have
# no submodule (nixpkgs) so that downstream follows for them are emitted
# into dep-graph.nix. These are excluded from `deps` (they aren't repos).
local ws_names_ordered=""
for entry in "${REPOS[@]}"; do
local iname
iname=$(get_field "$entry" 1)
[[ -n "$iname" ]] && ws_names_ordered+="$iname "
done
local ws_names_set=" $ws_names_ordered nixpkgs "
# Find max name length for alignment
local max_len=0
for name in $ws_names_ordered; do
[[ ${#name} -gt $max_len ]] && max_len=${#name}
done
local tmpfile flake_inputs_tmp
tmpfile=$(mktemp)
flake_inputs_tmp=$(mktemp)
cat > "$tmpfile" << 'HEADER'
# Dependency graph: flake input name → { deps, follows, hasTests }
#
# follows: map from this repo's downstream-input-name → workspace input name
# it should follow. Auto-derived from each repo's flake.lock by
# parsing root inputs (string refs become URL→workspace lookups,
# array refs are emitted when the input name itself matches a
# workspace input).
# deps: unique workspace input names this repo depends on (= unique values
# of follows, in REPOS table order, with "nixpkgs" elided). Used by
# `ws dirty`, `ws graph`, and CI to compute downstream dependents.
# 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
#
# Consumed by:
# - scripts/ws: ws dirty / ws graph / has_tests / get_dependents
# - .github/workflows/ci.yml: change-detection grep for `"$repo"` deps lines
# - flake.nix: `lib.depGraph` re-export for programmatic introspection
{
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 repo_dir="$REPOS_DIR/$dir_name"
local flake_file="$repo_dir/flake.nix"
local lock_file="$repo_dir/flake.lock"
if [[ ! -f "$flake_file" ]]; then
log " ${YELLOW}skip${NC} $dir_name (not cloned)"
continue
fi
# Only emit follows for inputs currently declared in flake.nix.
# flake.lock can retain stale root-input keys after input removal.
local declared_inputs
declared_inputs="$(get_declared_input_names "$flake_file")"
local declared_set=" $(printf '%s\n' "$declared_inputs" | tr '\n' ' ') "
# ── Parse flake.lock → tab-separated `iname<TAB>ws_id` per accepted input
# `follows_pairs` is a single multi-line string (bash 3.2 has no
# associative arrays). Lines are sorted alphabetically by iname later.
local follows_pairs=""
if ! follows_exempt "$input_name" && [[ -f "$lock_file" ]]; then
# jq emits one tab-separated record per root input:
# <kind>\t<input_name>\t<repo>\t<dir>
# where <kind> is "url" (string ref to a node) or "follows" (array ref,
# i.e. a pure follows declaration in the downstream flake).
local jq_out
jq_out=$(jq -r '
. as $r
| $r.nodes[$r.root].inputs
| to_entries[]
| .key as $iname
| .value as $v
| if ($v | type) == "string" then
($r.nodes[$v].original // {}) as $o
| "url\t\($iname)\t\($o.repo // "")\t\($o.dir // "")"
else
"follows\t\($iname)\t\t"
end
' "$lock_file" 2>/dev/null) || jq_out=""
while IFS=$'\t' read -r kind iname repo dir; do
[[ -z "$iname" ]] && continue
case "$declared_set" in
*" $iname "*) ;;
*) continue ;;
esac
local ws_id=""
if [[ "$kind" == "url" ]]; then
# Tutorial sub-flakes encode the actual sub-repo as `?dir=...`
# (e.g., github:logos-co/logos-tutorial?dir=logos-calc-module).
# Use the dir basename when present, otherwise the repo name.
if [[ -n "$dir" ]]; then
ws_id="${dir##*/}"
else
ws_id="$repo"
fi
elif [[ "$kind" == "follows" ]]; then
# Pure follows declaration in the downstream flake — only override
# at workspace level if the input name itself matches a workspace
# input (the conventional `nixpkgs` / `logos-nix` follows pattern).
ws_id="$iname"
fi
# Skip self-references and non-workspace targets
[[ -z "$ws_id" || "$ws_id" == "$input_name" ]] && continue
case "$ws_names_set" in
*" $ws_id "*) follows_pairs+="${iname} ${ws_id}
" ;;
esac
done <<< "$jq_out"
fi
# ── Apply manual workspace-level follows overrides ───────────────────
# Manual entries override (or add to) the auto-derived follows. We
# rebuild `follows_pairs` keeping only the latest entry per iname.
local manual_follows
manual_follows=$(get_manual_follows "$input_name")
if [[ -n "$manual_follows" ]]; then
local merged=""
# First, drop auto entries whose iname appears in manual_follows
while IFS=$'\t' read -r f_iname f_target; do
[[ -z "$f_iname" ]] && continue
local override_target=""
while IFS=$'\t' read -r m_iname m_target; do
[[ -z "$m_iname" ]] && continue
[[ "$m_iname" == "$f_iname" ]] && override_target="$m_target"
done <<< "$manual_follows"
if [[ -n "$override_target" ]]; then
merged+="${f_iname} ${override_target}
"
else
merged+="${f_iname} ${f_target}
"
fi
done <<< "$follows_pairs"
# Then append any manual entries not already present in follows_pairs
while IFS=$'\t' read -r m_iname m_target; do
[[ -z "$m_iname" ]] && continue
case "$declared_set" in
*" $m_iname "*) ;;
*) continue ;;
esac
case "$follows_pairs" in
*"$m_iname "*) ;;
*) merged+="${m_iname} ${m_target}
" ;;
esac
done <<< "$manual_follows"
follows_pairs="$merged"
fi
# Sort follows pairs alphabetically by input name for deterministic output
local follows_sorted=""
if [[ -n "$follows_pairs" ]]; then
follows_sorted=$(printf '%s' "$follows_pairs" | sort)
fi
# ── Compute deps: unique workspace targets in REPOS order ────────────
# Skip `nixpkgs` (not a tracked repo) and self-references. Each ws name
# is checked against the second column of follows_pairs.
local unique_targets=""
if [[ -n "$follows_sorted" ]]; then
unique_targets=" $(printf '%s\n' "$follows_sorted" | awk -F'\t' '{print $2}' | sort -u | tr '\n' ' ') "
fi
local deps_str="[]"
local deps_acc=""
for ws in $ws_names_ordered; do
[[ "$ws" == "$input_name" ]] && continue
case "$unique_targets" in
*" $ws "*) deps_acc+="\"$ws\" " ;;
esac
done
[[ -n "$deps_acc" ]] && deps_str="[ $deps_acc]"
# ── Format follows attrset (alphabetically sorted by iname) ──────────
local follows_str="{}"
if [[ -n "$follows_sorted" ]]; then
follows_str="{"
while IFS=$'\t' read -r f_iname f_target; do
[[ -z "$f_iname" ]] && continue
follows_str+=" \"${f_iname}\" = \"${f_target}\";"
done <<< "$follows_sorted"
follows_str+=" }"
fi
# ── 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
# ── Write dep-graph entry with name padding ──────────────────────────
local pad
pad=$(printf "%-${max_len}s" "$input_name")
echo " ${pad} = { deps = ${deps_str}; follows = ${follows_str}; hasTests = ${has_tests}; };" >> "$tmpfile"
# ── Write flake.nix input entry ──────────────────────────────────────
# Skip logos-nix here — it's declared in the manual section of flake.nix
# because it's the root nixpkgs pin and `nixpkgs.follows` references it.
if [[ "$input_name" != "logos-nix" ]]; then
local repo_url
repo_url=$(get_repo_url "$input_name")
# ── Resolve transitive URL overrides (see get_manual_url_overrides) ──
# Keep only inames the downstream flake actually declares (same guard as
# the follows path) so a renamed/removed upstream input can't leave a
# stale override that adds a spurious lock node. Sort for deterministic,
# idempotent output when a repo has more than one override.
local url_overrides url_overrides_sorted=""
url_overrides=$(get_manual_url_overrides "$input_name")
if [[ -n "$url_overrides" ]]; then
local url_kept=""
while IFS=$'\t' read -r u_iname u_url; do
[[ -z "$u_iname" ]] && continue
case "$declared_set" in
*" $u_iname "*) url_kept+="${u_iname} ${u_url}
" ;;
*) log " ${YELLOW}skip${NC} url-override ${input_name}/${u_iname} (not a declared input)" ;;
esac
done <<< "$url_overrides"
[[ -n "$url_kept" ]] && url_overrides_sorted=$(printf '%s' "$url_kept" | sort)
fi
if [[ -z "$follows_sorted" && -z "$url_overrides_sorted" ]]; then
echo " ${input_name}.url = \"${repo_url}\";" >> "$flake_inputs_tmp"
else
echo " ${input_name} = {" >> "$flake_inputs_tmp"
echo " url = \"${repo_url}\";" >> "$flake_inputs_tmp"
while IFS=$'\t' read -r f_iname f_target; do
[[ -z "$f_iname" ]] && continue
echo " inputs.${f_iname}.follows = \"${f_target}\";" >> "$flake_inputs_tmp"
done <<< "$follows_sorted"
# URL overrides go after the follows so a deleted upstream ref can't
# break the workspace re-lock.
if [[ -n "$url_overrides_sorted" ]]; then
while IFS=$'\t' read -r u_iname u_url; do
[[ -z "$u_iname" ]] && continue
echo " inputs.${u_iname}.url = \"${u_url}\";" >> "$flake_inputs_tmp"
done <<< "$url_overrides_sorted"
fi
echo " };" >> "$flake_inputs_tmp"
fi
fi
# Console status
local follow_count
follow_count=$(printf '%s' "$follows_pairs" | grep -c ' ' || true)
local marker="${GREEN}ok${NC}"
[[ "$has_tests" == "true" ]] && marker="${GREEN}ok${NC} ${CYAN}(tests)${NC}"
log " $marker $input_name (${follow_count} follows)"
done
echo "}" >> "$tmpfile"
log ""
# ── Update nix/dep-graph.nix ────────────────────────────────────────────
local dep_graph_changed=false
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 to nix/dep-graph.nix:${NC}"
diff --color=auto -u "$DEP_GRAPH" "$tmpfile" || true
echo ""
fi
mv "$tmpfile" "$DEP_GRAPH"
echo -e "${GREEN}Updated${NC} nix/dep-graph.nix"
dep_graph_changed=true
fi
# ── Update auto-inputs section in flake.nix ─────────────────────────────
local flake_file="$WORKSPACE_ROOT/flake.nix"
if [[ ! -f "$flake_file" ]]; then
echo -e "${RED}Error${NC}: $flake_file not found" >&2
rm -f "$flake_inputs_tmp"
return 1
fi
local has_begin has_end
has_begin=$(grep -cF "# BEGIN AUTO-INPUTS" "$flake_file")
has_end=$(grep -cF "# END AUTO-INPUTS" "$flake_file")
if [[ "$has_begin" -ne 1 || "$has_end" -ne 1 ]]; then
echo -e "${RED}Error${NC}: flake.nix must contain exactly one '# BEGIN AUTO-INPUTS' and one '# END AUTO-INPUTS' marker line (found BEGIN=$has_begin END=$has_end)" >&2
echo " Add the markers inside the \`inputs = { ... };\` block, e.g.:" >&2
echo " # BEGIN AUTO-INPUTS — generated by \`ws sync-graph\`, do not edit by hand" >&2
echo " # END AUTO-INPUTS" >&2
rm -f "$flake_inputs_tmp"
return 1
fi
local new_flake_tmp
new_flake_tmp=$(mktemp)
awk -v begin="# BEGIN AUTO-INPUTS" -v endm="# END AUTO-INPUTS" -v body_file="$flake_inputs_tmp" '
BEGIN { skipping = 0 }
{
if (index($0, begin) > 0) {
print
while ((getline line < body_file) > 0) print line
close(body_file)
skipping = 1
next
}
if (skipping && index($0, endm) > 0) {
skipping = 0
print
next
}
if (!skipping) print
}
' "$flake_file" > "$new_flake_tmp"
rm -f "$flake_inputs_tmp"
if diff -q "$flake_file" "$new_flake_tmp" >/dev/null 2>&1; then
echo -e "${GREEN}No changes${NC} — flake.nix auto-inputs section is already up to date."
rm "$new_flake_tmp"
else
echo -e "${BOLD}Changes to flake.nix:${NC}"
diff --color=auto -u "$flake_file" "$new_flake_tmp" | head -200 || true
echo ""
mv "$new_flake_tmp" "$flake_file"
echo -e "${GREEN}Updated${NC} flake.nix auto-inputs section"
fi
}
cmd_update_order() {
wants_help "$@" && show_help "Usage: ws update-order <dep> [--for <target>] [--json] [--commands]
Show the topological order for propagating a dependency change.
When a dependency (e.g. logos-module) gets a new commit merged, all repos
that depend on it need their flake.lock updated — but in the right order.
Repos at the same level can be updated in parallel; each level must be
committed and pushed to GitHub before the next level can run nix flake lock
(since nix fetches from remote URLs, not local paths).
Options:
--for <target> Scope output to repos on paths leading to this target
--json Output machine-readable JSON
--commands Also print nix flake lock + git commands per level
Examples:
ws update-order logos-module
ws update-order logos-cpp-sdk --for logos-basecamp
ws update-order logos-module-builder --commands
ws update-order logos-module --json"
local dep=""
local for_target=""
local json=false
local commands=false
while [[ $# -gt 0 ]]; do
case "$1" in
--for) for_target="$2"; shift 2 ;;
--json) json=true; shift ;;
--commands) commands=true; shift ;;
-*) shift ;;
*) dep="$1"; shift ;;
esac
done
if [[ -z "$dep" ]]; then
echo -e "${RED}Error:${NC} dependency name required" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo -e "${RED}jq is required but not found${NC}" >&2
exit 1
fi
local input_name
input_name=$(get_input_name "$dep")
local for_input=""
[[ -n "$for_target" ]] && for_input=$(get_input_name "$for_target")
local levels_raw
levels_raw=$(get_topo_levels "$input_name" ${for_input:+--for "$for_input"}) || true
if [[ -z "$levels_raw" ]]; then
$json && echo '{"dep":"'"$input_name"'","levels":[],"totalRepos":0,"totalLevels":0}' \
|| echo -e " ${YELLOW}No dependents found for${NC} ${BOLD}$input_name${NC}"
return
fi
# Count totals
local max_level=0 total_repos=0
while IFS='|' read -r lvl _r _d; do
[[ "$lvl" -gt "$max_level" ]] && max_level=$lvl
((total_repos++)) || true
done <<< "$levels_raw"
if $json; then
local out='{"dep":"'"$input_name"'"'
[[ -n "$for_input" ]] && out+=',"for":"'"$for_input"'"'
out+=',"totalRepos":'"$total_repos"',"totalLevels":'"$max_level"',"levels":['
local prev_lvl=-1 first_level=true first_repo=true
while IFS='|' read -r lvl repo deps; do
if [[ "$lvl" -ne "$prev_lvl" ]]; then
[[ "$prev_lvl" -ge 0 ]] && out+=']}'
$first_level && first_level=false || out+=','
out+='{"level":'"$lvl"',"repos":['
prev_lvl=$lvl first_repo=true
fi
$first_repo && first_repo=false || out+=','
out+='{"repo":"'"$repo"'","changedDeps":['
local first_dep=true
IFS=',' read -ra dep_arr <<< "$deps"
for d in "${dep_arr[@]}"; do
[[ -z "$d" ]] && continue
$first_dep && first_dep=false || out+=','
out+='"'"$d"'"'
done
out+=']}'
done <<< "$levels_raw"
[[ "$prev_lvl" -ge 0 ]] && out+=']}'
out+=']}'
echo "$out"
return
fi
# Human-readable output
echo ""
local header="${BOLD}Update order for ${CYAN}$input_name${NC}"
[[ -n "$for_input" ]] && header+=" ${BOLD}(scoped to $for_input)${NC}"
echo -e "$header"
echo ""
local prev_lvl=-1
while IFS='|' read -r lvl repo deps; do
if [[ "$lvl" -ne "$prev_lvl" ]]; then
[[ "$prev_lvl" -ge 0 ]] && echo ""
if [[ "$lvl" -eq 1 ]]; then
echo -e " ${BOLD}Level $lvl${NC} ${YELLOW}(update + push these first):${NC}"
else
echo -e " ${BOLD}Level $lvl${NC} ${YELLOW}(after level $(( lvl - 1 )) is pushed):${NC}"
fi
prev_lvl=$lvl
fi
local dep_display="${deps//,/, }"
printf " %-40s ${YELLOW}(depends on: %s)${NC}\n" "$repo" "$dep_display"
if $commands; then
local repo_dir
repo_dir=$(get_repo_path "$repo")
local update_flags=""
IFS=',' read -ra dep_arr <<< "$deps"
for d in "${dep_arr[@]}"; do
[[ -z "$d" ]] && continue
update_flags+=" --update-input $d"
done
echo -e " ${BLUE}cd repos/$repo_dir${NC}"
echo -e " ${BLUE}nix flake lock --extra-experimental-features 'nix-command flakes'$update_flags${NC}"
echo -e " ${BLUE}git add flake.lock && git commit -m 'update $input_name' && git push${NC}"
echo ""
fi
done <<< "$levels_raw"
echo ""
local repo_word="repos" level_word="levels"
[[ "$total_repos" -eq 1 ]] && repo_word="repo"
[[ "$max_level" -eq 1 ]] && level_word="level"
echo -e " ${BOLD}Total:${NC} $total_repos $repo_word across $max_level $level_word"
}
cmd_nix_diagnose() {
wants_help "$@" && show_help "Usage: ws nix-diagnose <repo> [--suggest]
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)
Options:
--suggest After showing conflicts, rank them by impact and suggest
the best starting point with ws update-order
Examples:
ws nix-diagnose logos-basecamp
ws nix-diagnose logos-basecamp --suggest
ws nix-diagnose logos-liblogos"
local repo="" suggest=false
while [[ $# -gt 0 ]]; do
case "$1" in
--suggest) suggest=true; shift ;;
-*) 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
# ── Suggested Resolution Order (--suggest) ────────────────────────────────
if $suggest; then
log ""
log "${BOLD}Suggested Resolution Order${NC}"
# Rank conflicts: score = rev_count * 1000 + total_lock_refs
# More distinct revisions = more divergence = higher priority
# Skip infrastructure-only deps (logos-nix, nixpkgs) — they're not actionable
local ranked
ranked=$(echo "$conflicts_json" | jq -r '
.[] | . as $group |
($group[0].repo_id) as $repo_id |
select($repo_id | test("logos-nix|nixpkgs") | not) |
($group | length) as $total_refs |
($group | map(.rev) | unique | length) as $rev_count |
"\($rev_count * 1000 + $total_refs)|\($rev_count)|\($total_refs)|\($repo_id)"
' | sort -t'|' -k1,1rn)
echo ""
if [[ -z "$ranked" ]]; then
echo -e " ${YELLOW}All conflicts are in infrastructure deps (logos-nix/nixpkgs) — no actionable suggestions.${NC}"
else
echo -e " ${BOLD}Conflicts ranked by impact (excluding logos-nix/nixpkgs):${NC}"
while IFS='|' read -r _score rev_count total_refs repo_id; do
local ws_input
ws_input=$(_github_repo_to_input_name "$repo_id")
local label="${ws_input:-$repo_id}"
echo -e " ${CYAN}$label${NC} ($rev_count revisions, $total_refs lock references)"
done <<< "$ranked"
# Show update-order for the most impactful conflict
local top_repo_id
top_repo_id=$(echo "$ranked" | head -1 | cut -d'|' -f4)
local top_input
top_input=$(_github_repo_to_input_name "$top_repo_id")
if [[ -n "$top_input" ]]; then
echo ""
echo -e " ${BOLD}Start with:${NC} ${CYAN}ws update-order $top_input --for $input_name${NC}"
echo ""
local levels_raw
levels_raw=$(get_topo_levels "$top_input" --for "$input_name") || true
if [[ -n "$levels_raw" ]]; then
local lv_max=0 lv_total=0
while IFS='|' read -r lvl _r _d; do
[[ "$lvl" -gt "$lv_max" ]] && lv_max=$lvl
((lv_total++)) || true
done <<< "$levels_raw"
echo -e " This affects $lv_total repos across $lv_max levels:"
local prev_lvl=-1 level_repos=""
while IFS='|' read -r lvl repo _deps; do
if [[ "$lvl" -ne "$prev_lvl" ]]; then
[[ -n "$level_repos" ]] && echo -e " $prev_lvl. $level_repos"
level_repos="$repo"
prev_lvl=$lvl
else
level_repos="$level_repos, $repo"
fi
done <<< "$levels_raw"
[[ -n "$level_repos" ]] && echo -e " $prev_lvl. $level_repos"
fi
fi
fi
echo ""
fi
}
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] [--verbose] <command> [args]
GLOBAL OPTIONS:
--quiet, -q Suppress informational output (for CI/scripting)
--verbose, -v Show full nix output (default: quiet, errors shown on failure)
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> [--suggest] Detect circular deps and version conflicts in flake.lock
update-order <dep> [--for target] [--json] [--commands]
Show topological update order for a dependency change
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)
lgpd <args> Package downloader (auto-builds logos-package-downloader)
lgpm <args> Package manager (Portable) (auto-builds logos-package-manager#cli-portable)
lgpm-dev <args> Package manager (Development) (auto-builds logos-package-manager#cli)
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-storage-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
# Show what order to update deps when logos-module changes
ws update-order logos-module
# Scope to repos that affect logos-basecamp only
ws update-order logos-cpp-sdk --for logos-basecamp
# Diagnose version conflicts in logos-basecamp + suggest fix order
ws nix-diagnose logos-basecamp --suggest
EOF
}
# ── Parse global flags ────────────────────────────────────────────────────────
_args=()
for _arg in "$@"; do
case "$_arg" in
--quiet|-q) QUIET=true ;;
--verbose|-v) VERBOSE=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 "$@" ;;
update-order) shift; cmd_update_order "$@" ;;
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|lgpm-dev|lgpd|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