# investibook-cli — Auto-Update Design

> Status: Implemented  
> Last updated: 2026-03-11

---

## Overview

Every time `investibook-cli` starts, it silently checks for a newer binary published to the `investibook-build` S3 bucket. If a newer build is found, it downloads the binary, atomically replaces itself on disk, and re-executes with the same arguments — all before the user's command runs. The experience is transparent: the user sees a two-line status message and the command proceeds with the latest binary.

This approach is modelled after well-known Go CLI self-updaters (e.g. `gh`, `flyctl`) but intentionally simplified: no signed manifests, no rollback, no staged rollout — just a single timestamp comparison and a forced replacement.

---

## Design Goals

| Goal | Decision |
|------|----------|
| No manual upgrade steps | Forced auto-update on every invocation |
| Simple freshness signal | Build timestamp (Unix epoch, UTC), not semver — any new CI build is "newer" |
| Safe for dev builds | `BuildTime = "0"` disables the check entirely |
| No startup latency on bad network | Version check has a hard 5 s timeout; errors are silently swallowed |
| Atomic replacement | `os.Rename` on the same filesystem — no partial writes visible to the OS |
| Escape hatch | `INVESTIBOOK_SKIP_UPDATE=1` env var disables the check |

---

## Components

### 1. Build-time injection (`main.go`)

Two variables are injected via `-ldflags` at build time:

```go
var Version   = "dev"  // e.g. "v0.2-6-g992844b" — output of git describe
var BuildTime = "0"   // Unix timestamp (UTC), e.g. "1773215972"
```

A `BuildTime` of `"0"` (the default for `go run` / untagged local builds) completely disables the update check.

**Version string** follows `git describe --tags --always --match 'investibook/cli/v*'` with the `investibook/cli/` prefix stripped:

| git state | `VERSION` example |
|-----------|------------------|
| On a tag | `v1.0.0` |
| 4 commits after a tag | `v1.0.0-4-gabcd1234` |
| Local uncommitted changes | `v1.0.0-dirty` |

**ldflags in `Makefile`:**

```makefile
VERSION    ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's|investibook/cli/||' || echo "dev")
BUILD_TIME ?= $(shell TZ=UTC date +%s)
LDFLAGS   := -s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)
```

---

### 2. Updater package (`internal/updater/updater.go`)

#### Entry point

```go
updater.CheckAndUpdate(buildTime string)
```

Called from two places in the root Cobra command:

- `PersistentPreRun` — runs before every subcommand (e.g. `workspace list`, `chat`), except `version`
- `Run` on the root command itself — runs when `investibook` is invoked with no subcommand

This guarantees one check per invocation regardless of how the binary is called.

#### Flow

```
CheckAndUpdate(localBuildTime)
        │
        ├─ localBuildTime == "0" or ""?  ──► return  (dev build / go run)
        ├─ INVESTIBOOK_SKIP_UPDATE set?  ──► return
        │
        ▼
  fetchVersionInfo()   GET https://investibook-build.s3.amazonaws.com/version.json
        │  timeout: 5s
        ├─ error / unreachable ──► return  (silent, never blocks startup)
        │
        ▼
  isBuildNewer(remote.build_time, localBuildTime)
        │  int64 comparison of Unix timestamps
        ├─ not newer ──► return
        │
        ▼
  print: "🔄  New build available (built: <human-readable>, current: <human-readable>)"
        │
        ▼
  downloadAndReplace(remote.version)
        │  GET .../latest/investibook-<os>-<arch>.tar.gz
        │  timeout: 30s
        ├─ error ──► print warning, return  (continues with old binary)
        │
        ▼
  os.Rename(tmpFile, executablePath)   ← atomic replacement
        │
        ▼
  syscall.Exec(execPath, os.Args, os.Environ())   ← re-exec, process image replaced
```

#### Timestamp formatting

