youmd 0.7.1 → 0.8.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.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
- # env-vault/backup.sh — backs up all .env.local files under CODE_2025 into an
3
- # encrypted vault. NEVER prints, echoes, logs, or writes any secret VALUE.
4
- # Only variable NAMES and counts appear in plaintext output.
2
+ # env-vault/backup.sh — backs up all .env.local files under CODE_2025 AND a
3
+ # fixed set of agent-auth secret files into an encrypted vault. NEVER prints,
4
+ # echoes, logs, or writes any secret VALUE. Only variable NAMES, counts, and
5
+ # file paths appear in plaintext output.
5
6
  #
6
7
  # Usage: bash backup.sh [--root <search-root>] [--out <output-dir>]
7
8
  # Defaults:
@@ -14,6 +15,16 @@ set -euo pipefail
14
15
  SEARCH_ROOT="${ENV_VAULT_ROOT:-/Users/houstongolden/Desktop/CODE_2025}"
15
16
  OUTPUT_DIR="${ENV_VAULT_OUT:-/Users/houstongolden/Desktop/CODE_2025/youmd/.env-vault}"
16
17
 
18
+ # ── agent-auth secret files ────────────────────────────────────────────────────
19
+ # Absolute paths to hard agent-auth secrets that must travel with the vault.
20
+ # Each entry is archived as agent-auth/<stable-name> inside the tar.
21
+ # To add more: append to this array. Format: "absolute-path:stable-archive-name"
22
+ AGENT_SECRET_FILES=(
23
+ "${HOME}/.codex/auth.json:codex-auth.json"
24
+ "${HOME}/.cursor/mcp.json:cursor-mcp.json"
25
+ "${HOME}/.claude.json:claude.json"
26
+ )
27
+
17
28
  # ── parse flags ────────────────────────────────────────────────────────────────
