@opencode-cloud/core 0.1.0

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.
package/Cargo.toml ADDED
@@ -0,0 +1,44 @@
1
+ [package]
2
+ name = "opencode-cloud-core"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ repository.workspace = true
8
+ homepage.workspace = true
9
+ documentation.workspace = true
10
+ keywords.workspace = true
11
+ categories.workspace = true
12
+ description = "Core library for opencode-cloud - config management, singleton enforcement, and shared utilities"
13
+ readme = "../../README.md"
14
+ exclude = ["*.node", "index.js", "index.d.ts"]
15
+
16
+ [lib]
17
+ # Both crate types: cdylib for NAPI Node bindings, rlib for Rust CLI
18
+ crate-type = ["cdylib", "rlib"]
19
+
20
+ [features]
21
+ default = []
22
+ napi = ["dep:napi", "dep:napi-derive"]
23
+
24
+ [dependencies]
25
+ clap.workspace = true
26
+ tokio.workspace = true
27
+ serde.workspace = true
28
+ serde_json.workspace = true
29
+ jsonc-parser.workspace = true
30
+ directories.workspace = true
31
+ thiserror.workspace = true
32
+ anyhow.workspace = true
33
+ tracing.workspace = true
34
+ console.workspace = true
35
+
36
+ # NAPI dependencies (optional - only for Node bindings)
37
+ napi = { workspace = true, optional = true }
38
+ napi-derive = { workspace = true, optional = true }
39
+
40
+ [build-dependencies]
41
+ napi-build = "2"
42
+
43
+ [dev-dependencies]
44
+ tempfile.workspace = true
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Ryszkiewicz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/build.rs ADDED
@@ -0,0 +1,4 @@
1
+ fn main() {
2
+ // Setup NAPI build for the cdylib target
3
+ napi_build::setup();
4
+ }
Binary file
package/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* NAPI-RS type definitions for @opencode-cloud/core */
5
+
6
+ /**
7
+ * Get the version string for Node.js consumers
8
+ */
9
+ export function getVersionJs(): string;
10
+
11
+ /**
12
+ * Get the long version string with build info for Node.js consumers
13
+ */
14
+ export function getVersionLongJs(): string;
package/index.js ADDED
@@ -0,0 +1,146 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* NAPI-RS bindings loader for @opencode-cloud/core */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ if (!process.report || typeof process.report.getReport !== 'function') {
18
+ try {
19
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
20
+ return readFileSync(lddPath, 'utf8').includes('musl')
21
+ } catch {
22
+ return true
23
+ }
24
+ } else {
25
+ const { glibcVersionRuntime } = process.report.getReport().header
26
+ return !glibcVersionRuntime
27
+ }
28
+ }
29
+
30
+ switch (platform) {
31
+ case 'darwin':
32
+ localFileExisted = existsSync(join(__dirname, 'core.darwin-arm64.node'))
33
+ if (arch === 'arm64') {
34
+ try {
35
+ if (localFileExisted) {
36
+ nativeBinding = require('./core.darwin-arm64.node')
37
+ } else {
38
+ nativeBinding = require('@opencode-cloud/core-darwin-arm64')
39
+ }
40
+ } catch (e) {
41
+ loadError = e
42
+ }
43
+ } else {
44
+ localFileExisted = existsSync(join(__dirname, 'core.darwin-x64.node'))
45
+ try {
46
+ if (localFileExisted) {
47
+ nativeBinding = require('./core.darwin-x64.node')
48
+ } else {
49
+ nativeBinding = require('@opencode-cloud/core-darwin-x64')
50
+ }
51
+ } catch (e) {
52
+ loadError = e
53
+ }
54
+ }
55
+ break
56
+ case 'linux':
57
+ if (arch === 'x64') {
58
+ if (isMusl()) {
59
+ localFileExisted = existsSync(join(__dirname, 'core.linux-x64-musl.node'))
60
+ try {
61
+ if (localFileExisted) {
62
+ nativeBinding = require('./core.linux-x64-musl.node')
63
+ } else {
64
+ nativeBinding = require('@opencode-cloud/core-linux-x64-musl')
65
+ }
66
+ } catch (e) {
67
+ loadError = e
68
+ }
69
+ } else {
70
+ localFileExisted = existsSync(join(__dirname, 'core.linux-x64-gnu.node'))
71
+ try {
72
+ if (localFileExisted) {
73
+ nativeBinding = require('./core.linux-x64-gnu.node')
74
+ } else {
75
+ nativeBinding = require('@opencode-cloud/core-linux-x64-gnu')
76
+ }
77
+ } catch (e) {
78
+ loadError = e
79
+ }
80
+ }
81
+ } else if (arch === 'arm64') {
82
+ if (isMusl()) {
83
+ localFileExisted = existsSync(join(__dirname, 'core.linux-arm64-musl.node'))
84
+ try {
85
+ if (localFileExisted) {
86
+ nativeBinding = require('./core.linux-arm64-musl.node')
87
+ } else {
88
+ nativeBinding = require('@opencode-cloud/core-linux-arm64-musl')
89
+ }
90
+ } catch (e) {
91
+ loadError = e
92
+ }
93
+ } else {
94
+ localFileExisted = existsSync(join(__dirname, 'core.linux-arm64-gnu.node'))
95
+ try {
96
+ if (localFileExisted) {
97
+ nativeBinding = require('./core.linux-arm64-gnu.node')
98
+ } else {
99
+ nativeBinding = require('@opencode-cloud/core-linux-arm64-gnu')
100
+ }
101
+ } catch (e) {
102
+ loadError = e
103
+ }
104
+ }
105
+ }
106
+ break
107
+ case 'win32':
108
+ if (arch === 'x64') {
109
+ localFileExisted = existsSync(join(__dirname, 'core.win32-x64-msvc.node'))
110
+ try {
111
+ if (localFileExisted) {
112
+ nativeBinding = require('./core.win32-x64-msvc.node')
113
+ } else {
114
+ nativeBinding = require('@opencode-cloud/core-win32-x64-msvc')
115
+ }
116
+ } catch (e) {
117
+ loadError = e
118
+ }
119
+ } else if (arch === 'arm64') {
120
+ localFileExisted = existsSync(join(__dirname, 'core.win32-arm64-msvc.node'))
121
+ try {
122
+ if (localFileExisted) {
123
+ nativeBinding = require('./core.win32-arm64-msvc.node')
124
+ } else {
125
+ nativeBinding = require('@opencode-cloud/core-win32-arm64-msvc')
126
+ }
127
+ } catch (e) {
128
+ loadError = e
129
+ }
130
+ }
131
+ break
132
+ default:
133
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
134
+ }
135
+
136
+ if (!nativeBinding) {
137
+ if (loadError) {
138
+ throw loadError
139
+ }
140
+ throw new Error(`Failed to load native binding`)
141
+ }
142
+
143
+ const { getVersionJs, getVersionLongJs } = nativeBinding
144
+
145
+ module.exports.getVersionJs = getVersionJs
146
+ module.exports.getVersionLongJs = getVersionLongJs
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@opencode-cloud/core",
3
+ "version": "0.1.0",
4
+ "description": "Core NAPI bindings for opencode-cloud (internal package)",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "license": "MIT",
8
+ "author": "pRizz",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/pRizz/opencode-cloud.git",
12
+ "directory": "packages/core"
13
+ },
14
+ "homepage": "https://github.com/pRizz/opencode-cloud",
15
+ "bugs": {
16
+ "url": "https://github.com/pRizz/opencode-cloud/issues"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "ai",
21
+ "cloud",
22
+ "napi",
23
+ "rust"
24
+ ],
25
+ "napi": {
26
+ "binaryName": "core",
27
+ "triples": {}
28
+ },
29
+ "engines": {
30
+ "node": ">=20.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@napi-rs/cli": "^3.0.0-alpha.69"
34
+ },
35
+ "scripts": {
36
+ "build": "napi build --platform --release --features napi --no-js && cp src/bindings.js index.js && cp src/bindings.d.ts index.d.ts",
37
+ "build:debug": "napi build --platform --features napi --no-js && cp src/bindings.js index.js && cp src/bindings.d.ts index.d.ts",
38
+ "postinstall": "npm run build || echo 'Build failed - ensure Rust 1.82+ is installed (rustup.rs)'"
39
+ }
40
+ }
@@ -0,0 +1,14 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* NAPI-RS type definitions for @opencode-cloud/core */
5
+
6
+ /**
7
+ * Get the version string for Node.js consumers
8
+ */
9
+ export function getVersionJs(): string;
10
+
11
+ /**
12
+ * Get the long version string with build info for Node.js consumers
13
+ */
14
+ export function getVersionLongJs(): string;
@@ -0,0 +1,146 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* NAPI-RS bindings loader for @opencode-cloud/core */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ if (!process.report || typeof process.report.getReport !== 'function') {
18
+ try {
19
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
20
+ return readFileSync(lddPath, 'utf8').includes('musl')
21
+ } catch {
22
+ return true
23
+ }
24
+ } else {
25
+ const { glibcVersionRuntime } = process.report.getReport().header
26
+ return !glibcVersionRuntime
27
+ }
28
+ }
29
+
30
+ switch (platform) {
31
+ case 'darwin':
32
+ localFileExisted = existsSync(join(__dirname, 'core.darwin-arm64.node'))
33
+ if (arch === 'arm64') {
34
+ try {
35
+ if (localFileExisted) {
36
+ nativeBinding = require('./core.darwin-arm64.node')
37
+ } else {
38
+ nativeBinding = require('@opencode-cloud/core-darwin-arm64')
39
+ }
40
+ } catch (e) {
41
+ loadError = e
42
+ }
43
+ } else {
44
+ localFileExisted = existsSync(join(__dirname, 'core.darwin-x64.node'))
45
+ try {
46
+ if (localFileExisted) {
47
+ nativeBinding = require('./core.darwin-x64.node')
48
+ } else {
49
+ nativeBinding = require('@opencode-cloud/core-darwin-x64')
50
+ }
51
+ } catch (e) {
52
+ loadError = e
53
+ }
54
+ }
55
+ break
56
+ case 'linux':
57
+ if (arch === 'x64') {
58
+ if (isMusl()) {
59
+ localFileExisted = existsSync(join(__dirname, 'core.linux-x64-musl.node'))
60
+ try {
61
+ if (localFileExisted) {
62
+ nativeBinding = require('./core.linux-x64-musl.node')
63
+ } else {
64
+ nativeBinding = require('@opencode-cloud/core-linux-x64-musl')
65
+ }
66
+ } catch (e) {
67
+ loadError = e
68
+ }
69
+ } else {
70
+ localFileExisted = existsSync(join(__dirname, 'core.linux-x64-gnu.node'))
71
+ try {
72
+ if (localFileExisted) {
73
+ nativeBinding = require('./core.linux-x64-gnu.node')
74
+ } else {
75
+ nativeBinding = require('@opencode-cloud/core-linux-x64-gnu')
76
+ }
77
+ } catch (e) {
78
+ loadError = e
79
+ }
80
+ }
81
+ } else if (arch === 'arm64') {
82
+ if (isMusl()) {
83
+ localFileExisted = existsSync(join(__dirname, 'core.linux-arm64-musl.node'))
84
+ try {
85
+ if (localFileExisted) {
86
+ nativeBinding = require('./core.linux-arm64-musl.node')
87
+ } else {
88
+ nativeBinding = require('@opencode-cloud/core-linux-arm64-musl')
89
+ }
90
+ } catch (e) {
91
+ loadError = e
92
+ }
93
+ } else {
94
+ localFileExisted = existsSync(join(__dirname, 'core.linux-arm64-gnu.node'))
95
+ try {
96
+ if (localFileExisted) {
97
+ nativeBinding = require('./core.linux-arm64-gnu.node')
98
+ } else {
99
+ nativeBinding = require('@opencode-cloud/core-linux-arm64-gnu')
100
+ }
101
+ } catch (e) {
102
+ loadError = e
103
+ }
104
+ }
105
+ }
106
+ break
107
+ case 'win32':
108
+ if (arch === 'x64') {
109
+ localFileExisted = existsSync(join(__dirname, 'core.win32-x64-msvc.node'))
110
+ try {
111
+ if (localFileExisted) {
112
+ nativeBinding = require('./core.win32-x64-msvc.node')
113
+ } else {
114
+ nativeBinding = require('@opencode-cloud/core-win32-x64-msvc')
115
+ }
116
+ } catch (e) {
117
+ loadError = e
118
+ }
119
+ } else if (arch === 'arm64') {
120
+ localFileExisted = existsSync(join(__dirname, 'core.win32-arm64-msvc.node'))
121
+ try {
122
+ if (localFileExisted) {
123
+ nativeBinding = require('./core.win32-arm64-msvc.node')
124
+ } else {
125
+ nativeBinding = require('@opencode-cloud/core-win32-arm64-msvc')
126
+ }
127
+ } catch (e) {
128
+ loadError = e
129
+ }
130
+ }
131
+ break
132
+ default:
133
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
134
+ }
135
+
136
+ if (!nativeBinding) {
137
+ if (loadError) {
138
+ throw loadError
139
+ }
140
+ throw new Error(`Failed to load native binding`)
141
+ }
142
+
143
+ const { getVersionJs, getVersionLongJs } = nativeBinding
144
+
145
+ module.exports.getVersionJs = getVersionJs
146
+ module.exports.getVersionLongJs = getVersionLongJs
@@ -0,0 +1,165 @@
1
+ //! Configuration management for opencode-cloud
2
+ //!
3
+ //! Handles loading, saving, and validating the JSONC configuration file.
4
+ //! Creates default config if missing, validates against schema.
5
+
6
+ pub mod paths;
7
+ pub mod schema;
8
+
9
+ use std::fs::{self, File};
10
+ use std::io::{Read, Write};
11
+ use std::path::PathBuf;
12
+
13
+ use anyhow::{Context, Result};
14
+ use jsonc_parser::parse_to_serde_value;
15
+
16
+ pub use paths::{get_config_dir, get_config_path, get_data_dir, get_pid_path};
17
+ pub use schema::Config;
18
+
19
+ /// Ensure the config directory exists
20
+ ///
21
+ /// Creates `~/.config/opencode-cloud/` if it doesn't exist.
22
+ /// Returns the path to the config directory.
23
+ pub fn ensure_config_dir() -> Result<PathBuf> {
24
+ let config_dir =
25
+ get_config_dir().ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
26
+
27
+ if !config_dir.exists() {
28
+ fs::create_dir_all(&config_dir).with_context(|| {
29
+ format!(
30
+ "Failed to create config directory: {}",
31
+ config_dir.display()
32
+ )
33
+ })?;
34
+ tracing::info!("Created config directory: {}", config_dir.display());
35
+ }
36
+
37
+ Ok(config_dir)
38
+ }
39
+
40
+ /// Ensure the data directory exists
41
+ ///
42
+ /// Creates `~/.local/share/opencode-cloud/` if it doesn't exist.
43
+ /// Returns the path to the data directory.
44
+ pub fn ensure_data_dir() -> Result<PathBuf> {
45
+ let data_dir =
46
+ get_data_dir().ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
47
+
48
+ if !data_dir.exists() {
49
+ fs::create_dir_all(&data_dir)
50
+ .with_context(|| format!("Failed to create data directory: {}", data_dir.display()))?;
51
+ tracing::info!("Created data directory: {}", data_dir.display());
52
+ }
53
+
54
+ Ok(data_dir)
55
+ }
56
+
57
+ /// Load configuration from the config file
58
+ ///
59
+ /// If the config file doesn't exist, creates a new one with default values.
60
+ /// Supports JSONC (JSON with comments).
61
+ /// Rejects unknown fields for strict validation.
62
+ pub fn load_config() -> Result<Config> {
63
+ let config_path =
64
+ get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
65
+
66
+ if !config_path.exists() {
67
+ // Create default config
68
+ tracing::info!(
69
+ "Config file not found, creating default at: {}",
70
+ config_path.display()
71
+ );
72
+ let config = Config::default();
73
+ save_config(&config)?;
74
+ return Ok(config);
75
+ }
76
+
77
+ // Read the file
78
+ let mut file = File::open(&config_path)
79
+ .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
80
+
81
+ let mut contents = String::new();
82
+ file.read_to_string(&mut contents)
83
+ .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
84
+
85
+ // Parse JSONC (JSON with comments)
86
+ let parsed_value = parse_to_serde_value(&contents, &Default::default())
87
+ .map_err(|e| anyhow::anyhow!("Invalid JSONC in config file: {}", e))?
88
+ .ok_or_else(|| anyhow::anyhow!("Config file is empty"))?;
89
+
90
+ // Deserialize into Config struct (deny_unknown_fields will reject unknown keys)
91
+ let config: Config = serde_json::from_value(parsed_value).with_context(|| {
92
+ format!(
93
+ "Invalid configuration in {}. Check for unknown fields or invalid values.",
94
+ config_path.display()
95
+ )
96
+ })?;
97
+
98
+ Ok(config)
99
+ }
100
+
101
+ /// Save configuration to the config file
102
+ ///
103
+ /// Creates a backup of the existing config (config.json.bak) before overwriting.
104
+ /// Ensures the config directory exists.
105
+ pub fn save_config(config: &Config) -> Result<()> {
106
+ ensure_config_dir()?;
107
+
108
+ let config_path =
109
+ get_config_path().ok_or_else(|| anyhow::anyhow!("Could not determine config file path"))?;
110
+
111
+ // Create backup if file exists
112
+ if config_path.exists() {
113
+ let backup_path = config_path.with_extension("json.bak");
114
+ fs::copy(&config_path, &backup_path)
115
+ .with_context(|| format!("Failed to create backup at: {}", backup_path.display()))?;
116
+ tracing::debug!("Created config backup: {}", backup_path.display());
117
+ }
118
+
119
+ // Serialize with pretty formatting
120
+ let json = serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
121
+
122
+ // Write to file
123
+ let mut file = File::create(&config_path)
124
+ .with_context(|| format!("Failed to create config file: {}", config_path.display()))?;
125
+
126
+ file.write_all(json.as_bytes())
127
+ .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
128
+
129
+ tracing::debug!("Saved config to: {}", config_path.display());
130
+
131
+ Ok(())
132
+ }
133
+
134
+ #[cfg(test)]
135
+ mod tests {
136
+ use super::*;
137
+
138
+ #[test]
139
+ fn test_path_resolution_returns_values() {
140
+ // Verify path functions return Some on supported platforms
141
+ assert!(get_config_dir().is_some());
142
+ assert!(get_data_dir().is_some());
143
+ assert!(get_config_path().is_some());
144
+ assert!(get_pid_path().is_some());
145
+ }
146
+
147
+ #[test]
148
+ fn test_paths_end_with_expected_names() {
149
+ let config_dir = get_config_dir().unwrap();
150
+ assert!(config_dir.ends_with("opencode-cloud"));
151
+
152
+ let data_dir = get_data_dir().unwrap();
153
+ assert!(data_dir.ends_with("opencode-cloud"));
154
+
155
+ let config_path = get_config_path().unwrap();
156
+ assert!(config_path.ends_with("config.json"));
157
+
158
+ let pid_path = get_pid_path().unwrap();
159
+ assert!(pid_path.ends_with("opencode-cloud.pid"));
160
+ }
161
+
162
+ // Note: Integration tests for load_config/save_config that modify the real
163
+ // filesystem are run via CLI commands rather than unit tests to avoid
164
+ // test isolation issues with environment variable manipulation in Rust 2024.
165
+ }
@@ -0,0 +1,108 @@
1
+ //! XDG-compliant path resolution for opencode-cloud
2
+ //!
3
+ //! Provides consistent path resolution across platforms:
4
+ //! - Linux/macOS: ~/.config/opencode-cloud/ and ~/.local/share/opencode-cloud/
5
+ //! - Windows: %APPDATA%\opencode-cloud\ and %LOCALAPPDATA%\opencode-cloud\
6
+
7
+ use std::path::PathBuf;
8
+
9
+ /// Get the configuration directory path
10
+ ///
11
+ /// Returns the directory where config.json should be stored:
12
+ /// - Linux: `~/.config/opencode-cloud/`
13
+ /// - macOS: `~/.config/opencode-cloud/` (XDG-style, not ~/Library)
14
+ /// - Windows: `%APPDATA%\opencode-cloud\`
15
+ pub fn get_config_dir() -> Option<PathBuf> {
16
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
17
+ {
18
+ directories::BaseDirs::new()
19
+ .map(|dirs| dirs.home_dir().join(".config").join("opencode-cloud"))
20
+ }
21
+ #[cfg(target_os = "windows")]
22
+ {
23
+ directories::BaseDirs::new()
24
+ .and_then(|dirs| dirs.config_dir().map(|d| d.to_path_buf()))
25
+ .map(|d| d.join("opencode-cloud"))
26
+ }
27
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
28
+ {
29
+ None
30
+ }
31
+ }
32
+
33
+ /// Get the data directory path
34
+ ///
35
+ /// Returns the directory where runtime data (PID file, logs, etc.) should be stored:
36
+ /// - Linux: `~/.local/share/opencode-cloud/`
37
+ /// - macOS: `~/.local/share/opencode-cloud/` (XDG-style, not ~/Library)
38
+ /// - Windows: `%LOCALAPPDATA%\opencode-cloud\`
39
+ pub fn get_data_dir() -> Option<PathBuf> {
40
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
41
+ {
42
+ directories::BaseDirs::new().map(|dirs| {
43
+ dirs.home_dir()
44
+ .join(".local")
45
+ .join("share")
46
+ .join("opencode-cloud")
47
+ })
48
+ }
49
+ #[cfg(target_os = "windows")]
50
+ {
51
+ directories::BaseDirs::new()
52
+ .and_then(|dirs| dirs.data_local_dir().map(|d| d.to_path_buf()))
53
+ .map(|d| d.join("opencode-cloud"))
54
+ }
55
+ #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
56
+ {
57
+ None
58
+ }
59
+ }
60
+
61
+ /// Get the full path to the config file
62
+ ///
63
+ /// Returns: `{config_dir}/config.json`
64
+ pub fn get_config_path() -> Option<PathBuf> {
65
+ get_config_dir().map(|d| d.join("config.json"))
66
+ }
67
+
68
+ /// Get the full path to the PID lock file
69
+ ///
70
+ /// Returns: `{data_dir}/opencode-cloud.pid`
71
+ pub fn get_pid_path() -> Option<PathBuf> {
72
+ get_data_dir().map(|d| d.join("opencode-cloud.pid"))
73
+ }
74
+
75
+ #[cfg(test)]
76
+ mod tests {
77
+ use super::*;
78
+
79
+ #[test]
80
+ fn test_config_dir_exists() {
81
+ let dir = get_config_dir();
82
+ assert!(dir.is_some());
83
+ let path = dir.unwrap();
84
+ assert!(path.ends_with("opencode-cloud"));
85
+ }
86
+
87
+ #[test]
88
+ fn test_data_dir_exists() {
89
+ let dir = get_data_dir();
90
+ assert!(dir.is_some());
91
+ let path = dir.unwrap();
92
+ assert!(path.ends_with("opencode-cloud"));
93
+ }
94
+
95
+ #[test]
96
+ fn test_config_path_ends_with_config_json() {
97
+ let path = get_config_path();
98
+ assert!(path.is_some());
99
+ assert!(path.unwrap().ends_with("config.json"));
100
+ }
101
+
102
+ #[test]
103
+ fn test_pid_path_ends_with_pid() {
104
+ let path = get_pid_path();
105
+ assert!(path.is_some());
106
+ assert!(path.unwrap().ends_with("opencode-cloud.pid"));
107
+ }
108
+ }
@@ -0,0 +1,98 @@
1
+ //! Configuration schema for opencode-cloud
2
+ //!
3
+ //! Defines the structure and defaults for the config.json file.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ /// Main configuration structure for opencode-cloud
8
+ ///
9
+ /// Serialized to/from `~/.config/opencode-cloud/config.json`
10
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11
+ #[serde(deny_unknown_fields)]
12
+ pub struct Config {
13
+ /// Config file version for migrations
14
+ pub version: u32,
15
+
16
+ /// Port for the opencode web UI (default: 8080)
17
+ #[serde(default = "default_port")]
18
+ pub port: u16,
19
+
20
+ /// Bind address (default: "localhost")
21
+ /// Use "localhost" for local-only access (secure default)
22
+ /// Use "0.0.0.0" for network access (requires explicit opt-in)
23
+ #[serde(default = "default_bind")]
24
+ pub bind: String,
25
+
26
+ /// Auto-restart service on crash (default: true)
27
+ #[serde(default = "default_auto_restart")]
28
+ pub auto_restart: bool,
29
+ }
30
+
31
+ fn default_port() -> u16 {
32
+ 8080
33
+ }
34
+
35
+ fn default_bind() -> String {
36
+ "localhost".to_string()
37
+ }
38
+
39
+ fn default_auto_restart() -> bool {
40
+ true
41
+ }
42
+
43
+ impl Default for Config {
44
+ fn default() -> Self {
45
+ Self {
46
+ version: 1,
47
+ port: default_port(),
48
+ bind: default_bind(),
49
+ auto_restart: default_auto_restart(),
50
+ }
51
+ }
52
+ }
53
+
54
+ impl Config {
55
+ /// Create a new Config with default values
56
+ pub fn new() -> Self {
57
+ Self::default()
58
+ }
59
+ }
60
+
61
+ #[cfg(test)]
62
+ mod tests {
63
+ use super::*;
64
+
65
+ #[test]
66
+ fn test_default_config() {
67
+ let config = Config::default();
68
+ assert_eq!(config.version, 1);
69
+ assert_eq!(config.port, 8080);
70
+ assert_eq!(config.bind, "localhost");
71
+ assert!(config.auto_restart);
72
+ }
73
+
74
+ #[test]
75
+ fn test_serialize_deserialize_roundtrip() {
76
+ let config = Config::default();
77
+ let json = serde_json::to_string(&config).unwrap();
78
+ let parsed: Config = serde_json::from_str(&json).unwrap();
79
+ assert_eq!(config, parsed);
80
+ }
81
+
82
+ #[test]
83
+ fn test_deserialize_with_missing_optional_fields() {
84
+ let json = r#"{"version": 1}"#;
85
+ let config: Config = serde_json::from_str(json).unwrap();
86
+ assert_eq!(config.version, 1);
87
+ assert_eq!(config.port, 8080);
88
+ assert_eq!(config.bind, "localhost");
89
+ assert!(config.auto_restart);
90
+ }
91
+
92
+ #[test]
93
+ fn test_reject_unknown_fields() {
94
+ let json = r#"{"version": 1, "unknown_field": "value"}"#;
95
+ let result: Result<Config, _> = serde_json::from_str(json);
96
+ assert!(result.is_err());
97
+ }
98
+ }
package/src/lib.rs ADDED
@@ -0,0 +1,35 @@
1
+ //! opencode-cloud-core - Core library for opencode-cloud
2
+ //!
3
+ //! This library provides the shared functionality for both the Rust CLI
4
+ //! and Node.js bindings via NAPI-RS.
5
+
6
+ pub mod config;
7
+ pub mod singleton;
8
+ pub mod version;
9
+
10
+ // Re-export version functions for Rust consumers
11
+ pub use version::{get_version, get_version_long};
12
+
13
+ // Re-export config types and functions
14
+ pub use config::{Config, load_config, save_config};
15
+
16
+ // Re-export singleton types
17
+ pub use singleton::{InstanceLock, SingletonError};
18
+
19
+ // NAPI bindings for Node.js consumers (only when napi feature is enabled)
20
+ #[cfg(feature = "napi")]
21
+ use napi_derive::napi;
22
+
23
+ /// Get the version string for Node.js consumers
24
+ #[cfg(feature = "napi")]
25
+ #[napi]
26
+ pub fn get_version_js() -> String {
27
+ get_version()
28
+ }
29
+
30
+ /// Get the long version string with build info for Node.js consumers
31
+ #[cfg(feature = "napi")]
32
+ #[napi]
33
+ pub fn get_version_long_js() -> String {
34
+ get_version_long()
35
+ }
@@ -0,0 +1,249 @@
1
+ //! Singleton enforcement via PID lock
2
+ //!
3
+ //! Ensures only one instance of opencode-cloud can run at a time.
4
+ //! Uses a PID file with stale detection - if a previous process crashed
5
+ //! without cleaning up, the stale lock is automatically removed.
6
+
7
+ use std::fs::{self, File};
8
+ use std::io::{Read, Write};
9
+ use std::path::PathBuf;
10
+
11
+ use thiserror::Error;
12
+
13
+ /// Errors that can occur during singleton lock operations
14
+ #[derive(Error, Debug)]
15
+ pub enum SingletonError {
16
+ /// Another instance is already running
17
+ #[error("Another instance is already running (PID: {0})")]
18
+ AlreadyRunning(u32),
19
+
20
+ /// Failed to create the lock directory
21
+ #[error("Failed to create lock directory: {0}")]
22
+ CreateDirFailed(String),
23
+
24
+ /// Failed to create or manage the lock file
25
+ #[error("Failed to create lock file: {0}")]
26
+ LockFailed(String),
27
+
28
+ /// The lock file path could not be determined
29
+ #[error("Invalid lock file path")]
30
+ InvalidPath,
31
+ }
32
+
33
+ /// A guard that holds the singleton instance lock
34
+ ///
35
+ /// The lock is automatically released when this struct is dropped.
36
+ /// The PID file is removed on drop to allow other instances to start.
37
+ pub struct InstanceLock {
38
+ pid_path: PathBuf,
39
+ }
40
+
41
+ impl InstanceLock {
42
+ /// Attempt to acquire the singleton lock
43
+ ///
44
+ /// # Returns
45
+ /// - `Ok(InstanceLock)` if the lock was successfully acquired
46
+ /// - `Err(SingletonError::AlreadyRunning(pid))` if another instance is running
47
+ /// - `Err(SingletonError::*)` for other errors
48
+ ///
49
+ /// # Stale Lock Detection
50
+ /// If a PID file exists but the process is no longer running,
51
+ /// the stale file is automatically cleaned up before acquiring the lock.
52
+ pub fn acquire(pid_path: PathBuf) -> Result<Self, SingletonError> {
53
+ // Ensure parent directory exists
54
+ if let Some(parent) = pid_path.parent() {
55
+ fs::create_dir_all(parent)
56
+ .map_err(|e| SingletonError::CreateDirFailed(e.to_string()))?;
57
+ }
58
+
59
+ // Check if PID file exists
60
+ if pid_path.exists() {
61
+ // Read existing PID
62
+ let mut file =
63
+ File::open(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
64
+ let mut contents = String::new();
65
+ file.read_to_string(&mut contents)
66
+ .map_err(|e| SingletonError::LockFailed(e.to_string()))?;
67
+
68
+ if let Ok(pid) = contents.trim().parse::<u32>() {
69
+ // Check if process is still running
70
+ if is_process_running(pid) {
71
+ return Err(SingletonError::AlreadyRunning(pid));
72
+ }
73
+ // Stale PID file - process not running, remove it
74
+ tracing::info!("Removing stale PID file (PID {} not running)", pid);
75
+ }
76
+ // Remove stale/invalid PID file
77
+ fs::remove_file(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
78
+ }
79
+
80
+ // Write our PID
81
+ let mut file =
82
+ File::create(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
83
+ write!(file, "{}", std::process::id())
84
+ .map_err(|e| SingletonError::LockFailed(e.to_string()))?;
85
+
86
+ tracing::debug!("Acquired singleton lock at: {}", pid_path.display());
87
+
88
+ Ok(Self { pid_path })
89
+ }
90
+
91
+ /// Explicitly release the lock
92
+ ///
93
+ /// This is called automatically on drop, but can be called explicitly
94
+ /// if you want to release the lock early.
95
+ pub fn release(self) {
96
+ // Dropping self will call Drop::drop which removes the file
97
+ }
98
+
99
+ /// Get the path to the PID file
100
+ pub fn pid_path(&self) -> &PathBuf {
101
+ &self.pid_path
102
+ }
103
+ }
104
+
105
+ impl Drop for InstanceLock {
106
+ fn drop(&mut self) {
107
+ if let Err(e) = fs::remove_file(&self.pid_path) {
108
+ tracing::warn!("Failed to remove PID file on drop: {}", e);
109
+ } else {
110
+ tracing::debug!("Released singleton lock: {}", self.pid_path.display());
111
+ }
112
+ }
113
+ }
114
+
115
+ /// Check if a process with the given PID is currently running
116
+ ///
117
+ /// Uses platform-specific methods to check process existence:
118
+ /// - Unix: `kill(pid, 0)` - signal 0 checks existence without sending signal
119
+ /// - Windows: OpenProcess API (deferred to v2)
120
+ fn is_process_running(pid: u32) -> bool {
121
+ #[cfg(unix)]
122
+ {
123
+ // On Unix, sending signal 0 checks if process exists
124
+ // without actually sending a signal
125
+ match std::process::Command::new("kill")
126
+ .args(["-0", &pid.to_string()])
127
+ .output()
128
+ {
129
+ Ok(output) => output.status.success(),
130
+ Err(_) => {
131
+ // Fallback: check /proc on Linux
132
+ #[cfg(target_os = "linux")]
133
+ {
134
+ std::path::Path::new(&format!("/proc/{}", pid)).exists()
135
+ }
136
+ #[cfg(not(target_os = "linux"))]
137
+ {
138
+ // On macOS, if kill -0 fails, assume process doesn't exist
139
+ false
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ #[cfg(windows)]
146
+ {
147
+ // Windows support deferred to v2
148
+ // For now, assume process is not running if we can't check
149
+ false
150
+ }
151
+
152
+ #[cfg(not(any(unix, windows)))]
153
+ {
154
+ // Unknown platform - assume not running
155
+ false
156
+ }
157
+ }
158
+
159
+ #[cfg(test)]
160
+ mod tests {
161
+ use super::*;
162
+ use tempfile::TempDir;
163
+
164
+ #[test]
165
+ fn test_acquire_creates_pid_file() {
166
+ let temp_dir = TempDir::new().unwrap();
167
+ let pid_path = temp_dir.path().join("test.pid");
168
+
169
+ let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
170
+
171
+ // Verify PID file exists
172
+ assert!(pid_path.exists());
173
+
174
+ // Verify it contains our PID
175
+ let contents = std::fs::read_to_string(&pid_path).unwrap();
176
+ let written_pid: u32 = contents.trim().parse().unwrap();
177
+ assert_eq!(written_pid, std::process::id());
178
+
179
+ // Drop the lock
180
+ drop(lock);
181
+
182
+ // Verify PID file was removed
183
+ assert!(!pid_path.exists());
184
+ }
185
+
186
+ #[test]
187
+ fn test_acquire_fails_when_already_locked() {
188
+ let temp_dir = TempDir::new().unwrap();
189
+ let pid_path = temp_dir.path().join("test.pid");
190
+
191
+ // Acquire first lock
192
+ let _lock1 = InstanceLock::acquire(pid_path.clone()).unwrap();
193
+
194
+ // Try to acquire second lock - should fail
195
+ let result = InstanceLock::acquire(pid_path.clone());
196
+ assert!(matches!(result, Err(SingletonError::AlreadyRunning(_))));
197
+ }
198
+
199
+ #[test]
200
+ fn test_stale_lock_cleanup() {
201
+ let temp_dir = TempDir::new().unwrap();
202
+ let pid_path = temp_dir.path().join("test.pid");
203
+
204
+ // Write a fake PID file with a PID that doesn't exist
205
+ // Using PID 999999 which is very unlikely to be running
206
+ std::fs::write(&pid_path, "999999").unwrap();
207
+
208
+ // Should be able to acquire lock (stale PID will be cleaned up)
209
+ let lock = InstanceLock::acquire(pid_path.clone());
210
+
211
+ // On Unix, this should succeed because 999999 likely isn't running
212
+ // On Windows or if 999999 happens to be running, this might fail
213
+ // which is acceptable - the test demonstrates the stale detection works
214
+ if lock.is_ok() {
215
+ assert!(pid_path.exists());
216
+ let contents = std::fs::read_to_string(&pid_path).unwrap();
217
+ let written_pid: u32 = contents.trim().parse().unwrap();
218
+ assert_eq!(written_pid, std::process::id());
219
+ }
220
+ }
221
+
222
+ #[test]
223
+ fn test_is_process_running_with_current_process() {
224
+ let current_pid = std::process::id();
225
+ assert!(is_process_running(current_pid));
226
+ }
227
+
228
+ #[test]
229
+ fn test_is_process_running_with_invalid_pid() {
230
+ // PID 0 is the kernel, PID 1 is init - use a very high unlikely PID
231
+ let unlikely_pid = 4_000_000_000;
232
+ assert!(!is_process_running(unlikely_pid));
233
+ }
234
+
235
+ #[test]
236
+ fn test_creates_parent_directories() {
237
+ let temp_dir = TempDir::new().unwrap();
238
+ let pid_path = temp_dir
239
+ .path()
240
+ .join("deep")
241
+ .join("nested")
242
+ .join("dir")
243
+ .join("test.pid");
244
+
245
+ let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
246
+ assert!(pid_path.exists());
247
+ drop(lock);
248
+ }
249
+ }
package/src/version.rs ADDED
@@ -0,0 +1,50 @@
1
+ //! Version information for opencode-cloud
2
+
3
+ /// Get the current version string
4
+ pub fn get_version() -> String {
5
+ env!("CARGO_PKG_VERSION").to_string()
6
+ }
7
+
8
+ /// Get the long version string with build information
9
+ ///
10
+ /// Returns version plus build metadata when available (git commit, build date).
11
+ /// Falls back gracefully if build info is not available.
12
+ pub fn get_version_long() -> String {
13
+ let version = get_version();
14
+
15
+ // Build info is set via environment variables during build
16
+ // These may be set by CI or build scripts
17
+ let git_hash = option_env!("OCC_GIT_HASH").unwrap_or("unknown");
18
+ let build_date = option_env!("OCC_BUILD_DATE").unwrap_or("unknown");
19
+
20
+ format!(
21
+ "{version} (git: {git_hash}, built: {build_date})",
22
+ version = version,
23
+ git_hash = git_hash,
24
+ build_date = build_date
25
+ )
26
+ }
27
+
28
+ #[cfg(test)]
29
+ mod tests {
30
+ use super::*;
31
+
32
+ #[test]
33
+ fn test_get_version_returns_valid_semver() {
34
+ let version = get_version();
35
+ assert!(!version.is_empty());
36
+ // Basic semver format check
37
+ let parts: Vec<&str> = version.split('.').collect();
38
+ assert!(parts.len() >= 2, "Version should have at least major.minor");
39
+ }
40
+
41
+ #[test]
42
+ fn test_get_version_long_contains_version() {
43
+ let long = get_version_long();
44
+ let short = get_version();
45
+ assert!(
46
+ long.contains(&short),
47
+ "Long version should contain short version"
48
+ );
49
+ }
50
+ }