happy-stacks 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -11,46 +11,23 @@ set -euo pipefail
11
11
  # - iTerm / Terminal: we run the command automatically via AppleScript.
12
12
  # - Ghostty: best-effort; if we can't run the command, we open Ghostty in the dir and copy the command to clipboard.
13
13
 
14
- CANONICAL_ENV_FILE="$HOME/.happy-stacks/.env"
15
-
16
- dotenv_get_quick() {
17
- local file="$1"
18
- local key="$2"
19
- [[ -n "$file" && -n "$key" && -f "$file" ]] || return 0
20
- local line
21
- line="$(grep -E "^${key}=" "$file" 2>/dev/null | head -n 1 || true)"
22
- [[ -n "$line" ]] || return 0
23
- local v="${line#*=}"
24
- v="${v%$'\r'}"
25
- if [[ "$v" == \"*\" && "$v" == *\" ]]; then v="${v#\"}"; v="${v%\"}"; fi
26
- if [[ "$v" == \'*\' && "$v" == *\' ]]; then v="${v#\'}"; v="${v%\'}"; fi
27
- echo "$v"
28
- }
29
-
30
- expand_home_quick() {
31
- local p="$1"
32
- if [[ "$p" == "~/"* ]]; then
33
- echo "$HOME/${p#~/}"
34
- else
35
- echo "$p"
36
- fi
37
- }
38
-
39
- home_from_canonical=""
40
- ws_from_canonical=""
41
- if [[ -f "$CANONICAL_ENV_FILE" ]]; then
42
- home_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_STACKS_HOME_DIR")"
43
- [[ -z "$home_from_canonical" ]] && home_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_LOCAL_HOME_DIR")"
44
- ws_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_STACKS_WORKSPACE_DIR")"
45
- [[ -z "$ws_from_canonical" ]] && ws_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_LOCAL_WORKSPACE_DIR")"
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ DEFAULT_HOME_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
16
+
17
+ # Prefer explicit env vars, but default to the install location inferred from this script path.
18
+ CANONICAL_HOME_DIR="${HAPPY_STACKS_CANONICAL_HOME_DIR:-${HAPPY_LOCAL_CANONICAL_HOME_DIR:-$DEFAULT_HOME_DIR}}"
19
+ HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-${HAPPY_STACKS_HOME_DIR:-$CANONICAL_HOME_DIR}}"
20
+ HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HAPPY_LOCAL_DIR}"
21
+
22
+ # Use shared resolver for workspace dir (respects HAPPY_STACKS_WORKSPACE_DIR and canonical pointer env).
23
+ LIB_DIR="$HAPPY_LOCAL_DIR/extras/swiftbar/lib"
24
+ if [[ -f "$LIB_DIR/utils.sh" ]]; then
25
+ # shellcheck source=/dev/null
26
+ source "$LIB_DIR/utils.sh"
46
27
  fi
47
- home_from_canonical="$(expand_home_quick "${home_from_canonical:-}")"
48
- ws_from_canonical="$(expand_home_quick "${ws_from_canonical:-}")"
49
-
50
- HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-${home_from_canonical:-$HOME/.happy-stacks}}"
51
- HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
52
28
 
53
- WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-${ws_from_canonical:-$HAPPY_STACKS_HOME_DIR/workspace}}"
29
+ WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$(resolve_workspace_dir 2>/dev/null || true)}"
30
+ [[ -z "$WORKDIR" ]] && WORKDIR="$HAPPY_STACKS_HOME_DIR/workspace"
54
31
  if [[ ! -d "$WORKDIR" ]]; then
55
32
  WORKDIR="$HOME"
56
33
  fi
@@ -2,45 +2,28 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # SwiftBar menu action wrapper.
5
- # Runs `happys` using the stable shim installed under ~/.happy-stacks/bin.
5
+ # Runs `happys` using the stable shim installed under <homeDir>/bin.
6
6
 
7
- CANONICAL_ENV_FILE="$HOME/.happy-stacks/.env"
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ DEFAULT_HOME_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
8
9
 