18
29
  while [[ $# -gt 0 ]]; do
19
30
  case $1 in
@@ -94,6 +105,38 @@ for ENV_FILE in "${ENV_FILES[@]}"; do
94
105
  >> "${MANIFEST_PATH}"
95
106
  done
96
107
 
108
+ # ── stage agent-auth secret files ─────────────────────────────────────────────
109
+ # Each entry in AGENT_SECRET_FILES has format "absolute-path:stable-archive-name".
110
+ # Files are stored under agent-auth/<stable-name> inside the archive.
111
+ # Only the path and byte-size appear in the manifest — never the contents.
112
+ AGENT_AUTH_STAGING="${STAGING_DIR}/agent-auth"
113
+ mkdir -p "${AGENT_AUTH_STAGING}"
114
+ AGENT_AUTH_COUNT=0
115
+
116
+ echo "" >> "${MANIFEST_PATH}"
117
+ echo "agent-auth files:" >> "${MANIFEST_PATH}"
118
+
119
+ for ENTRY in "${AGENT_SECRET_FILES[@]}"; do
120
+ SRC_PATH="${ENTRY%%:*}"
121
+ ARCHIVE_NAME="${ENTRY##*:}"
122
+
123
+ if [[ -f "${SRC_PATH}" ]]; then
124
+ cp "${SRC_PATH}" "${AGENT_AUTH_STAGING}/${ARCHIVE_NAME}"
125
+ FILE_BYTES=$(wc -c < "${SRC_PATH}" | tr -d ' ')
126
+ echo " agent-auth/${ARCHIVE_NAME} (${SRC_PATH}): ${FILE_BYTES} bytes" \
127
+ >> "${MANIFEST_PATH}"
128
+ echo " Staged agent-auth: ${SRC_PATH} → agent-auth/${ARCHIVE_NAME}"
129
+ (( AGENT_AUTH_COUNT++ )) || true
130
+ else
131
+ echo " agent-auth/${ARCHIVE_NAME} (${SRC_PATH}): NOT FOUND — skipped" \
132
+ >> "${MANIFEST_PATH}"
133
+ echo " Skipped (not found): ${SRC_PATH}"
134
+ fi
135
+ done
136
+
137
+ echo ""
138
+ echo "Staged ${AGENT_AUTH_COUNT} agent-auth file(s)."
139
+
97
140
  echo "" >> "${MANIFEST_PATH}"
98
141
  echo "SECURITY: The encrypted archive is safe to transport. Keep the passphrase private." \
99
142
  >> "${MANIFEST_PATH}"
@@ -1,15 +1,31 @@
1
1
  #!/usr/bin/env bash
2
- # env-vault/restore.sh — restores .env.local files from an encrypted vault
3
- # created by backup.sh. NEVER prints, echoes, or logs any secret VALUES.
4
- # Only prints which file paths were restored or skipped.
2
+ # env-vault/restore.sh — restores .env.local files AND agent-auth secrets from
3
+ # an encrypted vault created by backup.sh. NEVER prints, echoes, or logs any
4
+ # secret VALUES. Only prints which file paths were restored or skipped.
5
5
  #
6
6
  # Usage: bash restore.sh [--force] [--root <target-root>] <vault-file>
7
7
  # vault-file: path to env-vault-*.tar.age / .tar.gpg / .tar.enc
8
- # --force: overwrite existing .env.local files without backing them up
9
- # --root: destination root (default: /Users/houstongolden/Desktop/CODE_2025)
8
+ # --force: overwrite existing files without backing them up
9
+ # --root: destination root for .env.local files
10
+ # (default: /Users/houstongolden/Desktop/CODE_2025)
11
+ #
12
+ # agent-auth restore map (archive name → absolute home path):
13
+ # agent-auth/codex-auth.json → ~/.codex/auth.json
14
+ # agent-auth/cursor-mcp.json → ~/.cursor/mcp.json
15
+ # agent-auth/claude.json → ~/.claude.json
10
16
 
11
17
  set -euo pipefail
12
18
 
19
+ # ── agent-auth restore map ─────────────────────────────────────────────────────
20
+ # Maps archive name (inside agent-auth/) to its absolute home destination.
21
+ # Format: "archive-name:absolute-destination-path"
22
+ # Must stay in sync with AGENT_SECRET_FILES in backup.sh.
23
+ AGENT_AUTH_MAP=(
24
+ "codex-auth.json:${HOME}/.codex/auth.json"
25
+ "cursor-mcp.json:${HOME}/.cursor/mcp.json"
26
+ "claude.json:${HOME}/.claude.json"
27
+ )
28
+
13
29
  # ── defaults ──────────────────────────────────────────────────────────────────
14
30
  TARGET_ROOT="${ENV_VAULT_RESTORE_ROOT:-/Users/houstongolden/Desktop/CODE_2025}"
15
31
  FORCE=false
@@ -139,11 +155,61 @@ while IFS= read -r -d '' ENV_FILE; do
139
155
  fi
140
156
  done < <(find "${EXTRACT_DIR}" -name ".env.local" -print0)
141
157
 
158
+ # ── restore agent-auth files ───────────────────────────────────────────────────
159
+ AGENT_RESTORED=0
160
+ AGENT_SKIPPED=0
161
+ AGENT_BACKED_UP=0
162
+
163
+ AGENT_AUTH_SRC="${EXTRACT_DIR}/agent-auth"
164
+
165
+ if [[ -d "${AGENT_AUTH_SRC}" ]]; then
166
+ echo "Restoring agent-auth files…"
167
+ for ENTRY in "${AGENT_AUTH_MAP[@]}"; do
168
+ ARCHIVE_NAME="${ENTRY%%:*}"
169
+ DEST_PATH="${ENTRY##*:}"
170
+ SRC_FILE="${AGENT_AUTH_SRC}/${ARCHIVE_NAME}"
171
+
172
+ if [[ ! -f "${SRC_FILE}" ]]; then
173
+ echo " SKIPPED (not in vault): agent-auth/${ARCHIVE_NAME}"
174
+ (( AGENT_SKIPPED++ )) || true
175
+ continue
176
+ fi
177
+
178
+ DEST_DIR="$(dirname "${DEST_PATH}")"
179
+ mkdir -p "${DEST_DIR}"
180
+
181
+ if [[ -f "${DEST_PATH}" ]]; then
182
+ if [[ "${FORCE}" == "true" ]]; then
183
+ cp "${SRC_FILE}" "${DEST_PATH}"
184
+ echo " RESTORED (overwrite): ${DEST_PATH}"
185
+ (( AGENT_RESTORED++ )) || true
186
+ else
187
+ TS="$(date -u +%Y%m%dT%H%M%SZ)"
188
+ BAK="${DEST_PATH}.bak.${TS}"
189
+ cp "${DEST_PATH}" "${BAK}"
190
+ cp "${SRC_FILE}" "${DEST_PATH}"
191
+ echo " RESTORED (backed up old → $(basename "${BAK}")): ${DEST_PATH}"
192
+ (( AGENT_RESTORED++ )) || true
193
+ (( AGENT_BACKED_UP++ )) || true
194
+ fi
195
+ else
196
+ cp "${SRC_FILE}" "${DEST_PATH}"
197
+ echo " RESTORED (new): ${DEST_PATH}"
198
+ (( AGENT_RESTORED++ )) || true
199
+ fi
200
+ done
201
+ echo ""
202
+ else
203
+ echo "No agent-auth/ directory in vault — skipping agent-auth restore."
204
+ echo ""
205
+ fi
206
+
142
207
  echo ""
143
208
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
144
209
  echo " Restore summary"
145
210
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
146
- echo " Restored: ${RESTORED} | Skipped: ${SKIPPED} | Old files backed up: ${BACKED_UP}"
211
+ echo " .env.local — Restored: ${RESTORED} | Skipped: ${SKIPPED} | Old files backed up: ${BACKED_UP}"
212
+ echo " agent-auth — Restored: ${AGENT_RESTORED} | Skipped: ${AGENT_SKIPPED} | Old files backed up: ${AGENT_BACKED_UP}"
147
213
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
148
214
  echo ""
149
- echo "Secrets are now in place. Do not print or share .env.local files."
215
+ echo "Secrets are now in place. Do not print or share secret files."
@@ -101,14 +101,29 @@ for skill in ${LOOSE_SKILLS}; do
101
101
  done
102
102
 
103
103
  # ---------------------------------------------------------------------------
104
- step "5. Reminder: restore secrets"
104
+ step "5. Restore agent config from agent-shared"
105
+ # ---------------------------------------------------------------------------
106
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
107
+ RESTORE_SCRIPT="${SCRIPT_DIR}/restore-agent-config.sh"
108
+
109
+ if [ -f "${RESTORE_SCRIPT}" ]; then
110
+ info "Running restore-agent-config.sh (backs up existing files, safe to re-run)..."
111
+ bash "${RESTORE_SCRIPT}"
112
+ info "Agent config restored."
113
+ else
114
+ warn "restore-agent-config.sh not found at ${RESTORE_SCRIPT} — skipping agent config restore."
115
+ warn "You can run it manually later once youmd is fully installed:"
116
+ warn " bash ${RESTORE_SCRIPT}"
117
+ fi
118
+
119
+ # ---------------------------------------------------------------------------
120
+ step "6. Reminder: restore secrets"
105
121
  # ---------------------------------------------------------------------------
106
122
  echo ""
107
123
  echo " !! IMPORTANT: Secrets are NOT in git. Restore them separately:"
108
124
  echo ""
109
- echo " If you use the env-vault (~/Desktop/CODE_2025/youmd/cli/scripts/env-vault/):"
125
+ echo " If you use the env-vault:"
110
126
  echo " youmd env restore <vault-file> # CLI wrapper (preferred)"
111
- echo " bash ~/Desktop/CODE_2025/youmd/cli/scripts/env-vault/restore.sh <vault-file>"
112
127
  echo ""
113
128
  echo " Secrets to restore (set in your shell profile or .env files):"
114
129
  echo " OPENROUTER_API_KEY"
@@ -119,7 +134,7 @@ echo " ANTHROPIC_API_KEY"
119
134
  echo ""
120
135
 
121
136
  # ---------------------------------------------------------------------------
122
- step "6. Next steps"
137
+ step "7. Next steps"
123
138
  # ---------------------------------------------------------------------------
124
139
  echo ""
125
140
  echo " Bootstrap complete. To activate the auto-sync daemons, run:"
@@ -127,7 +142,6 @@ echo ""
127
142
  echo " youmd stack daemon install # preferred (CLI-native)"
128
143
  echo ""
129
144
  echo " Or run the raw script directly:"
130
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
131
145
  echo " bash ${SCRIPT_DIR}/install-daemons.sh"
132
146
  echo ""
133
147
  echo " To do a manual sync now:"
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ # capture-agent-config.sh — snapshot NON-SECRET agent config into ~/.agent-shared/
3
+ #
4
+ # This captures the agent-behavior config that isn't already in a synced repo —
5
+ # Claude/Codex/Warp settings, slash commands, plugin list, the orphaned
6
+ # ~/.agents skills, and the launchd/cron automations — into the agent-shared
7
+ # repo so they travel across machines. The skillstack-sync daemon then commits +
8
+ # pushes agent-shared, and `youmd machine setup`/restore applies them on a new Mac.
9
+ #
10
+ # SECRETS NEVER GO HERE. agent-shared is a git repo. Live secrets (codex
11
+ # auth.json, cursor mcp.json, ~/.claude.json BAMF token, .env.local) travel via
12
+ # the ENCRYPTED env-vault instead. This script captures only files confirmed to
13
+ # be secret-free, and a final guard greps the staged output for secret patterns
14
+ # and ABORTS if any are found (nothing is left in agent-shared on abort).
15
+ #
16
+ # bash 3.2 / BSD safe. Idempotent. --dry-run to preview.
17
+
18
+ set -euo pipefail
19
+
20
+ AGENT_SHARED="${HOME}/.agent-shared"
21
+ DEST="${AGENT_SHARED}/agent-config"
22
+ LOG_DIR="${HOME}/.youmd/logs"
23
+ LOG_FILE="${LOG_DIR}/capture-agent-config.log"
24
+
25
+ DRY_RUN=0
26
+ for arg in "$@"; do
27
+ case "$arg" in
28
+ --dry-run) DRY_RUN=1 ;;
29
+ *) echo "Unknown flag: $arg" >&2; exit 1 ;;
30
+ esac
31
+ done
32
+
33
+ mkdir -p "${LOG_DIR}"
34
+ log() { local ts; ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"; echo "[${ts}] $*"; echo "[${ts}] $*" >> "${LOG_FILE}"; }
35
+ is_dry() { [ "${DRY_RUN}" -eq 1 ]; }
36
+
37
+ if [ ! -d "${AGENT_SHARED}/.git" ]; then
38
+ log "ERROR: ${AGENT_SHARED} is not a git repo — run machine setup first."
39
+ exit 1
40
+ fi
41
+
42
+ # copy_in <src> <dest-subpath> — copy a file/dir into the staging dest if it exists
43
+ copy_in() {
44
+ local src="$1" rel="$2" target
45
+ target="${DEST}/${rel}"
46
+ if [ ! -e "${src}" ]; then return 0; fi
47
+ if is_dry; then log "DRY would capture ${src} → agent-config/${rel}"; return 0; fi
48
+ mkdir -p "$(dirname "${target}")"
49
+ if [ -d "${src}" ]; then
50
+ rsync -a --delete "${src}/" "${target}/"
51
+ else
52
+ cp "${src}" "${target}"
53
+ fi
54
+ log "captured ${src} → agent-config/${rel}"
55
+ }
56
+
57
+ log "=== capture-agent-config START ($([ "${DRY_RUN}" -eq 1 ] && echo DRY || echo LIVE)) ==="
58
+
59
+ # ── Claude config (settings have no raw secrets; ~/.claude.json is EXCLUDED — vaulted) ──
60
+ copy_in "${HOME}/.claude/settings.json" "claude/settings.json"
61
+ copy_in "${HOME}/.claude/settings.local.json" "claude/settings.local.json"
62
+ copy_in "${HOME}/.claude/commands" "claude/commands"
63
+ copy_in "${HOME}/.claude/mcp.json" "claude/mcp.json"
64
+ copy_in "${HOME}/.claude/plugins/installed_plugins.json" "claude/plugins/installed_plugins.json"
65
+
66
+ # ── Codex config (config.toml only; auth.json is EXCLUDED — vaulted) ──
67
+ copy_in "${HOME}/.codex/config.toml" "codex/config.toml"
68
+
69
+ # ── Warp (no secrets) ──
70
+ copy_in "${HOME}/.warp/settings.toml" "warp/settings.toml"
71
+ copy_in "${HOME}/.warp/keybindings.yaml" "warp/keybindings.yaml"
72
+
73
+ # ── Orphaned ~/.agents skills (no repo of their own today) ──
74
+ copy_in "${HOME}/.agents/skills/find-skills" "agents-skills/find-skills"
75
+ copy_in "${HOME}/.agents/skills/ui-ux-pro-max" "agents-skills/ui-ux-pro-max"
76
+
77
+ # ── Automations: launchd plists (HOME-templated) + crontab snapshot ──
78
+ capture_plist() {
79
+ local label="$1" src="${HOME}/Library/LaunchAgents/$1.plist" target="${DEST}/automations/launchd/$1.plist"
80
+ if [ ! -f "${src}" ]; then return 0; fi
81
+ if is_dry; then log "DRY would capture plist ${label} (HOME-templated)"; return 0; fi
82
+ mkdir -p "$(dirname "${target}")"
83
+ sed "s|${HOME}|__HOME__|g" "${src}" > "${target}"
84
+ log "captured plist ${label} → agent-config/automations/launchd/${label}.plist"
85
+ }
86
+ capture_plist "com.youmd.skillstack-sync"
87
+ capture_plist "com.youmd.identity-sync"
88
+ capture_plist "com.houstongolden.agent-runtime-guard"
89
+
90
+ if ! is_dry; then
91
+ mkdir -p "${DEST}/automations"
92
+ crontab -l 2>/dev/null > "${DEST}/automations/crontab.txt" || true
93
+ log "captured crontab → agent-config/automations/crontab.txt"
94
+ fi
95
+
96
+ # ── SECRET-LEAK GUARD — abort if anything captured looks like a live secret ──
97
+ if ! is_dry && [ -d "${DEST}" ]; then
98
+ # Match real secret token shapes; allow bare key NAMES (KEY=) which are fine.
99
+ HITS="$(grep -rIlE 'sk-ant-[A-Za-z0-9_-]{20}|sk-[A-Za-z0-9]{32}|ghp_[A-Za-z0-9]{30}|gho_[A-Za-z0-9]{30}|Bearer [A-Za-z0-9._-]{20}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10}' "${DEST}" 2>/dev/null || true)"
100
+ if [ -n "${HITS}" ]; then
101
+ log "ABORT: possible secret(s) found in captured config — removing capture, nothing committed:"
102
+ log "${HITS}"
103
+ rm -rf "${DEST}"
104
+ exit 1
105
+ fi
106
+ log "secret-leak guard: clean (no live secret patterns in captured config)"
107
+ fi
108
+
109
+ log "=== capture-agent-config DONE ==="
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env bash
2
+ # context-sync.sh — per-project agent context syncer (cross-machine, code-safe)
3
+ # Compatible: bash 3.2+, macOS BSD coreutils (NO mapfile, NO GNU mktemp flags)
4
+ #
5
+ # WHAT IT DOES
6
+ # ============
7
+ # For each of Houston's own git repos, commits ONLY the agent-context paths
8
+ # (AGENTS.md, CLAUDE.md, project-context/, .claude/) and pull+pushes, so
9
+ # agent context travels across machines without ever touching application code.
10
+ #
11
+ # SAFETY GUARANTEES
12
+ # =================
13
+ # 1. Only stages named context paths — NEVER uses `git add -A` or `git add .`
14
+ # 2. Never stashes, resets, checks out, or cleans anything in the working tree
15
+ # 3. Never touches a repo that is mid-merge or mid-rebase
16
+ # 4. Never touches a repo with no `origin` remote
17
+ # 5. If the working tree has uncommitted non-context changes, commits context
18
+ # locally but SKIPS pull/push to avoid merge conflicts touching his code;
19
+ # logs a clear "push manually" warning
20
+ # 6. On pull conflict: aborts merge, skips push, logs loudly — never force
21
+ # 7. Per-repo failures are non-fatal; the loop always continues
22
+ # 8. --dry-run logs intended actions; writes nothing to disk
23
+ #
24
+ # WHAT IS EXCLUDED (by design)
25
+ # =============================
26
+ # - Third-party / fork repos (disler/*, coffeefuelbump/*, etc.) — Houston
27
+ # doesn't own the remote, so auto-pushing context there is wrong
28
+ # - Repos with no origin remote or push-disabled remotes — detected at runtime
29
+ # - gstack, agent-shared, scistack — covered by sync.sh (skillstack layer)
30
+ #
31
+ # Usage:
32
+ # ./context-sync.sh # sync all context repos
33
+ # ./context-sync.sh --dry-run # log intended actions only, write nothing
34
+ #
35
+ # Env overrides:
36
+ # CONTEXT_REPOS space-separated list of repo DIRECTORY NAMES under
37
+ # ~/Desktop/CODE_2025/ (not full paths)
38
+ # GIT_USER_NAME commit author name (default: Houston Golden)
39
+ # GIT_USER_EMAIL commit author email (default: houston@bamf.ai)
40
+
41
+ set -euo pipefail
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Config
45
+ # ---------------------------------------------------------------------------
46
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
47
+ LOG_DIR="${HOME}/.youmd/logs"
48
+ LOG_FILE="${LOG_DIR}/context-sync.log"
49
+
50
+ GIT_USER_NAME="${GIT_USER_NAME:-Houston Golden}"
51
+ GIT_USER_EMAIL="${GIT_USER_EMAIL:-houston@bamf.ai}"
52
+
53
+ CODE_ROOT="${HOME}/Desktop/CODE_2025"
54
+
55
+ # Curated list of Houston's OWN repos with rich agent context.
56
+ # These all have houstongolden/* or hubify-projects/* remotes — confirmed safe
57
+ # to push to. Override via env CONTEXT_REPOS (space-separated dir names).
58
+ #
59
+ # Excluded by design:
60
+ # - disler/*, coffeefuelbump/*, etc. → not Houston's remote
61
+ # - repos with no origin → detected and skipped at runtime
62
+ # - gstack, agent-shared, scistack → handled by sync.sh
63
+ DEFAULT_CONTEXT_REPOS="bamfaiapp bigbounce hubify-aios myo hubifycode badapp claws"
64
+ CONTEXT_REPOS="${CONTEXT_REPOS:-${DEFAULT_CONTEXT_REPOS}}"
65
+
66
+ # The ONLY paths we will ever stage. Nothing else is touched.
67
+ CONTEXT_PATHSPECS="AGENTS.md CLAUDE.md project-context .claude"
68
+
69
+ DRY_RUN=0
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Parse flags
73
+ # ---------------------------------------------------------------------------
74
+ for arg in "$@"; do
75
+ case "$arg" in
76
+ --dry-run) DRY_RUN=1 ;;
77
+ --once) : ;; # accepted for explicit invocation parity with sync.sh
78
+ *) echo "Unknown flag: $arg" >&2; exit 1 ;;
79
+ esac
80
+ done
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Logging
84
+ # ---------------------------------------------------------------------------
85
+ mkdir -p "${LOG_DIR}"
86
+
87
+ log() {
88
+ local ts
89
+ ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
90
+ local msg="[${ts}] $*"
91
+ echo "${msg}"
92
+ echo "${msg}" >> "${LOG_FILE}"
93
+ }
94
+
95
+ log_warn() {
96
+ log "WARN $*"
97
+ }
98
+
99
+ log_error() {
100
+ log "ERROR $*"
101
+ }
102
+
103
+ log_dry() {
104
+ log "DRY $*"
105
+ }
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Helpers
109
+ # ---------------------------------------------------------------------------
110
+
111
+ hostname_short() {
112
+ # BSD hostname (no --short flag on macOS)
113
+ hostname | cut -d. -f1
114
+ }
115
+
116
+ is_dry() {
117
+ [ "${DRY_RUN}" -eq 1 ]
118
+ }
119
+
120
+ # Check if repo is in a mid-merge/rebase state
121
+ repo_is_in_progress() {
122
+ local repo="$1"
123
+ if [ -f "${repo}/.git/MERGE_HEAD" ] \
124
+ || [ -d "${repo}/.git/rebase-merge" ] \
125
+ || [ -d "${repo}/.git/rebase-apply" ]; then
126
+ return 0
127
+ fi
128
+ return 1
129
+ }
130
+
131
+ # Collect context pathspecs that actually exist in this repo.
132
+ # Returns a space-separated list (possibly empty).
133
+ existing_context_paths() {
134
+ local repo="$1"
135
+ local found=""
136
+ local p
137
+ for p in ${CONTEXT_PATHSPECS}; do
138
+ if [ -e "${repo}/${p}" ]; then
139
+ if [ -z "${found}" ]; then
140
+ found="${p}"
141
+ else
142
+ found="${found} ${p}"
143
+ fi
144
+ fi
145
+ done
146
+ echo "${found}"
147
+ }
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # SYNC ONE REPO
151
+ # Returns 0 on success, 1 on non-fatal failure.
152
+ #
153
+ # Pull/push safety decision (dirty non-context working tree):
154
+ # ============================================================
155
+ # After committing context changes, the working tree may still have untracked
156
+ # or modified files (Houston's WIP code). A `git pull` that brings in remote
157
+ # changes could attempt to merge those dirty files, causing conflicts or
158
+ # accidentally modifying them. The SAFE choice:
159
+ # - If the working tree has ANY remaining dirty files after the context
160
+ # commit, SKIP pull/push and log a clear warning.
161
+ # - The context commit is still made locally, so the changes are preserved
162
+ # and Houston can push manually when he's ready.
163
+ # - We never stash, reset, or touch his code.
164
+ # ---------------------------------------------------------------------------
165
+ sync_context_repo() {
166
+ local repo_name="$1"
167
+ local repo="${CODE_ROOT}/${repo_name}"
168
+
169
+ log "=== Syncing context for: ${repo_name} ==="
170
+
171
+ # Guard: directory must exist
172
+ if [ ! -d "${repo}" ]; then
173
+ log_warn "Skipping ${repo_name}: directory not found at ${repo}."
174
+ return 1
175
+ fi
176
+
177
+ # Guard: must be a git repo
178
+ if [ ! -d "${repo}/.git" ]; then
179
+ log_warn "Skipping ${repo_name}: not a git repository."
180
+ return 1
181
+ fi
182
+
183
+ # Guard: must not be mid-merge/rebase
184
+ if repo_is_in_progress "${repo}"; then
185
+ log_warn "Skipping ${repo_name}: repository is mid-merge or mid-rebase. Resolve manually first."
186
+ return 1
187
+ fi
188
+
189
+ # Guard: must have an origin remote
190
+ if ! git -C "${repo}" remote get-url origin >/dev/null 2>&1; then
191
+ log_warn "Skipping ${repo_name}: no 'origin' remote configured."
192
+ return 1
193
+ fi
194
+
195
+ # Determine which context paths exist in this repo
196
+ local paths
197
+ paths="$(existing_context_paths "${repo}")"
198
+
199
+ if [ -z "${paths}" ]; then
200
+ log "No context paths found in ${repo_name} (none of: ${CONTEXT_PATHSPECS}). Skipping."
201
+ return 0
202
+ fi
203
+
204
+ log "Context paths present in ${repo_name}: ${paths}"
205
+
206
+ # ---- Stage ONLY the context paths (never git add -A or git add .) --------
207
+ if is_dry; then
208
+ log_dry "Would stage in ${repo_name}: ${paths}"
209
+ else
210
+ # Build the git add command with only existing paths.
211
+ # We pass each path as a separate argument — safe because they come from
212
+ # our controlled CONTEXT_PATHSPECS list, not user input.
213
+ local add_args=""
214
+ local p
215
+ for p in ${paths}; do
216
+ add_args="${add_args} ${p}"
217
+ done
218
+ # shellcheck disable=SC2086
219
+ git -C "${repo}" add -- ${add_args}
220
+ log "Staged context paths in ${repo_name}: ${paths}"
221
+ fi
222
+
223
+ # ---- Commit if staged changes exist ---------------------------------------
224
+ local context_committed=0
225
+ if is_dry; then
226
+ log_dry "Would check for staged changes and commit context in ${repo_name} if any"
227
+ context_committed=1
228
+ else
229
+ if ! git -C "${repo}" diff --cached --quiet 2>/dev/null; then
230
+ local ts host commit_msg
231
+ ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
232
+ host="$(hostname_short)"
233
+ commit_msg="context-sync: ${host} ${ts}"
234
+
235
+ git -C "${repo}" \
236
+ -c "user.name=${GIT_USER_NAME}" \
237
+ -c "user.email=${GIT_USER_EMAIL}" \
238
+ commit -m "${commit_msg}"
239
+ log "Committed context changes in ${repo_name}: '${commit_msg}'"
240
+ context_committed=1
241
+ else
242
+ log "No staged context changes in ${repo_name} (already up to date)."
243
+ context_committed=0
244
+ fi
245
+ fi
246
+
247
+ # ---- Check for remaining dirty working tree (WIP code) -------------------
248
+ # After committing context, if there are still dirty files, skip pull/push
249
+ # to avoid merge conflicts or accidental code modification.
250
+ if ! is_dry; then
251
+ local remaining_dirty
252
+ remaining_dirty="$(git -C "${repo}" status --porcelain 2>/dev/null)"
253
+ if [ -n "${remaining_dirty}" ]; then
254
+ log_warn "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
255
+ log_warn "${repo_name} has uncommitted non-context changes (WIP code)."
256
+ log_warn "Context was committed locally. Skipping pull/push to avoid"
257
+ log_warn "touching your code. Push manually when your code is ready:"
258
+ log_warn " cd ${repo} && git pull --no-rebase && git push"
259
+ log_warn "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
260
+ return 0
261
+ fi
262
+ fi
263
+
264
+ # ---- Pull (merge, no rebase) ---------------------------------------------
265
+ if is_dry; then
266
+ log_dry "Would git pull --no-rebase --no-edit in ${repo_name}"
267
+ else
268
+ local pull_log
269
+ # BSD mktemp: no -p, use /tmp explicitly
270
+ pull_log="$(mktemp -t context_sync_pull)"
271
+ local pull_ok=0
272
+ git -C "${repo}" pull --no-rebase --no-edit >"${pull_log}" 2>&1 || pull_ok=$?
273
+
274
+ while IFS= read -r line; do
275
+ log "pull|${repo_name}: ${line}"
276
+ done < "${pull_log}"
277
+ rm -f "${pull_log}"
278
+
279
+ if [ "${pull_ok}" -ne 0 ]; then
280
+ log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
281
+ log_error "CONFLICT in ${repo_name} — pull failed."
282
+ log_error "Aborting merge and skipping push."
283
+ log_error "Manual resolution needed: cd ${repo} && git status"
284
+ log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
285
+ git -C "${repo}" merge --abort 2>/dev/null || true
286
+ git -C "${repo}" rebase --abort 2>/dev/null || true
287
+ return 1
288
+ fi
289
+ log "Pull succeeded for ${repo_name}."
290
+ fi
291
+
292
+ # ---- Push ----------------------------------------------------------------
293
+ if is_dry; then
294
+ log_dry "Would git push in ${repo_name}"
295
+ else
296
+ local push_log
297
+ push_log="$(mktemp -t context_sync_push)"
298
+ local push_ok=0
299
+ git -C "${repo}" push >"${push_log}" 2>&1 || push_ok=$?
300
+
301
+ while IFS= read -r line; do
302
+ log "push|${repo_name}: ${line}"
303
+ done < "${push_log}"
304
+ rm -f "${push_log}"
305
+
306
+ if [ "${push_ok}" -ne 0 ]; then
307
+ log_error "Push FAILED for ${repo_name}. Check connectivity/auth."
308
+ return 1
309
+ fi
310
+ log "Push succeeded for ${repo_name}."
311
+ fi
312
+
313
+ log "=== Done: ${repo_name} ==="
314
+ return 0
315
+ }
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # MAIN
319
+ # ---------------------------------------------------------------------------
320
+ main() {
321
+ local mode
322
+ if is_dry; then
323
+ mode="DRY-RUN"
324
+ else
325
+ mode="LIVE"
326
+ fi
327
+
328
+ log "============================================================"
329
+ log "context-sync START [${mode}] on $(hostname_short)"
330
+ log "Repos: ${CONTEXT_REPOS}"
331
+ log "Context paths: ${CONTEXT_PATHSPECS}"
332
+ log "============================================================"
333
+
334
+ local repo_name
335
+ for repo_name in ${CONTEXT_REPOS}; do
336
+ sync_context_repo "${repo_name}" || true
337
+ done
338
+
339
+ log "============================================================"
340
+ log "context-sync DONE [${mode}]"
341
+ log "============================================================"
342
+ }
343
+
344
+ main "$@"