All timestamps shown to the user are formatted as `2006-01-02 15:04:05 UTC` (Go reference time, UTC). Raw Unix integers are never displayed. The helper `formatTS(ts string) string` in the updater package handles this conversion.

#### `version.json` schema

```json
{
  "version":    "v0.2-6-g992844b",
  "build_time": "1773215972"
}
```

Both fields are required. Missing `build_time` causes the check to be skipped (treated as an error).

#### Platform detection

Supported platforms and their archive names:

| `runtime.GOOS` | `runtime.GOARCH` | Archive name |
|----------------|-----------------|______________|
| `linux`        | `amd64`         | `investibook-linux-amd64.tar.gz` |
| `linux`        | `arm64`         | `investibook-linux-arm64.tar.gz` |
| `darwin`       | `arm64`         | `investibook-darwin-arm64.tar.gz` |

Unsupported platforms print a warning and skip the update.

#### Archive layout

Each `.tar.gz` contains a **single file named `investibook`** (no OS/arch suffix). The updater extracts the first regular tar entry it encounters and writes it to a temp file next to the running executable (same filesystem, ensuring `os.Rename` is atomic).

---

### 3. CI / Release workflow (`.github/workflows/investibook-cli-release.yaml`)

Triggered by:
- **Tags** matching `investibook/cli/v*` — full release (build + GitHub Release + S3)
- **Branches** matching `handlerww/**` — dev pipeline test (build + S3, no GitHub Release)

```
push (tag or branch)
      │
      ▼
git describe --tags --always --match 'investibook/cli/v*' | sed 's|investibook/cli/||'
      │  VERSION = e.g. "v1.0.0" (on tag) or "v1.0.0-4-gabcd1234" (on branch)
      ▼
BUILD_TIME = $(TZ=UTC date +%s)
      │
      ▼
make dist-compressed  (VERSION=...  BUILD_TIME=<ts>)
      │  injects both into ldflags
      ├─ investibook-linux-amd64.tar.gz   (contains: investibook)
      ├─ investibook-linux-arm64.tar.gz
      └─ investibook-darwin-arm64.tar.gz
      │
      ▼
AWS S3 upload (OIDC via clinic-ci role, region us-west-2)
      │
      ├─ versioned paths (permanent archive):
      │   ├─ s3://investibook-build/releases/<VERSION>/investibook-linux-amd64.tar.gz
      │   ├─ s3://investibook-build/releases/<VERSION>/investibook-linux-arm64.tar.gz
      │   └─ s3://investibook-build/releases/<VERSION>/investibook-darwin-arm64.tar.gz
      │
      └─ latest paths (overwritten every release — used by auto-updater):
          ├─ s3://investibook-build/latest/investibook-linux-amd64.tar.gz
          ├─ s3://investibook-build/latest/investibook-linux-arm64.tar.gz
          └─ s3://investibook-build/latest/investibook-darwin-arm64.tar.gz
      │
      ▼
Update s3://investibook-build/version.json  ← written LAST
      └─ {"version":"<VERSION>","build_time":"<BUILD_TIME>"}
         Cache-Control: no-cache, no-store
      │
      ▼ (tag only)
GitHub Release  (no binary attachments — release body links to S3 latest/* URLs)
```

`version.json` is **always written last**, after all binaries are in place. This prevents a race where a client reads the freshness signal before the `latest/` binaries are ready.

---

### 4. S3 Bucket configuration

| Setting | Value |
|---------|-------|
| Bucket name | `investibook-build` |
| Region | `us-east-1` |
| Block public ACLs | ✅ enabled |
| Ignore public ACLs | ✅ enabled |
| Block public policy | ❌ disabled (bucket policy applies) |
| Restrict public buckets | ❌ disabled |

