@shigureni/minion 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 +65 -0
- package/cli/src/app.ts +27 -0
- package/cli/src/factory.ts +3 -0
- package/cli/src/gateway.ts +204 -0
- package/cli/src/index.ts +63 -0
- package/cli/src/lib/help-guard.ts +15 -0
- package/cli/src/lib/post-json.ts +8 -0
- package/cli/src/lib/process.ts +204 -0
- package/cli/src/on-error.ts +10 -0
- package/cli/src/router.ts +45 -0
- package/cli/src/routes/config/get/[key]/route.ts +17 -0
- package/cli/src/routes/config/list/route.ts +17 -0
- package/cli/src/routes/config/set/[key]/[value]/route.ts +20 -0
- package/cli/src/routes/dev/route.ts +17 -0
- package/cli/src/routes/kill/route.ts +15 -0
- package/cli/src/routes/not-found.ts +8 -0
- package/cli/src/routes/reboot/route.ts +17 -0
- package/cli/src/routes/start/route.ts +16 -0
- package/cli/src/routes/status/route.ts +15 -0
- package/package.json +48 -0
- package/swift/Package.swift +19 -0
- package/swift/Sources/open-minion/Resources/ChickBlue.png +0 -0
- package/swift/Sources/open-minion/open_minion.swift +598 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# minion
|
|
2
|
+
|
|
3
|
+
A tiny desktop pet that walks around your screen and reacts to your
|
|
4
|
+
[Claude Code](https://claude.com/claude-code) sessions — idle while you're idle,
|
|
5
|
+
running around while a session is busy.
|
|
6
|
+
|
|
7
|
+
It's two parts: a Swift menu-less overlay app (`swift/`) that draws the pet, and a
|
|
8
|
+
CLI (`cli/`) that builds/runs it and watches `~/.claude/sessions` to decide how the
|
|
9
|
+
pet should behave.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- macOS
|
|
14
|
+
- [Bun](https://bun.sh)
|
|
15
|
+
- Xcode Command Line Tools (`xcode-select --install`) — needed once, to compile the
|
|
16
|
+
Swift app on first run
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bunx @shigureni/minion
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This builds the app on first run (subsequent runs reuse the build unless the Swift
|
|
25
|
+
source changed) and starts it in the background, alongside a small local gateway
|
|
26
|
+
that watches your Claude Code sessions.
|
|
27
|
+
|
|
28
|
+
Or install it globally so the `minion` command is always available:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bun add -g @shigureni/minion
|
|
32
|
+
minion
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Commands
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
| --------------------------------- | ------------------------------------------------ |
|
|
39
|
+
| `minion` / `minion start` | Start (release build). No-op if already running. |
|
|
40
|
+
| `minion dev` | Restart with a debug build. |
|
|
41
|
+
| `minion kill` | Stop the app and its gateway. |
|
|
42
|
+
| `minion reboot` | Restart with a release build. |
|
|
43
|
+
| `minion status` | Show whether the app/gateway are running. |
|
|
44
|
+
| `minion config list` | List config values. |
|
|
45
|
+
| `minion config get <key>` | Get a config value. |
|
|
46
|
+
| `minion config set <key> <value>` | Set a config value. |
|
|
47
|
+
|
|
48
|
+
Add `-h` to any command for its help text (e.g. `minion start -h`).
|
|
49
|
+
|
|
50
|
+
State (pid files, config, and the Swift build output) lives in `~/.minion`,
|
|
51
|
+
independent of wherever the package itself is installed.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bun install
|
|
57
|
+
bun run check # format + lint + type-check (vite-plus)
|
|
58
|
+
bun run test
|
|
59
|
+
bun run fmt
|
|
60
|
+
bun run lint
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`cli/src/routes/<command>/route.ts` is one file per command (hono-routed, mirrors
|
|
64
|
+
the structure of jobantenna-cli) — adding a command means adding a route file and
|
|
65
|
+
wiring it into `cli/src/app.ts`.
|
package/cli/src/app.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { factory } from "@/factory"
|
|
2
|
+
import { onError } from "@/on-error"
|
|
3
|
+
import configGet from "@/routes/config/get/[key]/route"
|
|
4
|
+
import configList from "@/routes/config/list/route"
|
|
5
|
+
import configSet from "@/routes/config/set/[key]/[value]/route"
|
|
6
|
+
import dev from "@/routes/dev/route"
|
|
7
|
+
import kill from "@/routes/kill/route"
|
|
8
|
+
import { notFound } from "@/routes/not-found"
|
|
9
|
+
import reboot from "@/routes/reboot/route"
|
|
10
|
+
import start from "@/routes/start/route"
|
|
11
|
+
import status from "@/routes/status/route"
|
|
12
|
+
|
|
13
|
+
const base = factory.createApp()
|
|
14
|
+
base.onError(onError)
|
|
15
|
+
base.notFound(notFound)
|
|
16
|
+
|
|
17
|
+
export const app = base
|
|
18
|
+
.post("/start", ...start)
|
|
19
|
+
.post("/dev", ...dev)
|
|
20
|
+
.post("/kill", ...kill)
|
|
21
|
+
.post("/reboot", ...reboot)
|
|
22
|
+
.post("/status", ...status)
|
|
23
|
+
.post("/config/list", ...configList)
|
|
24
|
+
.post("/config/get/:key", ...configGet)
|
|
25
|
+
.post("/config/set/:key/:value", ...configSet)
|
|
26
|
+
|
|
27
|
+
export type AppType = typeof app
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
const SESSIONS_DIR = join(homedir(), ".claude", "sessions")
|
|
8
|
+
const STALE_MS = 8 * 60 * 1000
|
|
9
|
+
const TICK_MS = 250
|
|
10
|
+
const PORT = Number(process.env.MINION_GATEWAY_PORT) || 4756
|
|
11
|
+
|
|
12
|
+
// Swift側 PetView.clips と同じ並び順・重み(合計100)。座標や速度はSwiftが持つ
|
|
13
|
+
// clip定義とスクリーン形状に依存するため、ここでは「どの行動を・どれだけ続けるか」だけを決める。
|
|
14
|
+
const ACTIONS: { durationMs: [number, number]; weight: number }[] = [
|
|
15
|
+
{ weight: 14, durationMs: [3000, 6000] }, // 0 待機
|
|
16
|
+
{ weight: 10, durationMs: [3000, 6000] }, // 1 歩く
|
|
17
|
+
{ weight: 28, durationMs: [4000, 7000] }, // 2 走る
|
|
18
|
+
{ weight: 3, durationMs: [1000, 2000] }, // 3 ジャンプ1
|
|
19
|
+
{ weight: 3, durationMs: [1000, 2000] }, // 4 ジャンプ2
|
|
20
|
+
{ weight: 3, durationMs: [1000, 2000] }, // 5 ジャンプ3
|
|
21
|
+
{ weight: 3, durationMs: [1000, 2000] }, // 6 ジャンプ4
|
|
22
|
+
{ weight: 8, durationMs: [4000, 7000] }, // 7 座る1
|
|
23
|
+
{ weight: 8, durationMs: [4000, 7000] }, // 8 座る2
|
|
24
|
+
{ weight: 8, durationMs: [4000, 7000] }, // 9 座る3
|
|
25
|
+
{ weight: 8, durationMs: [4000, 7000] }, // 10 座る4
|
|
26
|
+
{ weight: 2, durationMs: [2000, 3000] }, // 11 食べる1
|
|
27
|
+
{ weight: 2, durationMs: [2000, 3000] }, // 12 食べる2
|
|
28
|
+
]
|
|
29
|
+
const IDLE_CLIP = 0
|
|
30
|
+
|
|
31
|
+
function pickAction(): { clipIndex: number; durationMs: number } {
|
|
32
|
+
const roll = Math.random() * 100
|
|
33
|
+
let cumulative = 0
|
|
34
|
+
let chosen = IDLE_CLIP
|
|
35
|
+
for (let i = 0; i < ACTIONS.length; i++) {
|
|
36
|
+
cumulative += ACTIONS[i].weight
|
|
37
|
+
if (roll < cumulative) {
|
|
38
|
+
chosen = i
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const [min, max] = ACTIONS[chosen].durationMs
|
|
43
|
+
return { clipIndex: chosen, durationMs: min + Math.random() * (max - min) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPidAlive(pid: number): boolean {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0)
|
|
49
|
+
return true
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// updatedAt はステータスが切り替わった時にしか更新されないため、
|
|
56
|
+
// ずっと busy のまま長時間動いているセッションは経過時間だけでは判定できない。
|
|
57
|
+
// プロセスが生きている間は経過時間を無視し、死んでから8分の猶予で消す。
|
|
58
|
+
interface SessionInfo {
|
|
59
|
+
running: boolean
|
|
60
|
+
name: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readActiveSessions(): Map<string, SessionInfo> {
|
|
64
|
+
let files: string[]
|
|
65
|
+
try {
|
|
66
|
+
files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"))
|
|
67
|
+
} catch {
|
|
68
|
+
return new Map()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
const result = new Map<string, SessionInfo>()
|
|
73
|
+
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(join(SESSIONS_DIR, file), "utf8"))
|
|
77
|
+
if (typeof raw.sessionId !== "string" || typeof raw.pid !== "number") continue
|
|
78
|
+
|
|
79
|
+
const alive = isPidAlive(raw.pid)
|
|
80
|
+
if (!alive) {
|
|
81
|
+
const updatedAt = raw.updatedAt ?? raw.statusUpdatedAt ?? raw.startedAt
|
|
82
|
+
if (typeof updatedAt !== "number" || now - updatedAt > STALE_MS) continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result.set(raw.sessionId, {
|
|
86
|
+
running: alive && raw.status === "busy",
|
|
87
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
88
|
+
})
|
|
89
|
+
} catch {
|
|
90
|
+
// 書き込み途中などで壊れているファイルは無視
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PetBehavior {
|
|
98
|
+
running: boolean
|
|
99
|
+
name: string
|
|
100
|
+
clipIndex: number
|
|
101
|
+
actionEndsAt: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const behaviors = new Map<string, PetBehavior>()
|
|
105
|
+
const clients = new Set()
|
|
106
|
+
|
|
107
|
+
function snapshot() {
|
|
108
|
+
return Array.from(behaviors.entries()).map(([id, b]) => ({
|
|
109
|
+
id,
|
|
110
|
+
state: b.running ? "running" : "sleeping",
|
|
111
|
+
clipIndex: b.clipIndex,
|
|
112
|
+
name: b.name,
|
|
113
|
+
}))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function broadcast(): void {
|
|
117
|
+
const payload = JSON.stringify({ sessions: snapshot() })
|
|
118
|
+
for (const ws of clients) {
|
|
119
|
+
;(ws as { send: (data: string) => void }).send(payload)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tick(): void {
|
|
124
|
+
const now = Date.now()
|
|
125
|
+
const activeSessions = readActiveSessions()
|
|
126
|
+
let dirty = false
|
|
127
|
+
|
|
128
|
+
for (const id of behaviors.keys()) {
|
|
129
|
+
if (!activeSessions.has(id)) {
|
|
130
|
+
behaviors.delete(id)
|
|
131
|
+
dirty = true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [id, info] of activeSessions) {
|
|
136
|
+
const existing = behaviors.get(id)
|
|
137
|
+
|
|
138
|
+
if (!existing) {
|
|
139
|
+
const action = info.running ? pickAction() : { clipIndex: IDLE_CLIP, durationMs: 0 }
|
|
140
|
+
behaviors.set(id, {
|
|
141
|
+
running: info.running,
|
|
142
|
+
name: info.name,
|
|
143
|
+
clipIndex: action.clipIndex,
|
|
144
|
+
actionEndsAt: now + action.durationMs,
|
|
145
|
+
})
|
|
146
|
+
dirty = true
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (existing.name !== info.name) {
|
|
151
|
+
existing.name = info.name
|
|
152
|
+
dirty = true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (existing.running !== info.running) {
|
|
156
|
+
existing.running = info.running
|
|
157
|
+
if (info.running) {
|
|
158
|
+
const action = pickAction()
|
|
159
|
+
existing.clipIndex = action.clipIndex
|
|
160
|
+
existing.actionEndsAt = now + action.durationMs
|
|
161
|
+
}
|
|
162
|
+
dirty = true
|
|
163
|
+
} else if (info.running && now >= existing.actionEndsAt) {
|
|
164
|
+
const action = pickAction()
|
|
165
|
+
existing.clipIndex = action.clipIndex
|
|
166
|
+
existing.actionEndsAt = now + action.durationMs
|
|
167
|
+
dirty = true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (dirty) broadcast()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setInterval(tick, TICK_MS)
|
|
175
|
+
tick()
|
|
176
|
+
|
|
177
|
+
Bun.serve({
|
|
178
|
+
port: PORT,
|
|
179
|
+
fetch(req, server) {
|
|
180
|
+
const url = new URL(req.url)
|
|
181
|
+
if (url.pathname === "/sessions") {
|
|
182
|
+
return Response.json({ sessions: snapshot() })
|
|
183
|
+
}
|
|
184
|
+
if (url.pathname === "/ws") {
|
|
185
|
+
if (server.upgrade(req)) return undefined
|
|
186
|
+
return new Response("upgrade failed", { status: 400 })
|
|
187
|
+
}
|
|
188
|
+
return new Response("not found", { status: 404 })
|
|
189
|
+
},
|
|
190
|
+
websocket: {
|
|
191
|
+
open(ws) {
|
|
192
|
+
clients.add(ws)
|
|
193
|
+
ws.send(JSON.stringify({ sessions: snapshot() }))
|
|
194
|
+
},
|
|
195
|
+
close(ws) {
|
|
196
|
+
clients.delete(ws)
|
|
197
|
+
},
|
|
198
|
+
message() {
|
|
199
|
+
// クライアントからのメッセージは使わない
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
console.log(`gateway listening on :${PORT}`)
|
package/cli/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { app } from "@/app"
|
|
3
|
+
import { postJson } from "@/lib/post-json"
|
|
4
|
+
import { toRequest } from "@/router"
|
|
5
|
+
|
|
6
|
+
import pkg from "../../package.json" with { type: "json" }
|
|
7
|
+
|
|
8
|
+
const HELP = `minion - open-minion CLI
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
minion [command] [options]
|
|
12
|
+
minion <command> -h Show help for a command
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
start 起動する (デフォルト)
|
|
16
|
+
dev 停止してデバッグビルドで起動し直す
|
|
17
|
+
kill 停止する
|
|
18
|
+
reboot 停止してリリースビルドで起動し直す
|
|
19
|
+
status 起動状況を表示する
|
|
20
|
+
config list 設定値を一覧表示する
|
|
21
|
+
config get <key> 設定値を取得する
|
|
22
|
+
config set <key> <value> 設定値を書き込む
|
|
23
|
+
|
|
24
|
+
Global flags:
|
|
25
|
+
-h, --help Show help
|
|
26
|
+
-v, --version Show version`
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2)
|
|
29
|
+
const args = argv.length === 0 ? ["start"] : argv
|
|
30
|
+
|
|
31
|
+
const request = toRequest(args)
|
|
32
|
+
|
|
33
|
+
if (request.global.version) {
|
|
34
|
+
console.log(pkg.version)
|
|
35
|
+
process.exit()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (request.global.help) {
|
|
39
|
+
if (request.path === "/") {
|
|
40
|
+
console.log(HELP)
|
|
41
|
+
process.exit()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const helpResponse = await app.request(request.path, postJson({ ...request.body, help: "true" }))
|
|
45
|
+
console.log(helpResponse.ok ? await helpResponse.text() : HELP)
|
|
46
|
+
process.exit()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const response = await app.request(request.path, postJson(request.body))
|
|
51
|
+
const text = await response.text()
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
console.error(text || `Error ${response.status}`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(text)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
61
|
+
console.error(error.message)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono"
|
|
2
|
+
|
|
3
|
+
// --help を zValidator より先に処理する。必須フィールドを持つコマンドでも
|
|
4
|
+
// `minion <command> --help` が検証エラー(400)で弾かれず help を返せるようにする。
|
|
5
|
+
export function helpGuard(help: string): MiddlewareHandler {
|
|
6
|
+
return async (c, next) => {
|
|
7
|
+
const body = await c.req.json().catch(() => null)
|
|
8
|
+
|
|
9
|
+
if (body && typeof body === "object" && "help" in body && body.help) {
|
|
10
|
+
return c.text(help)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
await next()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs"
|
|
11
|
+
import { homedir } from "node:os"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
|
|
14
|
+
const PACKAGE_ROOT = join(import.meta.dir, "..", "..", "..")
|
|
15
|
+
const APP_ROOT = join(PACKAGE_ROOT, "swift")
|
|
16
|
+
// bunx はパッケージ本体をキャッシュディレクトリに展開するため、状態とビルド
|
|
17
|
+
// 成果物はパッケージの場所に依存しないよう ~/.minion に置く。
|
|
18
|
+
const DATA_DIR = join(homedir(), ".minion")
|
|
19
|
+
const BUILD_PATH = join(DATA_DIR, "build")
|
|
20
|
+
const BIN_PATH = join(BUILD_PATH, "release", "open-minion")
|
|
21
|
+
const DEBUG_BIN_PATH = join(BUILD_PATH, "debug", "open-minion")
|
|
22
|
+
const DATA_FILE = join(DATA_DIR, "data.json")
|
|
23
|
+
const PID_FILE = join(DATA_DIR, "pid")
|
|
24
|
+
const CONFIG_FILE = join(DATA_DIR, "config.json")
|
|
25
|
+
const GATEWAY_SCRIPT = join(import.meta.dir, "..", "gateway.ts")
|
|
26
|
+
const GATEWAY_PID_FILE = join(DATA_DIR, "gateway.pid")
|
|
27
|
+
|
|
28
|
+
interface BuildData {
|
|
29
|
+
sourceHash: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function computeSourceHash(): string {
|
|
33
|
+
const files = [
|
|
34
|
+
join(APP_ROOT, "Package.swift"),
|
|
35
|
+
...Array.from(new Bun.Glob("Sources/**/*.swift").scanSync({ cwd: APP_ROOT })).map((f) =>
|
|
36
|
+
join(APP_ROOT, f),
|
|
37
|
+
),
|
|
38
|
+
].sort()
|
|
39
|
+
const hash = createHash("sha256")
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
hash.update(readFileSync(file))
|
|
42
|
+
}
|
|
43
|
+
return hash.digest("hex")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readBuildData(): BuildData | null {
|
|
47
|
+
if (!existsSync(DATA_FILE)) return null
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(DATA_FILE, "utf8"))
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeBuildData(data: BuildData): void {
|
|
56
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
57
|
+
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readPid(pidFile: string = PID_FILE): number | null {
|
|
61
|
+
if (!existsSync(pidFile)) return null
|
|
62
|
+
const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10)
|
|
63
|
+
return Number.isNaN(pid) ? null : pid
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRunning(pid: number): boolean {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0)
|
|
69
|
+
return true
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function acquireStartLock(): boolean {
|
|
76
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
77
|
+
try {
|
|
78
|
+
closeSync(openSync(PID_FILE, "wx"))
|
|
79
|
+
return true
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function build(debug: boolean): Promise<boolean> {
|
|
86
|
+
console.log("ビルド中...")
|
|
87
|
+
const args = debug
|
|
88
|
+
? ["swift", "build", "--scratch-path", BUILD_PATH]
|
|
89
|
+
: ["swift", "build", "-c", "release", "--scratch-path", BUILD_PATH]
|
|
90
|
+
const proc = Bun.spawn(args, {
|
|
91
|
+
cwd: APP_ROOT,
|
|
92
|
+
stdout: "inherit",
|
|
93
|
+
stderr: "inherit",
|
|
94
|
+
})
|
|
95
|
+
const code = await proc.exited
|
|
96
|
+
if (code !== 0) {
|
|
97
|
+
console.error("ビルド失敗")
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function startGateway(): void {
|
|
104
|
+
const pid = readPid(GATEWAY_PID_FILE)
|
|
105
|
+
if (pid !== null && isRunning(pid)) return
|
|
106
|
+
|
|
107
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
108
|
+
const proc = Bun.spawn(["bun", GATEWAY_SCRIPT], {
|
|
109
|
+
stdout: "ignore",
|
|
110
|
+
stderr: "ignore",
|
|
111
|
+
stdin: "ignore",
|
|
112
|
+
})
|
|
113
|
+
proc.unref()
|
|
114
|
+
writeFileSync(GATEWAY_PID_FILE, String(proc.pid))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function killGateway(): void {
|
|
118
|
+
const pid = readPid(GATEWAY_PID_FILE)
|
|
119
|
+
if (pid !== null && isRunning(pid)) {
|
|
120
|
+
process.kill(pid, "SIGTERM")
|
|
121
|
+
}
|
|
122
|
+
rmSync(GATEWAY_PID_FILE, { force: true })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function startApp(debug: boolean): Promise<string> {
|
|
126
|
+
const existingPid = readPid()
|
|
127
|
+
if (existingPid !== null) {
|
|
128
|
+
if (isRunning(existingPid)) {
|
|
129
|
+
return `すでに起動中 (pid ${existingPid})`
|
|
130
|
+
}
|
|
131
|
+
rmSync(PID_FILE, { force: true })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!acquireStartLock()) {
|
|
135
|
+
return "他の起動処理と競合したため中止"
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const binPath = debug ? DEBUG_BIN_PATH : BIN_PATH
|
|
139
|
+
|
|
140
|
+
if (debug) {
|
|
141
|
+
if (!(await build(true))) {
|
|
142
|
+
rmSync(PID_FILE, { force: true })
|
|
143
|
+
return "ビルド失敗"
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
const currentHash = computeSourceHash()
|
|
147
|
+
const buildData = readBuildData()
|
|
148
|
+
if (!existsSync(binPath) || buildData?.sourceHash !== currentHash) {
|
|
149
|
+
if (!(await build(false))) {
|
|
150
|
+
rmSync(PID_FILE, { force: true })
|
|
151
|
+
return "ビルド失敗"
|
|
152
|
+
}
|
|
153
|
+
writeBuildData({ sourceHash: currentHash })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const proc = Bun.spawn([binPath], {
|
|
158
|
+
cwd: APP_ROOT,
|
|
159
|
+
stdout: "ignore",
|
|
160
|
+
stderr: "ignore",
|
|
161
|
+
stdin: "ignore",
|
|
162
|
+
})
|
|
163
|
+
proc.unref()
|
|
164
|
+
writeFileSync(PID_FILE, String(proc.pid))
|
|
165
|
+
startGateway()
|
|
166
|
+
return `起動した (pid ${proc.pid})`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function killApp(): string {
|
|
170
|
+
killGateway()
|
|
171
|
+
const pid = readPid()
|
|
172
|
+
if (!pid || !isRunning(pid)) {
|
|
173
|
+
if (existsSync(PID_FILE)) rmSync(PID_FILE, { force: true })
|
|
174
|
+
return "起動していない"
|
|
175
|
+
}
|
|
176
|
+
process.kill(pid, "SIGTERM")
|
|
177
|
+
rmSync(PID_FILE, { force: true })
|
|
178
|
+
return `停止した (pid ${pid})`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function statusApp(): string {
|
|
182
|
+
const pid = readPid()
|
|
183
|
+
const appLine = pid && isRunning(pid) ? `起動中 (pid ${pid})` : "停止中"
|
|
184
|
+
|
|
185
|
+
const gatewayPid = readPid(GATEWAY_PID_FILE)
|
|
186
|
+
const gatewayLine =
|
|
187
|
+
gatewayPid && isRunning(gatewayPid) ? `gateway起動中 (pid ${gatewayPid})` : "gateway停止中"
|
|
188
|
+
|
|
189
|
+
return [appLine, gatewayLine].join("\n")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function readConfig(): Record<string, string> {
|
|
193
|
+
if (!existsSync(CONFIG_FILE)) return {}
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
|
|
196
|
+
} catch {
|
|
197
|
+
return {}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function writeConfig(config: Record<string, string>): void {
|
|
202
|
+
mkdirSync(DATA_DIR, { recursive: true })
|
|
203
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
|
|
204
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ErrorHandler } from "hono"
|
|
2
|
+
import { HTTPException } from "hono/http-exception"
|
|
3
|
+
|
|
4
|
+
export const onError: ErrorHandler = (err, c) => {
|
|
5
|
+
if (err instanceof HTTPException) {
|
|
6
|
+
return c.text(err.message, err.status)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return c.text(err.message, 500)
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SHORT_FLAGS: Record<string, string> = {
|
|
2
|
+
h: "help",
|
|
3
|
+
v: "version",
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const GLOBAL_FLAGS = new Set(["help", "version"])
|
|
7
|
+
|
|
8
|
+
export function toRequest(args: string[]) {
|
|
9
|
+
const segments: string[] = []
|
|
10
|
+
const body: Record<string, string> = {}
|
|
11
|
+
|
|
12
|
+
let i = 0
|
|
13
|
+
while (i < args.length) {
|
|
14
|
+
const arg = args[i]
|
|
15
|
+
if (arg.startsWith("--")) {
|
|
16
|
+
const key = arg.slice(2)
|
|
17
|
+
const next = args[i + 1]
|
|
18
|
+
if (next && !next.startsWith("-")) {
|
|
19
|
+
body[key] = next
|
|
20
|
+
i += 2
|
|
21
|
+
} else {
|
|
22
|
+
body[key] = "true"
|
|
23
|
+
i++
|
|
24
|
+
}
|
|
25
|
+
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
26
|
+
const long = SHORT_FLAGS[arg[1]]
|
|
27
|
+
if (long) body[long] = "true"
|
|
28
|
+
i++
|
|
29
|
+
} else {
|
|
30
|
+
segments.push(arg)
|
|
31
|
+
i++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const global: Record<string, boolean> = {}
|
|
36
|
+
for (const flag of GLOBAL_FLAGS) {
|
|
37
|
+
if (flag in body) {
|
|
38
|
+
global[flag] = true
|
|
39
|
+
delete body[flag]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const path = `/${segments.join("/")}`
|
|
44
|
+
return { path, body, global }
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { factory } from "@/factory"
|
|
4
|
+
import { helpGuard } from "@/lib/help-guard"
|
|
5
|
+
import { readConfig } from "@/lib/process"
|
|
6
|
+
|
|
7
|
+
const schema = z.object({})
|
|
8
|
+
|
|
9
|
+
export const help = `Usage: minion config get <key>
|
|
10
|
+
|
|
11
|
+
設定値を取得する。`
|
|
12
|
+
|
|
13
|
+
export default factory.createHandlers(helpGuard(help), zValidator("json", schema), async (c) => {
|
|
14
|
+
const key = c.req.param("key")
|
|
15
|
+
const config = readConfig()
|
|
16
|
+
return c.text(config[key ?? ""] ?? "")
|
|
17
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { factory } from "@/factory"
|
|
4
|
+
import { helpGuard } from "@/lib/help-guard"
|
|
5
|
+
import { readConfig } from "@/lib/process"
|
|
6
|
+
|
|
7
|
+
const schema = z.object({})
|
|
8
|
+
|
|
9
|
+
export const help = `Usage: minion config list
|
|
10
|
+
|
|
11
|
+
設定値を一覧表示する。`
|
|
12
|
+
|
|
13
|
+
export default factory.createHandlers(helpGuard(help), zValidator("json", schema), async (c) => {
|
|
14
|
+
const config = readConfig()
|
|
15
|
+
const lines = Object.entries(config).map(([k, v]) => `${k} = ${v}`)
|
|
16
|
+
return c.text(lines.join("\n"))
|
|
17
|
+
})
|