@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 +44 -0
- package/LICENSE +21 -0
- package/build.rs +4 -0
- package/core.darwin-arm64.node +0 -0
- package/index.d.ts +14 -0
- package/index.js +146 -0
- package/package.json +40 -0
- package/src/bindings.d.ts +14 -0
- package/src/bindings.js +146 -0
- package/src/config/mod.rs +165 -0
- package/src/config/paths.rs +108 -0
- package/src/config/schema.rs +98 -0
- package/src/lib.rs +35 -0
- package/src/singleton/mod.rs +249 -0
- package/src/version.rs +50 -0
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
|
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;
|
package/src/bindings.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
|
|
@@ -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
|
+
}
|