**Bucket policy** — `version.json`, `releases/*`, and `latest/*` are publicly readable:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PublicReadCLIDistribution",
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": [
      "arn:aws:s3:::investibook-build/version.json",
      "arn:aws:s3:::investibook-build/releases/*",
      "arn:aws:s3:::investibook-build/latest/*"
    ]
  }]
}
```

The CI role (`clinic-ci`) needs only `s3:PutObject` + `s3:GetObject` on `arn:aws:s3:::investibook-build/*`.

---

## User Experience

### Normal run (already up to date)

```
$ investibook workspace list
ws-1741234567-abc  My Investigation
ws-1741234568-xyz  Example Workspace
```

No output from the updater — fully transparent.

### Update available

```
$ investibook workspace list
🔄  New build available (built: 2026-03-11 09:00:00 UTC, current: 2026-03-11 08:35:09 UTC). Auto-updating...
✅  Updated to v0.2-6-g992844b. Restarting...

ws-1741234567-abc  My Investigation
ws-1741234568-xyz  Example Workspace
```

### No subcommand

```
$ investibook
🔄  New build available (built: 2026-03-11 09:00:00 UTC, current: 2026-03-11 08:35:09 UTC). Auto-updating...
✅  Updated to v0.2-6-g992844b. Restarting...

Usage:
  investibook [command]
...
```

Update check also runs when the binary is invoked with no arguments.

### Version command

```
$ investibook version
investibook-cli v0.2-6-g992844b (built: 2026-03-11 09:00:00 UTC)
```

No update check on the `version` subcommand.

### Dev build (`go run` or `make build` without timestamp)

```
$ go run main.go workspace list
ws-1741234567-abc  My Investigation
```

No update check — `BuildTime` is `"0"`.

### Skip update manually

```bash
INVESTIBOOK_SKIP_UPDATE=1 investibook workspace list
```

---

## Local Testing — Simulating an Update

To trigger the update flow locally without waiting for CI:

```bash
# 1. Check current latest on S3
curl -s https://investibook-build.s3.amazonaws.com/version.json
# e.g. {"version":"v0.2-6-g992844b","build_time":"1773215972"}

# 2. Verify the latest binary is reachable
curl -I https://investibook-build.s3.amazonaws.com/latest/investibook-linux-amd64.tar.gz

# 3. Build with a timestamp older than S3's build_time
BUILD_TIME=1773215960 make build

# 4. Run — will detect the newer S3 build, fetch latest/ binary, and auto-update
./investibook version
```

For normal local development, run `make build` without overriding `BUILD_TIME` — the current time will always be newer than any previously published build, so no update is triggered.

---

## Security Considerations

- Binaries are downloaded over **HTTPS** only (Go's `http.DefaultClient` validates TLS certificates).
- The bucket is **not world-writable** — only the CI role can upload.
- No signature verification on the downloaded binary. This is acceptable for an internal tool distributed over a private-by-default bucket where write access is restricted to CI. If the tool becomes public-facing, add checksum verification (SHA-256 manifest) in a future iteration.
- Path traversal is not possible: the updater replaces only `os.Executable()`, not an arbitrary path.
- The temp file is created in the same directory as the running binary (not `/tmp`), ensuring `os.Rename` is atomic and preventing cross-device moves.

---

## Future Improvements

| Item | Notes |
|------|-------|
| SHA-256 checksum manifest | Upload `checksums.txt` alongside binaries; verify before replacing |
| `--check-update` flag | Print update status without applying it |
| Staged rollout | Write `version-<channel>.json` (e.g. `stable`, `canary`) |
| Windows support | Add `windows-amd64` to matrix; handle `.exe` extension and no `syscall.Exec` |
| Rollback | Keep previous binary as `investibook.bak`; restore on crash |


---

## Design Goals

| Goal | Decision |
|------|----------|
| No manual upgrade steps | Forced auto-update on every invocation |
| Simple freshness signal | Build timestamp (Unix epoch), not semver — any new CI build is "newer" |
| Safe for dev builds | `BuildTime = "0"` disables the check entirely |
| No startup latency on bad network | Version check has a hard 5 s timeout; errors are silently swallowed |
| Atomic replacement | `os.Rename` on the same filesystem — no partial writes visible to the OS |
| Escape hatch | `INVESTIBOOK_SKIP_UPDATE=1` env var disables the check |

---

## Components

### 1. Build-time injection (`main.go`)

Two variables are injected via `-ldflags` at build time:

```go
var Version   = "dev"   // e.g. "v1.2.3" — human-readable tag
var BuildTime = "0"    // Unix timestamp (UTC), e.g. "1741680000"
```

A `BuildTime` of `"0"` (the default for `go run` / untagged local builds) completely disables the update check.

**ldflags used in `Makefile`:**

```makefile
BUILD_TIME ?= $(shell TZ=UTC date +%s)
LDFLAGS := -s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)
```

---

### 2. Updater package (`internal/updater/updater.go`)

#### Entry point

```go
updater.CheckAndUpdate(buildTime string)
```

Called from `PersistentPreRun` in the root Cobra command, so it runs before every subcommand (except `investibook version`).

#### Flow

```
CheckAndUpdate(localBuildTime)
        │
        ├─ localBuildTime == "0" or ""?  ──► return  (dev build)
        ├─ INVESTIBOOK_SKIP_UPDATE set?  ──► return
        │
        ▼
  fetchVersionInfo()   GET https://investibook-build.s3.amazonaws.com/version.json
        │  timeout: 5s
        ├─ error / unreachable ──► return  (silent, never blocks startup)
        │
        ▼
  isBuildNewer(remote.build_time, localBuildTime)
        │  simple int64 comparison
        ├─ not newer ──► return
        │
        ▼
  downloadAndReplace(remote.version)
        │  GET .../latest/investibook-<os>-<arch>.tar.gz
        │  timeout: 30s
        ├─ error ──► print warning, return  (continues with old binary)
        │
        ▼
  os.Rename(tmpFile, executablePath)   ← atomic replacement
        │
        ▼
  syscall.Exec(execPath, os.Args, os.Environ())   ← re-exec, process image replaced
```

#### `version.json` schema

```json
{
  "version":    "v1.2.3",
  "build_time": "1741680000"
}
```

Both fields are required. Missing `build_time` causes the check to be skipped (treated as an error).

#### Platform detection

Supported platforms and their archive names:

| `runtime.GOOS` | `runtime.GOARCH` | Archive name |
|----------------|-----------------|--------------|
| `linux`        | `amd64`         | `investibook-linux-amd64.tar.gz` |
| `linux`        | `arm64`         | `investibook-linux-arm64.tar.gz` |
| `darwin`       | `arm64`         | `investibook-darwin-arm64.tar.gz` |

Unsupported platforms print a warning and skip the update.

#### Archive layout

Each `.tar.gz` contains a **single file named `investibook`** (no OS/arch suffix). The updater extracts the first regular tar entry it encounters and writes it to a temp file next to the running executable (same filesystem, ensuring `os.Rename` is atomic).

---

### 3. CI / Release workflow (`.github/workflows/investibook-cli-release.yaml`)

Triggered by tags matching `investibook/cli/v*`.

```
tag push  investibook/cli/v1.2.3
      │
      ▼
Record build timestamp:  BUILD_TIME=$(TZ=UTC date +%s)
      │
      ▼
make dist-compressed  (VERSION=v1.2.3  BUILD_TIME=<ts>)
      │  injects both into ldflags
      ├─ investibook-linux-amd64.tar.gz   (contains: investibook)
      ├─ investibook-linux-arm64.tar.gz
      └─ investibook-darwin-arm64.tar.gz
      │
      ▼
AWS S3 upload (OIDC via clinic-ci role, region us-west-2)
      │
      ├─ versioned paths (permanent archive):
      │   ├─ s3://investibook-build/releases/v1.2.3/investibook-linux-amd64.tar.gz
      │   ├─ s3://investibook-build/releases/v1.2.3/investibook-linux-arm64.tar.gz
      │   └─ s3://investibook-build/releases/v1.2.3/investibook-darwin-arm64.tar.gz
      │
      └─ latest paths (overwritten every release — used by auto-updater):
          ├─ s3://investibook-build/latest/investibook-linux-amd64.tar.gz
          ├─ s3://investibook-build/latest/investibook-linux-arm64.tar.gz
          └─ s3://investibook-build/latest/investibook-darwin-arm64.tar.gz
      │
      ▼
Update s3://investibook-build/version.json  ← written LAST
      └─ {"version":"v1.2.3","build_time":"<ts>"}
         Cache-Control: no-cache, no-store
      │
      ▼
GitHub Release  (no binary attachments — release body links to S3 latest/* URLs)
```

`version.json` is **always written last**, after all binaries are in place. This prevents a race where a client reads the freshness signal before the `latest/` binaries are ready.

---

### 4. S3 Bucket configuration

| Setting | Value |
|---------|-------|
| Bucket name | `investibook-build` |
| Region | `us-east-1` |
| Block public ACLs | ✅ enabled |
| Ignore public ACLs | ✅ enabled |
| Block public policy | ❌ disabled (bucket policy applies) |
| Restrict public buckets | ❌ disabled |

**Bucket policy** — `version.json`, `releases/*`, and `latest/*` are publicly readable:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PublicReadCLIDistribution",
    "Effect": "Allow",
    "Principal": "*",
    "Action": "s3:GetObject",
    "Resource": [
      "arn:aws:s3:::investibook-build/version.json",
      "arn:aws:s3:::investibook-build/releases/*",
      "arn:aws:s3:::investibook-build/latest/*"
    ]
  }]
}
```

The CI role (`clinic-ci`) needs only `s3:PutObject` + `s3:GetObject` on `arn:aws:s3:::investibook-build/*`.

---

## User Experience

### Normal run (already up to date)

```
$ investibook workspace list
ws-1741234567-abc  My Investigation
ws-1741234568-xyz  Example Workspace
```

No output from the updater — fully transparent.

### Update available

```
$ investibook workspace list
🔄  New build available (built: 2026-03-11 09:00:00 UTC, current: 2026-03-10 14:23:00 UTC). Auto-updating...
✅  Updated to v1.2.4. Restarting...

ws-1741234567-abc  My Investigation
ws-1741234568-xyz  Example Workspace
```

### Version command

```
$ investibook version
investibook-cli v1.2.3 (built: 2026-03-11 08:30:00 UTC)
```

### Dev build

```
$ go run main.go workspace list
ws-1741234567-abc  My Investigation
```

No update check — `BuildTime` is `"0"`.

### Skip update manually

```bash
INVESTIBOOK_SKIP_UPDATE=1 investibook workspace list
```

---

## Security Considerations

- Binaries are downloaded over **HTTPS** only (Go's `http.DefaultClient` validates TLS certificates).
- The bucket is **not world-writable** — only the CI role can upload.
- No signature verification on the downloaded binary. This is acceptable for an internal tool distributed over a private-by-default bucket where write access is restricted to CI. If the tool becomes public-facing, add checksum verification (SHA-256 manifest) in a future iteration.
- Path traversal is not possible: the updater replaces only `os.Executable()`, not an arbitrary path.
- The temp file is created in the same directory as the running binary (not `/tmp`), ensuring `os.Rename` is atomic and preventing cross-device moves.

---

## Future Improvements

| Item | Notes |
|------|-------|
| SHA-256 checksum manifest | Upload `checksums.txt` alongside binaries; verify before replacing |
| `--check-update` flag | Print update status without applying it |
| Staged rollout | Write `version-<channel>.json` (e.g. `stable`, `canary`) |
| Windows support | Add `windows-amd64` to matrix; handle `.exe` extension and no `syscall.Exec` |
| Rollback | Keep previous binary as `investibook.bak`; restore on crash |
