Skip to content

Commit 9d4978e

Browse files
pRizzcursoragent
andcommitted
feat(update): add command-file update listener
Adds a bind-mount command-file listener for webapp-triggered opencode updates, with rate limiting and result reporting. Documents the flow and ensures the CloudFormation bootstrap installs the service so the listener stays active. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f8ebfd commit 9d4978e

10 files changed

Lines changed: 540 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ categories = ["command-line-utilities", "development-tools"]
1919
[workspace.dependencies]
2020
opencode-cloud-core = { version = "6.1.0", path = "packages/core" }
2121
clap = { version = "4.5", features = ["derive"] }
22-
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
22+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros", "signal"] }
2323
serde = { version = "1.0", features = ["derive"] }
2424
serde_json = "1.0"
2525
jsonc-parser = { version = "0.29", features = ["serde"] }

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,28 @@ occ update opencode
156156
occ update opencode --branch dev
157157
occ update opencode --commit <sha>
158158

159+
### Webapp-triggered update (command file)
160+
161+
When running in foreground mode (for example via `occ install`, which uses `occ start --no-daemon`),
162+
the host listens for a command file on a bind mount. The webapp can write a simple JSON payload
163+
to request an update.
164+
165+
Default paths (with default bind mounts enabled):
166+
- Host: `~/.local/state/opencode/opencode-cloud/commands/update-command.json`
167+
- Container: `/home/opencode/.local/state/opencode/opencode-cloud/commands/update-command.json`
168+
169+
Example payload:
170+
```json
171+
{
172+
"command": "update_opencode",
173+
"request_id": "webapp-1234",
174+
"branch": "dev"
175+
}
176+
```
177+
178+
The host writes the result to:
179+
`~/.local/state/opencode/opencode-cloud/commands/update-command.result.json`
180+
159181
# Install as a system service (starts on login/boot)
160182
occ install
161183

docs/update-flow.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
---
2+
title: Webapp Update Flow (Host Command File)
3+
---
4+
5+
# Webapp Update Flow (Host Command File)
6+
7+
This document explains how a webapp running inside the opencode container can trigger a host-side
8+
`occ update opencode` via a bind-mounted command file. It is written for implementors of the
9+
opencode webapp and describes the expected file paths, JSON payloads, error cases, and recommended
10+
write behavior.
11+
12+
## Overview
13+
14+
The host runs `occ start --no-daemon` as a foreground service (via `occ install`). When running,
15+
it polls a bind-mounted command file. The webapp writes a JSON command to that file, and the host
16+
executes `occ update opencode`. A result JSON is written back to a sibling file.
17+
18+
Key points:
19+
- No network port is required.
20+
- Access control is filesystem permissions on the bind mount.
21+
- The listener runs only when `occ` is running in foreground (service mode).
22+
23+
## Service Requirement
24+
25+
The listener is started when `occ start --no-daemon` runs. This happens automatically when the
26+
service is installed:
27+
28+
```
29+
occ install
30+
```
31+
32+
If `occ` is running in the background or not running as a service, the command file will not be
33+
processed. Ensure the service is installed and running.
34+
35+
## Paths and Bind Mounts
36+
37+
The command file lives under the state bind mount. With default mounts, the paths are:
38+
39+
- Host: `~/.local/state/opencode/opencode-cloud/commands/update-command.json`
40+
- Container: `/home/opencode/.local/state/opencode/opencode-cloud/commands/update-command.json`
41+
- Result file (host): `~/.local/state/opencode/opencode-cloud/commands/update-command.result.json`
42+
- Result file (container): `/home/opencode/.local/state/opencode/opencode-cloud/commands/update-command.result.json`
43+
44+
If you customize mounts, the container path is still under:
45+
46+
```
47+
/home/opencode/.local/state/opencode/opencode-cloud/commands/
48+
```
49+
50+
## Command File Contract
51+
52+
### Request JSON (webapp writes)
53+
54+
```json
55+
{
56+
"command": "update_opencode",
57+
"request_id": "optional-id",
58+
"branch": "dev",
59+
"commit": "optional-sha"
60+
}
61+
```
62+
63+
Fields:
64+
- `command` (required): must be `"update_opencode"`.
65+
- `request_id` (optional): any string to correlate request and result.
66+
- `branch` (optional): update to a branch (e.g., `"dev"`).
67+
- `commit` (optional): update to a commit SHA.
68+
69+
Rules:
70+
- Specify either `branch` or `commit`, not both.
71+
- If both are omitted, the host uses the default behavior (currently `dev`).
72+
73+
### Result JSON (host writes)
74+
75+
```json
76+
{
77+
"status": "success",
78+
"request_id": "optional-id",
79+
"message": "Update completed",
80+
"started_at": "2026-02-01T22:48:00Z",
81+
"finished_at": "2026-02-01T22:48:12Z"
82+
}
83+
```
84+
85+
Fields:
86+
- `status`: `"success"` or `"error"`.
87+
- `request_id`: echoed from the request when provided.
88+
- `message`: human-readable status or error message.
89+
- `started_at` / `finished_at`: ISO-8601 timestamps.
90+
91+
## Recommended Write Behavior (Webapp)
92+
93+
To avoid partial reads:
94+
95+
1. Write to a temporary file in the same directory.
96+
2. Atomically rename to `update-command.json`.
97+
98+
Example pseudo-steps:
99+
100+
```
101+
write /commands/update-command.json.tmp
102+
rename to /commands/update-command.json
103+
```
104+
105+
Then poll the result file until it exists and matches the `request_id`.
106+
107+
## Error Cases and Scenarios
108+
109+
### Rate Limit (Burst Protection)
110+
The listener processes at most one command every 5 seconds. If multiple commands are written
111+
back-to-back, the first one is handled and the next one waits until the interval elapses.
112+
113+
### Command File Is Missing
114+
If the command file does not exist, the host does nothing. This is normal.
115+
116+
### Invalid JSON
117+
If the JSON is invalid and the file is older than a short grace window, the host will:
118+
- record an error result in the result file
119+
- delete the command file
120+
121+
If invalid JSON is detected immediately after write, the host will retry once the file
122+
stabilizes (to avoid race conditions).
123+
124+
### Unsupported Command
125+
If `command` is not `"update_opencode"`, the host writes an error result and removes the file.
126+
127+
### Both `branch` and `commit` Set
128+
This is rejected with an error result.
129+
130+
### Missing Bind Mount or Read-Only Mount
131+
If the state mount is missing or read-only, the listener disables itself. The webapp will never
132+
see a response. Ensure the default mounts are enabled and writable.
133+
134+
### Host Not Running in Service Mode
135+
If the host service is not running (or running without `--no-daemon`), the listener is not active.
136+
The command file will not be processed.
137+
138+
### Container Not Running
139+
If the container is stopped, the listener exits. The command file will not be processed until the
140+
service is restarted.
141+
142+
### Update Already Up To Date
143+
If opencode is already at the requested commit, the update command returns success and writes a
144+
result indicating no change.
145+
146+
### Update Failure
147+
Any update failure writes a `"status": "error"` result with the failure message. The webapp should
148+
surface this to the user.
149+
150+
## UI/UX Suggestions
151+
152+
Recommended states for the webapp button:
153+
- Idle: show "Update opencode".
154+
- Pending: disable button, show "Updating..." and poll for result.
155+
- Success: show "Updated" with timestamp.
156+
- Error: show error message and allow retry.
157+
158+
## Alternatives (Context)
159+
160+
These are not implemented but are viable options for future work:
161+
162+
1. **Host HTTP API endpoint**
163+
- A local `POST /api/v1/update` endpoint with JSON payload.
164+
- Easier streaming updates, but requires network exposure and auth.
165+
166+
2. **Unix socket + proxy**
167+
- HTTP over a unix socket with a local reverse proxy in the container.
168+
- Strong local permissions but more moving parts.
169+
170+
3. **Docker socket from container**
171+
- Webapp triggers a helper that calls Docker directly.
172+
- Not recommended due to high privileges in the container.

