Skill Tracer
Skills › gstack › gstack-upgrade › v1.27.0.0.sh

v1.27.0.0.sh

432 lines · gstack-upgrade/migrations/v1.27.0.0.sh at 3f79d81

1#!/usr/bin/env bash
2# Migration: v1.27.0.0 — rename gstack-brain-* → gstack-artifacts-*
3#
4# Phase C of the v1.27.0.0 plan. Hard-rename, no compat shim. Steps:
5# 1. gh_repo_renamed — gh/glab repo rename gstack-brain-$USER →
6# gstack-artifacts-$USER (skipped on user opt-out)
7# 2. remote_txt_renamed — mv ~/.gstack-brain-remote.txt → artifacts-remote.txt
8# 3. config_key_renamed — rewrite gbrain_sync_mode → artifacts_sync_mode
9# in ~/.gstack/config.yaml
10# 4. claude_md_block_rewritten — find-and-replace any existing GBrain
11# Configuration block that references "Memory sync"
12# 5. sources_swapped — gbrain sources add new (verify) → remove old
13# (codex Finding #6: add-before-remove ordering)
14# 6. done — write touchfile, delete journal
16# Interruption-safe via journal at ~/.gstack/.migrations/v1.27.0.0.journal:
17# each step writes its name on success; re-entry resumes from the next un-done
18# step. Done touchfile at ~/.gstack/.migrations/v1.27.0.0.done.
20# Three host-mode branches per the plan:
21# Local CLI + GitHub — all steps run automatically
22# Local CLI + GitLab — same with glab repo rename
23# Remote MCP only — steps 1-4 still run; step 5 prints commands for
24# the brain admin to run on the brain host
26# All steps are idempotent. Re-running after partial completion is safe.
27# Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a
28# pipe in the forked child before exec, with no reader on the other end. On
29# macOS under pipe-KVA pressure a fresh pipe gets a 512-byte buffer, so any
30# body >=512B blocks write() forever and the script hangs at startup with no
31# output. Compat level 50 restores the tempfile path. These scripts are
32# bash-3.2-clean, so the compat level costs them nothing. Not exported: the
33# guard is per-script, and it survives `bash script.sh` call sites that
34# bypass the shebang.
35BASH_COMPAT=50
37set -euo pipefail
39if [ -z "${HOME:-}" ]; then
40 echo " [v1.27.0.0] HOME is unset — skipping migration." >&2
41 exit 0
42fi
44# ---------------------------------------------------------------------------
45# Configuration
46# ---------------------------------------------------------------------------
47_gstack_migration_dir="${BASH_SOURCE[0]//\\//}"; _gstack_migration_dir="${_gstack_migration_dir%/*}"
48. "${_gstack_migration_dir}/../../bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: ${_gstack_migration_dir}/../../bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
49gstack_state_root_select; GSTACK_HOME="$_gstack_sr_root"
50SKILLS_DIR="${HOME}/.claude/skills"
51BIN_DIR="${SKILLS_DIR}/gstack/bin"
52CONFIG_BIN="${BIN_DIR}/gstack-config"
53URL_BIN="${BIN_DIR}/gstack-artifacts-url"
55MIGRATION_DIR="${GSTACK_HOME}/.migrations"
56JOURNAL="${MIGRATION_DIR}/v1.27.0.0.journal"
57DONE="${MIGRATION_DIR}/v1.27.0.0.done"
58SKIPPED="${MIGRATION_DIR}/v1.27.0.0.skipped-by-user"
60# Real, copy-pasteable re-run command for every remediation message below.
61# There is no runner re-ask: the upgrade runners' version windows never
62# re-select an already-passed migration, so the only honest remediation is
63# a direct invocation of this script ($0-derived so it survives any cwd).
64SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
65RERUN_CMD="GSTACK_MIGRATE_ASSUME_YES=1 bash ${SELF_PATH}"
67USER_NAME="${USER:-$(whoami 2>/dev/null || echo unknown)}"
68OLD_REPO_NAME="gstack-brain-${USER_NAME}"
69NEW_REPO_NAME="gstack-artifacts-${USER_NAME}"
70OLD_REMOTE_TXT="${HOME}/.gstack-brain-remote.txt"
71NEW_REMOTE_TXT="${HOME}/.gstack-artifacts-remote.txt"
72OLD_SOURCE_ID="${OLD_REPO_NAME}"
73NEW_SOURCE_ID="${NEW_REPO_NAME}"
75# ---------------------------------------------------------------------------
76# Journal helpers
77# ---------------------------------------------------------------------------
78mkdir -p "$MIGRATION_DIR"
80# Already done? exit silently.
81[ -f "$DONE" ] && exit 0
83# User opted out previously? exit silently. (To re-run after an opt-out:
84# rm the skipped-by-user marker, then invoke this script directly.)
85[ -f "$SKIPPED" ] && exit 0
87journal_done() {
88 # Returns 0 if the named step is recorded as complete in the journal.
89 local step="$1"
90 [ -f "$JOURNAL" ] && grep -q "^${step}$" "$JOURNAL" 2>/dev/null
93mark_done() {
94 local step="$1"
95 echo "$step" >> "$JOURNAL"
98# The GitHub owner of the brain repo, from the recorded remote URL
99# (https://github.com/<owner>/<repo> or git@github.com:<owner>/<repo>), else
100# the authenticated gh user. `gh repo rename --repo` and `gh repo edit` need
101# the "[HOST/]OWNER/REPO" form; a bare name always fails (#1437).
102repo_owner() {
103 local url="" owner=""
104 for f in "$OLD_REMOTE_TXT" "$NEW_REMOTE_TXT"; do
105 [ -f "$f" ] && url=$(head -1 "$f" 2>/dev/null) && break
106 done
107 owner=$(printf '%s\n' "$url" | sed -n 's#^.*github\.com[:/]\([^/]*\)/.*$#\1#p')
108 [ -z "$owner" ] && owner=$(gh api user --jq .login 2>/dev/null || true)
109 printf '%s' "$owner"
112# Point ~/.gstack-artifacts-remote.txt at the renamed repo. Only called once
113# the rename is confirmed: rewriting it earlier pointed the artifacts remote
114# at a repository that did not exist (#1437).
115rewrite_remote_to_new() {
116 [ -f "$NEW_REMOTE_TXT" ] || return 0
117 local url new
118 url=$(head -1 "$NEW_REMOTE_TXT" 2>/dev/null)
119 new=$(echo "$url" | sed "s|/${OLD_REPO_NAME}|/${NEW_REPO_NAME}|; s|:${OLD_REPO_NAME}|:${NEW_REPO_NAME}|")
120 [ "$new" = "$url" ] && return 0
121 echo "$new" > "$NEW_REMOTE_TXT"
122 chmod 600 "$NEW_REMOTE_TXT"
123 echo " remote URL rewritten: $url → $new" >&2
126# ---------------------------------------------------------------------------
127# Detect environment + ask once if there's anything to migrate
128# ---------------------------------------------------------------------------
130# Has the user ever opted into brain sync? Two signals:
131# - presence of ~/.gstack-brain-remote.txt (legacy file)
132# - presence of ~/.gstack/.git (brain-init ever ran)
133HAS_LEGACY_STATE=0
134[ -f "$OLD_REMOTE_TXT" ] && HAS_LEGACY_STATE=1
135[ -d "$GSTACK_HOME/.git" ] && HAS_LEGACY_STATE=1
136# A journal means an earlier run left a step pending (step 2 already moved the
137# remote file), so the retry must run instead of declaring nothing to do.
138[ -f "$JOURNAL" ] && HAS_LEGACY_STATE=1
140# If nothing to migrate, finalize silently.
141if [ "$HAS_LEGACY_STATE" = "0" ]; then
142 echo " [v1.27.0.0] no legacy gstack-brain state detected — nothing to migrate." >&2
143 touch "$DONE"
144 rm -f "$JOURNAL" 2>/dev/null || true
145 exit 0
146fi
148# Ask once (idempotent: if journal exists from a prior partial run, skip ask).
149if [ ! -f "$JOURNAL" ]; then
150 cat >&2 <<EOF
152 [v1.27.0.0] gstack-brain has been renamed to gstack-artifacts.
153 This is a clearer name for what it actually holds: CEO plans, designs,
154 /investigate reports, retros (i.e. artifacts, not behavioral memory).
156 This migration will:
157 1. Rename your private GitHub/GitLab repo "$OLD_REPO_NAME" → "$NEW_REPO_NAME"
158 2. mv ~/.gstack-brain-remote.txt → ~/.gstack-artifacts-remote.txt
159 3. Rename gbrain_sync_mode → artifacts_sync_mode in ~/.gstack/config.yaml
160 4. Update any "## GBrain Configuration" block in CLAUDE.md
161 5. Update gbrain federated source registration (local CLI mode)
162 OR print commands for your brain admin (remote MCP mode)
164 Each step is journaled so a Ctrl-C mid-flight is safe to re-run.
166EOF
167 if [ -t 0 ]; then
168 printf " Proceed? [Y/n/skip-for-now]: " >&2
169 read -r REPLY || REPLY=""
170 case "$REPLY" in
171 n|N|no|No|NO)
172 echo " Skipping migration. To re-run later:" >&2
173 echo " rm ${SKIPPED} && ${RERUN_CMD}" >&2
174 touch "$SKIPPED"
175 exit 0
176 ;;
177 skip|skip-for-now|s)
178 echo " Skipping for now. Re-run manually with: ${RERUN_CMD}" >&2
179 # Don't write SKIPPED — leave both old + new state untouched. The
180 # upgrade runner will NOT re-select this migration, so re-running is
181 # manual via the command above.
182 exit 0
183 ;;
184 esac
185 else
186 # Non-interactive (CI, Claude Code Bash tool, scripted upgrade). Step 1
187 # renames a REMOTE repo — consent-shaped, and blanket auto-proceed once
188 # left an install half-migrated when that step failed mid-run (#1383).
189 # Skip for now by default (asked again next upgrade); explicit opt-in
190 # proceeds unattended.
191 if [ "${GSTACK_MIGRATE_ASSUME_YES:-0}" = "1" ]; then
192 echo " (non-interactive: proceeding — GSTACK_MIGRATE_ASSUME_YES=1)" >&2
193 else
194 echo " Non-interactive session: skipping for now." >&2
195 echo " Re-run manually with: ${RERUN_CMD}" >&2
196 echo " To run interactively: bash ${SELF_PATH}" >&2
197 exit 0
198 fi
199 fi
200fi
202# ---------------------------------------------------------------------------
203# Detect host (gh / glab / manual) for steps 1 + 5
204# ---------------------------------------------------------------------------
205detect_host() {
206 # Read the canonical-form remote URL (the legacy file in the migration window).
207 local url=""
208 if [ -f "$OLD_REMOTE_TXT" ]; then
209 url=$(head -1 "$OLD_REMOTE_TXT" 2>/dev/null | tr -d '[:space:]' || echo "")
210 elif [ -f "$NEW_REMOTE_TXT" ]; then
211 url=$(head -1 "$NEW_REMOTE_TXT" 2>/dev/null | tr -d '[:space:]' || echo "")
212 fi
213 if echo "$url" | grep -q 'github\.com'; then
214 echo "github"
215 elif echo "$url" | grep -q 'gitlab'; then
216 echo "gitlab"
217 else
218 echo "manual"
219 fi
222HOST=$(detect_host)
224# ---------------------------------------------------------------------------
225# Detect MCP mode (so step 5 knows whether to execute or print)
226# ---------------------------------------------------------------------------
227detect_mcp_mode() {
228 # Cheap probe: ~/.claude.json type field. Defense-in-depth tier 3 only;
229 # the migration script avoids invoking `claude` to keep upgrade fast.
230 if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
231 local t
232 t=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
233 case "$t" in
234 url|http|sse) echo "remote-http"; return ;;
235 stdio) echo "local-stdio"; return ;;
236 esac
237 fi
238 echo "none"
241MCP_MODE=$(detect_mcp_mode)
243# ---------------------------------------------------------------------------
244# Step 1: gh/glab repo rename
245# ---------------------------------------------------------------------------
246if ! journal_done "gh_repo_renamed"; then
247 echo " [v1.27.0.0] step 1: rename remote repo $OLD_REPO_NAME → $NEW_REPO_NAME" >&2
248 case "$HOST" in
249 github)
250 if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
251 GH_OWNER=$(repo_owner)
252 OLD_QUALIFIED="${GH_OWNER:+$GH_OWNER/}${OLD_REPO_NAME}"
253 NEW_QUALIFIED="${GH_OWNER:+$GH_OWNER/}${NEW_REPO_NAME}"
254 # Idempotent: if new name already exists, treat as success.
255 if gh repo view "$NEW_QUALIFIED" >/dev/null 2>&1; then
256 echo " repo already named $NEW_REPO_NAME on GitHub — no-op" >&2
257 mark_done "gh_repo_renamed"
258 mark_done "gh_repo_rename_verified"
259 rewrite_remote_to_new
260 else
261 RENAME_ERR=""
262 if RENAME_ERR=$(gh repo rename "$NEW_REPO_NAME" --repo "$OLD_QUALIFIED" --yes 2>&1) \
263 || RENAME_ERR=$(gh repo edit "$OLD_QUALIFIED" --name "$NEW_REPO_NAME" 2>&1); then
264 echo " renamed $OLD_QUALIFIED → $NEW_REPO_NAME on GitHub" >&2
265 mark_done "gh_repo_renamed"
266 mark_done "gh_repo_rename_verified"
267 rewrite_remote_to_new
268 else
269 echo " WARNING: gh rename failed for $OLD_QUALIFIED: ${RENAME_ERR:-no output}" >&2
270 echo " step 1 stays PENDING and will retry on re-run; later steps still run (#1383)" >&2
271 echo " manual: gh repo rename $NEW_REPO_NAME --repo $OLD_QUALIFIED --yes" >&2
272 fi
273 fi
274 else
275 echo " gh CLI not available — skipping rename step (manual: gh repo rename ...)" >&2
276 mark_done "gh_repo_renamed"
277 fi
278 ;;
279 gitlab)
280 if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then
281 if glab repo view "$NEW_REPO_NAME" >/dev/null 2>&1; then
282 echo " repo already named $NEW_REPO_NAME on GitLab — no-op" >&2
283 mark_done "gh_repo_renamed"
284 else
285 # GitLab CLI doesn't have a direct rename; user has to do it via API.
286 echo " glab repo rename isn't a single command on GitLab." >&2
287 echo " Manual: visit your GitLab project Settings → General → Advanced → Rename" >&2
288 echo " or use: glab api projects/:id -X PUT -f name=$NEW_REPO_NAME -f path=$NEW_REPO_NAME" >&2
289 mark_done "gh_repo_renamed"
290 fi
291 else
292 echo " glab not available — manual rename required" >&2
293 mark_done "gh_repo_renamed"
294 fi
295 ;;
296 manual|*)
297 echo " unknown host (not github/gitlab) — manual rename required" >&2
298 mark_done "gh_repo_renamed"
299 ;;
300 esac
301fi
303# ---------------------------------------------------------------------------
304# Step 2: rename ~/.gstack-brain-remote.txt → ~/.gstack-artifacts-remote.txt
305# ---------------------------------------------------------------------------
306if ! journal_done "remote_txt_renamed"; then
307 echo " [v1.27.0.0] step 2: rename ~/.gstack-brain-remote.txt → ~/.gstack-artifacts-remote.txt" >&2
308 if [ -f "$OLD_REMOTE_TXT" ] && [ ! -f "$NEW_REMOTE_TXT" ]; then
309 # Move the file; rewrite the URL inside only when step 1 confirmed the
310 # repository was renamed. Otherwise the old URL is the one that exists.
311 OLD_URL=$(head -1 "$OLD_REMOTE_TXT" 2>/dev/null)
312 echo "$OLD_URL" > "$NEW_REMOTE_TXT"
313 chmod 600 "$NEW_REMOTE_TXT"
314 rm -f "$OLD_REMOTE_TXT"
315 if journal_done "gh_repo_rename_verified"; then
316 echo " moved remote file" >&2
317 rewrite_remote_to_new
318 else
319 echo " moved remote file; kept $OLD_URL because the repository was not renamed" >&2
320 fi
321 elif [ -f "$NEW_REMOTE_TXT" ]; then
322 echo " new file already exists — no-op" >&2
323 rm -f "$OLD_REMOTE_TXT" 2>/dev/null || true
324 else
325 echo " no $OLD_REMOTE_TXT to migrate — no-op" >&2
326 fi
327 mark_done "remote_txt_renamed"
328fi
330# ---------------------------------------------------------------------------
331# Step 3: rename gbrain_sync_mode → artifacts_sync_mode in config.yaml
332# ---------------------------------------------------------------------------
333if ! journal_done "config_key_renamed"; then
334 echo " [v1.27.0.0] step 3: rename gbrain_sync_mode → artifacts_sync_mode in config.yaml" >&2
335 CFG="$GSTACK_HOME/config.yaml"
336 if [ -f "$CFG" ]; then
337 # Atomic in-place rewrite with a tmpfile.
338 TMP=$(mktemp "${CFG}.v1.27.0.0.XXXXXX")
339 sed -e 's/^gbrain_sync_mode:/artifacts_sync_mode:/' \
340 -e 's/^gbrain_sync_mode_prompted:/artifacts_sync_mode_prompted:/' \
341 "$CFG" > "$TMP" && mv "$TMP" "$CFG"
342 echo " rewritten in place" >&2
343 else
344 echo " no $CFG to migrate — no-op" >&2
345 fi
346 mark_done "config_key_renamed"
347fi
349# ---------------------------------------------------------------------------
350# Step 4: rewrite CLAUDE.md "## GBrain Configuration" block fields
351# ---------------------------------------------------------------------------
352if ! journal_done "claude_md_block_rewritten"; then
353 echo " [v1.27.0.0] step 4: rewrite CLAUDE.md GBrain Configuration block fields" >&2
354 # Look in cwd's CLAUDE.md (where /setup-gbrain wrote it) and ~/.gstack/CLAUDE.md
355 # if it exists. We can't know every project's CLAUDE.md; users rerunning
356 # /setup-gbrain in any project will overwrite that block fresh anyway.
357 for CMD in "$PWD/CLAUDE.md" "$GSTACK_HOME/CLAUDE.md"; do
358 [ -f "$CMD" ] || continue
359 if grep -q "## GBrain Configuration" "$CMD"; then
360 TMP=$(mktemp "${CMD}.v1.27.0.0.XXXXXX")
361 sed -e 's/^- Memory sync:/- Artifacts sync:/' "$CMD" > "$TMP" && mv "$TMP" "$CMD"
362 echo " rewritten field in $CMD" >&2
363 fi
364 done
365 mark_done "claude_md_block_rewritten"
366fi
368# ---------------------------------------------------------------------------
369# Step 5: gbrain sources swap (add-new before remove-old per codex Finding #6)
370# ---------------------------------------------------------------------------
371if ! journal_done "sources_swapped"; then
372 echo " [v1.27.0.0] step 5: gbrain federated source rename" >&2
373 if [ "$MCP_MODE" = "remote-http" ]; then
374 # Print commands for the brain admin; we can't execute them locally.
375 cat >&2 <<EOF
376 Remote MCP detected. The local gbrain CLI can't update the brain's
377 federated source registration. Send this to your brain admin:
379 gbrain sources add ${NEW_SOURCE_ID} --path <new-clone-path> --federated
380 # verify the new source is searching as expected, then:
381 gbrain sources remove ${OLD_SOURCE_ID} --yes
383 (Add-new before remove-old keeps search uninterrupted.)
385EOF
386 mark_done "sources_swapped"
387 elif command -v gbrain >/dev/null 2>&1 && [ -d "$GSTACK_HOME/.git" ]; then
388 # Local CLI mode. Sources point at the worktree path; rename the source
389 # ID add-then-remove. The actual on-disk worktree path stays the same.
390 WORKTREE="${GSTACK_BRAIN_WORKTREE:-$HOME/.gstack-brain-worktree}"
391 if gbrain sources list 2>/dev/null | grep -q "$OLD_SOURCE_ID"; then
392 if gbrain sources add "$NEW_SOURCE_ID" --path "$WORKTREE" --federated 2>/dev/null; then
393 echo " added $NEW_SOURCE_ID" >&2
394 if gbrain sources remove "$OLD_SOURCE_ID" --yes 2>/dev/null; then
395 echo " removed $OLD_SOURCE_ID" >&2
396 else
397 echo " WARNING: failed to remove $OLD_SOURCE_ID; both registered. Run manually:" >&2
398 echo " gbrain sources remove $OLD_SOURCE_ID --yes" >&2
399 fi
400 else
401 echo " WARNING: failed to add $NEW_SOURCE_ID. Old source still registered." >&2
402 fi
403 else
404 echo " no $OLD_SOURCE_ID source registered — no-op" >&2
405 fi
406 mark_done "sources_swapped"
407 else
408 echo " gbrain CLI not available or no ~/.gstack/.git — skipping" >&2
409 mark_done "sources_swapped"
410 fi
411fi
413# ---------------------------------------------------------------------------
414# Step 6: finalize (touchfile + clear journal) — only when EVERY step is
415# journaled. A failed step must leave the migration visibly incomplete and
416# retryable, never silently recorded as done (#1383).
417# ---------------------------------------------------------------------------
418INCOMPLETE=""
419for _step in gh_repo_renamed remote_txt_renamed config_key_renamed claude_md_block_rewritten sources_swapped; do
420 journal_done "$_step" || INCOMPLETE="$INCOMPLETE $_step"
421done
422if [ -n "$INCOMPLETE" ]; then
423 echo " [v1.27.0.0] migration INCOMPLETE — pending step(s):$INCOMPLETE" >&2
424 echo " Completed steps are journaled and will be skipped on re-run." >&2
425 echo " Re-run manually with: ${RERUN_CMD}" >&2
426 exit 1
427fi
428touch "$DONE"
429rm -f "$JOURNAL"
431echo " [v1.27.0.0] migration complete." >&2
432exit 0