Skip to content

Commit 27652f3

Browse files
fix(upgrade): use install receipts for upgrade detection - manual vs package manager
1 parent 2988f94 commit 27652f3

8 files changed

Lines changed: 445 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Earlier entries pre-date this convention and only carry their version's compare
1515

1616
## [Unreleased]
1717

18+
### Changed
19+
20+
- Add direct-installer provenance receipts (`agora.install.json`) and make `agora upgrade` use receipt-first install-method detection before falling back to package-manager path inference.
21+
1822
## [0.1.8] - 2026-04-30
1923

2024
### Fixed

docs/automation.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,55 @@ Example:
819819

820820
Returns the updated config object with the same shape as `config get`. Safe branch fields are the same as `config get`.
821821

822+
### `upgrade`
823+
824+
Example:
825+
826+
```bash
827+
./agora upgrade --json
828+
./agora upgrade --check --json
829+
./agora --upgrade-check --json
830+
```
831+
832+
Required `data` fields:
833+
- `action`
834+
`upgrade` for the subcommand and `upgrade-check` for the root `--upgrade-check` pseudo-command.
835+
- `installMethod`
836+
One of `installer`, `npm`, `homebrew`, `scoop`, `chocolatey`, `winget`, or `unknown`.
837+
- `installSource`
838+
`install.sh` / `install.ps1` when read from a valid direct-installer receipt, `path` when inferred from the resolved executable path, or `fallback` when no durable source was available.
839+
- `installedPath`
840+
Resolved executable path used for receipt validation and path inference.
841+
- `upgradeCommand`
842+
The user-facing command for the owning install channel.
843+
- `command`
844+
Backwards-compatible alias for `upgradeCommand`.
845+
- `status`
846+
One of `manual`, `dry-run`, `up-to-date`, or `upgraded`.
847+
848+
Optional fields:
849+
- `receiptPath`
850+
Path to the validated `agora.install.json` receipt when present.
851+
- `currentVersion`
852+
Version of the running binary for `agora upgrade`.
853+
- `latestVersion`
854+
Latest resolved release version when the command resolves GitHub release metadata.
855+
- `version`
856+
Structured version payload for `agora --upgrade-check`.
857+
- `receiptWarning`
858+
Nonfatal warning when a direct-installer self-update succeeded but the CLI could not refresh `agora.install.json`.
859+
860+
Upgrade behavior:
861+
- direct-installer installs (`installMethod: "installer"`) self-update in place after downloading the GitHub release archive and verifying it against `checksums.txt`
862+
- package-manager installs return `status: "manual"` with the owning package-manager command; agents should run that command only after user approval
863+
- `unknown` means the CLI could not verify the install channel, usually because the binary is a development/test build
864+
865+
Safe branch fields:
866+
- `installMethod`
867+
- `installSource`
868+
- `upgradeCommand`
869+
- `status`
870+
822871
## Human vs Machine Output
823872

824873
- Pretty output is optimized for humans.