infra/aws/cloudformation/opencode-cloud-quick.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,11 @@ Resources:
780780
run_as_ubuntu "opencode-cloud --quiet setup --bootstrap"
781781
log "opencode-cloud setup: bootstrap complete (ubuntu user)"
782782
783+
# Install the service so occ runs in foreground and listens for update commands.
784+
log "opencode-cloud setup: install service (ubuntu user)"
785+
run_as_ubuntu "opencode-cloud install --force"
786+
log "opencode-cloud setup: service install complete (ubuntu user)"
787+
783788
log "opencode-cloud setup: align host mount ownership"
784789
data_dir="$ubuntu_home/.local/share/opencode"
785790
state_dir="$ubuntu_home/.local/state/opencode"

packages/cli-rust/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod status;
1616
mod stop;
1717
mod uninstall;
1818
mod update;
19+
mod update_signal;
1920
mod user;
2021

2122
pub use cockpit::{CockpitArgs, cmd_cockpit};

packages/cli-rust/src/commands/start.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use opencode_cloud_core::docker::{
2323
use std::net::{TcpListener, TcpStream};
2424
use std::time::{Duration, Instant};
2525

26+
use super::update_signal::run_update_command_listener;
27+
2628
/// Arguments for the start command
2729
#[derive(Args)]
2830
pub struct StartArgs {
@@ -35,7 +37,7 @@ pub struct StartArgs {
3537
pub open: bool,
3638

3739
/// Run in foreground (for service managers like systemd/launchd)
38-
/// Note: This is the default behavior; flag exists for compatibility
40+
/// Keeps the process running and listens for update commands
3941
#[arg(long)]
4042
pub no_daemon: bool,
4143

@@ -817,6 +819,10 @@ pub async fn cmd_start(
817819
);
818820
open_browser_if_requested(args.open, port, bind_addr);
819821

822+
if args.no_daemon {
823+
run_update_command_listener(&client, &config, maybe_host, quiet, verbose).await?;
824+
}
825+
820826
Ok(())
821827
}
822828

packages/cli-rust/src/commands/update.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ fn is_dev_binary() -> bool {
287287
exe_str.contains("/target/debug/")
288288
}
289289

290-
async fn cmd_update_opencode(
290+
pub(crate) async fn cmd_update_opencode(
291291
args: &UpdateOpencodeArgs,
292292
maybe_host: Option<&str>,
293293
quiet: bool,

0 commit comments

Comments
 (0)