9
- dotenv_get_quick() {
10
- local file="$1"
11
- local key="$2"
12
- [[ -n "$file" && -n "$key" && -f "$file" ]] || return 0
13
- local line
14
- line="$(grep -E "^${key}=" "$file" 2>/dev/null | head -n 1 || true)"
15
- [[ -n "$line" ]] || return 0
16
- local v="${line#*=}"
17
- v="${v%$'\r'}"
18
- if [[ "$v" == \"*\" && "$v" == *\" ]]; then v="${v#\"}"; v="${v%\"}"; fi
19
- if [[ "$v" == \'*\' && "$v" == *\' ]]; then v="${v#\'}"; v="${v%\'}"; fi
20
- echo "$v"
10
+ # Treat presence of HAPPY_STACKS_SANDBOX_DIR as sandbox mode.
11
+ is_sandboxed() {
12
+ [[ -n "${HAPPY_STACKS_SANDBOX_DIR:-}" ]]
21
13
  }
22
14
 
23
- expand_home_quick() {
24
- local p="$1"
25
- if [[ "$p" == "~/"* ]]; then
26
- echo "$HOME/${p#~/}"
27
- else
28
- echo "$p"
29
- fi
30
- }
31
-
32
- home_from_canonical=""
33
- if [[ -f "$CANONICAL_ENV_FILE" ]]; then
34
- home_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_STACKS_HOME_DIR")"
35
- [[ -z "$home_from_canonical" ]] && home_from_canonical="$(dotenv_get_quick "$CANONICAL_ENV_FILE" "HAPPY_LOCAL_HOME_DIR")"
36
- fi
37
- home_from_canonical="$(expand_home_quick "${home_from_canonical:-}")"
38
-
39
- HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-${home_from_canonical:-$HOME/.happy-stacks}}"
40
- HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
15
+ # Prefer explicit env vars, but default to the install location inferred from this script path.
16
+ CANONICAL_HOME_DIR="${HAPPY_STACKS_CANONICAL_HOME_DIR:-${HAPPY_LOCAL_CANONICAL_HOME_DIR:-$DEFAULT_HOME_DIR}}"
17
+ HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-${HAPPY_STACKS_HOME_DIR:-$CANONICAL_HOME_DIR}}"
18
+ HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HAPPY_LOCAL_DIR}"
41
19
 
42
20
  HAPPYS_BIN="$HAPPY_LOCAL_DIR/bin/happys"
43
21
  if [[ ! -x "$HAPPYS_BIN" ]]; then
22
+ if is_sandboxed; then
23
+ echo "happys not found in sandbox home: $HAPPYS_BIN" >&2
24
+ echo "Tip: re-run: happys init (inside the sandbox) then re-install the menubar plugin." >&2
25
+ exit 1
26
+ fi
44
27
  HAPPYS_BIN="$(command -v happys 2>/dev/null || true)"
45
28
  fi
46
29
 
@@ -13,7 +13,19 @@ PLUGIN_SOURCE="$SCRIPT_DIR/happy-stacks.5s.sh"
13
13
  # You can override:
14
14
  # HAPPY_LOCAL_SWIFTBAR_INTERVAL=30s ./install.sh
15
15
  PLUGIN_INTERVAL="${HAPPY_STACKS_SWIFTBAR_INTERVAL:-${HAPPY_LOCAL_SWIFTBAR_INTERVAL:-5m}}"
16
- PLUGIN_FILE="happy-stacks.${PLUGIN_INTERVAL}.sh"
16
+ PLUGIN_BASENAME="${HAPPY_STACKS_SWIFTBAR_PLUGIN_BASENAME:-happy-stacks}"
17
+ PLUGIN_FILE="${PLUGIN_BASENAME}.${PLUGIN_INTERVAL}.sh"
18
+
19
+ # Optional: install a wrapper plugin instead of copying the source.
20
+ # This is useful for sandbox/test installs so the plugin can be pinned to a specific home/canonical dir
21
+ # even under SwiftBar's minimal environment.
22
+ WRAPPER="${HAPPY_STACKS_SWIFTBAR_PLUGIN_WRAPPER:-0}"
23
+
24
+ escape_single_quotes() {
25
+ # Escape a string so it can be safely embedded inside single quotes in a bash script.
26
+ # e.g. abc'def -> abc'"'"'def
27
+ printf "%s" "$1" | sed "s/'/'\"'\"'/g"
28
+ }
17
29
 
18
30
  FORCE=0
19
31
  for arg in "$@"; do
@@ -128,33 +140,107 @@ echo -e "${YELLOW}Step 3: Installing Happy Stacks plugin...${NC}"
128
140
 
129
141
  PLUGIN_DEST="$PLUGINS_DIR/$PLUGIN_FILE"
130
142
 
131
- # Remove any legacy happy-local plugins to avoid duplicates.
132
- rm -f "$PLUGINS_DIR"/happy-local.*.sh 2>/dev/null || true
143
+ # Remove any legacy happy-local plugins to avoid duplicates *only* for the primary plugin.
144
+ # For sandbox installs (separate basenames), never delete other plugins.
145
+ if [[ "$PLUGIN_BASENAME" == "happy-stacks" ]]; then
146
+ rm -f "$PLUGINS_DIR"/happy-local.*.sh 2>/dev/null || true
147
+ fi
133
148
 
149
+ EXISTED=0
134
150
  if [[ -f "$PLUGIN_DEST" ]]; then
151
+ EXISTED=1
152
+ fi
153
+
154
+ SHOULD_INSTALL=1
155
+ if [[ "$EXISTED" == "1" ]]; then
135
156
  echo "Plugin already exists at $PLUGIN_DEST"
136
157
  if [[ "$FORCE" == "1" ]] || [[ ! -t 0 ]]; then
137
- cp "$PLUGIN_SOURCE" "$PLUGIN_DEST"
138
- chmod +x "$PLUGIN_DEST"
139
- echo -e "${GREEN}✓ Plugin updated${NC}"
158
+ SHOULD_INSTALL=1
140
159
  else
141
160
  echo "Would you like to overwrite it? (y/n)"
142
161
  read -r OVERWRITE_CHOICE
143
-
144
162
  if [[ "$OVERWRITE_CHOICE" != "y" ]] && [[ "$OVERWRITE_CHOICE" != "Y" ]]; then
163
+ SHOULD_INSTALL=0
145
164
  echo "Skipping plugin installation."
165
+ fi
166
+ fi
167
+ fi
168
+
169
+ if [[ "$SHOULD_INSTALL" == "1" ]]; then
170
+ if [[ "$WRAPPER" == "1" ]]; then
171
+ # Generate a wrapper plugin that pins env vars and executes the real plugin source.
172
+ HOME_DIR_VAL="${HAPPY_STACKS_HOME_DIR:-${HAPPY_LOCAL_DIR:-$HOME/.happy-stacks}}"
173
+ CANONICAL_DIR_VAL="${HAPPY_STACKS_CANONICAL_HOME_DIR:-${HAPPY_LOCAL_CANONICAL_HOME_DIR:-$HOME/.happy-stacks}}"
174
+ SANDBOX_DIR_VAL="${HAPPY_STACKS_SANDBOX_DIR:-}"
175
+ WORKSPACE_DIR_VAL="${HAPPY_STACKS_WORKSPACE_DIR:-}"
176
+ RUNTIME_DIR_VAL="${HAPPY_STACKS_RUNTIME_DIR:-}"
177
+ STORAGE_DIR_VAL="${HAPPY_STACKS_STORAGE_DIR:-}"
178
+
179
+ if [[ -n "$SANDBOX_DIR_VAL" ]]; then
180
+ [[ -z "$WORKSPACE_DIR_VAL" ]] && WORKSPACE_DIR_VAL="${SANDBOX_DIR_VAL%/}/workspace"
181
+ [[ -z "$RUNTIME_DIR_VAL" ]] && RUNTIME_DIR_VAL="${SANDBOX_DIR_VAL%/}/runtime"
182
+ [[ -z "$STORAGE_DIR_VAL" ]] && STORAGE_DIR_VAL="${SANDBOX_DIR_VAL%/}/storage"
183
+ fi
184
+ HOME_DIR_ESC="$(escape_single_quotes "$HOME_DIR_VAL")"
185
+ CANONICAL_DIR_ESC="$(escape_single_quotes "$CANONICAL_DIR_VAL")"
186
+ SANDBOX_DIR_ESC="$(escape_single_quotes "$SANDBOX_DIR_VAL")"
187
+ WORKSPACE_DIR_ESC="$(escape_single_quotes "$WORKSPACE_DIR_VAL")"
188
+ RUNTIME_DIR_ESC="$(escape_single_quotes "$RUNTIME_DIR_VAL")"
189
+ STORAGE_DIR_ESC="$(escape_single_quotes "$STORAGE_DIR_VAL")"
190
+ SRC_ESC="$(escape_single_quotes "$PLUGIN_SOURCE")"
191
+ BASENAME_ESC="$(escape_single_quotes "$PLUGIN_BASENAME")"
192
+
193
+ cat >"$PLUGIN_DEST" <<EOF
194
+ #!/bin/bash
195
+ set -euo pipefail
196
+ export HAPPY_STACKS_HOME_DIR='$HOME_DIR_ESC'
197
+ export HAPPY_LOCAL_DIR='$HOME_DIR_ESC'
198
+ export HAPPY_STACKS_CANONICAL_HOME_DIR='$CANONICAL_DIR_ESC'
199
+ export HAPPY_LOCAL_CANONICAL_HOME_DIR='$CANONICAL_DIR_ESC'
200
+ export HAPPY_STACKS_SWIFTBAR_PLUGIN_BASENAME='$BASENAME_ESC'
201
+ export HAPPY_LOCAL_SWIFTBAR_PLUGIN_BASENAME='$BASENAME_ESC'
202
+ if [[ -n '$SANDBOX_DIR_ESC' ]]; then
203
+ export HAPPY_STACKS_SANDBOX_DIR='$SANDBOX_DIR_ESC'
204
+ fi
205
+ if [[ -n '$WORKSPACE_DIR_ESC' ]]; then
206
+ export HAPPY_STACKS_WORKSPACE_DIR='$WORKSPACE_DIR_ESC'
207
+ export HAPPY_LOCAL_WORKSPACE_DIR='$WORKSPACE_DIR_ESC'
208
+ fi
209
+ if [[ -n '$RUNTIME_DIR_ESC' ]]; then
210
+ export HAPPY_STACKS_RUNTIME_DIR='$RUNTIME_DIR_ESC'
211
+ export HAPPY_LOCAL_RUNTIME_DIR='$RUNTIME_DIR_ESC'
212
+ fi
213
+ if [[ -n '$STORAGE_DIR_ESC' ]]; then
214
+ export HAPPY_STACKS_STORAGE_DIR='$STORAGE_DIR_ESC'
215
+ export HAPPY_LOCAL_STORAGE_DIR='$STORAGE_DIR_ESC'
216
+ fi
217
+ # Prevent any re-exec into a "real" install when testing.
218
+ export HAPPY_STACKS_CLI_ROOT_DISABLE="1"
219
+ exec '$SRC_ESC'
220
+ EOF
221
+ chmod +x "$PLUGIN_DEST"
222
+ if [[ "$EXISTED" == "1" ]]; then
223
+ echo -e "${GREEN}✓ Plugin updated (wrapper)${NC}"
146
224
  else
147
- cp "$PLUGIN_SOURCE" "$PLUGIN_DEST"
148
- chmod +x "$PLUGIN_DEST"
225
+ echo -e "${GREEN}✓ Plugin installed (wrapper)${NC}"
226
+ fi
227
+ else
228
+ cp "$PLUGIN_SOURCE" "$PLUGIN_DEST"
229
+ chmod +x "$PLUGIN_DEST"
230
+ if [[ "$EXISTED" == "1" ]]; then
149
231
  echo -e "${GREEN}✓ Plugin updated${NC}"
232
+ else
233
+ echo -e "${GREEN}✓ Plugin installed${NC}"
150
234
  fi
151
235
  fi
152
- else
153
- cp "$PLUGIN_SOURCE" "$PLUGIN_DEST"
154
- chmod +x "$PLUGIN_DEST"
155
- echo -e "${GREEN}✓ Plugin installed${NC}"
156
236
  fi
157
237
 
238
+ #
239
+ # Ensure helper scripts are executable (SwiftBar menu actions rely on this).
240
+ # The repo usually tracks +x, but home installs can lose mode bits depending on how assets are copied.
241
+ #
242
+ chmod +x "$SCRIPT_DIR"/*.sh 2>/dev/null || true
243
+
158
244
  echo ""
159
245
 
160
246
  # Step 4: Launch SwiftBar if not running
@@ -8,13 +8,321 @@ is_git_repo() {
8
8
  [[ -n "$dir" && -d "$dir" && ( -d "$dir/.git" || -f "$dir/.git" ) ]]
9
9
  }
10
10
 
11
+ git_cache_dir() {
12
+ local canonical="${HAPPY_STACKS_CANONICAL_HOME_DIR:-${HAPPY_LOCAL_CANONICAL_HOME_DIR:-$HOME/.happy-stacks}}"
13
+ local home="${HAPPY_STACKS_HOME_DIR:-${HAPPY_LOCAL_DIR:-$canonical}}"
14
+ local dir="${home}/cache/swiftbar/git"
15
+ mkdir -p "$dir" 2>/dev/null || true
16
+ echo "$dir"
17
+ }
18
+
19
+ git_cache_ttl_sec() {
20
+ # Default: 6 hours.
21
+ local v="${HAPPY_STACKS_SWIFTBAR_GIT_TTL_SEC:-${HAPPY_LOCAL_SWIFTBAR_GIT_TTL_SEC:-21600}}"
22
+ [[ "$v" =~ ^[0-9]+$ ]] || v=21600
23
+ echo "$v"
24
+ }
25
+
26
+ git_cache_refresh_on_stale() {
27
+ [[ "${HAPPY_STACKS_SWIFTBAR_GIT_REFRESH_ON_STALE:-${HAPPY_LOCAL_SWIFTBAR_GIT_REFRESH_ON_STALE:-0}}" == "1" ]]
28
+ }
29
+
30
+ git_cache_auto_refresh_scope() {
31
+ # off | main | all
32
+ local s="${HAPPY_STACKS_SWIFTBAR_GIT_AUTO_REFRESH_SCOPE:-${HAPPY_LOCAL_SWIFTBAR_GIT_AUTO_REFRESH_SCOPE:-main}}"
33
+ s="$(echo "$s" | tr '[:upper:]' '[:lower:]')"
34
+ case "$s" in
35
+ off|none|0) echo "off" ;;
36
+ all) echo "all" ;;
37
+ *) echo "main" ;;
38
+ esac
39
+ }
40
+
41
+ git_cache_last_refresh_file() {
42
+ local scope="${1:-main}" # main|all|stack:<name>
43
+ local dir
44
+ dir="$(git_cache_dir)"
45
+ local key="last_refresh|${scope}"
46
+ echo "${dir}/$(swiftbar_cache_hash12 "$key").last"
47
+ }
48
+
49
+ git_cache_background_refresh_lockdir() {
50
+ local scope="${1:-main}"
51
+ local dir
52
+ dir="$(git_cache_dir)"
53
+ local key="bg_refresh_lock|${scope}"
54
+ echo "${dir}/$(swiftbar_cache_hash12 "$key").lock"
55
+ }
56
+
57
+ git_cache_touch_last_refresh() {
58
+ local scope="${1:-main}"
59
+ local f
60
+ f="$(git_cache_last_refresh_file "$scope")"
61
+ mkdir -p "$(dirname "$f")" 2>/dev/null || true
62
+ date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null >"$f" || true
63
+ touch "$f" 2>/dev/null || true
64
+ }
65
+
66
+ git_cache_age_since_last_refresh_sec() {
67
+ local scope="${1:-main}"
68
+ local f
69
+ f="$(git_cache_last_refresh_file "$scope")"
70
+ [[ -f "$f" ]] || { echo ""; return; }
71
+ local mtime now
72
+ mtime="$(stat -f %m "$f" 2>/dev/null || echo 0)"
73
+ now="$(date +%s 2>/dev/null || echo 0)"
74
+ if [[ "$mtime" =~ ^[0-9]+$ && "$now" =~ ^[0-9]+$ && "$now" -ge "$mtime" ]]; then
75
+ echo $((now - mtime))
76
+ else
77
+ echo ""
78
+ fi
79
+ }
80
+
81
+ git_cache_maybe_refresh_async() {
82
+ # Non-blocking cache refresh.
83
+ # Usage: git_cache_maybe_refresh_async <scope> <refresh_cmd...>
84
+ local scope="$1"
85
+ shift
86
+
87
+ local ttl age
88
+ ttl="$(git_cache_ttl_sec)"
89
+ age="$(git_cache_age_since_last_refresh_sec "$scope")"
90
+
91
+ # If never refreshed, treat as stale and allow.
92
+ if [[ -n "$age" && "$age" =~ ^[0-9]+$ && "$age" -le "$ttl" ]]; then
93
+ return 0
94
+ fi
95
+
96
+ local lockdir
97
+ lockdir="$(git_cache_background_refresh_lockdir "$scope")"
98
+ if [[ -d "$lockdir" ]]; then
99
+ # If lock is too old, break it (e.g. crashed refresh).
100
+ local lock_age
101
+ lock_age="$(git_cache_age_sec "$lockdir" 2>/dev/null || true)"
102
+ if [[ -n "$lock_age" && "$lock_age" =~ ^[0-9]+$ && "$lock_age" -gt 3600 ]]; then
103
+ rm -rf "$lockdir" 2>/dev/null || true
104
+ else
105
+ return 0
106
+ fi
107
+ fi
108
+
109
+ mkdir "$lockdir" 2>/dev/null || return 0
110
+ echo "$$" >"${lockdir}/pid" 2>/dev/null || true
111
+ date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null >"${lockdir}/started_at" || true
112
+
113
+ # Run in the background; on success, update last-refresh marker.
114
+ (
115
+ "$@" >/dev/null 2>&1 || true
116
+ git_cache_touch_last_refresh "$scope"
117
+ rm -rf "$lockdir" >/dev/null 2>&1 || true
118
+ ) >/dev/null 2>&1 &
119
+ }
120
+
121
+ git_cache_mode() {
122
+ # cached (default) | live
123
+ local m="${HAPPY_STACKS_SWIFTBAR_GIT_MODE:-${HAPPY_LOCAL_SWIFTBAR_GIT_MODE:-cached}}"
124
+ m="$(echo "$m" | tr '[:upper:]' '[:lower:]')"
125
+ [[ "$m" == "live" ]] && echo "live" || echo "cached"
126
+ }
127
+
128
+ git_cache_key() {
129
+ # Include context+stack because stacks can point components at different worktrees/dirs.
130
+ local context="$1"
131
+ local stack="$2"
132
+ local component="$3"
133
+ local active_dir="$4"
134
+ echo "ctx=${context}|stack=${stack}|comp=${component}|dir=${active_dir}"
135
+ }
136
+
137
+ git_cache_paths() {
138
+ # Usage: git_cache_paths <key>
139
+ # Output: meta<TAB>info<TAB>worktrees
140
+ local key="$1"
141
+ local dir
142
+ dir="$(git_cache_dir)"
143
+ local h
144
+ h="$(swiftbar_hash "$key")"
145
+ echo -e "${dir}/${h}.meta\t${dir}/${h}.info.tsv\t${dir}/${h}.worktrees.tsv"
146
+ }
147
+
148
+ git_cache_age_sec() {
149
+ local meta="$1"
150
+ [[ -f "$meta" ]] || { echo ""; return; }
151
+ local mtime now
152
+ mtime="$(stat -f %m "$meta" 2>/dev/null || echo 0)"
153
+ now="$(date +%s 2>/dev/null || echo 0)"
154
+ if [[ "$mtime" =~ ^[0-9]+$ && "$now" =~ ^[0-9]+$ && "$now" -ge "$mtime" ]]; then
155
+ echo $((now - mtime))
156
+ else
157
+ echo ""
158
+ fi
159
+ }
160
+
161
+ git_cache_is_fresh() {
162
+ local meta="$1"
163
+ local ttl
164
+ ttl="$(git_cache_ttl_sec)"
165
+ local age
166
+ age="$(git_cache_age_sec "$meta")"
167
+ [[ -n "$age" && "$age" =~ ^[0-9]+$ && "$age" -le "$ttl" ]]
168
+ }
169
+
170
+ git_cache_write_meta() {
171
+ local meta="$1"
172
+ local key="$2"
173
+ mkdir -p "$(dirname "$meta")" 2>/dev/null || true
174
+ {
175
+ echo "key=$key"
176
+ echo "updated_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date)"
177
+ } >"$meta" 2>/dev/null || true
178
+ # touch to update mtime (age calculation uses mtime).
179
+ touch "$meta" 2>/dev/null || true
180
+ }
181
+
182
+ git_cache_refresh_one() {
183
+ # Computes and writes cached snapshot for one component context.
184
+ # Usage: git_cache_refresh_one <context> <stack> <component> <active_dir>
185
+ local context="$1"
186
+ local stack="$2"
187
+ local component="$3"
188
+ local active_dir="$4"
189
+
190
+ local key
191
+ key="$(git_cache_key "$context" "$stack" "$component" "$active_dir")"
192
+ local meta info wts
193
+ IFS=$'\t' read -r meta info wts <<<"$(git_cache_paths "$key")"
194
+
195
+ # Missing/non-repo: still write meta so we don't thrash.
196
+ if ! is_git_repo "$active_dir"; then
197
+ echo -e "missing\t${active_dir}\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-\t-" >"$info" 2>/dev/null || true
198
+ : >"$wts" 2>/dev/null || true
199
+ git_cache_write_meta "$meta" "$key"
200
+ return 0
201
+ fi
202
+
203
+ # Collect snapshot.
204
+ local branch head upstream dirty ab ahead behind
205
+ branch="$(git_head_branch "$active_dir")"
206
+ head="$(git_head_short "$active_dir")"
207
+ upstream="$(git_upstream_short "$active_dir")"
208
+ dirty="$(git_dirty_flag "$active_dir")"
209
+ ab="$(git_ahead_behind "$active_dir")"
210
+ ahead=""
211
+ behind=""
212
+ if [[ -n "$ab" ]]; then
213
+ ahead="$(echo "$ab" | cut -d'|' -f1)"
214
+ behind="$(echo "$ab" | cut -d'|' -f2)"
215
+ fi
216
+
217
+ local main_branch main_upstream main_ab main_ahead main_behind
218
+ main_branch="$(git_main_branch_name "$active_dir")"
219
+ main_upstream=""
220
+ main_ahead=""
221
+ main_behind=""
222
+ if [[ -n "$main_branch" ]]; then
223
+ main_upstream="$(git_branch_upstream_short "$active_dir" "$main_branch")"
224
+ main_ab="$(git_branch_ahead_behind "$active_dir" "$main_branch")"
225
+ if [[ -n "$main_ab" ]]; then
226
+ main_ahead="$(echo "$main_ab" | cut -d'|' -f1)"
227
+ main_behind="$(echo "$main_ab" | cut -d'|' -f2)"
228
+ fi
229
+ fi
230
+
231
+ local oref uref oab o_ahead o_behind uab u_ahead u_behind
232
+ oref="$(git_remote_main_ref "$active_dir" "origin")"
233
+ uref="$(git_remote_main_ref "$active_dir" "upstream")"
234
+ o_ahead=""; o_behind=""; u_ahead=""; u_behind=""
235
+ if [[ -n "$main_branch" && -n "$oref" ]]; then
236
+ oab="$(git_ahead_behind_refs "$active_dir" "$oref" "$main_branch")"
237
+ if [[ -n "$oab" ]]; then
238
+ o_ahead="$(echo "$oab" | cut -d'|' -f1)"
239
+ o_behind="$(echo "$oab" | cut -d'|' -f2)"
240
+ fi
241
+ fi
242
+ if [[ -n "$main_branch" && -n "$uref" ]]; then
243
+ uab="$(git_ahead_behind_refs "$active_dir" "$uref" "$main_branch")"
244
+ if [[ -n "$uab" ]]; then
245
+ u_ahead="$(echo "$uab" | cut -d'|' -f1)"
246
+ u_behind="$(echo "$uab" | cut -d'|' -f2)"
247
+ fi
248
+ fi
249
+
250
+ local wt_count
251
+ wt_count="$(git_worktree_count "$active_dir")"
252
+ git_worktrees_tsv "$active_dir" >"$wts" 2>/dev/null || true
253
+
254
+ # status active_dir branch head upstream dirty ahead behind main_branch main_upstream main_ahead main_behind oref o_ahead o_behind uref u_ahead u_behind wt_count
255
+ echo -e "ok\t${active_dir}\t${branch}\t${head}\t${upstream}\t${dirty}\t${ahead}\t${behind}\t${main_branch}\t${main_upstream}\t${main_ahead}\t${main_behind}\t${oref}\t${o_ahead}\t${o_behind}\t${uref}\t${u_ahead}\t${u_behind}\t${wt_count}" >"$info" 2>/dev/null || true
256
+ git_cache_write_meta "$meta" "$key"
257
+ return 0
258
+ }
259
+
260
+ git_cache_load_or_refresh() {
261
+ # Usage: git_cache_load_or_refresh <context> <stack> <component> <active_dir> <allow_refresh_on_miss:0|1>
262
+ # Output: meta<TAB>info<TAB>worktrees<TAB>stale(0|1)
263
+ local context="$1"
264
+ local stack="$2"
265
+ local component="$3"
266
+ local active_dir="$4"
267
+ local allow_refresh_on_miss="${5:-0}"
268
+
269
+ local key
270
+ key="$(git_cache_key "$context" "$stack" "$component" "$active_dir")"
271
+ local meta info wts
272
+ IFS=$'\t' read -r meta info wts <<<"$(git_cache_paths "$key")"
273
+
274
+ # If cache exists and is fresh, use it.
275
+ if [[ -f "$meta" && -f "$info" ]]; then
276
+ if git_cache_is_fresh "$meta"; then
277
+ echo -e "${meta}\t${info}\t${wts}\t0"
278
+ return 0
279
+ fi
280
+ # Stale: do not refresh synchronously during menu render. Background refresh is handled elsewhere.
281
+ echo -e "${meta}\t${info}\t${wts}\t1"
282
+ return 0
283
+ fi
284
+
285
+ # Missing: only refresh synchronously when allowed by caller.
286
+ if [[ "$allow_refresh_on_miss" == "1" ]]; then
287
+ git_cache_refresh_one "$context" "$stack" "$component" "$active_dir" >/dev/null 2>&1 || true
288
+ if [[ -f "$info" ]]; then
289
+ echo -e "${meta}\t${info}\t${wts}\t0"
290
+ return 0
291
+ fi
292
+ fi
293
+
294
+ # Still missing; report missing and stale=1 so callers can show "refresh" action.
295
+ echo -e "${meta}\t${info}\t${wts}\t1"
296
+ return 0
297
+ }
298
+
11
299
  git_try() {
12
300
  local dir="$1"
13
301
  shift
14
302
  if ! command -v git >/dev/null 2>&1; then
15
303
  return 1
16
304
  fi
17
- git -C "$dir" "$@" 2>/dev/null
305
+ local subcmd="${1:-}"
306
+
307
+ # Run-cache: many stacks render the same component git info; cache by repo path + args for this SwiftBar run.
308
+ local cache_key="git|${dir}|$*"
309
+ swiftbar_cache_get "$cache_key"
310
+ local cached_rc=$?
311
+ if [[ $cached_rc -ne 111 ]]; then
312
+ # Cache hit: swiftbar_cache_get already printed stdout. Preserve rc.
313
+ return $cached_rc
314
+ fi
315
+
316
+ local t0 t1 rc out
317
+ t0="$(swiftbar_now_ms 2>/dev/null || echo 0)"
318
+ out="$(git -C "$dir" "$@" 2>/dev/null)"
319
+ rc=$?
320
+ t1="$(swiftbar_now_ms 2>/dev/null || echo 0)"
321
+ swiftbar_cache_set "$cache_key" "$rc" "$out"
322
+ # Keep label short; include subcommand for aggregation.
323
+ swiftbar_profile_log "time" "label=git" "subcmd=$subcmd" "ms=$((t1 - t0))" "rc=${rc}"
324
+ printf '%s\n' "$out"
325
+ return $rc
18
326
  }
19
327
 
20
328
  git_head_branch() {
@@ -10,8 +10,8 @@ get_menu_icon_b64() {
10
10
  fi
11
11
 
12
12
  # Default: prefer a menu-bar friendly PNG icon (repo-local).
13
- if [[ -z "$source" ]] && [[ -f "$HAPPY_LOCAL_DIR/extras/swiftbar/logo-white.png" ]]; then
14
- source="$HAPPY_LOCAL_DIR/extras/swiftbar/logo-white.png"
13
+ if [[ -z "$source" ]] && [[ -f "$HAPPY_LOCAL_DIR/extras/swiftbar/icons/logo-white.png" ]]; then
14
+ source="$HAPPY_LOCAL_DIR/extras/swiftbar/icons/logo-white.png"
15
15
  fi
16
16
 
17
17
  # Fallback: use Happy's favicon if present.