docs/install.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ Both installers refuse to overwrite an `agora` binary that came from a package m
135135
| Chocolatey (Windows) | `$env:ChocolateyInstall` or path contains `\chocolatey\bin\` | `choco upgrade agora` |
136136
| winget (Windows) | path contains `\WinGet\Packages\` | `winget upgrade Agora.Cli` |
137137

138+
### Install receipt and upgrades
139+
140+
Direct installer runs (`install.sh` and `install.ps1`) write `agora.install.json` next to the installed binary after the binary has been downloaded, checksum-verified, installed, and smoke-tested. Direct self-updates refresh the same receipt after replacing the binary. The receipt records the install method, install path, version, timestamp, and installer source so `agora upgrade` can choose the right update path without relying on shell environment variables.
141+
142+
`agora upgrade` uses this order:
143+
144+
1. Read and validate the adjacent `agora.install.json` receipt.
145+
2. Fall back to the resolved binary path for package-manager installs (`node_modules`, Homebrew `Cellar`, Scoop, Chocolatey, or winget paths).
146+
3. Fall back to the direct installer path when the binary is named `agora` / `agora.exe` and no package-manager path is detected.
147+
4. Report `unknown` for development/test binaries where the install method cannot be verified.
148+
149+
Direct-installer installs self-update in place. Package-manager installs print the package-manager command and exit successfully so the package manager remains the owner of the installed files.
150+
138151
## Build From Source
139152

140153
Requirements:

install.ps1

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ $EXIT_NETWORK = 5
3737
$EXIT_CHECKSUM = 6
3838
$EXIT_INSTALL = 7
3939
$EXIT_VERIFY = 8
40+
$InstallReceiptFileName = 'agora.install.json'
4041

4142
$GitHubApiUrl = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' }
4243
$ReleasesDownloadBaseUrl = if ($env:RELEASES_DOWNLOAD_BASE_URL) { $env:RELEASES_DOWNLOAD_BASE_URL } else { "https://github.com/$GitHubRepo/releases/download" }
@@ -196,6 +197,23 @@ function Verify-Binary {
196197
}
197198
}
198199

200+
function Write-InstallReceipt {
201+
param(
202+
[Parameter(Mandatory = $true)][string]$BinaryPath
203+
)
204+
$receiptPath = Join-Path (Split-Path -Parent $BinaryPath) $InstallReceiptFileName
205+
$receipt = [ordered]@{
206+
schemaVersion = 1
207+
tool = 'agora'
208+
installMethod = 'installer'
209+
installPath = $BinaryPath
210+
version = $Version
211+
installedAt = [DateTimeOffset]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
212+
source = 'install.ps1'
213+
}
214+
$receipt | ConvertTo-Json -Depth 3 | Set-Content -Path $receiptPath -Encoding UTF8
215+
}
216+
199217
function Get-InstalledVersion {
200218
param([string]$Path)
201219
if (-not (Test-Path -LiteralPath $Path)) {
@@ -356,6 +374,7 @@ try {
356374
Move-Item -LiteralPath $tempDestinationBinary -Destination $destinationBinary -Force
357375

358376
Verify-Binary -Path $destinationBinary
377+
Write-InstallReceipt -BinaryPath $destinationBinary
359378
Write-Info "Installed agora to $destinationBinary"
360379

361380
$resolved = Get-Command agora -ErrorAction SilentlyContinue

install.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ if (set -o pipefail) >/dev/null 2>&1; then
2323
fi
2424

2525
INSTALLER_VERSION="2026.04.27"
26+
INSTALL_RECEIPT_FILE="agora.install.json"
2627

2728
# ---- Defaults --------------------------------------------------------------
2829
GITHUB_REPO="${GITHUB_REPO:-AgoraIO/cli}"
@@ -600,6 +601,38 @@ install_binary() {
600601
mv -f "$temp_dest" "$final_dest"
601602
}
602603

604+
json_escape() {
605+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
606+
}
607+
608+
write_install_receipt() {
609+
final_dest=$1
610+
receipt_path="${INSTALL_DIR}/${INSTALL_RECEIPT_FILE}"
611+
receipt_tmp="${TMP}/${INSTALL_RECEIPT_FILE}"
612+
installed_at=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
613+
614+
{
615+
printf '{\n'
616+
printf ' "schemaVersion": 1,\n'
617+
printf ' "tool": "agora",\n'
618+
printf ' "installMethod": "installer",\n'
619+
printf ' "installPath": "%s",\n' "$(json_escape "$final_dest")"
620+
printf ' "version": "%s",\n' "$(json_escape "$VERSION")"
621+
printf ' "installedAt": "%s",\n' "$(json_escape "$installed_at")"
622+
printf ' "source": "install.sh"\n'
623+
printf '}\n'
624+
} >"$receipt_tmp"
625+
626+
if [ "$USE_SUDO" = "1" ]; then
627+
run_elevated cp "$receipt_tmp" "$receipt_path"
628+
run_elevated chmod 644 "$receipt_path" || true
629+
return
630+
fi
631+
632+
cp "$receipt_tmp" "$receipt_path"
633+
chmod 644 "$receipt_path" || true
634+
}
635+
603636
extract_archive() {
604637
archive_path=$1
605638
if [ "$OS" = "windows" ]; then
@@ -983,6 +1016,7 @@ main() {
9831016
if ! verify_installed_binary "$DESTINATION"; then
9841017
die "Installed binary did not start correctly." "$EXIT_VERIFY"
9851018
fi
1019+
write_install_receipt "$DESTINATION"
9861020
say_ok "agora ${VERSION} installed."
9871021

9881022
if [ "$ADD_TO_PATH" = "1" ]; then

internal/cli/app_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,124 @@ func TestResolveAgoraDirectoryUsesAgoraHomeDirectly(t *testing.T) {
2525
}
2626
}
2727

28+
func TestDetectInstallProvenanceUsesReceiptThenExecutablePath(t *testing.T) {
29+
installerDir := t.TempDir()
30+
installerPath := filepath.Join(installerDir, "agora")
31+
receipt := installReceipt{
32+
SchemaVersion: 1,
33+
Tool: "agora",
34+
InstallMethod: "installer",
35+
InstallPath: installerPath,
36+
Version: "0.1.9",
37+
InstalledAt: "2026-04-30T11:00:00Z",
38+
Source: "install.sh",
39+
}
40+
raw, err := json.Marshal(receipt)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if err := os.WriteFile(installReceiptPath(installerPath), raw, 0o644); err != nil {
45+
t.Fatal(err)
46+
}
47+
48+
provenance := detectInstallProvenanceForPath(map[string]string{"HOMEBREW_PREFIX": "/usr/local"}, installerPath)
49+
if provenance.Method != "installer" || provenance.Source != "install.sh" || provenance.ReceiptPath == "" {
50+
t.Fatalf("expected installer receipt provenance, got %+v", provenance)
51+
}
52+
}
53+
54+
func TestDetectInstallProvenanceFallsBackToExecutablePath(t *testing.T) {
55+
tests := []struct {
56+
name string
57+
env map[string]string
58+
exePath string
59+
wantMethod string
60+
}{
61+
{
62+
name: "installer wins when Homebrew is only in environment",
63+
env: map[string]string{"HOMEBREW_PREFIX": "/usr/local"},
64+
exePath: "/usr/local/bin/agora",
65+
wantMethod: "installer",
66+
},
67+
{
68+
name: "homebrew detected from resolved Cellar path",
69+
env: map[string]string{"HOMEBREW_PREFIX": "/usr/local"},
70+
exePath: "/usr/local/Cellar/agora-cli/0.1.8/bin/agora",
71+
wantMethod: "homebrew",
72+
},
73+
{
74+
name: "installer wins when npm is only in environment",
75+
env: map[string]string{"npm_config_prefix": "/usr/local"},
76+
exePath: "/usr/local/bin/agora",
77+
wantMethod: "installer",
78+
},
79+
{
80+
name: "npm detected from node_modules path",
81+
env: map[string]string{"npm_config_prefix": "/usr/local"},
82+
exePath: "/usr/local/lib/node_modules/@agoraio/cli-darwin-arm64/bin/agora",
83+
wantMethod: "npm",
84+
},
85+
{
86+
name: "unknown detected for test binary",
87+
env: map[string]string{},
88+
exePath: "/tmp/go-build/cli.test",
89+
wantMethod: "unknown",
90+
},
91+
}
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
provenance := detectInstallProvenanceForPath(tt.env, tt.exePath)
95+
if provenance.Method != tt.wantMethod {
96+
t.Fatalf("expected %s, got %s", tt.wantMethod, provenance.Method)
97+
}
98+
})
99+
}
100+
}
101+
102+
func TestDetectInstallProvenanceIgnoresStaleReceipt(t *testing.T) {
103+
dir := t.TempDir()
104+
exePath := filepath.Join(dir, "agora")
105+
receipt := installReceipt{
106+
SchemaVersion: 1,
107+
Tool: "agora",
108+
InstallMethod: "npm",
109+
InstallPath: filepath.Join(dir, "old-agora"),
110+
Version: "0.1.9",
111+
InstalledAt: "2026-04-30T11:00:00Z",
112+
Source: "test",
113+
}
114+
raw, err := json.Marshal(receipt)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
if err := os.WriteFile(installReceiptPath(exePath), raw, 0o644); err != nil {
119+
t.Fatal(err)
120+
}
121+
122+
provenance := detectInstallProvenanceForPath(map[string]string{}, exePath)
123+
if provenance.Method != "installer" || provenance.Source != "fallback" {
124+
t.Fatalf("expected stale receipt fallback, got %+v", provenance)
125+
}
126+
}
127+
128+
func TestWriteInstallReceiptRoundTrips(t *testing.T) {
129+
exePath := filepath.Join(t.TempDir(), "agora")
130+
receiptPath, err := writeInstallReceipt(exePath, "v0.1.9", "agora upgrade")
131+
if err != nil {
132+
t.Fatal(err)
133+
}
134+
receipt, err := readInstallReceipt(receiptPath)
135+
if err != nil {
136+
t.Fatal(err)
137+
}
138+
if !receipt.validForPath(exePath) {
139+
t.Fatalf("expected valid receipt for %s: %+v", exePath, receipt)
140+
}
141+
if receipt.Version != "0.1.9" || receipt.Source != "agora upgrade" {
142+
t.Fatalf("unexpected receipt contents: %+v", receipt)
143+
}
144+
}
145+
28146
func TestRenderProjectEnvDotenvAndShell(t *testing.T) {
29147
values := map[string]any{
30148
"AGORA_PROJECT_ID": "prj_123",

internal/cli/commands.go

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ Use "agora --help --all --json" for a machine-readable command tree (agent tooli
3636
`),
3737
RunE: func(cmd *cobra.Command, _ []string) error {
3838
if a.rootUpgradeCheck {
39-
method, command := detectUpgradeCommand(a.env)
39+
provenance := detectInstallProvenance(a.env)
4040
return renderResult(cmd, "upgrade check", map[string]any{
41-
"action": "upgrade-check",
42-
"command": command,
43-
"installMethod": method,
44-
"status": "manual",
45-
"version": versionInfo(),
41+
"action": "upgrade-check",
42+
"command": provenance.UpgradeCommand,
43+
"installMethod": provenance.Method,
44+
"installSource": provenance.Source,
45+
"installedPath": provenance.InstalledPath,
46+
"status": "manual",
47+
"upgradeCommand": provenance.UpgradeCommand,
48+
"version": versionInfo(),
4649
})
4750
}
4851
return cmd.Help()
@@ -241,16 +244,6 @@ Use --check to resolve the latest version and report what would happen without w
241244
return cmd
242245
}
243246

244-
func detectUpgradeCommand(env map[string]string) (string, string) {
245-
if strings.TrimSpace(env["npm_config_prefix"]) != "" {
246-
return "npm", "npm install -g agoraio-cli@latest"
247-
}
248-
if strings.Contains(strings.ToLower(strings.TrimSpace(env["HOMEBREW_PREFIX"])), "brew") {
249-
return "homebrew", "brew upgrade agoraio/tap/agora-cli"
250-
}
251-
return "installer", "curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --add-to-path"
252-
}
253-
254247
func (a *App) buildOpenCommand() *cobra.Command {
255248
var target string
256249
var noBrowser bool

0 commit comments

Comments
 (0)