From ca45bcc341d7fdd567fe678358fe02cab4e80712 Mon Sep 17 00:00:00 2001 From: Alex Ivantsov Date: Thu, 5 Nov 2026 16:48:30 -0500 Subject: [PATCH 1/2] fix(deploy): schedule the nightly backup only with an origin remote A copy with no origin remote is a supported setup, but deploy installed the nightly backup on every run. Its OPS stage then logged "'origin' does not appear to be a git repository" each night and the session briefing showed "Backup: ... did not finish clean" in every session. deploy.sh and deploy.ps1 now schedule the cron entry or the OPS Daily Backup task only when `git remote get-url origin` succeeds. With none they print one line ("No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy.") and carry on; it is not a failure. An entry or task that already exists is never touched, and a warning names the fix when there is no remote. daily-sync.sh is unchanged, so a copy with a remote that cannot push still reports loudly. The job also runs the nightly project pull and branch-gc, so a copy with no remote runs those by hand; README and DEPLOYMENT.md say so. deploy.sh --help prints its last two header lines again. Tests: tests/deploy-cron-test.sh (new) and tests/deploy-windows-test.ps1 cover origin present, no origin, a remote under another name, no repository, an existing entry with and without an origin, and --no-system. --- .claude-config/bin/tests/deploy-cron-test.sh | 167 ++++++++++++++++++ .../bin/tests/deploy-windows-test.ps1 | 82 +++++++++ .claude-config/deploy.ps1 | 32 +++- .claude-config/deploy.sh | 23 ++- CHANGELOG.md | 18 ++ CONTEXT/how-i-work.md | 3 +- DEPLOYMENT.md | 26 ++- README.md | 17 +- 8 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 .claude-config/bin/tests/deploy-cron-test.sh diff --git a/.claude-config/bin/tests/deploy-cron-test.sh b/.claude-config/bin/tests/deploy-cron-test.sh new file mode 100644 index 0000000..2f894f5 --- /dev/null +++ b/.claude-config/bin/tests/deploy-cron-test.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# deploy-cron-test.sh: deploy.sh schedules the nightly backup only when the OPS +# repo has an `origin` remote. +# +# A copy with no remote is a supported setup (OPS works without GitHub). There +# the backup's OPS stage logs "'origin' does not appear to be a git repository" +# on every run and the session briefing reports a failed backup in every +# session, so deploy must not schedule the job. daily-sync.sh is unchanged: a +# copy that has a remote and cannot push still logs and reports loudly (that is +# daily-sync-test.sh's ground). +# +# This runs the real cron block sliced out of deploy.sh, against a fake +# `crontab` kept in a file and throwaway git repos as the OPS root, and asserts: +# - origin present: the cron line is installed once, exactly as before, and a +# second run installs nothing more +# - no origin (a fresh `git init`, a remote under another name, a directory +# that is not a repository): nothing is written to the crontab, the one +# "No git remote" line is printed, and the deploy does not count it a failure +# - an existing cron line is left alone, with and without an origin: the +# crontab is byte-identical and written zero times; without an origin the +# deploy says so and names the fix, and still removes nothing +# - other crontab entries survive a run in every case +# - --no-system touches no crontab either way +# +# Writes only inside its own mktemp directory. It slices deploy.sh, so like the +# other deploy suites it starts with the sandbox guard (verify-ops check 24) and +# runs inside sandbox-run: +# ~/OPS/.claude-config/bin/sandbox-run --no-bind --no-net --ro-bind ~/OPS -- \ +# bash ~/OPS/.claude-config/bin/tests/deploy-cron-test.sh +set -uo pipefail +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/../sandbox-guard.sh" && sandbox_guard || exit 97 + +CFG="$(cd "$(dirname "$0")/../.." && pwd)" +DEPLOY="$CFG/deploy.sh" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/deploy-cron-test.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +fail() { echo "FAIL: $*" >&2; [ -f "${OUT:-}" ] && sed 's/^/ deploy said: /' "$OUT" >&2; exit 1; } +pass() { echo "PASS: $*"; } + +export HOME="$WORK/home" GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +mkdir -p "$HOME" "$WORK/bin" + +# A per-user crontab kept in $CRONTAB_FILE; every write is counted. +cat > "$WORK/bin/crontab" <<'STUB' +#!/usr/bin/env bash +case "${1:-}" in + -l) [ -f "$CRONTAB_FILE" ] || { echo "no crontab for test" >&2; exit 1; }; cat "$CRONTAB_FILE" ;; + -) cat > "$CRONTAB_FILE.new" && mv "$CRONTAB_FILE.new" "$CRONTAB_FILE" && echo write >> "$CRONTAB_FILE.writes" ;; + *) echo "fake crontab: unexpected arguments: $*" >&2; exit 2 ;; +esac +STUB +chmod +x "$WORK/bin/crontab" + +BLOCK="$(awk '/^# --- Daily backup cron job ---/ {p=1} p && /^# --- Claude Code plugins/ {exit} p {print}' "$DEPLOY")" +printf '%s\n' "$BLOCK" | grep -q 'crontab' && printf '%s\n' "$BLOCK" | grep -q 'CRON_LINE' \ + || fail "the cron block was not found in deploy.sh (its start or end marker moved)" + +# A throwaway OPS root: a git repo with the backup script in place. $2 names a +# remote to add (the URL is never contacted); none adds no remote. +mk_repo() { + git init -q "$1" || fail "git init $1" + mkdir -p "$1/.claude-config/backup" + printf '#!/usr/bin/env bash\n' > "$1/.claude-config/backup/daily-sync.sh" + [ -z "${2:-}" ] || git -C "$1" remote add "$2" git@example.invalid:acme/ops-copy.git +} + +N=0 +# run_block [no-system]: the cron block, with deploy's message +# helpers replaced by recorders. Output lands in $OUT; the block's failure +# list on its last line. +run_block() { + N=$((N + 1)); OUT="$WORK/out.$N" + ( + msg_ok() { echo "OK $*"; } + msg_warn() { echo "WARN $*"; } + msg_bad() { echo "BAD $*"; } + c_yel() { :; } + OPS_ROOT="$1"; CONFIG_DIR="$1/.claude-config"; NO_SYSTEM="${2:-0}"; DEPLOY_FAILED='' + PATH="$WORK/bin:$PATH" + eval "$BLOCK" + echo "DEPLOY_FAILED=[$DEPLOY_FAILED]" + ) > "$OUT" 2>&1 +} + +new_crontab() { export CRONTAB_FILE="$WORK/crontab.$1"; rm -f "$CRONTAB_FILE" "$CRONTAB_FILE.writes"; } +cron_count() { grep -cF '# OPS Daily Backup' "$CRONTAB_FILE" 2>/dev/null || true; } +cron_writes() { if [ -f "$CRONTAB_FILE.writes" ]; then wc -l < "$CRONTAB_FILE.writes" | tr -d ' '; else echo 0; fi; } +NOREMOTE_MSG='No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy.' + +# --- 1. origin present: scheduled as before ---------------------------------- +mk_repo "$WORK/with-origin" origin +new_crontab 1 +run_block "$WORK/with-origin" +[ "$(cron_count)" = 1 ] || fail "with an origin, the cron line was not installed exactly once ($(cron_count))" +[ "$(grep -F '# OPS Daily Backup' "$CRONTAB_FILE")" = "0 9 * * * $WORK/with-origin/.claude-config/backup/daily-sync.sh # OPS Daily Backup" ] \ + || fail "the installed cron line changed shape: $(cat "$CRONTAB_FILE")" +grep -q '^OK Cron job installed' "$OUT" || fail "no 'installed' message with an origin" +grep -q '^DEPLOY_FAILED=\[\]$' "$OUT" || fail "a clean install counted as a failed deploy" +! grep -q 'No git remote' "$OUT" || fail "the no-remote line was printed with an origin" +run_block "$WORK/with-origin" +[ "$(cron_count)" = 1 ] && [ "$(cron_writes)" = 1 ] || fail "a second run installed another line (lines $(cron_count), writes $(cron_writes))" +grep -q '^OK Cron job already installed' "$OUT" || fail "no 'already installed' message on the second run" +pass "origin present: the cron line is installed once as before, and a rerun adds nothing" + +# --- 2. no origin: nothing scheduled, one clear line -------------------------- +mk_repo "$WORK/bare-init" +new_crontab 2 +run_block "$WORK/bare-init" +[ ! -e "$CRONTAB_FILE" ] && [ "$(cron_writes)" = 0 ] || fail "with no origin, the crontab was written" +[ "$(grep -c '^WARN No git remote' "$OUT")" = 1 ] && grep -qxF "WARN $NOREMOTE_MSG" "$OUT" || fail "the no-remote line is missing, duplicated or reworded" +grep -q '^DEPLOY_FAILED=\[\]$' "$OUT" || fail "a copy with no remote counted as a failed deploy" +! grep -q '^BAD' "$OUT" || fail "a copy with no remote printed a failure line" +pass "no origin: nothing is scheduled, the one no-remote line is printed, and deploy still passes" + +# Other crontab entries survive, byte for byte, with no write. +new_crontab 2b +printf '30 2 * * * /usr/local/bin/other-job\n' > "$CRONTAB_FILE" +cp "$CRONTAB_FILE" "$WORK/before" +run_block "$WORK/bare-init" +cmp -s "$CRONTAB_FILE" "$WORK/before" && [ "$(cron_writes)" = 0 ] || fail "no-origin deploy changed an unrelated crontab" +pass "no origin: unrelated crontab entries are untouched" + +# A remote under another name is not an origin: daily-sync.sh pushes origin. +mk_repo "$WORK/other-remote" upstream +new_crontab 2c +run_block "$WORK/other-remote" +[ ! -e "$CRONTAB_FILE" ] && grep -q '^WARN No git remote' "$OUT" || fail "a remote named upstream was treated as an origin" +pass "no origin: a remote under another name does not count" + +# Not a repository at all (a tarball copy): no origin either, and no crash. +mkdir -p "$WORK/not-a-repo/.claude-config/backup" +printf '#!/usr/bin/env bash\n' > "$WORK/not-a-repo/.claude-config/backup/daily-sync.sh" +new_crontab 2d +run_block "$WORK/not-a-repo" +[ ! -e "$CRONTAB_FILE" ] && grep -q '^WARN No git remote' "$OUT" && grep -q '^DEPLOY_FAILED=\[\]$' "$OUT" \ + || fail "a directory that is not a repository was scheduled or failed the deploy" +pass "no origin: a directory that is not a git repository is handled the same way" + +# --- 3. an existing cron line is left alone ------------------------------------ +EXISTING="0 9 * * * $WORK/with-origin/.claude-config/backup/daily-sync.sh # OPS Daily Backup" +for case in with-origin bare-init; do + new_crontab "3-$case" + printf '30 2 * * * /usr/local/bin/other-job\n%s\n' "$EXISTING" > "$CRONTAB_FILE" + cp "$CRONTAB_FILE" "$WORK/before" + run_block "$WORK/$case" + cmp -s "$CRONTAB_FILE" "$WORK/before" || fail "an existing cron line was changed ($case)" + [ "$(cron_writes)" = 0 ] || fail "an existing cron line was rewritten ($case)" + [ "$(cron_count)" = 1 ] || fail "an existing cron line was removed or duplicated ($case)" + grep -q '^DEPLOY_FAILED=\[\]$' "$OUT" || fail "an existing cron line failed the deploy ($case)" + if [ "$case" = with-origin ]; then + grep -q '^OK Cron job already installed' "$OUT" || fail "no 'already installed' message with an origin" + else + grep -q '^WARN Cron job already installed, but this copy has no git remote' "$OUT" && grep -q 'crontab -e' "$OUT" \ + || fail "an existing line with no origin did not say so and name the fix" + ! grep -q 'No git remote: the nightly backup is not scheduled' "$OUT" || fail "claimed 'not scheduled' while a line exists" + fi +done +pass "an existing cron line is left alone with and without an origin (no write, nothing removed); without one the deploy names the fix" + +# --- 4. --no-system touches no crontab, with or without an origin -------------- +for case in with-origin bare-init; do + new_crontab "4-$case" + run_block "$WORK/$case" 1 + [ ! -e "$CRONTAB_FILE" ] && grep -q '^WARN --no-system: crontab not touched' "$OUT" || fail "--no-system touched the crontab ($case)" +done +pass "--no-system leaves the crontab alone either way" diff --git a/.claude-config/bin/tests/deploy-windows-test.ps1 b/.claude-config/bin/tests/deploy-windows-test.ps1 index eecf71c..3bdbccc 100644 --- a/.claude-config/bin/tests/deploy-windows-test.ps1 +++ b/.claude-config/bin/tests/deploy-windows-test.ps1 @@ -239,6 +239,88 @@ esac } finally { $env:USERPROFILE = $previousProfile } + # The nightly task is registered only when the OPS repo has an origin remote + # (deploy.sh follows the same rule; a copy with no remote would log an error + # every night and the briefing would report a failed backup). Run the real + # block with the ScheduledTask cmdlets replaced by recorders; git is the + # real git, on throwaway repos. + $taskBlock = Extract-Block '# --- Register daily backup scheduled task ---' '# --- Mount the shared skills' + $bashLine = '$BashExe = Resolve-BashExe' + Assert ($taskBlock.Contains($bashLine)) 'The task block no longer resolves bash with Resolve-BashExe' + $taskBlock = $taskBlock.Replace($bashLine, '$BashExe = $FixtureBash') + $taskRoot = Join-Path $fixture 'task' + Microsoft.PowerShell.Management\New-Item -ItemType Directory -Path $taskRoot -Force | Out-Null + $FixtureBash = Join-Path $taskRoot 'bash' + [IO.File]::WriteAllText($FixtureBash, '') + function New-TaskRepo { + param([string]$Name, [string]$Remote, [switch]$NoGit) + $dir = Join-Path $taskRoot $Name + Microsoft.PowerShell.Management\New-Item -ItemType Directory -Path (Join-Path $dir '.claude-config/backup') -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $dir '.claude-config/backup/daily-sync.sh'), '') + if (-not $NoGit) { $null = @(& git init -q $dir 2>&1) } + if ($Remote) { $null = @(& git -C $dir remote add $Remote 'git@example.invalid:acme/ops-copy.git' 2>&1) } + return $dir + } + function New-ScheduledTaskAction { param($Execute, $Argument, $WorkingDirectory) [pscustomobject]@{ Execute = $Execute; Arguments = $Argument } } + function New-ScheduledTaskTrigger { param([switch]$Daily, $At) 'trigger' } + function New-ScheduledTaskSettingsSet { + param([switch]$StartWhenAvailable, [switch]$RunOnlyIfNetworkAvailable, $MultipleInstances, $ExecutionTimeLimit, [switch]$AllowStartIfOnBatteries, [switch]$DontStopIfGoingOnBatteries) + 'settings' + } + function Register-ScheduledTask { + param($TaskName, $Action, $Trigger, $Settings, $Description, [switch]$Force) + $script:registerCalls++; $script:registeredAction = $Action + } + function Get-ScheduledTask { + param($TaskName, $ErrorAction) + if ($script:registerCalls -gt 0) { [pscustomobject]@{ Actions = @($script:registeredAction) } } + elseif ($script:taskExists) { [pscustomobject]@{ Actions = @([pscustomobject]@{ Execute = 'old'; Arguments = 'old' }) } } + } + function Write-Warn { param($Msg) $script:taskWarns += @("$Msg") } + function Write-Good { param($Msg) $script:taskGoods += @("$Msg") } + function Invoke-TaskBlock { + param([string]$Root, [bool]$System = $true, [bool]$Existing = $false) + $script:registerCalls = 0; $script:registeredAction = $null; $script:taskExists = $Existing + $script:taskWarns = @(); $script:taskGoods = @() + $OpsRoot = $Root; $BackupDir = Join-Path $Root '.claude-config/backup'; $NoSystem = -not $System + Invoke-Expression $taskBlock + } + $noRemote = 'No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy.' + + Invoke-TaskBlock -Root (New-TaskRepo 'with-origin' 'origin') + Assert ($script:registerCalls -eq 1) "With an origin the task was not registered once ($script:registerCalls)" + Assert (@($script:taskGoods | Where-Object { $_ -like 'Daily backup task registered + verified*' }).Count -eq 1) "No verified-registration line: $($script:taskGoods -join ' | ')" + Assert (@($script:taskWarns | Where-Object { $_ -like 'No git remote*' }).Count -eq 0) 'The no-remote line was printed with an origin' + Write-Host 'PASS: origin present: the daily backup task is registered and verified as before' + + Invoke-TaskBlock -Root (New-TaskRepo 'no-origin') + Assert ($script:registerCalls -eq 0) 'With no origin the task was registered' + Assert (@($script:taskWarns | Where-Object { $_ -ceq $noRemote }).Count -eq 1) "The no-remote line is missing or reworded: $($script:taskWarns -join ' | ')" + Assert ($script:taskGoods.Count -eq 0) 'A registration success line was printed with no origin' + Write-Host 'PASS: no origin: nothing is registered and the one no-remote line is printed' + + Invoke-TaskBlock -Root (New-TaskRepo 'other-remote' 'upstream') + Assert ($script:registerCalls -eq 0 -and @($script:taskWarns | Where-Object { $_ -ceq $noRemote }).Count -eq 1) 'A remote named upstream was treated as an origin' + Invoke-TaskBlock -Root (New-TaskRepo 'not-a-repo' -NoGit) + Assert ($script:registerCalls -eq 0 -and @($script:taskWarns | Where-Object { $_ -ceq $noRemote }).Count -eq 1) 'A directory that is not a repository was treated as having an origin' + Write-Host 'PASS: no origin: a remote under another name, or no repository at all, does not count' + + Invoke-TaskBlock -Root (Join-Path $taskRoot 'no-origin') -Existing $true + Assert ($script:registerCalls -eq 0) 'An existing task with no origin was re-registered' + Assert (@($script:taskWarns | Where-Object { $_ -like 'Daily backup task already registered, but this copy has no git remote*Unregister-ScheduledTask*' }).Count -eq 1) "An existing task with no origin did not say so and name the fix: $($script:taskWarns -join ' | ')" + Assert (@($script:taskWarns | Where-Object { $_ -like 'No git remote: the nightly backup is not scheduled*' }).Count -eq 0) 'Claimed not scheduled while a task exists' + Write-Host 'PASS: an existing task with no origin is left alone and the fix is named' + + Invoke-TaskBlock -Root (Join-Path $taskRoot 'with-origin') -System $false + Invoke-TaskBlock -Root (Join-Path $taskRoot 'no-origin') -System $false + Assert ($script:registerCalls -eq 0) '-NoSystem registered a task' + Write-Host 'PASS: -NoSystem registers nothing' + + foreach ($fn in 'New-TaskRepo', 'New-ScheduledTaskAction', 'New-ScheduledTaskTrigger', 'New-ScheduledTaskSettingsSet', 'Register-ScheduledTask', 'Get-ScheduledTask', 'Write-Good', 'Invoke-TaskBlock') { + Remove-Item "Function:\$fn" -ErrorAction SilentlyContinue + } + function Write-Warn { param($Msg) } + # Execute only the actual summary in a child process so exit 1 is observable. $summaryStart = $script:deployText.IndexOf('# --- Summary ---') $summaryEnd = $script:deployText.IndexOf('if ($Host.Name', $summaryStart) diff --git a/.claude-config/deploy.ps1 b/.claude-config/deploy.ps1 index b99bb87..1088f27 100644 --- a/.claude-config/deploy.ps1 +++ b/.claude-config/deploy.ps1 @@ -16,7 +16,8 @@ 4. Initialize Claude Code auto-memory git-sync (bin/memory-init.ps1) 5. Seed the trust anchor ($HOME trusted, no folder-trust prompt) 6. Emit the always-on ultracode shim to the profile.local.ps1 seam - 7. Register the daily backup scheduled task (dynamic bash + WorkingDirectory) + 7. Register the daily backup scheduled task (dynamic bash + WorkingDirectory), + only when the OPS repo has an origin remote 8. Mount the shared skills: clone the public skill source under PROJECTS/ when absent, then run bin/skills-vendor.sh sync in Git Bash 9. Retire the caveman + ponytail Claude Code plugins (uninstall if present) @@ -478,6 +479,27 @@ function Resolve-BashExe { $BashExe = Resolve-BashExe +# The nightly task backs the copy up to its `origin` remote. A copy with no +# remote is a supported setup (OPS works without GitHub), but there the task's +# OPS stage fails on every run ("'origin' does not appear to be a git +# repository") and the session briefing then reports a failed backup in every +# session, so a copy with no origin is not scheduled. daily-sync.sh itself is +# unchanged: a copy that has a remote and cannot push still logs and reports +# loudly. A task that is already registered is never touched. Same rule as +# deploy.sh. +function Test-GitOrigin { + param([string]$Repo) + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $null = @(& git -C $Repo remote get-url origin 2>&1) + return ($LASTEXITCODE -eq 0) + } + catch { return $false } + finally { $ErrorActionPreference = $prevEap } +} +$HasOrigin = Test-GitOrigin -Repo $OpsRoot + if ($NoSystem) { Write-Warn "-NoSystem: daily backup task not registered." } @@ -488,6 +510,14 @@ elseif (-not $BashExe) { elseif (-not (Test-Path $BackupScript)) { Write-Warn "Backup script not found at $BackupScript -- skipping backup task." } +elseif (-not $HasOrigin) { + if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Write-Warn "Daily backup task already registered, but this copy has no git remote, so each nightly run logs an error. Add a GitHub remote (DEPLOYMENT.md), or remove the task: Unregister-ScheduledTask -TaskName '$TaskName'" + } + else { + Write-Warn "No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy." + } +} else { try { # -l (login shell) so PATH/gh/git identity are present, matching cron. diff --git a/.claude-config/deploy.sh b/.claude-config/deploy.sh index 8873532..9e86566 100755 --- a/.claude-config/deploy.sh +++ b/.claude-config/deploy.sh @@ -18,7 +18,8 @@ # 6. Write the ultracode-only claude() shim to ~/.rc.local # 7. Check the git identity, then initialize Claude Code auto-memory git-sync # (bin/memory-init) -# 8. Schedule the daily backup cron entry +# 8. Schedule the daily backup cron entry (only when the OPS repo has an +# `origin` remote; with none it prints that and schedules nothing) # 9. Retire the caveman + ponytail Claude Code plugins (uninstall if present) # 10. Seed the trust anchor # @@ -46,7 +47,7 @@ NO_SYSTEM="${OPS_DEPLOY_NO_SYSTEM:-0}" for arg in "$@"; do case "$arg" in --no-system) NO_SYSTEM=1 ;; - -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;; + -h|--help) sed -n '2,42p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "deploy.sh: unknown argument: $arg (try --help)" >&2; exit 2 ;; esac done @@ -656,6 +657,16 @@ BACKUP_SCRIPT="${CONFIG_DIR}/backup/daily-sync.sh" CRON_TAG="# OPS Daily Backup" CRON_LINE="0 9 * * * ${BACKUP_SCRIPT} ${CRON_TAG}" +# The nightly job backs the copy up to its `origin` remote. A copy with no +# remote is a supported setup (OPS works without GitHub), but there the job's +# OPS stage fails on every run ("'origin' does not appear to be a git +# repository") and the session briefing then reports a failed backup in every +# session, so a copy with no origin is not scheduled. daily-sync.sh itself is +# unchanged: a copy that has a remote and cannot push still logs and reports +# loudly. An entry that is already installed is never touched. +HAS_ORIGIN=0 +git -C "$OPS_ROOT" remote get-url origin >/dev/null 2>&1 && HAS_ORIGIN=1 + if [[ "$NO_SYSTEM" == 1 ]]; then msg_warn "--no-system: crontab not touched. Manual: $CRON_LINE" elif [[ ! -f "$BACKUP_SCRIPT" ]]; then @@ -664,7 +675,13 @@ else chmod +x "$BACKUP_SCRIPT" if crontab -l 2>/dev/null | grep -qF "$CRON_TAG"; then - msg_ok "Cron job already installed." + if [[ "$HAS_ORIGIN" == 1 ]]; then + msg_ok "Cron job already installed." + else + msg_warn "Cron job already installed, but this copy has no git remote, so each nightly run logs an error. Add a GitHub remote (DEPLOYMENT.md), or remove the entry with: crontab -e" + fi + elif [[ "$HAS_ORIGIN" != 1 ]]; then + msg_warn "No git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then rerun deploy." else if (crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -; then msg_ok "Cron job installed (daily at 9:00 AM): $BACKUP_SCRIPT" diff --git a/CHANGELOG.md b/CHANGELOG.md index db723fe..2882a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ Notable changes to OPS, newest first. Format: the date, then what changed and wh matters. This file starts fresh at the public release; the harness's private prehistory is deliberately not part of it. +## 2026-11-05: a copy with no git remote does not schedule the nightly backup + +OPS works without GitHub, but deploy installed the nightly backup on every run. With no +`origin` remote its OPS stage logged `'origin' does not appear to be a git repository` each +night, and the session briefing then showed `Backup: ... did not finish clean` in every +session. `deploy.sh` and `deploy.ps1` now schedule the job (the user cron entry, the `OPS +Daily Backup` task) only when the OPS repo has an `origin` remote. With none they print `No +git remote: the nightly backup is not scheduled. Add a GitHub remote (DEPLOYMENT.md), then +rerun deploy.` and go on; that is not a failure. An entry or task that already exists is +never touched (a warning names the fix when there is no remote), and `daily-sync.sh` is +unchanged, so a copy with a remote that cannot push still reports loudly. Note that the job +also runs the nightly project pull and `branch-gc --apply`: a copy with no remote runs those +by hand (README, "The daily backup"). `tests/deploy-cron-test.sh` covers deploy.sh (origin: +installed once; none, another remote name or no repository: nothing written and the line +printed; an existing entry byte-identical with and without an origin), and +`tests/deploy-windows-test.ps1` covers the same rules for the scheduled task. `deploy.sh +--help` also prints its last two header lines again. + ## 2026-11-05: synced org skills stay out of your OPS repo Claude Code syncs claude.ai org and Anthropic skills into `~/.claude/skills/synced`, and diff --git a/CONTEXT/how-i-work.md b/CONTEXT/how-i-work.md index ee98133..8442f91 100644 --- a/CONTEXT/how-i-work.md +++ b/CONTEXT/how-i-work.md @@ -106,7 +106,8 @@ only when the why is not obvious, and no AI attribution. For an eligible green `MERGEABLE` PR, use `gh pr merge --squash` and report the SHA. Never add `--delete-branch`: worktrees share a ref store and that flag can rotate another session's checkout. `.claude-config/bin/branch-gc` -cleans up instead: nightly from `backup/daily-sync.sh` on every repo it +cleans up instead: nightly from `backup/daily-sync.sh` (scheduled only on a +copy with an `origin` remote) on every repo it manages (`~/OPS`, `~/OPS/PROJECTS//`, `~/linuxploitacious`), at pre-compaction for the repo holding the cwd, and at closeout on each repo touched. It deletes a branch only when a merged PR's head or the default diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0b0ed8a..bf084ff 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -202,6 +202,24 @@ In order: always exits 0, so the session briefing's `Backup:` line repeats the last run's OPS-stage errors and skipped pushes at boot. Skipped with `--no-system`. + **Only with an `origin` remote.** A copy with no remote is a supported setup + (OPS works without GitHub), but there every run's OPS stage would log + `'origin' does not appear to be a git repository` and the briefing would + report a failed backup in every session. With no `origin` (a remote under + another name does not count: the sync script pushes `origin`), deploy prints + `No git remote: the nightly backup is not scheduled. Add a GitHub remote + (DEPLOYMENT.md), then rerun deploy.` and installs nothing; it is not a + failure. An entry already in your crontab is left alone: on a copy with no + remote deploy warns that it will log an error each night and names + `crontab -e`. The job is also what pulls your project repos and runs + `branch-gc --apply` nightly, so without it run `PROJECTS/sync-check.sh + --auto` and `.claude-config/bin/branch-gc --apply` by hand (branch cleanup + also runs at pre-compaction and closeout). To turn the backup on later: + `git -C ~/OPS remote add origin ` (a private repo; see the push gate + below), then rerun `deploy.sh`. A copy that has a remote and cannot push is + a different case and stays loud: the script's own error reaches the + briefing as before. + **The push gate.** Your copy holds your memory, context, and notes, so the two automatic pushes (memory-init's and the daily backup's) run only when `.claude-config/bin/origin-private-check` verifies that every push URL of @@ -272,7 +290,9 @@ which is tied to the public linuxploitacious repo); the git identity check (step 7); auto-memory git-sync (`bin\memory-init.ps1`, behind the same push gate as step 8); the trust anchor; the ultracode shim in the seam; an `OPS Daily Backup` scheduled task -that runs `daily-sync.sh` in Git Bash (skipped with `-NoSystem`); the skill mount +that runs `daily-sync.sh` in Git Bash (skipped with `-NoSystem`, and when the +repo has no `origin` remote: it prints the same no-remote line; a task already +registered is left alone); the skill mount (clone, then `skills-vendor.sh sync` through Git Bash); plugin retirement (same CLI calls as step 9); and a Git Bash pin plus the guard self-test. @@ -302,8 +322,8 @@ git -C ~/linuxploitacious status --short -- claude/.claude/settings.json bash ~/OPS/.claude-config/bin/verify-ops.sh # Backup scheduler -crontab -l | grep OPS # Linux -schtasks /Query /TN "OPS Daily Backup" # Windows +crontab -l | grep OPS # Linux (no line on a copy with no origin remote: deploy skips it) +schtasks /Query /TN "OPS Daily Backup" # Windows (same: not registered without an origin remote) # The backup may push (expect exit 0 and the line # "origin-private-check: private: github.com// is private"; diff --git a/README.md b/README.md index 5fd42ac..b421b56 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,8 @@ idempotent and safe to re-run after any pull. On Linux, in order: `~/.bashrc.local`. 7. Check the git identity, then link each Claude auto-memory directory into `.claude-memory/` (`memory-init --auto-commit`). -8. Install the daily backup cron entry. +8. Install the daily backup cron entry, when the copy has an `origin` remote + (with none it prints one line and schedules nothing; see "The daily backup"). 9. Retire the caveman and ponytail plugins through the `claude plugin` CLI. 10. Mark `$HOME` trusted in `~/.claude.json`, so the repos under it raise no folder-trust prompt. @@ -273,6 +274,20 @@ Bash). Each run: request provably carried and parks their worktrees in a 7-day trash. The script always exits 0, so a failure cannot be seen from the exit code. + +**No GitHub remote.** A copy with no `origin` remote is a supported setup: OPS +works without GitHub. Deploy then does not schedule the job, because its first +stage would fail on every run (`'origin' does not appear to be a git +repository`) and the session briefing would report a failed backup in every +session. It prints `No git remote: the nightly backup is not scheduled.` and +goes on; an entry already in your crontab (or the `OPS Daily Backup` task on +Windows) is never touched. The job also does the project pull and the branch +cleanup above, so a copy with no remote has neither nightly: run +`PROJECTS/sync-check.sh --auto` and `.claude-config/bin/branch-gc --apply` by +hand when you want them (branch cleanup also runs at pre-compaction and +closeout). To turn the backup on, add a private GitHub remote +(`git -C ~/OPS remote add origin `, see the push gate above) and rerun +`deploy.sh`. It logs to `.claude-config/backup/sync.log` instead, and the session briefing's `Backup:` line repeats the last run's errors and skipped pushes at the next boot. From fcbd95f686704c47833841427df6043d70fffd2f Mon Sep 17 00:00:00 2001 From: Alex Ivantsov Date: Thu, 5 Nov 2026 16:52:05 -0500 Subject: [PATCH 2/2] test(deploy): mark deploy-cron-test.sh executable like its siblings --- .claude-config/bin/tests/deploy-cron-test.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .claude-config/bin/tests/deploy-cron-test.sh diff --git a/.claude-config/bin/tests/deploy-cron-test.sh b/.claude-config/bin/tests/deploy-cron-test.sh old mode 100644 new mode 100755