From 0d0a5b5a2197287b84790fc052fca4e2375f7534 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 18:56:50 -0300 Subject: [PATCH 1/6] fix(cloud): add fail-visible sync wrappers --- .github/workflows/ci.yml | 10 ++ DOCS.md | 32 ++++++ tools/cloud-sync-projects.ps1 | 100 ++++++++++++++++++ tools/cloud-sync-projects.sh | 92 +++++++++++++++++ tools/cloud_sync_projects_test.go | 165 ++++++++++++++++++++++++++++++ 5 files changed, 399 insertions(+) create mode 100755 tools/cloud-sync-projects.ps1 create mode 100755 tools/cloud-sync-projects.sh create mode 100644 tools/cloud_sync_projects_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74577bb2d..25b0538f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,13 @@ jobs: - name: Run e2e tests run: go test -tags e2e ./internal/server/... + + wrapper-tests-windows: + name: Cloud Sync Wrapper Tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.25.10" + - run: go test ./tools/ -run TestCloudSyncWrappers -v diff --git a/DOCS.md b/DOCS.md index a0971d245..659bf3567 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1476,6 +1476,38 @@ For a step-by-step recovery guide covering `chunk_id does not match payload cont --- +## Scheduled Explicit Cloud Sync Wrappers + +The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running (CI runners, ephemeral agents, cron-only boxes). They run `engram sync --cloud --project ` once per explicitly named project and stop. **Choose ONE mode** — native autosync (above) is recommended when a daemon is feasible; the wrappers are for the no-daemon case. Do **not** run both at once: it produces redundant overlapping sync attempts. Cloud `--all` is intentionally unsupported; these wrappers never infer projects from cwd or an env var — every project is passed explicitly. + +### Bash: `tools/cloud-sync-projects.sh` + +```sh +./tools/cloud-sync-projects.sh my-project my-other-project +./tools/cloud-sync-projects.sh --log /var/log/engram-cloud-sync.log my-project # override default log +``` + +Exit `0` if all project syncs and the log succeeded; `1` if any project sync or logging op failed; `2` on usage error (no projects, bad flag). Default durable append-only log: `$ENGRAM_DATA_DIR/cloud-sync-projects.log` (`ENGRAM_DATA_DIR` defaults to `~/.engram`); override precedence `--log` > `ENGRAM_CLOUD_SYNC_LOG` > default. Wrapper start/end and per-project start/success/failure lines go to **both** the timestamped console and the log; the command's stdout+stderr are preserved on console and appended to the log. Nothing is retried or silenced. + +### PowerShell: `tools/cloud-sync-projects.ps1` + +```powershell +pwsh ./tools/cloud-sync-projects.ps1 my-project my-other-project +pwsh ./tools/cloud-sync-projects.ps1 -LogPath C:\logs\engram-cloud-sync.log my-project +``` + +Same behavior and exit codes. Log default `$ENGRAM_DATA_DIR\cloud-sync-projects.log`; override `-LogPath` > `ENGRAM_CLOUD_SYNC_LOG` > default. + +### Inspecting the last failure + +The log is append-only. `project FAILURE project= exit=` records the exact exit code from `engram sync --cloud --project `: + +```sh +grep 'project FAILURE' "$ENGRAM_DATA_DIR/cloud-sync-projects.log" | tail -n 5 +``` + +Pass the failing project to the normal [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) flow — the wrappers record and propagate the failure, they do not interpret or retry it. + --- ## Cloud Sync Audit Log diff --git a/tools/cloud-sync-projects.ps1 b/tools/cloud-sync-projects.ps1 new file mode 100755 index 000000000..19fbfadaa --- /dev/null +++ b/tools/cloud-sync-projects.ps1 @@ -0,0 +1,100 @@ +# Scheduled explicit cloud sync wrapper (PowerShell) — ALTERNATIVE to native +# autosync. Runs `engram sync --cloud --project ` once per explicitly +# named project, continuing through all; nonzero if any project or logging op +# fails. Choose ONE mode: native autosync (recommended) OR this wrapper — +# running both creates redundant overlapping sync. Exit: 0 ok, 1 fail, 2 usage. + +[CmdletBinding()] +param( + [string]$LogPath, + [Parameter(Position = 0, ValueFromRemainingArguments = $true)] + [string[]]$Projects +) + +$ErrorActionPreference = 'Stop' +$defaultLogName = 'cloud-sync-projects.log' + +function Write-Usage { + @' +Usage: cloud-sync-projects.ps1 [-LogPath ] [ ...] +Run `engram sync --cloud --project ` once per explicitly named project, +in order, continuing through all. Exit 0 if all succeed, 1 if any project sync +or logging op fails, 2 on usage error. + -LogPath Append-only log. Overrides default ($ENGRAM_DATA_DIR\ + cloud-sync-projects.log) and ENGRAM_CLOUD_SYNC_LOG. + -Help Show this help. +Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). +'@ | Out-Host +} + +# Strip -Help from remaining args. +$helpRequested = $false +$cleanProjects = @() +foreach ($a in $Projects) { + if ($a -in @('-Help', '--help', '-h')) { $helpRequested = $true } else { $cleanProjects += $a } +} +$Projects = $cleanProjects +if ($helpRequested) { Write-Usage; exit 2 } +if ($Projects.Count -eq 0) { + [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: at least one project is required') + [Console]::Error.WriteLine('Run with -Help for usage.') + exit 2 +} + +# Log path precedence: -LogPath > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. +$resolvedLog = $LogPath +if ([string]::IsNullOrEmpty($resolvedLog)) { $resolvedLog = $env:ENGRAM_CLOUD_SYNC_LOG } +if ([string]::IsNullOrEmpty($resolvedLog)) { + $dataDir = if ($env:ENGRAM_DATA_DIR) { $env:ENGRAM_DATA_DIR } else { (Join-Path $HOME '.engram') } + $resolvedLog = Join-Path $dataDir $defaultLogName +} +$resolvedLog = [System.IO.Path]::GetFullPath($resolvedLog) +$logDir = [System.IO.Path]::GetDirectoryName($resolvedLog) +if (-not (Test-Path -LiteralPath $logDir -PathType Container)) { + [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: log directory does not exist: $logDir"); exit 2 +} + +# Timestamped [ts] message to BOTH console and the append-only log; returns +# $false on log write failure so callers aggregate failures. +function Write-LogLine { + param([string]$Message) + $line = "[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')] $Message" + try { Add-Content -LiteralPath $resolvedLog -Value $line -Encoding UTF8 -ErrorAction Stop } + catch { [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: failed to append to log: $resolvedLog"); return $false } + Write-Host $line + return $true +} + +# Run the verified command for one project via native call operator (safe +# argument tokens), piping combined stdout/stderr through Tee-Object -Append. +# A scoped Continue preference lets ordinary native stderr stream without +# aborting a successful command; Tee-Object -ErrorAction Stop makes log write +# failures terminating. $LASTEXITCODE captured before any later native command. +# Returns the engram exit, or -1 if invoke/tee/logging failed. +function Invoke-Project { + param([string]$Project) + if (-not (Write-LogLine "project START project=$Project")) { return -1 } + $exitCode = 0 + $prevPref = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & engram sync --cloud --project $Project 2>&1 | Tee-Object -FilePath $resolvedLog -Append -ErrorAction Stop + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } catch { + [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: invoke/tee failed for '$Project': $($_.Exception.Message)") + return -1 + } finally { + $ErrorActionPreference = $prevPref + } + if ($exitCode -eq 0) { if (-not (Write-LogLine "project SUCCESS project=$Project exit=0")) { return -1 } } + else { if (-not (Write-LogLine "project FAILURE project=$Project exit=$exitCode")) { return -1 } } + return $exitCode +} + +$overall = 0 +if (-not (Write-LogLine "wrapper START projects=$($Projects.Count) log=$resolvedLog")) { $overall = 1 } +foreach ($proj in $Projects) { if ((Invoke-Project -Project $proj) -ne 0) { $overall = 1 } } +if ($overall -eq 0) { if (-not (Write-LogLine 'wrapper END result=success')) { $overall = 1 } } +else { if (-not (Write-LogLine "wrapper END result=failure overall=$overall")) { $overall = 1 } } +exit $overall diff --git a/tools/cloud-sync-projects.sh b/tools/cloud-sync-projects.sh new file mode 100755 index 000000000..5d3c05a5c --- /dev/null +++ b/tools/cloud-sync-projects.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Scheduled explicit cloud sync wrapper — ALTERNATIVE to native autosync. +# Runs `engram sync --cloud --project ` once per explicitly named +# project, continuing through all; nonzero if any project or logging op fails. +# Choose ONE mode: native autosync (ENGRAM_CLOUD_AUTOSYNC=1, recommended) OR +# this wrapper — running both creates redundant overlapping sync attempts. +# Projects are never inferred from cwd or an env var. Exit: 0 ok, 1 fail, 2 usage. + +PROG_NAME="cloud-sync-projects.sh" +DEFAULT_LOG_NAME="cloud-sync-projects.log" + +usage() { + cat <<'USAGE' +Usage: cloud-sync-projects.sh [--log ] [ ...] + +Run `engram sync --cloud --project ` once per explicitly named project, +in order, continuing through all. Exit 0 if all succeed, 1 if any project sync +or logging op fails, 2 on usage error. + + --log Append-only log. Overrides default ($ENGRAM_DATA_DIR/ + cloud-sync-projects.log) and ENGRAM_CLOUD_SYNC_LOG. + -h, --help Show this help. + +Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). +Projects are never inferred from cwd or an env var. +USAGE +} + +die_usage() { printf '%s: error: %s\n' "$PROG_NAME" "$*" >&2; printf 'Run with --help for usage.\n' >&2; exit 2; } + +log_path="" +projects=() +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 2 ;; + --log) [ $# -ge 2 ] || die_usage "--log requires a path argument"; log_path="$2"; shift 2 ;; + --log=*) log_path="${1#--log=}"; [ -n "$log_path" ] || die_usage "--log requires a non-empty path"; shift ;; + --) shift; while [ $# -gt 0 ]; do projects+=("$1"); shift; done ;; + -*) die_usage "unknown option: $1" ;; + *) projects+=("$1"); shift ;; + esac +done + +[ "${#projects[@]}" -gt 0 ] || die_usage "at least one project is required" + +# Log path precedence: --log > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. +[ -z "$log_path" ] && log_path="${ENGRAM_CLOUD_SYNC_LOG:-}" +if [ -z "$log_path" ]; then + log_path="${ENGRAM_DATA_DIR:-$HOME/.engram}/$DEFAULT_LOG_NAME" +fi +case "$log_path" in /*) ;; *) log_path="$PWD/$log_path" ;; esac # absolute + +log_dir="$(dirname "$log_path")" +[ -d "$log_dir" ] || { printf '%s: error: log directory does not exist: %s\n' "$PROG_NAME" "$log_dir" >&2; exit 2; } + +# Timestamped [ts] message to BOTH console and the append-only log; returns +# nonzero on log write failure. +logline() { + local ts; ts="$(date '+%Y-%m-%dT%H:%M:%S%z')" || return 1 + printf '[%s] %s\n' "$ts" "$*" >>"$log_path" || return 1 + printf '[%s] %s\n' "$ts" "$*" +} + +# Run the verified command for one project, tee output live to log and console. +# Returns the engram exit status, or 1 if tee/logging failed. Never hides failures. +run_project() { + local proj="$1" rc tee_rc + local -a statuses + logline "project START project=$proj" || return 1 + engram sync --cloud --project "$proj" 2>&1 | tee -a "$log_path" + statuses=("${PIPESTATUS[@]}") # snapshot before any other command mutates it + rc=${statuses[0]:-1}; tee_rc=${statuses[1]:-1} + if [ "$rc" -eq 0 ]; then + logline "project SUCCESS project=$proj exit=0" || return 1 + else + logline "project FAILURE project=$proj exit=$rc" || return 1 + fi + [ "$tee_rc" -ne 0 ] && [ "$rc" -eq 0 ] && return 1 # tee/log failed + return "$rc" +} + +overall=0 +logline "wrapper START projects=${#projects[@]} log=$log_path" || overall=1 +for proj in "${projects[@]}"; do + run_project "$proj" || overall=1 +done +if [ "$overall" -eq 0 ]; then + logline "wrapper END result=success" || overall=1 +else + logline "wrapper END result=failure overall=$overall" || overall=1 +fi +exit "$overall" diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go new file mode 100644 index 000000000..1385dd94b --- /dev/null +++ b/tools/cloud_sync_projects_test.go @@ -0,0 +1,165 @@ +package tools_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Deterministic wrapper tests with a fake `engram` (no network, no real data +// dir). Bash runs non-Windows; PowerShell runs Windows when available; +// otherwise skipped. Covers: success+durable capture, partial failure +// aggregate 1, missing args usage 2, space-containing project args. +func wrapperAbs(t *testing.T, name string) string { + t.Helper() + abs, err := filepath.Abs(name) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(abs); err != nil { + t.Fatalf("wrapper not found at %s: %v", abs, err) + } + return abs +} +func assertContains(t *testing.T, label, out string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(out, w) { + t.Fatalf("%s missing %q:\n%s", label, w, out) + } + } +} + +// fakeEngram writes a fake `engram` to dir that echoes stdout+stderr, exits 0 +// (failProj exits 1). Windows: .cmd; otherwise: bash script. +func fakeEngram(t *testing.T, dir, failProj string) { + t.Helper() + if runtime.GOOS == "windows" { + body := "@echo off\r\nset PROJ=\r\n:parse\r\nif \"%1\"==\"\" goto run\r\nif \"%1\"==\"--project\" (set PROJ=%~2& shift & shift & goto parse)\r\nshift\r\ngoto parse\r\n:run\r\necho stdout: syncing project=%PROJ%\r\necho stderr: project=%PROJ% 1>&2\r\n" + if failProj != "" { + body += "if \"%PROJ%\"==\"" + failProj + "\" (echo fake: forced failure for %PROJ% 1>&2 & exit 1)\r\n" + } + body += "exit 0\r\n" + if err := os.WriteFile(filepath.Join(dir, "engram.cmd"), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return + } + s := `#!/usr/bin/env bash +proj=""; while [ $# -gt 0 ]; do case "$1" in --project) proj="$2"; shift 2 ;; *) shift ;; esac; done +printf 'stdout: syncing project=%s\n' "$proj"; printf 'stderr: project=%s\n' "$proj" >&2 +` + if failProj != "" { + s += fmt.Sprintf("if [ \"$proj\" = %q ]; then echo \"fake: forced failure for $proj\" >&2; exit 1; fi\n", failProj) + } + s += "exit 0\n" + if err := os.WriteFile(filepath.Join(dir, "engram"), []byte(s), 0o755); err != nil { + t.Fatal(err) + } +} + +type wcase struct { + name string + projects []string + failProj string + wantExit int + wantIn, wantLog []string +} + +func run(t *testing.T, interp, wrapper, fakeDir, dataDir string, args ...string) (int, string, string) { + t.Helper() + var cmd *exec.Cmd + // Preserve Windows command resolution while putting fake engram first. + env := os.Environ() + env = append(env, "ENGRAM_DATA_DIR="+dataDir) + if interp == "bash" { + cmd = exec.Command("bash", append([]string{wrapper}, args...)...) + env = append(env, "HOME="+t.TempDir()) + } else { + cmd = exec.Command(interp, append([]string{"-NoProfile", "-File", wrapper}, args...)...) + env = append(env, "USERPROFILE="+t.TempDir()) + } + for i, e := range env { + key, value, ok := strings.Cut(e, "=") + if ok && strings.EqualFold(key, "PATH") { + env[i] = "PATH=" + fakeDir + string(os.PathListSeparator) + value + break + } + } + cmd.Env = env + out, err := cmd.CombinedOutput() + exit := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exit = exitErr.ExitCode() + } else if err != nil { + t.Fatalf("run %s: %v; output:\n%s", interp, err, string(out)) + } + return exit, string(out), filepath.Join(dataDir, "cloud-sync-projects.log") +} +func TestCloudSyncWrappers(t *testing.T) { + type interp struct{ name, file, flag string } + var interps []interp + if runtime.GOOS != "windows" { + if _, err := exec.LookPath("bash"); err == nil { + interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) + } + } else { + for _, name := range []string{"pwsh", "powershell"} { + if p, err := exec.LookPath(name); err == nil { + interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) + break + } + } + } + if len(interps) == 0 { + t.Skip("no native wrapper interpreter available") + } + cases := []wcase{ + {name: "SuccessWithLogOverride", projects: []string{"alpha", "beta"}, wantExit: 0, wantIn: []string{"stdout: syncing project=alpha", "stderr: project=alpha", "project SUCCESS project=alpha exit=0", "wrapper END result=success"}, wantLog: []string{"] project SUCCESS project=alpha exit=0", "stderr: project=alpha"}}, + {name: "PartialFailureContinuesAggregate1", projects: []string{"good", "mid", "tail"}, failProj: "mid", wantExit: 1, wantIn: []string{"project FAILURE project=mid exit=1", "project START project=tail", "wrapper END result=failure overall=1"}, wantLog: []string{"] project FAILURE project=mid exit=1"}}, + {name: "SpaceInProjectName", projects: []string{"my project"}, wantExit: 0, wantIn: []string{"stdout: syncing project=my project", "project SUCCESS project=my project exit=0"}, wantLog: []string{"] project SUCCESS project=my project exit=0"}}, + } + for _, it := range interps { + t.Run(it.file, func(t *testing.T) { + wrapper := wrapperAbs(t, it.file) + tmp := t.TempDir() + fakeDir, dataDir := filepath.Join(tmp, "bin"), filepath.Join(tmp, "data") + if err := os.MkdirAll(fakeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatal(err) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fakeEngram(t, fakeDir, tc.failProj) + args := append([]string{it.flag, filepath.Join(dataDir, "cloud-sync-projects.log")}, tc.projects...) + exit, out, logPath := run(t, it.name, wrapper, fakeDir, dataDir, args...) + if exit != tc.wantExit { + t.Fatalf("exit=%d want %d; output:\n%s", exit, tc.wantExit, out) + } + assertContains(t, "console", out, tc.wantIn...) + if lb, rerr := os.ReadFile(logPath); rerr != nil { + t.Fatalf("read log: %v", rerr) + } else { + assertContains(t, "log", string(lb), tc.wantLog...) + } + }) + } + t.Run("MissingArgsUsage2", func(t *testing.T) { + fakeEngram(t, fakeDir, "") + exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir) + if exit != 2 { + t.Fatalf("exit=%d want 2; output:\n%s", exit, out) + } + if !strings.Contains(out, "at least one project is required") { + t.Fatalf("missing usage message:\n%s", out) + } + }) + }) + } +} From 022dc2482ca659e17b3b957bc909ca29c47b2ed3 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 19:09:04 -0300 Subject: [PATCH 2/6] fix(cloud): preserve PowerShell sync exit status --- tools/cloud-sync-projects.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cloud-sync-projects.ps1 b/tools/cloud-sync-projects.ps1 index 19fbfadaa..9110de309 100755 --- a/tools/cloud-sync-projects.ps1 +++ b/tools/cloud-sync-projects.ps1 @@ -78,7 +78,7 @@ function Invoke-Project { $prevPref = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' - & engram sync --cloud --project $Project 2>&1 | Tee-Object -FilePath $resolvedLog -Append -ErrorAction Stop + & engram sync --cloud --project $Project 2>&1 | Tee-Object -FilePath $resolvedLog -Append -ErrorAction Stop | ForEach-Object { Write-Host $_ } $exitCode = $LASTEXITCODE if ($null -eq $exitCode) { $exitCode = 0 } } catch { From 2fb4e4a647b13f837af6bd4ca784ba5f59d4cbc4 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 19:38:10 -0300 Subject: [PATCH 3/6] fix(cloud): scope wrapper support to PowerShell 7 --- .github/workflows/ci.yml | 18 ++++++++----- DOCS.md | 24 +++++++++--------- tools/cloud-sync-projects.ps1 | 42 +++++++++++++------------------ tools/cloud-sync-projects.sh | 16 +++--------- tools/cloud_sync_projects_test.go | 37 ++++++++++++++------------- 5 files changed, 64 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25b0538f9..703623835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 with: go-version: "1.25.10" @@ -30,10 +32,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 with: go-version: "1.25.10" @@ -44,8 +48,10 @@ jobs: name: Cloud Sync Wrapper Tests (Windows) runs-on: windows-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 with: go-version: "1.25.10" - run: go test ./tools/ -run TestCloudSyncWrappers -v diff --git a/DOCS.md b/DOCS.md index 659bf3567..97a786dac 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1478,37 +1478,37 @@ For a step-by-step recovery guide covering `chunk_id does not match payload cont ## Scheduled Explicit Cloud Sync Wrappers -The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running (CI runners, ephemeral agents, cron-only boxes). They run `engram sync --cloud --project ` once per explicitly named project and stop. **Choose ONE mode** — native autosync (above) is recommended when a daemon is feasible; the wrappers are for the no-daemon case. Do **not** run both at once: it produces redundant overlapping sync attempts. Cloud `--all` is intentionally unsupported; these wrappers never infer projects from cwd or an env var — every project is passed explicitly. - +The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running (CI runners, ephemeral agents, cron-only boxes). They run `engram sync --cloud --project ` once per explicitly named project and stop. **Choose ONE mode** — native autosync (above) is recommended when a daemon is feasible; the wrappers are for the no-daemon case. Do **not** run both at once: it produces redundant overlapping sync attempts. Cloud `--all` is intentionally unsupported; projects are never inferred from cwd or an env var. ### Bash: `tools/cloud-sync-projects.sh` ```sh ./tools/cloud-sync-projects.sh my-project my-other-project -./tools/cloud-sync-projects.sh --log /var/log/engram-cloud-sync.log my-project # override default log +./tools/cloud-sync-projects.sh --log /var/log/engram-cloud-sync.log my-project ``` - -Exit `0` if all project syncs and the log succeeded; `1` if any project sync or logging op failed; `2` on usage error (no projects, bad flag). Default durable append-only log: `$ENGRAM_DATA_DIR/cloud-sync-projects.log` (`ENGRAM_DATA_DIR` defaults to `~/.engram`); override precedence `--log` > `ENGRAM_CLOUD_SYNC_LOG` > default. Wrapper start/end and per-project start/success/failure lines go to **both** the timestamped console and the log; the command's stdout+stderr are preserved on console and appended to the log. Nothing is retried or silenced. +Exit `0` if all syncs and log succeeded; `1` if any project or logging op failed; `2` on usage error. Default durable log `$ENGRAM_DATA_DIR/cloud-sync-projects.log` (`~/.engram` fallback); override `--log` > `ENGRAM_CLOUD_SYNC_LOG` > default. Status lines go to both timestamped console and log; command stdout+stderr preserved on console and appended to log. Nothing retried or silenced. ### PowerShell: `tools/cloud-sync-projects.ps1` - ```powershell pwsh ./tools/cloud-sync-projects.ps1 my-project my-other-project pwsh ./tools/cloud-sync-projects.ps1 -LogPath C:\logs\engram-cloud-sync.log my-project ``` - -Same behavior and exit codes. Log default `$ENGRAM_DATA_DIR\cloud-sync-projects.log`; override `-LogPath` > `ENGRAM_CLOUD_SYNC_LOG` > default. +Requires PowerShell 7 (`pwsh`); 5.1 is not supported. Same behavior, exit codes, and log defaults as Bash; override `-LogPath` > `ENGRAM_CLOUD_SYNC_LOG` > default. ### Inspecting the last failure -The log is append-only. `project FAILURE project= exit=` records the exact exit code from `engram sync --cloud --project `: +`project FAILURE project= exit=` records the exact exit code from `engram sync --cloud --project `: ```sh -grep 'project FAILURE' "$ENGRAM_DATA_DIR/cloud-sync-projects.log" | tail -n 5 +grep 'project FAILURE' "${ENGRAM_DATA_DIR:-$HOME/.engram}/cloud-sync-projects.log" | tail -n 5 ``` +PowerShell 7 (`$env:ENGRAM_DATA_DIR` or `$HOME/.engram` fallback): -Pass the failing project to the normal [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) flow — the wrappers record and propagate the failure, they do not interpret or retry it. +```powershell +$d = if ($env:ENGRAM_DATA_DIR) { $env:ENGRAM_DATA_DIR } else { Join-Path $HOME '.engram' } +Select-String 'project FAILURE' (Join-Path $d 'cloud-sync-projects.log') | Select-Object -Last 5 +``` ---- +Pass the failing project to [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) — the wrappers record and propagate, not interpret or retry. ## Cloud Sync Audit Log diff --git a/tools/cloud-sync-projects.ps1 b/tools/cloud-sync-projects.ps1 index 9110de309..30797dc1f 100755 --- a/tools/cloud-sync-projects.ps1 +++ b/tools/cloud-sync-projects.ps1 @@ -1,8 +1,6 @@ -# Scheduled explicit cloud sync wrapper (PowerShell) — ALTERNATIVE to native +# Scheduled explicit cloud sync wrapper (PowerShell 7) — ALTERNATIVE to native # autosync. Runs `engram sync --cloud --project ` once per explicitly -# named project, continuing through all; nonzero if any project or logging op -# fails. Choose ONE mode: native autosync (recommended) OR this wrapper — -# running both creates redundant overlapping sync. Exit: 0 ok, 1 fail, 2 usage. +# named project. Choose ONE mode: native autosync (recommended) OR this wrapper. [CmdletBinding()] param( @@ -14,34 +12,34 @@ param( $ErrorActionPreference = 'Stop' $defaultLogName = 'cloud-sync-projects.log' +if ($PSVersionTable.PSVersion.Major -lt 7) { + [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: PowerShell 7 (pwsh) is required. 5.1 is not supported.') + exit 2 +} + function Write-Usage { @' Usage: cloud-sync-projects.ps1 [-LogPath ] [ ...] Run `engram sync --cloud --project ` once per explicitly named project, in order, continuing through all. Exit 0 if all succeed, 1 if any project sync or logging op fails, 2 on usage error. - -LogPath Append-only log. Overrides default ($ENGRAM_DATA_DIR\ - cloud-sync-projects.log) and ENGRAM_CLOUD_SYNC_LOG. - -Help Show this help. -Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). + -LogPath Append-only log. Overrides default and ENGRAM_CLOUD_SYNC_LOG. + -Help Show this help. +Requires PowerShell 7 (pwsh); 5.1 is not supported. '@ | Out-Host } # Strip -Help from remaining args. $helpRequested = $false $cleanProjects = @() -foreach ($a in $Projects) { - if ($a -in @('-Help', '--help', '-h')) { $helpRequested = $true } else { $cleanProjects += $a } -} +foreach ($a in $Projects) { if ($a -in @('-Help', '--help', '-h')) { $helpRequested = $true } else { $cleanProjects += $a } } $Projects = $cleanProjects if ($helpRequested) { Write-Usage; exit 2 } if ($Projects.Count -eq 0) { - [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: at least one project is required') - [Console]::Error.WriteLine('Run with -Help for usage.') - exit 2 + [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: at least one project is required'); exit 2 } -# Log path precedence: -LogPath > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. +# Log path: -LogPath > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. $resolvedLog = $LogPath if ([string]::IsNullOrEmpty($resolvedLog)) { $resolvedLog = $env:ENGRAM_CLOUD_SYNC_LOG } if ([string]::IsNullOrEmpty($resolvedLog)) { @@ -49,13 +47,10 @@ if ([string]::IsNullOrEmpty($resolvedLog)) { $resolvedLog = Join-Path $dataDir $defaultLogName } $resolvedLog = [System.IO.Path]::GetFullPath($resolvedLog) -$logDir = [System.IO.Path]::GetDirectoryName($resolvedLog) -if (-not (Test-Path -LiteralPath $logDir -PathType Container)) { - [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: log directory does not exist: $logDir"); exit 2 +if (-not (Test-Path -LiteralPath ([System.IO.Path]::GetDirectoryName($resolvedLog)) -PathType Container)) { + [Console]::Error.WriteLine("cloud-sync-projects.ps1: error: log directory does not exist: $resolvedLog"); exit 2 } -# Timestamped [ts] message to BOTH console and the append-only log; returns -# $false on log write failure so callers aggregate failures. function Write-LogLine { param([string]$Message) $line = "[$(Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz')] $Message" @@ -66,11 +61,8 @@ function Write-LogLine { } # Run the verified command for one project via native call operator (safe -# argument tokens), piping combined stdout/stderr through Tee-Object -Append. -# A scoped Continue preference lets ordinary native stderr stream without -# aborting a successful command; Tee-Object -ErrorAction Stop makes log write -# failures terminating. $LASTEXITCODE captured before any later native command. -# Returns the engram exit, or -1 if invoke/tee/logging failed. +# argument tokens), Tee-Object -Append. Scoped Continue lets native stderr +# stream without aborting. $LASTEXITCODE captured before any later native cmd. function Invoke-Project { param([string]$Project) if (-not (Write-LogLine "project START project=$Project")) { return -1 } diff --git a/tools/cloud-sync-projects.sh b/tools/cloud-sync-projects.sh index 5d3c05a5c..d53f77a43 100755 --- a/tools/cloud-sync-projects.sh +++ b/tools/cloud-sync-projects.sh @@ -1,10 +1,7 @@ #!/usr/bin/env bash # Scheduled explicit cloud sync wrapper — ALTERNATIVE to native autosync. # Runs `engram sync --cloud --project ` once per explicitly named -# project, continuing through all; nonzero if any project or logging op fails. -# Choose ONE mode: native autosync (ENGRAM_CLOUD_AUTOSYNC=1, recommended) OR -# this wrapper — running both creates redundant overlapping sync attempts. -# Projects are never inferred from cwd or an env var. Exit: 0 ok, 1 fail, 2 usage. +# project. Choose ONE mode: native autosync (recommended) OR this wrapper. PROG_NAME="cloud-sync-projects.sh" DEFAULT_LOG_NAME="cloud-sync-projects.log" @@ -12,17 +9,12 @@ DEFAULT_LOG_NAME="cloud-sync-projects.log" usage() { cat <<'USAGE' Usage: cloud-sync-projects.sh [--log ] [ ...] - Run `engram sync --cloud --project ` once per explicitly named project, in order, continuing through all. Exit 0 if all succeed, 1 if any project sync or logging op fails, 2 on usage error. - - --log Append-only log. Overrides default ($ENGRAM_DATA_DIR/ - cloud-sync-projects.log) and ENGRAM_CLOUD_SYNC_LOG. + --log Append-only log. Overrides default and ENGRAM_CLOUD_SYNC_LOG. -h, --help Show this help. - Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). -Projects are never inferred from cwd or an env var. USAGE } @@ -53,8 +45,7 @@ case "$log_path" in /*) ;; *) log_path="$PWD/$log_path" ;; esac # absolute log_dir="$(dirname "$log_path")" [ -d "$log_dir" ] || { printf '%s: error: log directory does not exist: %s\n' "$PROG_NAME" "$log_dir" >&2; exit 2; } -# Timestamped [ts] message to BOTH console and the append-only log; returns -# nonzero on log write failure. +# Timestamped [ts] message to BOTH console and the append-only log. logline() { local ts; ts="$(date '+%Y-%m-%dT%H:%M:%S%z')" || return 1 printf '[%s] %s\n' "$ts" "$*" >>"$log_path" || return 1 @@ -62,7 +53,6 @@ logline() { } # Run the verified command for one project, tee output live to log and console. -# Returns the engram exit status, or 1 if tee/logging failed. Never hides failures. run_project() { local proj="$1" rc tee_rc local -a statuses diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go index 1385dd94b..1c50ec7a3 100644 --- a/tools/cloud_sync_projects_test.go +++ b/tools/cloud_sync_projects_test.go @@ -11,9 +11,7 @@ import ( ) // Deterministic wrapper tests with a fake `engram` (no network, no real data -// dir). Bash runs non-Windows; PowerShell runs Windows when available; -// otherwise skipped. Covers: success+durable capture, partial failure -// aggregate 1, missing args usage 2, space-containing project args. +// dir). Bash runs non-Windows; pwsh runs Windows; otherwise skipped. func wrapperAbs(t *testing.T, name string) string { t.Helper() abs, err := filepath.Abs(name) @@ -34,8 +32,8 @@ func assertContains(t *testing.T, label, out string, wants ...string) { } } -// fakeEngram writes a fake `engram` to dir that echoes stdout+stderr, exits 0 -// (failProj exits 1). Windows: .cmd; otherwise: bash script. +// fakeEngram writes a fake `engram` to dir; echoes stdout+stderr, exits 0 +// (failProj exits 1). Windows: .cmd; else: bash script. func fakeEngram(t *testing.T, dir, failProj string) { t.Helper() if runtime.GOOS == "windows" { @@ -73,7 +71,6 @@ type wcase struct { func run(t *testing.T, interp, wrapper, fakeDir, dataDir string, args ...string) (int, string, string) { t.Helper() var cmd *exec.Cmd - // Preserve Windows command resolution while putting fake engram first. env := os.Environ() env = append(env, "ENGRAM_DATA_DIR="+dataDir) if interp == "bash" { @@ -108,11 +105,8 @@ func TestCloudSyncWrappers(t *testing.T) { interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) } } else { - for _, name := range []string{"pwsh", "powershell"} { - if p, err := exec.LookPath(name); err == nil { - interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) - break - } + if p, err := exec.LookPath("pwsh"); err == nil { + interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) } } if len(interps) == 0 { @@ -128,11 +122,10 @@ func TestCloudSyncWrappers(t *testing.T) { wrapper := wrapperAbs(t, it.file) tmp := t.TempDir() fakeDir, dataDir := filepath.Join(tmp, "bin"), filepath.Join(tmp, "data") - if err := os.MkdirAll(fakeDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(dataDir, 0o755); err != nil { - t.Fatal(err) + for _, d := range []string{fakeDir, dataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -157,7 +150,17 @@ func TestCloudSyncWrappers(t *testing.T) { t.Fatalf("exit=%d want 2; output:\n%s", exit, out) } if !strings.Contains(out, "at least one project is required") { - t.Fatalf("missing usage message:\n%s", out) + t.Fatalf("missing usage:\n%s", out) + } + }) + t.Run("InvalidLogExits1", func(t *testing.T) { + fakeEngram(t, fakeDir, "") + exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir, it.flag, dataDir, "alpha") + if exit != 1 { + t.Fatalf("exit=%d want 1; output:\n%s", exit, out) + } + if strings.Contains(out, "stdout: syncing project=alpha") { + t.Fatalf("engram invoked despite invalid log:\n%s", out) } }) }) From bdd5c51cf14ded0d07e509762c28c71ad98e80c8 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 20:04:10 -0300 Subject: [PATCH 4/6] fix(cloud): make wrapper help successful --- DOCS.md | 10 ++++++--- tools/cloud-sync-projects.ps1 | 18 ++++------------ tools/cloud-sync-projects.sh | 19 ++++++----------- tools/cloud_sync_projects_test.go | 35 +++++++++++++++++++++---------- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/DOCS.md b/DOCS.md index 97a786dac..bf2348705 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1478,20 +1478,24 @@ For a step-by-step recovery guide covering `chunk_id does not match payload cont ## Scheduled Explicit Cloud Sync Wrappers -The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running (CI runners, ephemeral agents, cron-only boxes). They run `engram sync --cloud --project ` once per explicitly named project and stop. **Choose ONE mode** — native autosync (above) is recommended when a daemon is feasible; the wrappers are for the no-daemon case. Do **not** run both at once: it produces redundant overlapping sync attempts. Cloud `--all` is intentionally unsupported; projects are never inferred from cwd or an env var. +The wrappers under `tools/` are an **alternative** to native autosync for hosts where you cannot keep `engram serve` running. They run `engram sync --cloud --project ` once per explicitly named project. **Choose ONE mode** -- native autosync (recommended) when a daemon is feasible, OR these wrappers for the no-daemon case. Do **not** run both at once. Cloud `--all` is intentionally unsupported; projects are never inferred from cwd or an env var. + ### Bash: `tools/cloud-sync-projects.sh` ```sh ./tools/cloud-sync-projects.sh my-project my-other-project ./tools/cloud-sync-projects.sh --log /var/log/engram-cloud-sync.log my-project ``` + Exit `0` if all syncs and log succeeded; `1` if any project or logging op failed; `2` on usage error. Default durable log `$ENGRAM_DATA_DIR/cloud-sync-projects.log` (`~/.engram` fallback); override `--log` > `ENGRAM_CLOUD_SYNC_LOG` > default. Status lines go to both timestamped console and log; command stdout+stderr preserved on console and appended to log. Nothing retried or silenced. ### PowerShell: `tools/cloud-sync-projects.ps1` + ```powershell pwsh ./tools/cloud-sync-projects.ps1 my-project my-other-project pwsh ./tools/cloud-sync-projects.ps1 -LogPath C:\logs\engram-cloud-sync.log my-project ``` + Requires PowerShell 7 (`pwsh`); 5.1 is not supported. Same behavior, exit codes, and log defaults as Bash; override `-LogPath` > `ENGRAM_CLOUD_SYNC_LOG` > default. ### Inspecting the last failure @@ -1501,14 +1505,14 @@ Requires PowerShell 7 (`pwsh`); 5.1 is not supported. Same behavior, exit codes, ```sh grep 'project FAILURE' "${ENGRAM_DATA_DIR:-$HOME/.engram}/cloud-sync-projects.log" | tail -n 5 ``` -PowerShell 7 (`$env:ENGRAM_DATA_DIR` or `$HOME/.engram` fallback): ```powershell +# PowerShell 7 ($env:ENGRAM_DATA_DIR or $HOME/.engram fallback) $d = if ($env:ENGRAM_DATA_DIR) { $env:ENGRAM_DATA_DIR } else { Join-Path $HOME '.engram' } Select-String 'project FAILURE' (Join-Path $d 'cloud-sync-projects.log') | Select-Object -Last 5 ``` -Pass the failing project to [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) — the wrappers record and propagate, not interpret or retry. +Pass the failing project to [Engram Cloud Troubleshooting](docs/engram-cloud/troubleshooting.md) -- the wrappers record and propagate, not interpret or retry. ## Cloud Sync Audit Log diff --git a/tools/cloud-sync-projects.ps1 b/tools/cloud-sync-projects.ps1 index 30797dc1f..9cb7ed0b5 100755 --- a/tools/cloud-sync-projects.ps1 +++ b/tools/cloud-sync-projects.ps1 @@ -1,7 +1,3 @@ -# Scheduled explicit cloud sync wrapper (PowerShell 7) — ALTERNATIVE to native -# autosync. Runs `engram sync --cloud --project ` once per explicitly -# named project. Choose ONE mode: native autosync (recommended) OR this wrapper. - [CmdletBinding()] param( [string]$LogPath, @@ -20,26 +16,23 @@ if ($PSVersionTable.PSVersion.Major -lt 7) { function Write-Usage { @' Usage: cloud-sync-projects.ps1 [-LogPath ] [ ...] -Run `engram sync --cloud --project ` once per explicitly named project, -in order, continuing through all. Exit 0 if all succeed, 1 if any project sync -or logging op fails, 2 on usage error. - -LogPath Append-only log. Overrides default and ENGRAM_CLOUD_SYNC_LOG. +Run `engram sync --cloud --project ` once per explicitly named project. +Exit 0 if all succeed, 1 if any project/log op fails, 2 on usage error. + -LogPath Overrides default and ENGRAM_CLOUD_SYNC_LOG. -Help Show this help. Requires PowerShell 7 (pwsh); 5.1 is not supported. '@ | Out-Host } -# Strip -Help from remaining args. $helpRequested = $false $cleanProjects = @() foreach ($a in $Projects) { if ($a -in @('-Help', '--help', '-h')) { $helpRequested = $true } else { $cleanProjects += $a } } $Projects = $cleanProjects -if ($helpRequested) { Write-Usage; exit 2 } +if ($helpRequested) { Write-Usage; exit 0 } if ($Projects.Count -eq 0) { [Console]::Error.WriteLine('cloud-sync-projects.ps1: error: at least one project is required'); exit 2 } -# Log path: -LogPath > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. $resolvedLog = $LogPath if ([string]::IsNullOrEmpty($resolvedLog)) { $resolvedLog = $env:ENGRAM_CLOUD_SYNC_LOG } if ([string]::IsNullOrEmpty($resolvedLog)) { @@ -60,9 +53,6 @@ function Write-LogLine { return $true } -# Run the verified command for one project via native call operator (safe -# argument tokens), Tee-Object -Append. Scoped Continue lets native stderr -# stream without aborting. $LASTEXITCODE captured before any later native cmd. function Invoke-Project { param([string]$Project) if (-not (Write-LogLine "project START project=$Project")) { return -1 } diff --git a/tools/cloud-sync-projects.sh b/tools/cloud-sync-projects.sh index d53f77a43..f8612ad4b 100755 --- a/tools/cloud-sync-projects.sh +++ b/tools/cloud-sync-projects.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# Scheduled explicit cloud sync wrapper — ALTERNATIVE to native autosync. -# Runs `engram sync --cloud --project ` once per explicitly named -# project. Choose ONE mode: native autosync (recommended) OR this wrapper. +set -uo pipefail PROG_NAME="cloud-sync-projects.sh" DEFAULT_LOG_NAME="cloud-sync-projects.log" @@ -9,22 +7,20 @@ DEFAULT_LOG_NAME="cloud-sync-projects.log" usage() { cat <<'USAGE' Usage: cloud-sync-projects.sh [--log ] [ ...] -Run `engram sync --cloud --project ` once per explicitly named project, -in order, continuing through all. Exit 0 if all succeed, 1 if any project sync -or logging op fails, 2 on usage error. - --log Append-only log. Overrides default and ENGRAM_CLOUD_SYNC_LOG. +Run `engram sync --cloud --project ` once per explicitly named project. +Exit 0 if all succeed, 1 if any project/log op fails, 2 on usage error. + --log Overrides default and ENGRAM_CLOUD_SYNC_LOG. -h, --help Show this help. Env: ENGRAM_DATA_DIR (defaults to ~/.engram); ENGRAM_CLOUD_SYNC_LOG (log override). USAGE } -die_usage() { printf '%s: error: %s\n' "$PROG_NAME" "$*" >&2; printf 'Run with --help for usage.\n' >&2; exit 2; } - +die_usage() { printf '%s: error: %s\n' "$PROG_NAME" "$*" >&2; exit 2; } log_path="" projects=() while [ $# -gt 0 ]; do case "$1" in - -h|--help) usage; exit 2 ;; + -h|--help) usage; exit 0 ;; --log) [ $# -ge 2 ] || die_usage "--log requires a path argument"; log_path="$2"; shift 2 ;; --log=*) log_path="${1#--log=}"; [ -n "$log_path" ] || die_usage "--log requires a non-empty path"; shift ;; --) shift; while [ $# -gt 0 ]; do projects+=("$1"); shift; done ;; @@ -35,7 +31,6 @@ done [ "${#projects[@]}" -gt 0 ] || die_usage "at least one project is required" -# Log path precedence: --log > ENGRAM_CLOUD_SYNC_LOG > ENGRAM_DATA_DIR default. [ -z "$log_path" ] && log_path="${ENGRAM_CLOUD_SYNC_LOG:-}" if [ -z "$log_path" ]; then log_path="${ENGRAM_DATA_DIR:-$HOME/.engram}/$DEFAULT_LOG_NAME" @@ -45,14 +40,12 @@ case "$log_path" in /*) ;; *) log_path="$PWD/$log_path" ;; esac # absolute log_dir="$(dirname "$log_path")" [ -d "$log_dir" ] || { printf '%s: error: log directory does not exist: %s\n' "$PROG_NAME" "$log_dir" >&2; exit 2; } -# Timestamped [ts] message to BOTH console and the append-only log. logline() { local ts; ts="$(date '+%Y-%m-%dT%H:%M:%S%z')" || return 1 printf '[%s] %s\n' "$ts" "$*" >>"$log_path" || return 1 printf '[%s] %s\n' "$ts" "$*" } -# Run the verified command for one project, tee output live to log and console. run_project() { local proj="$1" rc tee_rc local -a statuses diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go index 1c50ec7a3..d28e931b6 100644 --- a/tools/cloud_sync_projects_test.go +++ b/tools/cloud_sync_projects_test.go @@ -10,8 +10,7 @@ import ( "testing" ) -// Deterministic wrapper tests with a fake `engram` (no network, no real data -// dir). Bash runs non-Windows; pwsh runs Windows; otherwise skipped. +// Wrapper tests with a fake `engram`. Bash non-Windows; pwsh Windows. func wrapperAbs(t *testing.T, name string) string { t.Helper() abs, err := filepath.Abs(name) @@ -32,8 +31,6 @@ func assertContains(t *testing.T, label, out string, wants ...string) { } } -// fakeEngram writes a fake `engram` to dir; echoes stdout+stderr, exits 0 -// (failProj exits 1). Windows: .cmd; else: bash script. func fakeEngram(t *testing.T, dir, failProj string) { t.Helper() if runtime.GOOS == "windows" { @@ -81,21 +78,20 @@ func run(t *testing.T, interp, wrapper, fakeDir, dataDir string, args ...string) env = append(env, "USERPROFILE="+t.TempDir()) } for i, e := range env { - key, value, ok := strings.Cut(e, "=") - if ok && strings.EqualFold(key, "PATH") { - env[i] = "PATH=" + fakeDir + string(os.PathListSeparator) + value + if k, v, ok := strings.Cut(e, "="); ok && strings.EqualFold(k, "PATH") { + env[i] = "PATH=" + fakeDir + string(os.PathListSeparator) + v break } } cmd.Env = env out, err := cmd.CombinedOutput() - exit := 0 if exitErr, ok := err.(*exec.ExitError); ok { - exit = exitErr.ExitCode() - } else if err != nil { + return exitErr.ExitCode(), string(out), filepath.Join(dataDir, "cloud-sync-projects.log") + } + if err != nil { t.Fatalf("run %s: %v; output:\n%s", interp, err, string(out)) } - return exit, string(out), filepath.Join(dataDir, "cloud-sync-projects.log") + return 0, string(out), filepath.Join(dataDir, "cloud-sync-projects.log") } func TestCloudSyncWrappers(t *testing.T) { type interp struct{ name, file, flag string } @@ -163,6 +159,23 @@ func TestCloudSyncWrappers(t *testing.T) { t.Fatalf("engram invoked despite invalid log:\n%s", out) } }) + var helpFlags []string + if it.file == "cloud-sync-projects.sh" { + helpFlags = []string{"-h", "--help"} + } else { + helpFlags = []string{"-Help", "--help", "-h"} + } + for _, hf := range helpFlags { + t.Run("HelpExits0_"+hf, func(t *testing.T) { + exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir, hf) + if exit != 0 { + t.Fatalf("exit=%d want 0; output:\n%s", exit, out) + } + if !strings.Contains(out, "Usage:") { + t.Fatalf("missing Usage text:\n%s", out) + } + }) + } }) } } From 783815c8e0209d6db28831943fb529230dc361ee Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 21:11:41 -0300 Subject: [PATCH 5/6] test(cloud): cover wrapper configuration paths --- tools/cloud_sync_projects_test.go | 186 +++++++++++++++--------------- 1 file changed, 93 insertions(+), 93 deletions(-) diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go index d28e931b6..9224fc9a6 100644 --- a/tools/cloud_sync_projects_test.go +++ b/tools/cloud_sync_projects_test.go @@ -10,16 +10,14 @@ import ( "testing" ) -// Wrapper tests with a fake `engram`. Bash non-Windows; pwsh Windows. +// Wrapper tests with a fake `engram`: bash on non-Windows, pwsh on Windows; +// PowerShell 5.1 is rejected in a separate Windows-only subtest. func wrapperAbs(t *testing.T, name string) string { t.Helper() abs, err := filepath.Abs(name) if err != nil { t.Fatal(err) } - if _, err := os.Stat(abs); err != nil { - t.Fatalf("wrapper not found at %s: %v", abs, err) - } return abs } func assertContains(t *testing.T, label, out string, wants ...string) { @@ -38,61 +36,62 @@ func fakeEngram(t *testing.T, dir, failProj string) { if failProj != "" { body += "if \"%PROJ%\"==\"" + failProj + "\" (echo fake: forced failure for %PROJ% 1>&2 & exit 1)\r\n" } - body += "exit 0\r\n" - if err := os.WriteFile(filepath.Join(dir, "engram.cmd"), []byte(body), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(dir, "engram.cmd"), []byte(body+"exit 0\r\n"), 0o755); err != nil { t.Fatal(err) } return } - s := `#!/usr/bin/env bash -proj=""; while [ $# -gt 0 ]; do case "$1" in --project) proj="$2"; shift 2 ;; *) shift ;; esac; done -printf 'stdout: syncing project=%s\n' "$proj"; printf 'stderr: project=%s\n' "$proj" >&2 -` + s := "#!/usr/bin/env bash\nproj=\"\"; while [ $# -gt 0 ]; do case \"$1\" in --project) proj=\"$2\"; shift 2 ;; *) shift ;; esac; done\nprintf 'stdout: syncing project=%s\\n' \"$proj\"; printf 'stderr: project=%s\\n' \"$proj\" >&2\n" if failProj != "" { s += fmt.Sprintf("if [ \"$proj\" = %q ]; then echo \"fake: forced failure for $proj\" >&2; exit 1; fi\n", failProj) } - s += "exit 0\n" - if err := os.WriteFile(filepath.Join(dir, "engram"), []byte(s), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(dir, "engram"), []byte(s+"exit 0\n"), 0o755); err != nil { t.Fatal(err) } } -type wcase struct { - name string - projects []string - failProj string - wantExit int - wantIn, wantLog []string -} - -func run(t *testing.T, interp, wrapper, fakeDir, dataDir string, args ...string) (int, string, string) { +// run: controlled env (ENGRAM_CLOUD_SYNC_LOG removed case-insensitively for Windows, add appended, fakeDir on PATH); never returns a hard-coded log path. +func run(t *testing.T, interp, wrapper, fakeDir string, add, args []string) (int, string) { t.Helper() - var cmd *exec.Cmd - env := os.Environ() - env = append(env, "ENGRAM_DATA_DIR="+dataDir) - if interp == "bash" { - cmd = exec.Command("bash", append([]string{wrapper}, args...)...) - env = append(env, "HOME="+t.TempDir()) - } else { - cmd = exec.Command(interp, append([]string{"-NoProfile", "-File", wrapper}, args...)...) - env = append(env, "USERPROFILE="+t.TempDir()) - } - for i, e := range env { - if k, v, ok := strings.Cut(e, "="); ok && strings.EqualFold(k, "PATH") { - env[i] = "PATH=" + fakeDir + string(os.PathListSeparator) + v - break + env := []string{} + for _, e := range os.Environ() { + k, v, ok := strings.Cut(e, "=") + if !ok || strings.EqualFold(k, "ENGRAM_CLOUD_SYNC_LOG") || strings.EqualFold(k, "ENGRAM_DATA_DIR") { + continue + } + if fakeDir != "" && strings.EqualFold(k, "PATH") { + e = "PATH=" + fakeDir + string(os.PathListSeparator) + v } + env = append(env, e) } + env = append(env, add...) + argv := append([]string{wrapper}, args...) + if interp != "bash" { + argv = append([]string{"-NoProfile", "-File", wrapper}, args...) + } + cmd := exec.Command(interp, argv...) cmd.Env = env out, err := cmd.CombinedOutput() if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode(), string(out), filepath.Join(dataDir, "cloud-sync-projects.log") + return exitErr.ExitCode(), string(out) } if err != nil { t.Fatalf("run %s: %v; output:\n%s", interp, err, string(out)) } - return 0, string(out), filepath.Join(dataDir, "cloud-sync-projects.log") + return 0, string(out) } + +// wcase covers log-path precedence, failure aggregation, usage errors, and help in one table; envLog/explicitLog "" = unset/omitted. +type wcase struct { + name string + projects []string + failProj string + envLog, explicitLog string + wantExit int + wantLogPath string + wantIn, wantLog, wantNotIn []string +} + func TestCloudSyncWrappers(t *testing.T) { type interp struct{ name, file, flag string } var interps []interp @@ -100,81 +99,82 @@ func TestCloudSyncWrappers(t *testing.T) { if _, err := exec.LookPath("bash"); err == nil { interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) } - } else { - if p, err := exec.LookPath("pwsh"); err == nil { - interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) - } - } - if len(interps) == 0 { - t.Skip("no native wrapper interpreter available") - } - cases := []wcase{ - {name: "SuccessWithLogOverride", projects: []string{"alpha", "beta"}, wantExit: 0, wantIn: []string{"stdout: syncing project=alpha", "stderr: project=alpha", "project SUCCESS project=alpha exit=0", "wrapper END result=success"}, wantLog: []string{"] project SUCCESS project=alpha exit=0", "stderr: project=alpha"}}, - {name: "PartialFailureContinuesAggregate1", projects: []string{"good", "mid", "tail"}, failProj: "mid", wantExit: 1, wantIn: []string{"project FAILURE project=mid exit=1", "project START project=tail", "wrapper END result=failure overall=1"}, wantLog: []string{"] project FAILURE project=mid exit=1"}}, - {name: "SpaceInProjectName", projects: []string{"my project"}, wantExit: 0, wantIn: []string{"stdout: syncing project=my project", "project SUCCESS project=my project exit=0"}, wantLog: []string{"] project SUCCESS project=my project exit=0"}}, + } else if p, err := exec.LookPath("pwsh"); err == nil { + interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) } for _, it := range interps { t.Run(it.file, func(t *testing.T) { wrapper := wrapperAbs(t, it.file) tmp := t.TempDir() fakeDir, dataDir := filepath.Join(tmp, "bin"), filepath.Join(tmp, "data") - for _, d := range []string{fakeDir, dataDir} { - if err := os.MkdirAll(d, 0o755); err != nil { - t.Fatal(err) - } + os.MkdirAll(fakeDir, 0o755) + os.MkdirAll(dataDir, 0o755) + defLog := filepath.Join(dataDir, "cloud-sync-projects.log") + envLog, envLogUnused, explicitLog, pfLog := filepath.Join(tmp, "env.log"), filepath.Join(tmp, "env-unused.log"), filepath.Join(tmp, "explicit.log"), filepath.Join(tmp, "pf.log") + cases := []wcase{ + {name: "DefaultLogPath", projects: []string{"alpha"}, wantExit: 0, wantLogPath: defLog, wantIn: []string{"stdout: syncing project=alpha", "stderr: project=alpha", "project SUCCESS project=alpha exit=0"}, wantLog: []string{"] project SUCCESS project=alpha exit=0", "stderr: project=alpha"}}, + {name: "EnvLogOverride", projects: []string{"beta"}, envLog: envLog, wantExit: 0, wantLogPath: envLog, wantIn: []string{"stdout: syncing project=beta", "project SUCCESS project=beta exit=0"}, wantLog: []string{"] project SUCCESS project=beta exit=0"}}, + {name: "ExplicitLogPrecedence", projects: []string{"gamma"}, envLog: envLogUnused, explicitLog: explicitLog, wantExit: 0, wantLogPath: explicitLog, wantIn: []string{"stdout: syncing project=gamma", "project SUCCESS project=gamma exit=0"}, wantLog: []string{"] project SUCCESS project=gamma exit=0"}}, + {name: "PartialFailureContinuesAggregate1", projects: []string{"good", "mid", "tail"}, failProj: "mid", envLog: pfLog, wantExit: 1, wantLogPath: pfLog, wantIn: []string{"project FAILURE project=mid exit=1", "project START project=tail", "wrapper END result=failure overall=1"}, wantLog: []string{"] project FAILURE project=mid exit=1"}}, + {name: "SpaceInProjectName", projects: []string{"my project"}, wantExit: 0, wantLogPath: defLog, wantIn: []string{"stdout: syncing project=my project", "project SUCCESS project=my project exit=0"}, wantLog: []string{"] project SUCCESS project=my project exit=0"}}, + {name: "MissingArgsUsage2", wantExit: 2, wantIn: []string{"at least one project is required"}}, + {name: "InvalidLogExits1", projects: []string{"alpha"}, explicitLog: dataDir, wantExit: 1, wantNotIn: []string{"stdout: syncing project=alpha"}}, + } + if it.file == "cloud-sync-projects.sh" { + cases = append(cases, wcase{name: "HelpExits0_-h", projects: []string{"-h"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_--help", projects: []string{"--help"}, wantIn: []string{"Usage:"}}) + } else { + cases = append(cases, wcase{name: "HelpExits0_-Help", projects: []string{"-Help"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_--help", projects: []string{"--help"}, wantIn: []string{"Usage:"}}, wcase{name: "HelpExits0_-h", projects: []string{"-h"}, wantIn: []string{"Usage:"}}) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { fakeEngram(t, fakeDir, tc.failProj) - args := append([]string{it.flag, filepath.Join(dataDir, "cloud-sync-projects.log")}, tc.projects...) - exit, out, logPath := run(t, it.name, wrapper, fakeDir, dataDir, args...) + add := []string{"ENGRAM_DATA_DIR=" + dataDir} + if tc.envLog != "" { + add = append(add, "ENGRAM_CLOUD_SYNC_LOG="+tc.envLog) + } + args := tc.projects + if tc.explicitLog != "" { + args = append([]string{it.flag, tc.explicitLog}, args...) + } + exit, out := run(t, it.name, wrapper, fakeDir, add, args) if exit != tc.wantExit { t.Fatalf("exit=%d want %d; output:\n%s", exit, tc.wantExit, out) } assertContains(t, "console", out, tc.wantIn...) - if lb, rerr := os.ReadFile(logPath); rerr != nil { - t.Fatalf("read log: %v", rerr) - } else { + for _, n := range tc.wantNotIn { + if strings.Contains(out, n) { + t.Fatalf("console unexpectedly contains %q:\n%s", n, out) + } + } + if tc.wantLogPath != "" { + lb, rerr := os.ReadFile(tc.wantLogPath) + if rerr != nil { + t.Fatalf("read expected log %s: %v", tc.wantLogPath, rerr) + } assertContains(t, "log", string(lb), tc.wantLog...) + if tc.explicitLog != "" { + if _, err := os.Stat(tc.envLog); err == nil { + t.Fatalf("env log %s should not exist when explicit override used", tc.envLog) + } + } } }) } - t.Run("MissingArgsUsage2", func(t *testing.T) { - fakeEngram(t, fakeDir, "") - exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir) - if exit != 2 { - t.Fatalf("exit=%d want 2; output:\n%s", exit, out) - } - if !strings.Contains(out, "at least one project is required") { - t.Fatalf("missing usage:\n%s", out) - } - }) - t.Run("InvalidLogExits1", func(t *testing.T) { - fakeEngram(t, fakeDir, "") - exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir, it.flag, dataDir, "alpha") - if exit != 1 { - t.Fatalf("exit=%d want 1; output:\n%s", exit, out) - } - if strings.Contains(out, "stdout: syncing project=alpha") { - t.Fatalf("engram invoked despite invalid log:\n%s", out) - } - }) - var helpFlags []string - if it.file == "cloud-sync-projects.sh" { - helpFlags = []string{"-h", "--help"} - } else { - helpFlags = []string{"-Help", "--help", "-h"} + }) + } + // PowerShell 5.1 rejection (Windows-only; separate from the pwsh-only matrix): powershell.exe must exit 2 with the exact PS7-required diagnostic. + if runtime.GOOS == "windows" { + t.Run("PS5Rejection", func(t *testing.T) { + ps, err := exec.LookPath("powershell.exe") + if err != nil { + t.Skip("powershell.exe not available") } - for _, hf := range helpFlags { - t.Run("HelpExits0_"+hf, func(t *testing.T) { - exit, out, _ := run(t, it.name, wrapper, fakeDir, dataDir, hf) - if exit != 0 { - t.Fatalf("exit=%d want 0; output:\n%s", exit, out) - } - if !strings.Contains(out, "Usage:") { - t.Fatalf("missing Usage text:\n%s", out) - } - }) + exit, out := run(t, ps, wrapperAbs(t, "cloud-sync-projects.ps1"), "", nil, []string{"my-project"}) + if exit != 2 { + t.Fatalf("exit=%d want 2; output:\n%s", exit, out) + } + if want := "PowerShell 7 (pwsh) is required"; !strings.Contains(out, want) { + t.Fatalf("missing %q diagnostic:\n%s", want, out) } }) } From f4e88fc13c7800a67893d75b30ac1540f3791a04 Mon Sep 17 00:00:00 2001 From: Juan Barbat Date: Wed, 12 Aug 2026 21:23:35 -0300 Subject: [PATCH 6/6] test(cloud): fail fast on missing prerequisites --- tools/cloud_sync_projects_test.go | 35 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/tools/cloud_sync_projects_test.go b/tools/cloud_sync_projects_test.go index 9224fc9a6..ae563a19e 100644 --- a/tools/cloud_sync_projects_test.go +++ b/tools/cloud_sync_projects_test.go @@ -10,8 +10,6 @@ import ( "testing" ) -// Wrapper tests with a fake `engram`: bash on non-Windows, pwsh on Windows; -// PowerShell 5.1 is rejected in a separate Windows-only subtest. func wrapperAbs(t *testing.T, name string) string { t.Helper() abs, err := filepath.Abs(name) @@ -20,6 +18,7 @@ func wrapperAbs(t *testing.T, name string) string { } return abs } + func assertContains(t *testing.T, label, out string, wants ...string) { t.Helper() for _, w := range wants { @@ -28,7 +27,6 @@ func assertContains(t *testing.T, label, out string, wants ...string) { } } } - func fakeEngram(t *testing.T, dir, failProj string) { t.Helper() if runtime.GOOS == "windows" { @@ -49,8 +47,6 @@ func fakeEngram(t *testing.T, dir, failProj string) { t.Fatal(err) } } - -// run: controlled env (ENGRAM_CLOUD_SYNC_LOG removed case-insensitively for Windows, add appended, fakeDir on PATH); never returns a hard-coded log path. func run(t *testing.T, interp, wrapper, fakeDir string, add, args []string) (int, string) { t.Helper() env := []string{} @@ -81,25 +77,25 @@ func run(t *testing.T, interp, wrapper, fakeDir string, add, args []string) (int return 0, string(out) } -// wcase covers log-path precedence, failure aggregation, usage errors, and help in one table; envLog/explicitLog "" = unset/omitted. type wcase struct { - name string - projects []string - failProj string - envLog, explicitLog string - wantExit int - wantLogPath string - wantIn, wantLog, wantNotIn []string + name string + projects []string + failProj, envLog, explicitLog, wantLogPath string + wantExit int + wantIn, wantLog, wantNotIn []string } func TestCloudSyncWrappers(t *testing.T) { type interp struct{ name, file, flag string } var interps []interp if runtime.GOOS != "windows" { - if _, err := exec.LookPath("bash"); err == nil { - interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) + if _, err := exec.LookPath("bash"); err != nil { + t.Fatal("bash is required to test cloud-sync-projects.sh") } - } else if p, err := exec.LookPath("pwsh"); err == nil { + interps = append(interps, interp{"bash", "cloud-sync-projects.sh", "--log"}) + } else if p, err := exec.LookPath("pwsh"); err != nil { + t.Fatal("pwsh is required to test cloud-sync-projects.ps1") + } else { interps = append(interps, interp{p, "cloud-sync-projects.ps1", "-LogPath"}) } for _, it := range interps { @@ -107,8 +103,11 @@ func TestCloudSyncWrappers(t *testing.T) { wrapper := wrapperAbs(t, it.file) tmp := t.TempDir() fakeDir, dataDir := filepath.Join(tmp, "bin"), filepath.Join(tmp, "data") - os.MkdirAll(fakeDir, 0o755) - os.MkdirAll(dataDir, 0o755) + for _, d := range []string{fakeDir, dataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } defLog := filepath.Join(dataDir, "cloud-sync-projects.log") envLog, envLogUnused, explicitLog, pfLog := filepath.Join(tmp, "env.log"), filepath.Join(tmp, "env-unused.log"), filepath.Join(tmp, "explicit.log"), filepath.Join(tmp, "pf.log") cases := []wcase{