loren-code 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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/package.json +70 -0
- package/scripts/ClaudeWrapperLauncher.cs +78 -0
- package/scripts/claude-wrapper.js +216 -0
- package/scripts/install-claude-ollama.ps1 +184 -0
- package/scripts/loren.js +515 -0
- package/scripts/uninstall-claude-ollama.ps1 +73 -0
- package/src/bootstrap.js +30 -0
- package/src/cache.js +64 -0
- package/src/config-watcher.js +73 -0
- package/src/config.js +98 -0
- package/src/http-agents.js +80 -0
- package/src/key-manager.js +69 -0
- package/src/logger.js +46 -0
- package/src/metrics.js +210 -0
- package/src/schemas.js +66 -0
- package/src/server.js +1238 -0
- package/src/usage-tracker.js +346 -0
package/.env.example
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copy to .env.local and fill in your real Ollama Cloud API keys.
|
|
2
|
+
# The bridge rotates one key per upstream request.
|
|
3
|
+
OLLAMA_API_KEYS=
|
|
4
|
+
|
|
5
|
+
# Optional: override where the bridge listens
|
|
6
|
+
# BRIDGE_HOST=127.0.0.1
|
|
7
|
+
# BRIDGE_PORT=8788
|
|
8
|
+
|
|
9
|
+
# Optional: Ollama cloud host
|
|
10
|
+
# OLLAMA_UPSTREAM_BASE_URL=https://ollama.com
|
|
11
|
+
|
|
12
|
+
# Alias map shown to Claude Code users. Left side = selectable model in Claude.
|
|
13
|
+
# Right side = real Ollama Cloud model id.
|
|
14
|
+
OLLAMA_MODEL_ALIASES={"ollama-free-auto":"gpt-oss:20b","ollama-free-fast":"gemma3:12b","ollama-free-tools":"qwen3:32b"}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LOREN CODE
|
|
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/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# LOREN CODE
|
|
2
|
+
|
|
3
|
+
Ollama Cloud model manager and local bridge for Claude Code, with dynamic model switching, API key rotation, and first-run bootstrap.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Dynamic model switching without restarting the server
|
|
8
|
+
- Live model list fetched from Ollama Cloud
|
|
9
|
+
- API key add/remove/rotate commands
|
|
10
|
+
- First-run setup for `.env.local` and `.runtime`
|
|
11
|
+
- Local bridge on port `8788`
|
|
12
|
+
- Claude Code wrapper support
|
|
13
|
+
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
### Prerequisites
|
|
17
|
+
|
|
18
|
+
- Node.js 18+
|
|
19
|
+
- Ollama Cloud API key(s)
|
|
20
|
+
|
|
21
|
+
### Clone And Run Locally
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/lorenzune/loren-code.git
|
|
25
|
+
cd loren-code
|
|
26
|
+
npm install
|
|
27
|
+
node scripts/loren.js help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If `.env.local` is missing, Loren creates it automatically from `.env.example`.
|
|
31
|
+
You still need to add real `OLLAMA_API_KEYS`.
|
|
32
|
+
|
|
33
|
+
### Install From npm
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g loren-code
|
|
37
|
+
loren help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The published package exposes `loren` via the `bin` field automatically.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Example `.env.local`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
BRIDGE_HOST=127.0.0.1
|
|
48
|
+
BRIDGE_PORT=8788
|
|
49
|
+
OLLAMA_API_KEYS=sk-key1,sk-key2
|
|
50
|
+
OLLAMA_UPSTREAM_BASE_URL=https://ollama.com
|
|
51
|
+
DEFAULT_MODEL_ALIAS=gpt-oss:20b
|
|
52
|
+
OLLAMA_MODEL_ALIASES={"ollama-free-auto":"gpt-oss:20b","ollama-free-fast":"gemma3:12b"}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### CLI
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
loren help
|
|
61
|
+
loren config:show
|
|
62
|
+
loren status
|
|
63
|
+
loren model:list
|
|
64
|
+
loren model:set gpt-oss:20b
|
|
65
|
+
loren model:refresh
|
|
66
|
+
loren keys:list
|
|
67
|
+
loren keys:add sk-your-new-key
|
|
68
|
+
loren keys:remove 0
|
|
69
|
+
loren keys:rotate
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Server
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm start
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
or:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
loren start
|
|
82
|
+
loren stop
|
|
83
|
+
loren status
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Bridge Endpoints
|
|
87
|
+
|
|
88
|
+
- `GET /health`
|
|
89
|
+
- `GET /v1/models`
|
|
90
|
+
- `GET /v1/models?refresh=true`
|
|
91
|
+
- `POST /v1/refresh`
|
|
92
|
+
- `POST /v1/messages`
|
|
93
|
+
- `POST /v1/messages/count_tokens`
|
|
94
|
+
- `GET /metrics`
|
|
95
|
+
- `GET /dashboard`
|
|
96
|
+
|
|
97
|
+
## Claude Code Integration
|
|
98
|
+
|
|
99
|
+
1. Start the bridge.
|
|
100
|
+
2. Point Claude Code to `http://127.0.0.1:8788`.
|
|
101
|
+
3. Use `loren model:set` to switch model aliases.
|
|
102
|
+
4. Use `loren model:refresh` to refresh the model list.
|
|
103
|
+
|
|
104
|
+
## Troubleshooting
|
|
105
|
+
|
|
106
|
+
### `Command not found: loren`
|
|
107
|
+
|
|
108
|
+
Install the package globally:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install -g loren-code
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If you are working from a local clone, use `node scripts/loren.js ...`.
|
|
115
|
+
|
|
116
|
+
### `npm` blocked in PowerShell
|
|
117
|
+
|
|
118
|
+
Use `npm.cmd` instead:
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
npm.cmd run help
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Missing API keys
|
|
125
|
+
|
|
126
|
+
Populate `OLLAMA_API_KEYS` in `.env.local`.
|
|
127
|
+
|
|
128
|
+
### Port already in use
|
|
129
|
+
|
|
130
|
+
Change `BRIDGE_PORT` in `.env.local`.
|
|
131
|
+
|
|
132
|
+
## Project Structure
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
loren-code/
|
|
136
|
+
|- scripts/
|
|
137
|
+
| |- loren.js
|
|
138
|
+
| |- claude-wrapper.js
|
|
139
|
+
| `- install-claude-ollama.ps1
|
|
140
|
+
|- src/
|
|
141
|
+
| |- bootstrap.js
|
|
142
|
+
| |- server.js
|
|
143
|
+
| |- config.js
|
|
144
|
+
| |- key-manager.js
|
|
145
|
+
| `- ...
|
|
146
|
+
|- .env.example
|
|
147
|
+
|- package.json
|
|
148
|
+
`- README.md
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "loren-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ollama Cloud Model Manager - Dynamic model switching, API key rotation, and real-time configuration updates",
|
|
5
|
+
"author": "lorenzune",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lorenzune/loren-code.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/lorenzune/loren-code#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/lorenzune/loren-code/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"ollama",
|
|
17
|
+
"ollama-cloud",
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"ai",
|
|
21
|
+
"llm",
|
|
22
|
+
"model-manager",
|
|
23
|
+
"cli",
|
|
24
|
+
"bridge"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "src/server.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"loren": "scripts/loren.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"scripts/ClaudeWrapperLauncher.cs",
|
|
33
|
+
"scripts/claude-wrapper.js",
|
|
34
|
+
"scripts/install-claude-ollama.ps1",
|
|
35
|
+
"scripts/loren.js",
|
|
36
|
+
"scripts/uninstall-claude-ollama.ps1",
|
|
37
|
+
"src/*.js",
|
|
38
|
+
".env.example",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"start": "node src/server.js",
|
|
43
|
+
"loren": "node scripts/loren.js",
|
|
44
|
+
"install:claude": "powershell -ExecutionPolicy Bypass -File scripts/install-claude-ollama.ps1",
|
|
45
|
+
"uninstall:claude": "powershell -ExecutionPolicy Bypass -File scripts/uninstall-claude-ollama.ps1",
|
|
46
|
+
"model:list": "node scripts/loren.js model:list",
|
|
47
|
+
"model:set": "node scripts/loren.js model:set",
|
|
48
|
+
"model:current": "node scripts/loren.js model:current",
|
|
49
|
+
"model:refresh": "node scripts/loren.js model:refresh",
|
|
50
|
+
"keys:list": "node scripts/loren.js keys:list",
|
|
51
|
+
"keys:add": "node scripts/loren.js keys:add",
|
|
52
|
+
"keys:remove": "node scripts/loren.js keys:remove",
|
|
53
|
+
"keys:rotate": "node scripts/loren.js keys:rotate",
|
|
54
|
+
"config:show": "node scripts/loren.js config:show",
|
|
55
|
+
"help": "node scripts/loren.js help",
|
|
56
|
+
"smoke": "node scripts/smoke-test.js",
|
|
57
|
+
"check:publish": "node scripts/publish-check.js",
|
|
58
|
+
"prepublishOnly": "npm test && npm run lint",
|
|
59
|
+
"test": "node scripts/publish-check.js",
|
|
60
|
+
"lint": "node -e \"console.log('No linter configured')\""
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"node-cache": "^5.1.2",
|
|
64
|
+
"winston": "^3.19.0",
|
|
65
|
+
"zod": "^4.3.6"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=18.0.0"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Diagnostics;
|
|
3
|
+
using System.IO;
|
|
4
|
+
|
|
5
|
+
internal static class ClaudeWrapperLauncher
|
|
6
|
+
{
|
|
7
|
+
private static int Main(string[] args)
|
|
8
|
+
{
|
|
9
|
+
try
|
|
10
|
+
{
|
|
11
|
+
var launcherDir = AppDomain.CurrentDomain.BaseDirectory;
|
|
12
|
+
var wrapperScript = Path.Combine(launcherDir, "claude-wrapper.js");
|
|
13
|
+
|
|
14
|
+
if (!File.Exists(wrapperScript))
|
|
15
|
+
{
|
|
16
|
+
Console.Error.WriteLine("Missing wrapper script: " + wrapperScript);
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var parent = Directory.GetParent(launcherDir);
|
|
21
|
+
var workingDirectory = parent != null ? parent.FullName : launcherDir;
|
|
22
|
+
|
|
23
|
+
var psi = new ProcessStartInfo
|
|
24
|
+
{
|
|
25
|
+
FileName = "node.exe",
|
|
26
|
+
Arguments = Quote(wrapperScript) + BuildArgumentString(args),
|
|
27
|
+
UseShellExecute = false,
|
|
28
|
+
RedirectStandardInput = false,
|
|
29
|
+
RedirectStandardOutput = false,
|
|
30
|
+
RedirectStandardError = false,
|
|
31
|
+
WorkingDirectory = workingDirectory,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
using (var process = Process.Start(psi))
|
|
35
|
+
{
|
|
36
|
+
if (process == null)
|
|
37
|
+
{
|
|
38
|
+
Console.Error.WriteLine("Failed to start node.exe");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.WaitForExit();
|
|
43
|
+
return process.ExitCode;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (Exception ex)
|
|
47
|
+
{
|
|
48
|
+
Console.Error.WriteLine(ex.Message);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private static string BuildArgumentString(string[] args)
|
|
54
|
+
{
|
|
55
|
+
if (args == null || args.Length == 0)
|
|
56
|
+
{
|
|
57
|
+
return string.Empty;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var pieces = new string[args.Length];
|
|
61
|
+
for (var i = 0; i < args.Length; i++)
|
|
62
|
+
{
|
|
63
|
+
pieces[i] = Quote(args[i]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return " " + string.Join(" ", pieces);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private static string Quote(string value)
|
|
70
|
+
{
|
|
71
|
+
if (string.IsNullOrEmpty(value))
|
|
72
|
+
{
|
|
73
|
+
return "\"\"";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { ensureEnvLocal, ensureRuntimeDir, getBridgeBaseUrl } from "../src/bootstrap.js";
|
|
7
|
+
import { loadConfig } from "../src/config.js";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
12
|
+
const stateDir = path.join(repoRoot, ".runtime");
|
|
13
|
+
const bridgePidPath = path.join(stateDir, "bridge.pid");
|
|
14
|
+
const bridgeLogPath = path.join(stateDir, "bridge.log");
|
|
15
|
+
const envFilePath = path.join(repoRoot, ".env.local");
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
process.chdir(repoRoot);
|
|
19
|
+
ensureRuntimeDir(repoRoot);
|
|
20
|
+
ensureEnvLocal(repoRoot);
|
|
21
|
+
const bridgeConfig = loadConfig();
|
|
22
|
+
const bridgeBaseUrl = getBridgeBaseUrl(bridgeConfig);
|
|
23
|
+
|
|
24
|
+
const env = {
|
|
25
|
+
...process.env,
|
|
26
|
+
...loadEnvFile(envFilePath),
|
|
27
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || bridgeBaseUrl,
|
|
28
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "bridge-local",
|
|
29
|
+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || "",
|
|
30
|
+
CLAUDE_CODE_SKIP_AUTH_LOGIN: process.env.CLAUDE_CODE_SKIP_AUTH_LOGIN || "1",
|
|
31
|
+
CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT || "claude-vscode",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
await ensureBridgeRunning(env, bridgeBaseUrl);
|
|
35
|
+
|
|
36
|
+
const claudeExecutable = resolveClaudeExecutable();
|
|
37
|
+
if (process.env.CLAUDE_WRAPPER_TEST === "1") {
|
|
38
|
+
console.log(
|
|
39
|
+
JSON.stringify(
|
|
40
|
+
{
|
|
41
|
+
bridgeUrl: env.ANTHROPIC_BASE_URL,
|
|
42
|
+
executable: claudeExecutable.command,
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const child = spawn(claudeExecutable.command, [...claudeExecutable.args, ...process.argv.slice(2)], {
|
|
52
|
+
stdio: "inherit",
|
|
53
|
+
env,
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
windowsHide: false,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
child.on("error", (error) => {
|
|
59
|
+
console.error(`Failed to start Claude executable: ${error.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
child.on("exit", (code, signal) => {
|
|
64
|
+
if (signal) {
|
|
65
|
+
process.kill(process.pid, signal);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
process.exit(code ?? 0);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveClaudeExecutable() {
|
|
74
|
+
const override = process.env.CLAUDE_REAL_EXECUTABLE;
|
|
75
|
+
if (override && fs.existsSync(override)) {
|
|
76
|
+
return { command: override, args: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const candidates = findClaudeExtensionExecutables();
|
|
80
|
+
if (candidates.length > 0) {
|
|
81
|
+
return { command: candidates[0], args: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { command: "claude", args: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findClaudeExtensionExecutables() {
|
|
88
|
+
const home = process.env.USERPROFILE || process.env.HOME;
|
|
89
|
+
if (!home) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const extensionRoot = path.join(home, ".vscode", "extensions");
|
|
94
|
+
if (!fs.existsSync(extensionRoot)) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const matches = [];
|
|
99
|
+
for (const entry of fs.readdirSync(extensionRoot, { withFileTypes: true })) {
|
|
100
|
+
if (!entry.isDirectory() || !entry.name.startsWith("anthropic.claude-code-")) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const candidates = [
|
|
105
|
+
path.join(extensionRoot, entry.name, "resources", "native-binary", "claude.exe"),
|
|
106
|
+
path.join(extensionRoot, entry.name, "resources", "native-binaries", "win32-x64", "claude.exe"),
|
|
107
|
+
path.join(extensionRoot, entry.name, "resources", "native-binaries", "win32-arm64", "claude.exe"),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const executable of candidates) {
|
|
111
|
+
if (fs.existsSync(executable)) {
|
|
112
|
+
matches.push(executable);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return matches.sort().reverse();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function ensureBridgeRunning(env, bridgeBaseUrl) {
|
|
121
|
+
if (await isBridgeHealthy(bridgeBaseUrl)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (fs.existsSync(bridgePidPath)) {
|
|
126
|
+
try {
|
|
127
|
+
const pid = Number.parseInt(fs.readFileSync(bridgePidPath, "utf8").trim(), 10);
|
|
128
|
+
if (Number.isInteger(pid)) {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
safeUnlink(bridgePidPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const bridge = spawn(process.execPath, [path.join(repoRoot, "src", "server.js")], {
|
|
137
|
+
cwd: repoRoot,
|
|
138
|
+
env,
|
|
139
|
+
detached: true,
|
|
140
|
+
stdio: ["ignore", fs.openSync(bridgeLogPath, "a"), fs.openSync(bridgeLogPath, "a")],
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(bridgePidPath, `${bridge.pid}\n`);
|
|
145
|
+
bridge.unref();
|
|
146
|
+
|
|
147
|
+
const deadline = Date.now() + 15000;
|
|
148
|
+
while (Date.now() < deadline) {
|
|
149
|
+
if (await isBridgeHealthy(bridgeBaseUrl)) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await sleep(400);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error(`Bridge did not become healthy. Check ${bridgeLogPath}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function isBridgeHealthy(bridgeBaseUrl) {
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(`${bridgeBaseUrl}/health`);
|
|
162
|
+
return response.ok;
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function loadEnvFile(filePath) {
|
|
169
|
+
if (!fs.existsSync(filePath)) {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = {};
|
|
174
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
175
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const separator = trimmed.indexOf("=");
|
|
182
|
+
if (separator === -1) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const key = trimmed.slice(0, separator).trim();
|
|
187
|
+
let value = trimmed.slice(separator + 1).trim();
|
|
188
|
+
if (
|
|
189
|
+
(value.startsWith("\"") && value.endsWith("\"")) ||
|
|
190
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
191
|
+
) {
|
|
192
|
+
value = value.slice(1, -1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
result[key] = value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function safeUnlink(filePath) {
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(filePath);
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sleep(ms) {
|
|
210
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
main().catch((error) => {
|
|
214
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
215
|
+
process.exit(1);
|
|
216
|
+
});
|