@opencode-cloud/core 0.1.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ //! Progress reporting utilities for Docker operations
2
+ //!
3
+ //! This module provides progress bars and spinners for Docker image
4
+ //! builds and pulls, using indicatif for terminal output.
5
+
6
+ use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
7
+ use std::collections::HashMap;
8
+ use std::time::{Duration, Instant};
9
+
10
+ /// Minimum time between spinner message updates to prevent flickering
11
+ const SPINNER_UPDATE_THROTTLE: Duration = Duration::from_millis(150);
12
+
13
+ /// Strip ANSI escape codes from a string
14
+ ///
15
+ /// Docker build output often contains ANSI color codes that can interfere
16
+ /// with our spinner display. This removes them for clean output.
17
+ fn strip_ansi_codes(s: &str) -> String {
18
+ let mut result = String::with_capacity(s.len());
19
+ let mut chars = s.chars().peekable();
20
+
21
+ while let Some(c) = chars.next() {
22
+ if c == '\x1b' {
23
+ // Check for CSI sequence: ESC [
24
+ if chars.peek() == Some(&'[') {
25
+ chars.next(); // consume '['
26
+ // Skip until we hit a letter (the command character)
27
+ while let Some(&next) = chars.peek() {
28
+ chars.next();
29
+ if next.is_ascii_alphabetic() {
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ // Also handle ESC followed by other sequences (less common)
35
+ } else {
36
+ result.push(c);
37
+ }
38
+ }
39
+
40
+ result
41
+ }
42
+
43
+ /// Format duration as MM:SS, or HH:MM:SS if over an hour
44
+ fn format_elapsed(duration: Duration) -> String {
45
+ let total_secs = duration.as_secs();
46
+ let hours = total_secs / 3600;
47
+ let minutes = (total_secs % 3600) / 60;
48
+ let seconds = total_secs % 60;
49
+
50
+ if hours > 0 {
51
+ format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
52
+ } else {
53
+ format!("{:02}:{:02}", minutes, seconds)
54
+ }
55
+ }
56
+
57
+ /// Progress reporter for Docker operations
58
+ ///
59
+ /// Manages multiple progress bars for concurrent operations like
60
+ /// multi-layer image pulls and build steps.
61
+ pub struct ProgressReporter {
62
+ multi: MultiProgress,
63
+ bars: HashMap<String, ProgressBar>,
64
+ last_update: HashMap<String, Instant>,
65
+ last_message: HashMap<String, String>,
66
+ start_time: Instant,
67
+ /// Optional context prefix shown before step messages (e.g., "Building Docker image")
68
+ context: Option<String>,
69
+ }
70
+
71
+ impl Default for ProgressReporter {
72
+ fn default() -> Self {
73
+ Self::new()
74
+ }
75
+ }
76
+
77
+ impl ProgressReporter {
78
+ /// Create a new progress reporter
79
+ pub fn new() -> Self {
80
+ Self {
81
+ multi: MultiProgress::new(),
82
+ bars: HashMap::new(),
83
+ last_update: HashMap::new(),
84
+ last_message: HashMap::new(),
85
+ start_time: Instant::now(),
86
+ context: None,
87
+ }
88
+ }
89
+
90
+ /// Create a new progress reporter with a context prefix
91
+ ///
92
+ /// The context is shown before step messages, e.g., "Building Docker image · Step 1/10"
93
+ pub fn with_context(context: &str) -> Self {
94
+ Self {
95
+ multi: MultiProgress::new(),
96
+ bars: HashMap::new(),
97
+ last_update: HashMap::new(),
98
+ last_message: HashMap::new(),
99
+ start_time: Instant::now(),
100
+ context: Some(context.to_string()),
101
+ }
102
+ }
103
+
104
+ /// Format a message with context prefix if set
105
+ fn format_message(&self, message: &str) -> String {
106
+ let elapsed = format_elapsed(self.start_time.elapsed());
107
+
108
+ // Strip ANSI escape codes that Docker may include in its output
109
+ let stripped = strip_ansi_codes(message);
110
+
111
+ // Collapse message to single line for spinner display:
112
+ // - Replace all whitespace sequences (including newlines) with single space
113
+ // - Trim leading/trailing whitespace
114
+ let clean_msg = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
115
+
116
+ // Format: "[elapsed] Context · message" or "[elapsed] message"
117
+ // Timer at the beginning for easy scanning
118
+ match &self.context {
119
+ Some(ctx) => format!("[{}] {} · {}", elapsed, ctx, clean_msg),
120
+ None => format!("[{}] {}", elapsed, clean_msg),
121
+ }
122
+ }
123
+
124
+ /// Create a spinner for indeterminate progress (e.g., build steps)
125
+ pub fn add_spinner(&mut self, id: &str, message: &str) -> &ProgressBar {
126
+ let spinner = self.multi.add(ProgressBar::new_spinner());
127
+ spinner.set_style(
128
+ ProgressStyle::default_spinner()
129
+ .template("{spinner:.green} {msg}")
130
+ .expect("valid template")
131
+ .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
132
+ );
133
+ spinner.set_message(self.format_message(message));
134
+ spinner.enable_steady_tick(std::time::Duration::from_millis(100));
135
+ self.bars.insert(id.to_string(), spinner);
136
+ self.bars.get(id).expect("just inserted")
137
+ }
138
+
139
+ /// Create a progress bar for determinate progress (e.g., layer download)
140
+ ///
141
+ /// `total` is in bytes
142
+ pub fn add_bar(&mut self, id: &str, total: u64) -> &ProgressBar {
143
+ let bar = self.multi.add(ProgressBar::new(total));
144
+ bar.set_style(
145
+ ProgressStyle::default_bar()
146
+ .template(
147
+ "{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) {msg}",
148
+ )
149
+ .expect("valid template")
150
+ .progress_chars("=>-"),
151
+ );
152
+ bar.enable_steady_tick(std::time::Duration::from_millis(100));
153
+ self.bars.insert(id.to_string(), bar);
154
+ self.bars.get(id).expect("just inserted")
155
+ }
156
+
157
+ /// Update progress for a layer (used during image pull)
158
+ ///
159
+ /// `current` and `total` are in bytes, `status` is the Docker status message
160
+ pub fn update_layer(&mut self, layer_id: &str, current: u64, total: u64, status: &str) {
161
+ if let Some(bar) = self.bars.get(layer_id) {
162
+ // Update total if it changed (Docker sometimes updates this)
163
+ if bar.length() != Some(total) && total > 0 {
164
+ bar.set_length(total);
165
+ }
166
+ bar.set_position(current);
167
+ bar.set_message(status.to_string());
168
+ } else {
169
+ // Create new bar for this layer
170
+ let bar = self.add_bar(layer_id, total);
171
+ bar.set_position(current);
172
+ bar.set_message(status.to_string());
173
+ }
174
+ }
175
+
176
+ /// Update spinner message (used during build)
177
+ ///
178
+ /// Updates are throttled to prevent flickering from rapid message changes.
179
+ /// "Step X/Y" messages always update immediately as they indicate significant progress.
180
+ pub fn update_spinner(&mut self, id: &str, message: &str) {
181
+ let now = Instant::now();
182
+ let is_step_message = message.starts_with("Step ");
183
+
184
+ // Check if we should throttle this update
185
+ if !is_step_message {
186
+ if let Some(last) = self.last_update.get(id) {
187
+ if now.duration_since(*last) < SPINNER_UPDATE_THROTTLE {
188
+ return; // Throttle: too soon since last update
189
+ }
190
+ }
191
+
192
+ // Skip if message is identical to last one
193
+ if let Some(last_msg) = self.last_message.get(id) {
194
+ if last_msg == message {
195
+ return;
196
+ }
197
+ }
198
+ }
199
+
200
+ // Perform the update with context and elapsed time
201
+ let formatted = self.format_message(message);
202
+
203
+ if let Some(spinner) = self.bars.get(id) {
204
+ spinner.set_message(formatted);
205
+ } else {
206
+ // Create new spinner if doesn't exist
207
+ self.add_spinner(id, message);
208
+ }
209
+
210
+ // Track update time and message
211
+ self.last_update.insert(id.to_string(), now);
212
+ self.last_message
213
+ .insert(id.to_string(), message.to_string());
214
+ }
215
+
216
+ /// Mark a layer/step as complete
217
+ pub fn finish(&mut self, id: &str, message: &str) {
218
+ if let Some(bar) = self.bars.get(id) {
219
+ bar.finish_with_message(message.to_string());
220
+ }
221
+ }
222
+
223
+ /// Mark all progress as complete
224
+ pub fn finish_all(&self, message: &str) {
225
+ for bar in self.bars.values() {
226
+ bar.finish_with_message(message.to_string());
227
+ }
228
+ }
229
+
230
+ /// Mark all progress as failed
231
+ pub fn abandon_all(&self, message: &str) {
232
+ for bar in self.bars.values() {
233
+ bar.abandon_with_message(message.to_string());
234
+ }
235
+ }
236
+ }
237
+
238
+ #[cfg(test)]
239
+ mod tests {
240
+ use super::*;
241
+
242
+ #[test]
243
+ fn progress_reporter_creation() {
244
+ let reporter = ProgressReporter::new();
245
+ assert!(reporter.bars.is_empty());
246
+ }
247
+
248
+ #[test]
249
+ fn progress_reporter_default() {
250
+ let reporter = ProgressReporter::default();
251
+ assert!(reporter.bars.is_empty());
252
+ }
253
+
254
+ #[test]
255
+ fn add_spinner_creates_entry() {
256
+ let mut reporter = ProgressReporter::new();
257
+ reporter.add_spinner("test", "Testing...");
258
+ assert!(reporter.bars.contains_key("test"));
259
+ }
260
+
261
+ #[test]
262
+ fn add_bar_creates_entry() {
263
+ let mut reporter = ProgressReporter::new();
264
+ reporter.add_bar("layer1", 1000);
265
+ assert!(reporter.bars.contains_key("layer1"));
266
+ }
267
+
268
+ #[test]
269
+ fn update_layer_creates_if_missing() {
270
+ let mut reporter = ProgressReporter::new();
271
+ reporter.update_layer("layer1", 500, 1000, "Downloading");
272
+ assert!(reporter.bars.contains_key("layer1"));
273
+ }
274
+
275
+ #[test]
276
+ fn update_spinner_creates_if_missing() {
277
+ let mut reporter = ProgressReporter::new();
278
+ reporter.update_spinner("step1", "Building...");
279
+ assert!(reporter.bars.contains_key("step1"));
280
+ }
281
+
282
+ #[test]
283
+ fn finish_handles_missing_id() {
284
+ let mut reporter = ProgressReporter::new();
285
+ // Should not panic on missing id
286
+ reporter.finish("nonexistent", "Done");
287
+ }
288
+
289
+ #[test]
290
+ fn finish_all_handles_empty() {
291
+ let reporter = ProgressReporter::new();
292
+ // Should not panic on empty
293
+ reporter.finish_all("Done");
294
+ }
295
+
296
+ #[test]
297
+ fn abandon_all_handles_empty() {
298
+ let reporter = ProgressReporter::new();
299
+ // Should not panic on empty
300
+ reporter.abandon_all("Failed");
301
+ }
302
+
303
+ #[test]
304
+ fn format_elapsed_shows_seconds_only() {
305
+ let duration = Duration::from_secs(45);
306
+ assert_eq!(format_elapsed(duration), "00:45");
307
+ }
308
+
309
+ #[test]
310
+ fn format_elapsed_shows_minutes_and_seconds() {
311
+ let duration = Duration::from_secs(90); // 1m 30s
312
+ assert_eq!(format_elapsed(duration), "01:30");
313
+ }
314
+
315
+ #[test]
316
+ fn format_elapsed_shows_hours_when_needed() {
317
+ let duration = Duration::from_secs(3661); // 1h 1m 1s
318
+ assert_eq!(format_elapsed(duration), "01:01:01");
319
+ }
320
+
321
+ #[test]
322
+ fn format_elapsed_zero() {
323
+ let duration = Duration::from_secs(0);
324
+ assert_eq!(format_elapsed(duration), "00:00");
325
+ }
326
+
327
+ #[test]
328
+ fn with_context_sets_context() {
329
+ let reporter = ProgressReporter::with_context("Building Docker image");
330
+ assert!(reporter.context.is_some());
331
+ assert_eq!(reporter.context.unwrap(), "Building Docker image");
332
+ }
333
+
334
+ #[test]
335
+ fn format_message_includes_context_for_steps() {
336
+ let reporter = ProgressReporter::with_context("Building Docker image");
337
+ let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
338
+ // Format: [elapsed] Context · message
339
+ assert!(msg.contains("Building Docker image · Step 1/10"));
340
+ assert!(msg.starts_with("[00:00]"));
341
+ }
342
+
343
+ #[test]
344
+ fn format_message_includes_context_for_all_messages() {
345
+ let reporter = ProgressReporter::with_context("Building Docker image");
346
+ let msg = reporter.format_message("Compiling foo v1.0");
347
+ // Format: [elapsed] Context · message
348
+ assert!(msg.contains("Building Docker image · Compiling foo"));
349
+ assert!(msg.starts_with("[00:00]"));
350
+ }
351
+
352
+ #[test]
353
+ fn format_message_without_context() {
354
+ let reporter = ProgressReporter::new();
355
+ let msg = reporter.format_message("Step 1/10 : FROM ubuntu");
356
+ // Format: [elapsed] message (no context, no dot)
357
+ assert!(msg.contains("Step 1/10"));
358
+ assert!(msg.starts_with("[00:00]"));
359
+ assert!(!msg.contains("·"));
360
+ }
361
+
362
+ #[test]
363
+ fn format_message_collapses_whitespace() {
364
+ let reporter = ProgressReporter::new();
365
+ // All whitespace (including newlines) collapsed to single spaces for spinner display
366
+ let msg = reporter.format_message("Compiling foo\n Compiling bar\n");
367
+ assert!(!msg.contains('\n'));
368
+ assert!(msg.contains("Compiling foo Compiling bar"));
369
+ }
370
+
371
+ #[test]
372
+ fn strip_ansi_codes_removes_color_codes() {
373
+ // Red text: \x1b[31m ... \x1b[0m
374
+ let input = "\x1b[31mError:\x1b[0m something failed";
375
+ let result = strip_ansi_codes(input);
376
+ assert_eq!(result, "Error: something failed");
377
+ }
378
+
379
+ #[test]
380
+ fn strip_ansi_codes_handles_plain_text() {
381
+ let input = "Just plain text";
382
+ let result = strip_ansi_codes(input);
383
+ assert_eq!(result, "Just plain text");
384
+ }
385
+
386
+ #[test]
387
+ fn strip_ansi_codes_handles_multiple_codes() {
388
+ // Bold green: \x1b[1;32m
389
+ let input = "\x1b[1;32mSuccess\x1b[0m and \x1b[33mwarning\x1b[0m";
390
+ let result = strip_ansi_codes(input);
391
+ assert_eq!(result, "Success and warning");
392
+ }
393
+
394
+ #[test]
395
+ fn format_message_strips_ansi_codes() {
396
+ let reporter = ProgressReporter::new();
397
+ let msg = reporter.format_message("\x1b[31mCompiling\x1b[0m foo");
398
+ assert!(msg.contains("Compiling foo"));
399
+ assert!(!msg.contains("\x1b"));
400
+ }
401
+ }
@@ -0,0 +1,144 @@
1
+ //! Docker volume management
2
+ //!
3
+ //! This module provides functions to create and manage Docker volumes
4
+ //! for persistent storage across container restarts.
5
+
6
+ use super::{DockerClient, DockerError};
7
+ use bollard::volume::CreateVolumeOptions;
8
+ use std::collections::HashMap;
9
+ use tracing::debug;
10
+
11
+ /// Volume name for opencode session history
12
+ pub const VOLUME_SESSION: &str = "opencode-cloud-session";
13
+
14
+ /// Volume name for project files
15
+ pub const VOLUME_PROJECTS: &str = "opencode-cloud-projects";
16
+
17
+ /// Volume name for opencode configuration
18
+ pub const VOLUME_CONFIG: &str = "opencode-cloud-config";
19
+
20
+ /// All volume names as array for iteration
21
+ pub const VOLUME_NAMES: [&str; 3] = [VOLUME_SESSION, VOLUME_PROJECTS, VOLUME_CONFIG];
22
+
23
+ /// Mount point for session history inside container
24
+ pub const MOUNT_SESSION: &str = "/home/opencode/.opencode";
25
+
26
+ /// Mount point for project files inside container
27
+ pub const MOUNT_PROJECTS: &str = "/workspace";
28
+
29
+ /// Mount point for configuration inside container
30
+ pub const MOUNT_CONFIG: &str = "/home/opencode/.config";
31
+
32
+ /// Ensure all required volumes exist
33
+ ///
34
+ /// Creates volumes if they don't exist. This operation is idempotent -
35
+ /// calling it multiple times has no additional effect.
36
+ pub async fn ensure_volumes_exist(client: &DockerClient) -> Result<(), DockerError> {
37
+ debug!("Ensuring all required volumes exist");
38
+
39
+ for volume_name in VOLUME_NAMES {
40
+ ensure_volume_exists(client, volume_name).await?;
41
+ }
42
+
43
+ debug!("All volumes verified/created");
44
+ Ok(())
45
+ }
46
+
47
+ /// Ensure a specific volume exists
48
+ async fn ensure_volume_exists(client: &DockerClient, name: &str) -> Result<(), DockerError> {
49
+ debug!("Checking volume: {}", name);
50
+
51
+ // Create volume options with default local driver
52
+ let options = CreateVolumeOptions {
53
+ name,
54
+ driver: "local",
55
+ driver_opts: HashMap::new(),
56
+ labels: HashMap::from([("managed-by", "opencode-cloud")]),
57
+ };
58
+
59
+ // create_volume is idempotent - returns existing volume if it exists
60
+ client
61
+ .inner()
62
+ .create_volume(options)
63
+ .await
64
+ .map_err(|e| DockerError::Volume(format!("Failed to create volume {name}: {e}")))?;
65
+
66
+ debug!("Volume {} ready", name);
67
+ Ok(())
68
+ }
69
+
70
+ /// Check if a specific volume exists
71
+ pub async fn volume_exists(client: &DockerClient, name: &str) -> Result<bool, DockerError> {
72
+ debug!("Checking if volume exists: {}", name);
73
+
74
+ match client.inner().inspect_volume(name).await {
75
+ Ok(_) => Ok(true),
76
+ Err(bollard::errors::Error::DockerResponseServerError {
77
+ status_code: 404, ..
78
+ }) => Ok(false),
79
+ Err(e) => Err(DockerError::Volume(format!(
80
+ "Failed to inspect volume {name}: {e}"
81
+ ))),
82
+ }
83
+ }
84
+
85
+ /// Remove a volume
86
+ ///
87
+ /// Returns error if volume is in use by a container.
88
+ /// Use force_remove_volume for cleanup during uninstall.
89
+ pub async fn remove_volume(client: &DockerClient, name: &str) -> Result<(), DockerError> {
90
+ debug!("Removing volume: {}", name);
91
+
92
+ client
93
+ .inner()
94
+ .remove_volume(name, None)
95
+ .await
96
+ .map_err(|e| DockerError::Volume(format!("Failed to remove volume {name}: {e}")))?;
97
+
98
+ debug!("Volume {} removed", name);
99
+ Ok(())
100
+ }
101
+
102
+ /// Remove all opencode-cloud volumes
103
+ ///
104
+ /// Used during uninstall. Fails if any volume is in use.
105
+ pub async fn remove_all_volumes(client: &DockerClient) -> Result<(), DockerError> {
106
+ debug!("Removing all opencode-cloud volumes");
107
+
108
+ for volume_name in VOLUME_NAMES {
109
+ // Check if volume exists before trying to remove
110
+ if volume_exists(client, volume_name).await? {
111
+ remove_volume(client, volume_name).await?;
112
+ }
113
+ }
114
+
115
+ debug!("All volumes removed");
116
+ Ok(())
117
+ }
118
+
119
+ #[cfg(test)]
120
+ mod tests {
121
+ use super::*;
122
+
123
+ #[test]
124
+ fn volume_constants_are_correct() {
125
+ assert_eq!(VOLUME_SESSION, "opencode-cloud-session");
126
+ assert_eq!(VOLUME_PROJECTS, "opencode-cloud-projects");
127
+ assert_eq!(VOLUME_CONFIG, "opencode-cloud-config");
128
+ }
129
+
130
+ #[test]
131
+ fn volume_names_array_has_all_volumes() {
132
+ assert_eq!(VOLUME_NAMES.len(), 3);
133
+ assert!(VOLUME_NAMES.contains(&VOLUME_SESSION));
134
+ assert!(VOLUME_NAMES.contains(&VOLUME_PROJECTS));
135
+ assert!(VOLUME_NAMES.contains(&VOLUME_CONFIG));
136
+ }
137
+
138
+ #[test]
139
+ fn mount_points_are_correct() {
140
+ assert_eq!(MOUNT_SESSION, "/home/opencode/.opencode");
141
+ assert_eq!(MOUNT_PROJECTS, "/workspace");
142
+ assert_eq!(MOUNT_CONFIG, "/home/opencode/.config");
143
+ }
144
+ }
package/src/lib.rs CHANGED
@@ -4,6 +4,8 @@
4
4
  //! and Node.js bindings via NAPI-RS.
5
5
 
6
6
  pub mod config;
7
+ pub mod docker;
8
+ pub mod platform;
7
9
  pub mod singleton;
8
10
  pub mod version;
9
11
 
@@ -16,6 +18,18 @@ pub use config::{Config, load_config, save_config};
16
18
  // Re-export singleton types
17
19
  pub use singleton::{InstanceLock, SingletonError};
18
20
 
21
+ // Re-export docker types
22
+ pub use docker::{CONTAINER_NAME, DockerClient, DockerError, OPENCODE_WEB_PORT};
23
+
24
+ // Re-export platform types
25
+ pub use platform::{
26
+ InstallResult, ServiceConfig, ServiceManager, get_service_manager,
27
+ is_service_registration_supported,
28
+ };
29
+
30
+ // Re-export bollard to ensure all crates use the same version
31
+ pub use bollard;
32
+
19
33
  // NAPI bindings for Node.js consumers (only when napi feature is enabled)
20
34
  #[cfg(feature = "napi")]
21
35
  use napi_derive::napi;