remo-code-supervisor 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/README.md +60 -0
- package/package.json +28 -0
- package/src/config.ts +55 -0
- package/src/git-ops.ts +85 -0
- package/src/hub-client.ts +195 -0
- package/src/index.ts +113 -0
- package/src/nssm-installer.ts +104 -0
- package/src/process-manager.ts +189 -0
- package/src/repo-scanner.ts +94 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# remo-code-supervisor
|
|
2
|
+
|
|
3
|
+
Local supervisor for Remo Code — manage and remote-launch `claude-code` sessions in your local repos from the Remo Code web UI.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Windows 10/11 (other OSes can run the foreground mode but not the service)
|
|
8
|
+
- [Bun](https://bun.sh) installed and on PATH
|
|
9
|
+
- `claude` CLI installed and on PATH
|
|
10
|
+
- `git` on PATH
|
|
11
|
+
- A Remo Code account with an API key (https://app.remo-code.com → Settings → API Keys)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
npx remo-code-supervisor install `
|
|
17
|
+
--api-key olx_xxx `
|
|
18
|
+
--roots "C:\Users\you\GitHub" `
|
|
19
|
+
--service-user ".\you" `
|
|
20
|
+
--service-password "<your windows password>"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This installs a Windows Service named `RemoCodeSupervisor` (via NSSM) that runs as your user, auto-starts at boot, and stays connected to the hub.
|
|
24
|
+
|
|
25
|
+
If you don't supply `--service-user`/`--service-password`, the service is created but runs as LocalSystem — which **cannot read your SSH keys, `gh` auth, or `~/.config`**. Strongly prefer running as your own user.
|
|
26
|
+
|
|
27
|
+
## Run in foreground (no service)
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
npx remo-code-supervisor run
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Other commands
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
npx remo-code-supervisor status # service status
|
|
37
|
+
npx remo-code-supervisor scan # list discovered repos
|
|
38
|
+
npx remo-code-supervisor uninstall # remove the service
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Config file
|
|
42
|
+
|
|
43
|
+
`%APPDATA%\remo-code\supervisor.json`
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"api_key": "olx_xxx",
|
|
48
|
+
"hub_url": "https://app.remo-code.com",
|
|
49
|
+
"roots": ["C:\\Users\\you\\GitHub"],
|
|
50
|
+
"max_concurrent": 1
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Logs
|
|
55
|
+
|
|
56
|
+
`%LOCALAPPDATA%\remo-code\logs\stdout.log` and `stderr.log` (rotating at 10MB).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "remo-code-supervisor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local supervisor for Remo Code — manage and remote-launch claude-code sessions in your local repos from the web UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"remo-code-supervisor": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "bun --watch src/index.ts run",
|
|
16
|
+
"start": "bun src/index.ts run"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["claude", "claude-code", "remote", "supervisor", "remo-code"],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/finedesignz/remo-code.git",
|
|
26
|
+
"directory": "supervisor"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
2
|
+
import { join, dirname } from 'path'
|
|
3
|
+
import { homedir, platform } from 'os'
|
|
4
|
+
|
|
5
|
+
export interface SupervisorConfig {
|
|
6
|
+
hubUrl: string
|
|
7
|
+
apiKey: string
|
|
8
|
+
roots: string[]
|
|
9
|
+
maxConcurrent: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function defaultConfigDir(): string {
|
|
13
|
+
if (platform() === 'win32') {
|
|
14
|
+
const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming')
|
|
15
|
+
return join(appData, 'remo-code')
|
|
16
|
+
}
|
|
17
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
18
|
+
return join(xdg, 'remo-code')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const CONFIG_DIR = defaultConfigDir()
|
|
22
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'supervisor.json')
|
|
23
|
+
const DEFAULT_HUB_URL = 'https://app.remo-code.com'
|
|
24
|
+
|
|
25
|
+
export function loadConfig(): SupervisorConfig {
|
|
26
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
27
|
+
throw new Error(`Supervisor not configured. Run: npx remo-code-supervisor install --api-key <olx_...>`)
|
|
28
|
+
}
|
|
29
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
|
30
|
+
if (!raw.api_key) throw new Error('config missing api_key')
|
|
31
|
+
return {
|
|
32
|
+
hubUrl: raw.hub_url || DEFAULT_HUB_URL,
|
|
33
|
+
apiKey: raw.api_key,
|
|
34
|
+
roots: raw.roots || [],
|
|
35
|
+
maxConcurrent: raw.max_concurrent || 1,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function saveConfig(cfg: Partial<SupervisorConfig> & { apiKey: string }) {
|
|
40
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
41
|
+
const existing = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) : {}
|
|
42
|
+
const merged = {
|
|
43
|
+
...existing,
|
|
44
|
+
api_key: cfg.apiKey,
|
|
45
|
+
hub_url: cfg.hubUrl || existing.hub_url || DEFAULT_HUB_URL,
|
|
46
|
+
roots: cfg.roots || existing.roots || [],
|
|
47
|
+
max_concurrent: cfg.maxConcurrent || existing.max_concurrent || 1,
|
|
48
|
+
}
|
|
49
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseRoots(input: string | undefined): string[] {
|
|
53
|
+
if (!input) return []
|
|
54
|
+
return input.split(/[,;]/).map((s) => s.trim()).filter(Boolean)
|
|
55
|
+
}
|
package/src/git-ops.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, readFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
async function runGit(args: string[], cwd?: string, timeoutMs = 60_000): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
5
|
+
const proc = Bun.spawn(['git', ...args], {
|
|
6
|
+
cwd,
|
|
7
|
+
stdout: 'pipe',
|
|
8
|
+
stderr: 'pipe',
|
|
9
|
+
})
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
11
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
12
|
+
timer = setTimeout(() => {
|
|
13
|
+
try { proc.kill() } catch {}
|
|
14
|
+
reject(new Error(`git timed out: ${args.join(' ')}`))
|
|
15
|
+
}, timeoutMs)
|
|
16
|
+
})
|
|
17
|
+
try {
|
|
18
|
+
const code = await Promise.race([proc.exited, timeout])
|
|
19
|
+
const stdout = await new Response(proc.stdout).text()
|
|
20
|
+
const stderr = await new Response(proc.stderr).text()
|
|
21
|
+
if (code !== 0) throw new Error(stderr.trim() || `git exited ${code}`)
|
|
22
|
+
return { stdout, stderr, code: code as number }
|
|
23
|
+
} finally {
|
|
24
|
+
if (timer) clearTimeout(timer)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function isDirty(repoPath: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await runGit(['status', '--porcelain'], repoPath, 10_000)
|
|
31
|
+
return stdout.trim().length > 0
|
|
32
|
+
} catch { return false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function currentBranch(repoPath: string): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath, 10_000)
|
|
38
|
+
return stdout.trim() || null
|
|
39
|
+
} catch { return null }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface GitOpResult {
|
|
43
|
+
ok: boolean
|
|
44
|
+
error?: string
|
|
45
|
+
data?: any
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function cloneRepo(cloneUrl: string, targetPath: string): Promise<GitOpResult> {
|
|
49
|
+
try {
|
|
50
|
+
if (existsSync(targetPath)) return { ok: false, error: `target already exists: ${targetPath}` }
|
|
51
|
+
await runGit(['clone', '--no-recurse-submodules', cloneUrl, targetPath], undefined, 600_000)
|
|
52
|
+
const cfgPath = join(targetPath, '.git', 'config')
|
|
53
|
+
if (existsSync(cfgPath)) {
|
|
54
|
+
const raw = readFileSync(cfgPath, 'utf-8')
|
|
55
|
+
const stripped = raw.replace(/https:\/\/[^@\s]+@github\.com/g, 'https://github.com')
|
|
56
|
+
writeFileSync(cfgPath, stripped, 'utf-8')
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, data: { path: targetPath } }
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
return { ok: false, error: err.message }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function pullRepo(repoPath: string, branch: string, tokenizedUrl: string): Promise<GitOpResult> {
|
|
65
|
+
try {
|
|
66
|
+
if (await isDirty(repoPath)) return { ok: false, error: 'worktree is dirty; refusing to pull' }
|
|
67
|
+
await runGit(['fetch', tokenizedUrl, branch], repoPath, 120_000)
|
|
68
|
+
await runGit(['checkout', branch], repoPath, 60_000)
|
|
69
|
+
await runGit(['merge', '--ff-only', 'FETCH_HEAD'], repoPath, 60_000)
|
|
70
|
+
return { ok: true }
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
return { ok: false, error: err.message }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function checkoutBranch(repoPath: string, branch: string, create: boolean): Promise<GitOpResult> {
|
|
77
|
+
try {
|
|
78
|
+
if (await isDirty(repoPath)) return { ok: false, error: 'worktree is dirty; refusing to switch branches' }
|
|
79
|
+
if (create) await runGit(['checkout', '-b', branch], repoPath, 30_000)
|
|
80
|
+
else await runGit(['checkout', branch], repoPath, 30_000)
|
|
81
|
+
return { ok: true }
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
return { ok: false, error: err.message }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { hostname, platform, release } from 'os'
|
|
2
|
+
import { scanAll } from './repo-scanner'
|
|
3
|
+
import { cloneRepo, pullRepo, checkoutBranch } from './git-ops'
|
|
4
|
+
import { ProcessManager, type ProcState } from './process-manager'
|
|
5
|
+
import type { SupervisorConfig } from './config'
|
|
6
|
+
|
|
7
|
+
const VERSION = '0.1.0'
|
|
8
|
+
|
|
9
|
+
type OutboundMsg =
|
|
10
|
+
| { type: 'auth'; api_key: string; project_dir: string; hostname: string; role: 'supervisor' }
|
|
11
|
+
| { type: 'supervisor.hello'; version: string; os: string; hostname: string; roots: string[]; capabilities: string[] }
|
|
12
|
+
| { type: 'supervisor.state'; state: ProcState; run_id?: string | null; repo_path?: string | null; pid?: number | null; restart_count?: number; last_exit?: any }
|
|
13
|
+
| { type: 'supervisor.log'; level: string; message: string; run_id?: string; ts?: string }
|
|
14
|
+
| { type: 'repo.scan_result'; req_id: string; repos: any[] }
|
|
15
|
+
| { type: 'repo.op_result'; req_id: string; op: string; ok: boolean; error?: string; data?: any }
|
|
16
|
+
| { type: 'repo.clone_progress'; req_id: string; stage: string; percent?: number }
|
|
17
|
+
| { type: 'pong' }
|
|
18
|
+
|
|
19
|
+
export class SupervisorClient {
|
|
20
|
+
private ws: WebSocket | null = null
|
|
21
|
+
private cfg: SupervisorConfig
|
|
22
|
+
private authenticated = false
|
|
23
|
+
private reconnectAttempts = 0
|
|
24
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
25
|
+
private pm: ProcessManager
|
|
26
|
+
|
|
27
|
+
constructor(cfg: SupervisorConfig) {
|
|
28
|
+
this.cfg = cfg
|
|
29
|
+
this.pm = new ProcessManager({
|
|
30
|
+
onStateChange: (state, info) => {
|
|
31
|
+
this.send({
|
|
32
|
+
type: 'supervisor.state',
|
|
33
|
+
state,
|
|
34
|
+
run_id: info.runId ?? null,
|
|
35
|
+
repo_path: info.repoPath ?? null,
|
|
36
|
+
pid: info.pid ?? null,
|
|
37
|
+
restart_count: info.restartCount ?? 0,
|
|
38
|
+
last_exit: info.lastExit,
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
onLog: (level, message, runId) => {
|
|
42
|
+
this.log(level, message, runId)
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
connect() {
|
|
48
|
+
const wsUrl = this.cfg.hubUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/ws/agent'
|
|
49
|
+
this.log('info', `connecting to ${wsUrl}`)
|
|
50
|
+
try {
|
|
51
|
+
this.ws = new WebSocket(wsUrl)
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
this.log('error', `WebSocket construct failed: ${err.message}`)
|
|
54
|
+
this.scheduleReconnect()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
this.ws.onopen = () => {
|
|
58
|
+
this.reconnectAttempts = 0
|
|
59
|
+
this.send({
|
|
60
|
+
type: 'auth',
|
|
61
|
+
api_key: this.cfg.apiKey,
|
|
62
|
+
project_dir: '__supervisor__',
|
|
63
|
+
hostname: hostname(),
|
|
64
|
+
role: 'supervisor',
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
this.ws.onmessage = (event) => {
|
|
68
|
+
let msg: any
|
|
69
|
+
try { msg = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as any)) } catch { return }
|
|
70
|
+
this.handleMessage(msg)
|
|
71
|
+
}
|
|
72
|
+
this.ws.onclose = () => {
|
|
73
|
+
this.authenticated = false
|
|
74
|
+
this.log('warn', 'WebSocket closed')
|
|
75
|
+
this.scheduleReconnect()
|
|
76
|
+
}
|
|
77
|
+
this.ws.onerror = () => {
|
|
78
|
+
// onclose will follow
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private scheduleReconnect() {
|
|
83
|
+
if (this.reconnectTimer) return
|
|
84
|
+
const delay = Math.min(60_000, 1000 * Math.pow(2, Math.min(this.reconnectAttempts, 6)))
|
|
85
|
+
this.reconnectAttempts++
|
|
86
|
+
this.log('info', `reconnecting in ${delay}ms`)
|
|
87
|
+
this.reconnectTimer = setTimeout(() => {
|
|
88
|
+
this.reconnectTimer = null
|
|
89
|
+
this.connect()
|
|
90
|
+
}, delay)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private send(msg: OutboundMsg) {
|
|
94
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
95
|
+
try { this.ws.send(JSON.stringify(msg)) } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private log(level: string, message: string, runId?: string) {
|
|
100
|
+
console.log(`[${level}] ${message}`)
|
|
101
|
+
this.send({ type: 'supervisor.log', level, message, run_id: runId, ts: new Date().toISOString() })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async handleMessage(msg: any) {
|
|
105
|
+
if (msg.type === 'ping') { this.send({ type: 'pong' }); return }
|
|
106
|
+
if (msg.type === 'auth_ok') {
|
|
107
|
+
this.authenticated = true
|
|
108
|
+
this.log('info', 'authenticated; sending hello')
|
|
109
|
+
this.send({
|
|
110
|
+
type: 'supervisor.hello',
|
|
111
|
+
version: VERSION,
|
|
112
|
+
os: `${platform()} ${release()}`,
|
|
113
|
+
hostname: hostname(),
|
|
114
|
+
roots: this.cfg.roots,
|
|
115
|
+
capabilities: ['supervisor', 'agent'],
|
|
116
|
+
})
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (msg.type === 'auth_error') {
|
|
120
|
+
this.log('error', `auth failed: ${msg.error}`)
|
|
121
|
+
try { this.ws?.close() } catch {}
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!this.authenticated) return
|
|
126
|
+
|
|
127
|
+
switch (msg.type) {
|
|
128
|
+
case 'repo.scan': await this.onRepoScan(msg); break
|
|
129
|
+
case 'repo.clone': await this.onRepoClone(msg); break
|
|
130
|
+
case 'repo.pull': await this.onRepoPull(msg); break
|
|
131
|
+
case 'repo.branch_checkout': await this.onBranchCheckout(msg); break
|
|
132
|
+
case 'session.start': await this.onSessionStart(msg); break
|
|
133
|
+
case 'session.stop': await this.onSessionStop(msg); break
|
|
134
|
+
case 'session.status': this.onSessionStatus(msg); break
|
|
135
|
+
default:
|
|
136
|
+
// unknown
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async onRepoScan(msg: { req_id: string }) {
|
|
142
|
+
const repos = scanAll(this.cfg.roots)
|
|
143
|
+
this.send({ type: 'repo.scan_result', req_id: msg.req_id, repos })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async onRepoClone(msg: { req_id: string; clone_url: string; target_path: string; repo_full_name: string }) {
|
|
147
|
+
this.send({ type: 'repo.clone_progress', req_id: msg.req_id, stage: 'cloning' })
|
|
148
|
+
const res = await cloneRepo(msg.clone_url, msg.target_path)
|
|
149
|
+
this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'clone', ok: res.ok, error: res.error, data: res.data })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async onRepoPull(msg: { req_id: string; repo_path: string; branch: string; clone_url: string }) {
|
|
153
|
+
const res = await pullRepo(msg.repo_path, msg.branch, msg.clone_url)
|
|
154
|
+
this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'pull', ok: res.ok, error: res.error })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async onBranchCheckout(msg: { req_id: string; repo_path: string; branch: string; create: boolean }) {
|
|
158
|
+
const res = await checkoutBranch(msg.repo_path, msg.branch, msg.create)
|
|
159
|
+
this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'checkout', ok: res.ok, error: res.error })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async onSessionStart(msg: { run_id: string; repo_path: string; branch?: string; pull?: boolean; initial_prompt?: string; api_key: string; hub_url: string }) {
|
|
163
|
+
// pull/checkout pre-flight (best-effort)
|
|
164
|
+
if (msg.pull && msg.branch) {
|
|
165
|
+
this.log('info', `pre-flight: checkout+pull not implemented w/o tokenized url; skipping`, msg.run_id)
|
|
166
|
+
}
|
|
167
|
+
if (msg.branch) {
|
|
168
|
+
const checkout = await checkoutBranch(msg.repo_path, msg.branch, false)
|
|
169
|
+
if (!checkout.ok) {
|
|
170
|
+
this.log('warn', `checkout failed: ${checkout.error}`, msg.run_id)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await this.pm.start({
|
|
174
|
+
runId: msg.run_id,
|
|
175
|
+
repoPath: msg.repo_path,
|
|
176
|
+
branch: msg.branch ?? null,
|
|
177
|
+
initialPrompt: msg.initial_prompt ?? null,
|
|
178
|
+
apiKey: this.cfg.apiKey,
|
|
179
|
+
hubUrl: this.cfg.hubUrl,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async onSessionStop(msg: { run_id: string; reason: string }) {
|
|
184
|
+
await this.pm.stop(msg.reason)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private onSessionStatus(msg: { req_id: string }) {
|
|
188
|
+
this.send({
|
|
189
|
+
type: 'supervisor.state',
|
|
190
|
+
state: this.pm.currentState,
|
|
191
|
+
run_id: this.pm.currentRunId,
|
|
192
|
+
repo_path: this.pm.currentRepoPath,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { loadConfig, saveConfig, parseRoots, CONFIG_PATH } from './config'
|
|
3
|
+
import { SupervisorClient } from './hub-client'
|
|
4
|
+
import { installService, uninstallService, statusService, SERVICE_NAME } from './nssm-installer'
|
|
5
|
+
import { scanAll } from './repo-scanner'
|
|
6
|
+
|
|
7
|
+
const VERSION = '0.1.0'
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv: string[]): { cmd: string; flags: Record<string, string | boolean> } {
|
|
10
|
+
const cmd = argv[0] || 'help'
|
|
11
|
+
const flags: Record<string, string | boolean> = {}
|
|
12
|
+
for (let i = 1; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i]
|
|
14
|
+
if (a.startsWith('--')) {
|
|
15
|
+
if (a.includes('=')) {
|
|
16
|
+
const [k, ...v] = a.split('=')
|
|
17
|
+
flags[k.slice(2)] = v.join('=')
|
|
18
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
19
|
+
flags[a.slice(2)] = argv[++i]
|
|
20
|
+
} else {
|
|
21
|
+
flags[a.slice(2)] = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { cmd, flags }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printHelp() {
|
|
29
|
+
console.log(`remo-code-supervisor v${VERSION}
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
npx remo-code-supervisor install --api-key <olx_...> [--roots <paths>] [--hub-url <url>] [--service-user <user> --service-password <pwd>]
|
|
33
|
+
npx remo-code-supervisor uninstall
|
|
34
|
+
npx remo-code-supervisor run # foreground (used by the Windows service)
|
|
35
|
+
npx remo-code-supervisor status
|
|
36
|
+
npx remo-code-supervisor scan # one-shot: list discovered repos
|
|
37
|
+
|
|
38
|
+
Notes:
|
|
39
|
+
- --roots is comma- or semicolon-separated; e.g. "C:\\Users\\artic\\GitHub"
|
|
40
|
+
- On Windows, install creates an NSSM-backed service that auto-starts at boot.
|
|
41
|
+
Provide --service-user (e.g. .\\artic) and --service-password to run the
|
|
42
|
+
service as your own Windows user so SSH keys, gh auth, and ~/.config remain reachable.
|
|
43
|
+
- Config file: ${CONFIG_PATH}
|
|
44
|
+
`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const { cmd, flags } = parseArgs(process.argv.slice(2))
|
|
49
|
+
|
|
50
|
+
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
51
|
+
printHelp(); return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cmd === 'install') {
|
|
55
|
+
const apiKey = (flags['api-key'] as string) || ''
|
|
56
|
+
if (!apiKey) { console.error('error: --api-key required'); process.exit(1) }
|
|
57
|
+
const hubUrl = (flags['hub-url'] as string) || undefined
|
|
58
|
+
const roots = parseRoots(flags['roots'] as string | undefined)
|
|
59
|
+
saveConfig({ apiKey, hubUrl, roots })
|
|
60
|
+
console.log(`[install] config saved to ${CONFIG_PATH}`)
|
|
61
|
+
const serviceUser = flags['service-user'] as string | undefined
|
|
62
|
+
const servicePassword = flags['service-password'] as string | undefined
|
|
63
|
+
try {
|
|
64
|
+
await installService({ apiKey, hubUrl, roots, serviceUser, servicePassword })
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
console.error(`[install] ${err.message}`)
|
|
67
|
+
console.error(`[install] You can still run the supervisor manually with: npx remo-code-supervisor run`)
|
|
68
|
+
process.exit(2)
|
|
69
|
+
}
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (cmd === 'uninstall') {
|
|
74
|
+
await uninstallService()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cmd === 'status') {
|
|
79
|
+
const s = await statusService().catch(() => 'unknown')
|
|
80
|
+
console.log(`service: ${s}`)
|
|
81
|
+
console.log(`config: ${CONFIG_PATH}`)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (cmd === 'scan') {
|
|
86
|
+
const cfg = loadConfig()
|
|
87
|
+
const repos = scanAll(cfg.roots)
|
|
88
|
+
console.log(JSON.stringify(repos, null, 2))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (cmd === 'run') {
|
|
93
|
+
const cfg = loadConfig()
|
|
94
|
+
console.log(`[run] remo-code-supervisor v${VERSION}`)
|
|
95
|
+
console.log(`[run] hub=${cfg.hubUrl} roots=${cfg.roots.length}`)
|
|
96
|
+
const client = new SupervisorClient(cfg)
|
|
97
|
+
client.connect()
|
|
98
|
+
process.on('SIGINT', () => { console.log('shutting down'); process.exit(0) })
|
|
99
|
+
process.on('SIGTERM', () => { console.log('shutting down'); process.exit(0) })
|
|
100
|
+
// Keep alive
|
|
101
|
+
await new Promise(() => {})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.error(`unknown command: ${cmd}`)
|
|
106
|
+
printHelp()
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main().catch((err) => {
|
|
111
|
+
console.error('fatal:', err.message)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { homedir, platform } from 'os'
|
|
4
|
+
|
|
5
|
+
export const NSSM_DIR = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'remo-code')
|
|
6
|
+
export const LOG_DIR = join(NSSM_DIR, 'logs')
|
|
7
|
+
export const NSSM_PATH = join(NSSM_DIR, 'nssm.exe')
|
|
8
|
+
export const SERVICE_NAME = 'RemoCodeSupervisor'
|
|
9
|
+
|
|
10
|
+
const NSSM_DOWNLOAD = 'https://nssm.cc/release/nssm-2.24.zip'
|
|
11
|
+
|
|
12
|
+
async function nssm(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
13
|
+
const proc = Bun.spawn([NSSM_PATH, ...args], { stdout: 'pipe', stderr: 'pipe' })
|
|
14
|
+
const code = await proc.exited
|
|
15
|
+
const stdout = await new Response(proc.stdout).text()
|
|
16
|
+
const stderr = await new Response(proc.stderr).text()
|
|
17
|
+
return { code: code as number, stdout, stderr }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function ensureNssm(): Promise<boolean> {
|
|
21
|
+
if (existsSync(NSSM_PATH)) return true
|
|
22
|
+
mkdirSync(NSSM_DIR, { recursive: true })
|
|
23
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
24
|
+
console.log(`[install] NSSM not found at ${NSSM_PATH}`)
|
|
25
|
+
console.log(`[install] Please download NSSM from ${NSSM_DOWNLOAD}, extract nssm.exe (win64 version), and place it at ${NSSM_PATH}`)
|
|
26
|
+
console.log(`[install] Then re-run: npx remo-code-supervisor install`)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bunPath(): string {
|
|
31
|
+
// Best-effort: resolve from Bun.argv0 or PATH
|
|
32
|
+
return process.execPath
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function entryScriptPath(): string {
|
|
36
|
+
// For `npx remo-code-supervisor install`, the script we want the service to run is this same package's run command.
|
|
37
|
+
// We resolve the supervisor's own bin script by walking up from process.argv[1].
|
|
38
|
+
// Fallback: just shell out to `npx` style.
|
|
39
|
+
return process.argv[1] || ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InstallOptions {
|
|
43
|
+
apiKey: string
|
|
44
|
+
hubUrl?: string
|
|
45
|
+
roots: string[]
|
|
46
|
+
serviceUser?: string
|
|
47
|
+
servicePassword?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function installService(opts: InstallOptions): Promise<void> {
|
|
51
|
+
if (platform() !== 'win32') {
|
|
52
|
+
throw new Error('Service install currently supports Windows only. Use `remo-code-supervisor run` to run in foreground on other OSes.')
|
|
53
|
+
}
|
|
54
|
+
const ok = await ensureNssm()
|
|
55
|
+
if (!ok) throw new Error('NSSM not installed')
|
|
56
|
+
|
|
57
|
+
mkdirSync(LOG_DIR, { recursive: true })
|
|
58
|
+
|
|
59
|
+
const bun = bunPath()
|
|
60
|
+
const entry = entryScriptPath()
|
|
61
|
+
|
|
62
|
+
// Remove existing service first (idempotent)
|
|
63
|
+
await nssm(['stop', SERVICE_NAME]).catch(() => {})
|
|
64
|
+
await nssm(['remove', SERVICE_NAME, 'confirm']).catch(() => {})
|
|
65
|
+
|
|
66
|
+
let r = await nssm(['install', SERVICE_NAME, bun, entry, 'run'])
|
|
67
|
+
if (r.code !== 0) throw new Error(`nssm install failed: ${r.stderr || r.stdout}`)
|
|
68
|
+
|
|
69
|
+
await nssm(['set', SERVICE_NAME, 'DisplayName', 'Remo Code Supervisor'])
|
|
70
|
+
await nssm(['set', SERVICE_NAME, 'Description', 'Remote-control supervisor for Claude Code sessions'])
|
|
71
|
+
await nssm(['set', SERVICE_NAME, 'Start', 'SERVICE_AUTO_START'])
|
|
72
|
+
await nssm(['set', SERVICE_NAME, 'AppStdout', join(LOG_DIR, 'stdout.log')])
|
|
73
|
+
await nssm(['set', SERVICE_NAME, 'AppStderr', join(LOG_DIR, 'stderr.log')])
|
|
74
|
+
await nssm(['set', SERVICE_NAME, 'AppRotateFiles', '1'])
|
|
75
|
+
await nssm(['set', SERVICE_NAME, 'AppRotateBytes', '10485760'])
|
|
76
|
+
|
|
77
|
+
if (opts.serviceUser && opts.servicePassword) {
|
|
78
|
+
const r2 = await nssm(['set', SERVICE_NAME, 'ObjectName', opts.serviceUser, opts.servicePassword])
|
|
79
|
+
if (r2.code !== 0) throw new Error(`nssm set ObjectName failed: ${r2.stderr || r2.stdout}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const r3 = await nssm(['start', SERVICE_NAME])
|
|
83
|
+
if (r3.code !== 0) throw new Error(`nssm start failed: ${r3.stderr || r3.stdout}`)
|
|
84
|
+
console.log(`[install] Service ${SERVICE_NAME} installed and started.`)
|
|
85
|
+
console.log(`[install] Logs: ${LOG_DIR}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function uninstallService() {
|
|
89
|
+
if (platform() !== 'win32') return
|
|
90
|
+
if (!existsSync(NSSM_PATH)) {
|
|
91
|
+
console.log('[uninstall] nssm not present; nothing to do')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
await nssm(['stop', SERVICE_NAME]).catch(() => {})
|
|
95
|
+
await nssm(['remove', SERVICE_NAME, 'confirm']).catch(() => {})
|
|
96
|
+
console.log(`[uninstall] Service ${SERVICE_NAME} removed.`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function statusService(): Promise<string> {
|
|
100
|
+
if (platform() !== 'win32') return 'not-windows'
|
|
101
|
+
if (!existsSync(NSSM_PATH)) return 'nssm-missing'
|
|
102
|
+
const r = await nssm(['status', SERVICE_NAME])
|
|
103
|
+
return r.stdout.trim() || 'unknown'
|
|
104
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Subprocess } from 'bun'
|
|
2
|
+
|
|
3
|
+
export type ProcState = 'idle' | 'starting' | 'running' | 'stopping' | 'crashed' | 'stopped'
|
|
4
|
+
|
|
5
|
+
export interface RunSpec {
|
|
6
|
+
runId: string
|
|
7
|
+
repoPath: string
|
|
8
|
+
branch: string | null
|
|
9
|
+
initialPrompt: string | null
|
|
10
|
+
apiKey: string
|
|
11
|
+
hubUrl: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProcessManagerCallbacks {
|
|
15
|
+
onStateChange: (state: ProcState, info: { runId?: string; repoPath?: string; pid?: number; restartCount?: number; lastExit?: { code: number | null; reason: string; stderrTail?: string } }) => void
|
|
16
|
+
onLog: (level: 'info' | 'warn' | 'error', message: string, runId?: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const BACKOFF_SCHEDULE = [1000, 2000, 4000, 8000, 16000, 30000]
|
|
20
|
+
const CIRCUIT_WINDOW_MS = 10 * 60_000
|
|
21
|
+
const CIRCUIT_THRESHOLD = 5
|
|
22
|
+
|
|
23
|
+
export class ProcessManager {
|
|
24
|
+
private state: ProcState = 'idle'
|
|
25
|
+
private proc: Subprocess | null = null
|
|
26
|
+
private currentRun: RunSpec | null = null
|
|
27
|
+
private restartCount = 0
|
|
28
|
+
private recentCrashes: number[] = []
|
|
29
|
+
private restartTimer: ReturnType<typeof setTimeout> | null = null
|
|
30
|
+
private userStop = false
|
|
31
|
+
private cb: ProcessManagerCallbacks
|
|
32
|
+
private stderrTail: string[] = []
|
|
33
|
+
|
|
34
|
+
constructor(cb: ProcessManagerCallbacks) {
|
|
35
|
+
this.cb = cb
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get currentState() { return this.state }
|
|
39
|
+
get currentRunId() { return this.currentRun?.runId ?? null }
|
|
40
|
+
get currentRepoPath() { return this.currentRun?.repoPath ?? null }
|
|
41
|
+
|
|
42
|
+
async start(spec: RunSpec) {
|
|
43
|
+
if (this.state !== 'idle') {
|
|
44
|
+
this.cb.onLog('warn', `Refusing start: current state=${this.state}`, spec.runId)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
this.currentRun = spec
|
|
48
|
+
this.restartCount = 0
|
|
49
|
+
this.recentCrashes = []
|
|
50
|
+
this.userStop = false
|
|
51
|
+
this.spawnInner()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private spawnInner() {
|
|
55
|
+
if (!this.currentRun) return
|
|
56
|
+
const spec = this.currentRun
|
|
57
|
+
|
|
58
|
+
this.setState('starting', { runId: spec.runId, repoPath: spec.repoPath, restartCount: this.restartCount })
|
|
59
|
+
|
|
60
|
+
// Find the claude binary the same way claude-remote-agent does:
|
|
61
|
+
// We spawn 'claude' directly (assumed in PATH on user's machine).
|
|
62
|
+
// Claude's stream-json IO is handled by sending an initial user message via stdin.
|
|
63
|
+
this.stderrTail = []
|
|
64
|
+
const cmd = ['claude', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']
|
|
65
|
+
const env = { ...process.env }
|
|
66
|
+
delete (env as any).ANTHROPIC_API_KEY
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
this.proc = Bun.spawn(cmd, {
|
|
70
|
+
cwd: spec.repoPath,
|
|
71
|
+
stdin: 'pipe',
|
|
72
|
+
stdout: 'pipe',
|
|
73
|
+
stderr: 'pipe',
|
|
74
|
+
env,
|
|
75
|
+
})
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
this.cb.onLog('error', `failed to spawn claude: ${err.message}`, spec.runId)
|
|
78
|
+
this.setState('crashed', { runId: spec.runId, repoPath: spec.repoPath, lastExit: { code: null, reason: `spawn_error: ${err.message}` } })
|
|
79
|
+
this.scheduleRestart(null, 'spawn_error')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pid = this.proc.pid
|
|
84
|
+
this.cb.onLog('info', `claude spawned pid=${pid} in ${spec.repoPath}`, spec.runId)
|
|
85
|
+
this.setState('running', { runId: spec.runId, repoPath: spec.repoPath, pid, restartCount: this.restartCount })
|
|
86
|
+
|
|
87
|
+
// If initial prompt provided, send it once the process is ready.
|
|
88
|
+
// We treat "ready" as 3s post-spawn (matches agent pattern).
|
|
89
|
+
if (spec.initialPrompt) {
|
|
90
|
+
setTimeout(() => this.injectInitialPrompt(spec.initialPrompt!), 4000)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.consumeStderr()
|
|
94
|
+
|
|
95
|
+
this.proc.exited.then((code) => {
|
|
96
|
+
const reason = this.userStop ? 'user' : code === 0 ? 'clean' : 'crash'
|
|
97
|
+
const tail = this.stderrTail.slice(-40).join('\n')
|
|
98
|
+
this.cb.onLog(reason === 'crash' ? 'error' : 'info', `claude exited code=${code} reason=${reason}`, spec.runId)
|
|
99
|
+
|
|
100
|
+
if (this.userStop) {
|
|
101
|
+
this.setState('idle', { runId: spec.runId, lastExit: { code: code ?? null, reason, stderrTail: tail } })
|
|
102
|
+
this.currentRun = null
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (reason === 'clean') {
|
|
107
|
+
this.setState('idle', { runId: spec.runId, lastExit: { code: code ?? null, reason, stderrTail: tail } })
|
|
108
|
+
this.currentRun = null
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// crash
|
|
113
|
+
this.recentCrashes.push(Date.now())
|
|
114
|
+
this.recentCrashes = this.recentCrashes.filter((t) => Date.now() - t < CIRCUIT_WINDOW_MS)
|
|
115
|
+
if (this.recentCrashes.length >= CIRCUIT_THRESHOLD) {
|
|
116
|
+
this.cb.onLog('error', `circuit breaker open: ${this.recentCrashes.length} crashes in ${CIRCUIT_WINDOW_MS / 60_000}min — stopping`, spec.runId)
|
|
117
|
+
this.setState('stopped', { runId: spec.runId, lastExit: { code: code ?? null, reason: 'circuit_open', stderrTail: tail } })
|
|
118
|
+
this.currentRun = null
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
this.scheduleRestart(code ?? null, 'crash', tail)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private scheduleRestart(exitCode: number | null, reason: string, stderrTail = '') {
|
|
126
|
+
if (!this.currentRun) return
|
|
127
|
+
const delay = BACKOFF_SCHEDULE[Math.min(this.restartCount, BACKOFF_SCHEDULE.length - 1)]
|
|
128
|
+
this.restartCount++
|
|
129
|
+
this.cb.onLog('warn', `restarting in ${delay}ms (attempt ${this.restartCount})`, this.currentRun.runId)
|
|
130
|
+
this.setState('crashed', { runId: this.currentRun.runId, lastExit: { code: exitCode, reason, stderrTail } })
|
|
131
|
+
this.restartTimer = setTimeout(() => {
|
|
132
|
+
this.restartTimer = null
|
|
133
|
+
if (!this.currentRun) return
|
|
134
|
+
this.spawnInner()
|
|
135
|
+
}, delay)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private injectInitialPrompt(prompt: string) {
|
|
139
|
+
if (!this.proc?.stdin) return
|
|
140
|
+
const msg = JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } })
|
|
141
|
+
try {
|
|
142
|
+
this.proc.stdin.write(msg + '\n')
|
|
143
|
+
this.proc.stdin.flush?.()
|
|
144
|
+
this.cb.onLog('info', `injected initial prompt (${prompt.length} chars)`, this.currentRun?.runId)
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
this.cb.onLog('warn', `failed to inject initial prompt: ${err.message}`, this.currentRun?.runId)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async stop(reason: string) {
|
|
151
|
+
if (this.state === 'idle') return
|
|
152
|
+
this.userStop = true
|
|
153
|
+
if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null }
|
|
154
|
+
this.setState('stopping', { runId: this.currentRun?.runId })
|
|
155
|
+
if (this.proc) {
|
|
156
|
+
try { this.proc.kill('SIGINT') } catch {}
|
|
157
|
+
// SIGKILL after 10s
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (this.proc) {
|
|
160
|
+
try { this.proc.kill('SIGKILL') } catch {}
|
|
161
|
+
}
|
|
162
|
+
}, 10_000)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private setState(state: ProcState, info: any = {}) {
|
|
167
|
+
this.state = state
|
|
168
|
+
this.cb.onStateChange(state, { restartCount: this.restartCount, ...info })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async consumeStderr() {
|
|
172
|
+
if (!this.proc?.stderr) return
|
|
173
|
+
try {
|
|
174
|
+
const reader = this.proc.stderr.getReader()
|
|
175
|
+
const decoder = new TextDecoder()
|
|
176
|
+
while (true) {
|
|
177
|
+
const { done, value } = await reader.read()
|
|
178
|
+
if (done) break
|
|
179
|
+
const text = decoder.decode(value, { stream: true })
|
|
180
|
+
for (const line of text.split('\n')) {
|
|
181
|
+
if (line.trim()) {
|
|
182
|
+
this.stderrTail.push(line)
|
|
183
|
+
if (this.stderrTail.length > 200) this.stderrTail.shift()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync, readFileSync } from 'fs'
|
|
2
|
+
import { join, basename } from 'path'
|
|
3
|
+
|
|
4
|
+
export interface ScannedRepo {
|
|
5
|
+
path: string
|
|
6
|
+
name: string
|
|
7
|
+
remote: string | null
|
|
8
|
+
branch: string | null
|
|
9
|
+
dirty: boolean
|
|
10
|
+
last_commit: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isDir(p: string): boolean {
|
|
14
|
+
try { return statSync(p).isDirectory() } catch { return false }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readRemote(repoPath: string): string | null {
|
|
18
|
+
try {
|
|
19
|
+
const cfgPath = join(repoPath, '.git', 'config')
|
|
20
|
+
if (!existsSync(cfgPath)) return null
|
|
21
|
+
const cfg = readFileSync(cfgPath, 'utf-8')
|
|
22
|
+
const m = cfg.match(/\[remote "origin"\][^[]*url\s*=\s*(.+)/m)
|
|
23
|
+
return m ? m[1].trim() : null
|
|
24
|
+
} catch { return null }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readBranch(repoPath: string): string | null {
|
|
28
|
+
try {
|
|
29
|
+
const headPath = join(repoPath, '.git', 'HEAD')
|
|
30
|
+
if (!existsSync(headPath)) return null
|
|
31
|
+
const head = readFileSync(headPath, 'utf-8').trim()
|
|
32
|
+
if (head.startsWith('ref: refs/heads/')) return head.slice('ref: refs/heads/'.length)
|
|
33
|
+
return head.slice(0, 12)
|
|
34
|
+
} catch { return null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function gitSync(args: string[], cwd: string, timeoutMs = 5000): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const result = Bun.spawnSync(['git', ...args], {
|
|
40
|
+
cwd,
|
|
41
|
+
stdout: 'pipe',
|
|
42
|
+
stderr: 'ignore',
|
|
43
|
+
timeout: timeoutMs,
|
|
44
|
+
})
|
|
45
|
+
if (result.exitCode !== 0) return null
|
|
46
|
+
return new TextDecoder().decode(result.stdout)
|
|
47
|
+
} catch { return null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isDirty(repoPath: string): boolean {
|
|
51
|
+
const out = gitSync(['status', '--porcelain'], repoPath)
|
|
52
|
+
if (out == null) return false
|
|
53
|
+
return out.trim().length > 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lastCommit(repoPath: string): string | null {
|
|
57
|
+
const out = gitSync(['log', '-1', '--pretty=%h%x09%s'], repoPath)
|
|
58
|
+
return out?.trim() || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function scanRoot(root: string): ScannedRepo[] {
|
|
62
|
+
const out: ScannedRepo[] = []
|
|
63
|
+
if (!isDir(root)) return out
|
|
64
|
+
let entries: string[] = []
|
|
65
|
+
try { entries = readdirSync(root) } catch { return out }
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.startsWith('.')) continue
|
|
68
|
+
const path = join(root, entry)
|
|
69
|
+
if (!isDir(path)) continue
|
|
70
|
+
if (!existsSync(join(path, '.git'))) continue
|
|
71
|
+
out.push({
|
|
72
|
+
path: path.replace(/\\/g, '/'),
|
|
73
|
+
name: basename(path),
|
|
74
|
+
remote: readRemote(path),
|
|
75
|
+
branch: readBranch(path),
|
|
76
|
+
dirty: isDirty(path),
|
|
77
|
+
last_commit: lastCommit(path),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function scanAll(roots: string[]): ScannedRepo[] {
|
|
84
|
+
const all: ScannedRepo[] = []
|
|
85
|
+
const seen = new Set<string>()
|
|
86
|
+
for (const r of roots) {
|
|
87
|
+
for (const repo of scanRoot(r)) {
|
|
88
|
+
if (seen.has(repo.path)) continue
|
|
89
|
+
seen.add(repo.path)
|
|
90
|
+
all.push(repo)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return all.sort((a, b) => a.name.localeCompare(b.name))
|
|
94
|
+
}
|