@shigureni/minion 0.1.0 → 0.3.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.
Files changed (58) hide show
  1. package/README.md +79 -33
  2. package/cli/src/app.ts +2 -0
  3. package/cli/src/factory.ts +8 -1
  4. package/cli/src/index.ts +15 -2
  5. package/cli/src/lib/body-validator.ts +13 -0
  6. package/cli/src/lib/format.ts +36 -0
  7. package/cli/src/router.ts +2 -1
  8. package/cli/src/routes/config/get/[key]/route.ts +7 -7
  9. package/cli/src/routes/config/list/route.ts +4 -5
  10. package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
  11. package/cli/src/routes/dev/route.ts +12 -7
  12. package/cli/src/routes/dex/route.ts +66 -0
  13. package/cli/src/routes/kill/route.ts +7 -5
  14. package/cli/src/routes/not-found.ts +1 -1
  15. package/cli/src/routes/reboot/route.ts +12 -7
  16. package/cli/src/routes/start/route.ts +7 -6
  17. package/cli/src/routes/status/route.ts +5 -5
  18. package/lib/engine/app/app-paths.ts +46 -0
  19. package/lib/engine/app/app-runner.ts +235 -0
  20. package/lib/engine/app/source-hash.ts +34 -0
  21. package/lib/engine/collection/achievements.ts +143 -0
  22. package/lib/engine/collection/collection-store.ts +43 -0
  23. package/lib/engine/collection/collection-tracker.ts +97 -0
  24. package/lib/engine/collection/moon.ts +26 -0
  25. package/lib/engine/collection/species.ts +275 -0
  26. package/lib/engine/config/config-store.ts +36 -0
  27. package/lib/engine/errors.ts +20 -0
  28. package/lib/engine/fs/file-system.ts +31 -0
  29. package/lib/engine/fs/json-file-store.ts +57 -0
  30. package/lib/engine/fs/memory-file-system.ts +106 -0
  31. package/lib/engine/fs/node-file-system.ts +87 -0
  32. package/lib/engine/gateway/gateway-daemon.ts +14 -0
  33. package/lib/engine/gateway/gateway-routes.ts +17 -0
  34. package/lib/engine/gateway/gateway-server.ts +126 -0
  35. package/lib/engine/gateway/pet-behavior.ts +164 -0
  36. package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
  37. package/lib/engine/gateway/sessions.ts +127 -0
  38. package/lib/engine/process/memory-process-runner.ts +67 -0
  39. package/lib/engine/process/node-process-runner.ts +51 -0
  40. package/lib/engine/process/process-runner.ts +21 -0
  41. package/lib/engine/random/memory-random-source.ts +22 -0
  42. package/lib/engine/random/node-random-source.ts +7 -0
  43. package/lib/engine/random/random-source.ts +9 -0
  44. package/lib/engine/stats/date-window.ts +31 -0
  45. package/lib/engine/stats/session-stats-tracker.ts +125 -0
  46. package/lib/engine/stats/stats-collector.ts +100 -0
  47. package/lib/engine/stats/stats-snapshot.ts +31 -0
  48. package/lib/engine/stats/token-usage-tracker.ts +170 -0
  49. package/lib/engine/time/clock.ts +11 -0
  50. package/lib/engine/time/memory-clock.ts +26 -0
  51. package/lib/engine/time/node-clock.ts +7 -0
  52. package/lib/index.ts +65 -0
  53. package/lib/minion.ts +194 -0
  54. package/package.json +9 -1
  55. package/swift/Sources/open-minion/open_minion.swift +5 -1
  56. package/tsconfig.json +3 -2
  57. package/cli/src/gateway.ts +0 -204
  58. package/cli/src/lib/process.ts +0 -204
package/lib/minion.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
5
+ import { MemoryMinionFileSystem } from "@lib/engine/fs/memory-file-system"
6
+ import { NodeMinionFileSystem } from "@lib/engine/fs/node-file-system"
7
+ import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
8
+ import { MemoryMinionProcessRunner } from "@lib/engine/process/memory-process-runner"
9
+ import { NodeMinionProcessRunner } from "@lib/engine/process/node-process-runner"
10
+ import type { MinionClock } from "@lib/engine/time/clock"
11
+ import { MemoryMinionClock } from "@lib/engine/time/memory-clock"
12
+ import { NodeMinionClock } from "@lib/engine/time/node-clock"
13
+ import type { MinionRandomSource } from "@lib/engine/random/random-source"
14
+ import { MemoryMinionRandomSource } from "@lib/engine/random/memory-random-source"
15
+ import { NodeMinionRandomSource } from "@lib/engine/random/node-random-source"
16
+ import { type MinionPaths, resolveMinionPaths } from "@lib/engine/app/app-paths"
17
+ import { type MinionAppEvent, MinionAppRunner } from "@lib/engine/app/app-runner"
18
+ import { MinionConfigStore } from "@lib/engine/config/config-store"
19
+ import type { Achievement } from "@lib/engine/collection/achievements"
20
+ import { MinionCollectionStore } from "@lib/engine/collection/collection-store"
21
+ import { MinionCollectionTracker } from "@lib/engine/collection/collection-tracker"
22
+ import type { MinionSpecies } from "@lib/engine/collection/species"
23
+ import { MinionGatewayServer } from "@lib/engine/gateway/gateway-server"
24
+ import { resolveGatewayDaemonScript } from "@lib/engine/gateway/resolve-daemon-script"
25
+ import { SessionStatsTracker } from "@lib/engine/stats/session-stats-tracker"
26
+ import { TokenUsageTracker } from "@lib/engine/stats/token-usage-tracker"
27
+ import { MinionStatsCollector } from "@lib/engine/stats/stats-collector"
28
+
29
+ const SANDBOX_PACKAGE_ROOT = "/sandbox/pkg"
30
+ const SANDBOX_DATA_DIR = "/sandbox/.minion"
31
+ const SANDBOX_SESSIONS_DIR = "/sandbox/.claude/sessions"
32
+ const SANDBOX_PROJECTS_DIR = "/sandbox/.claude/projects"
33
+
34
+ export type MinionOptions = {
35
+ /** Filesystem boundary. Replace with MemoryMinionFileSystem to sandbox all disk I/O. */
36
+ fs?: MinionFileSystem
37
+ /** Process runner used to build/spawn the Swift app and the gateway daemon. */
38
+ process?: MinionProcessRunner
39
+ /** Clock used for session-staleness checks and pet-behavior timing. */
40
+ clock?: MinionClock
41
+ /** Randomness used by the pet behavior picker. */
42
+ random?: MinionRandomSource
43
+ /** Directory containing `swift/` (the package root). Defaults to the installed package root. */
44
+ packageRoot?: string
45
+ /** State directory (pid files, config, build output). Defaults to `~/.minion`. */
46
+ dataDir?: string
47
+ /** Directory of Claude Code session files the gateway watches. Defaults to `~/.claude/sessions`. */
48
+ sessionsDir?: string
49
+ /** Claude Code's transcript root, scanned for token usage. Defaults to `~/.claude/projects`. */
50
+ projectsDir?: string
51
+ /** Minion species catalog for `minion.collection`. Defaults to `DEFAULT_MINION_SPECIES` — pass your own to add species, retheme the built-ins, or attach real image `asset` references. */
52
+ species?: MinionSpecies[]
53
+ /** Achievement catalog for `minion.collection`. Defaults to `DEFAULT_ACHIEVEMENTS` — pass your own to replace it entirely. */
54
+ achievements?: Achievement[]
55
+ /** Progress events from `minion.app` (build started, ...). Defaults to a no-op — the library never writes to stdout itself. */
56
+ onEvent?: (event: MinionAppEvent) => void
57
+ }
58
+
59
+ export type MinionGatewayServerOptions = {
60
+ port?: number
61
+ tickMs?: number
62
+ }
63
+
64
+ /**
65
+ * Facade that wires every minion facet together and exposes the public surface.
66
+ *
67
+ * All side-effecting boundaries (filesystem, process, clock, random) are
68
+ * injected via Props — passing memory implementations gives a fully
69
+ * sandboxed Minion that touches no real disk, processes, or wall-clock time.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * import { Minion } from "@shigureni/minion"
74
+ *
75
+ * const minion = new Minion()
76
+ * const result = await minion.app.start() // { kind: "started", pid: 123 } | ... | Error
77
+ * if (result instanceof Error) console.error(result.message)
78
+ * console.log(minion.app.status().app.running)
79
+ * ```
80
+ */
81
+ export class Minion {
82
+ readonly paths: MinionPaths
83
+ readonly config: MinionConfigStore
84
+ readonly app: MinionAppRunner
85
+ readonly stats: MinionStatsCollector
86
+ readonly collection: MinionCollectionTracker
87
+
88
+ private readonly fs: MinionFileSystem
89
+ private readonly process: MinionProcessRunner
90
+ private readonly clock: MinionClock
91
+ private readonly random: MinionRandomSource
92
+ private readonly sessionsDir: string
93
+ private readonly projectsDir: string
94
+
95
+ constructor(props: MinionOptions = {}) {
96
+ const packageRoot = props.packageRoot ?? defaultPackageRoot()
97
+ const fs = props.fs ?? new NodeMinionFileSystem()
98
+ const processRunner = props.process ?? new NodeMinionProcessRunner()
99
+ const clock = props.clock ?? new NodeMinionClock()
100
+ const random = props.random ?? new NodeMinionRandomSource()
101
+ const projectsDir = props.projectsDir ?? join(homedir(), ".claude", "projects")
102
+
103
+ this.fs = fs
104
+ this.process = processRunner
105
+ this.clock = clock
106
+ this.random = random
107
+ this.sessionsDir = props.sessionsDir ?? join(homedir(), ".claude", "sessions")
108
+ this.projectsDir = projectsDir
109
+
110
+ this.paths = resolveMinionPaths({ packageRoot, dataDir: props.dataDir })
111
+
112
+ this.config = new MinionConfigStore({ fs, path: this.paths.configFile })
113
+
114
+ this.app = new MinionAppRunner({
115
+ fs,
116
+ process: processRunner,
117
+ paths: this.paths,
118
+ gatewayCommand: ["bun", resolveGatewayDaemonScript()],
119
+ onEvent: props.onEvent,
120
+ })
121
+
122
+ this.stats = new MinionStatsCollector({
123
+ fs,
124
+ process: processRunner,
125
+ clock,
126
+ sessionsDir: this.sessionsDir,
127
+ projectsDir,
128
+ sessionStats: new SessionStatsTracker({ fs, path: this.paths.sessionStatsFile }),
129
+ tokenUsage: new TokenUsageTracker({
130
+ fs,
131
+ clock,
132
+ path: this.paths.usageScanFile,
133
+ projectsDir,
134
+ }),
135
+ })
136
+
137
+ this.collection = new MinionCollectionTracker({
138
+ store: new MinionCollectionStore({ fs, path: this.paths.collectionFile }),
139
+ species: props.species,
140
+ achievements: props.achievements,
141
+ })
142
+
143
+ Object.freeze(this)
144
+ }
145
+
146
+ /**
147
+ * Sandboxed Minion wired with in-memory implementations for every IO
148
+ * boundary. Touches no real disk, processes, or wall-clock time — safe
149
+ * for tests and ad-hoc experiments. Override individual fields via `props`.
150
+ *
151
+ * NOT covered by `inMemory()`: `gatewayServer().start()` still calls
152
+ * `Bun.serve` and binds a real port; pass `port: 0` to let the OS pick one.
153
+ */
154
+ static inMemory(props: MinionOptions = {}): Minion {
155
+ // The default sandbox fs stamps mtimes off the sandbox clock, so
156
+ // recency-based logic (e.g. the token-scan backfill window) sees one
157
+ // consistent timeline instead of mixing wall-clock mtimes with a frozen clock.
158
+ const clock = props.clock ?? new MemoryMinionClock()
159
+ return new Minion({
160
+ ...props,
161
+ fs: props.fs ?? new MemoryMinionFileSystem({ now: () => clock.millis() }),
162
+ process: props.process ?? new MemoryMinionProcessRunner(),
163
+ clock,
164
+ random: props.random ?? new MemoryMinionRandomSource(),
165
+ packageRoot: props.packageRoot ?? SANDBOX_PACKAGE_ROOT,
166
+ dataDir: props.dataDir ?? SANDBOX_DATA_DIR,
167
+ sessionsDir: props.sessionsDir ?? SANDBOX_SESSIONS_DIR,
168
+ projectsDir: props.projectsDir ?? SANDBOX_PROJECTS_DIR,
169
+ })
170
+ }
171
+
172
+ /**
173
+ * In-process gateway server (HTTP + WebSocket) that watches the sessions
174
+ * directory and drives the pet's animation state. The daemon spawned by
175
+ * `app.start()` runs this same class out-of-process via `gateway-daemon.ts`.
176
+ */
177
+ gatewayServer(options: MinionGatewayServerOptions = {}): MinionGatewayServer {
178
+ return new MinionGatewayServer({
179
+ fs: this.fs,
180
+ process: this.process,
181
+ clock: this.clock,
182
+ random: this.random,
183
+ sessionsDir: this.sessionsDir,
184
+ projectsDir: this.projectsDir,
185
+ port: options.port,
186
+ tickMs: options.tickMs,
187
+ })
188
+ }
189
+ }
190
+
191
+ function defaultPackageRoot(): string {
192
+ // URL.pathname はパスに空白や非ASCII文字があるとパーセントエンコードされたまま壊れる
193
+ return fileURLToPath(new URL("..", import.meta.url))
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shigureni/minion",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Desktop pet that reacts to your Claude Code session activity",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -15,12 +15,20 @@
15
15
  },
16
16
  "files": [
17
17
  "cli/src",
18
+ "lib",
18
19
  "swift",
19
20
  "tsconfig.json",
20
21
  "!cli/src/**/*.test.ts",
22
+ "!lib/**/*.test.ts",
21
23
  "!swift/.build"
22
24
  ],
23
25
  "type": "module",
26
+ "main": "./lib/index.ts",
27
+ "types": "./lib/index.ts",
28
+ "exports": {
29
+ ".": "./lib/index.ts",
30
+ "./package.json": "./package.json"
31
+ },
24
32
  "publishConfig": {
25
33
  "access": "public"
26
34
  },
@@ -280,6 +280,9 @@ final class Pet {
280
280
  window.setFrameOrigin(NSPoint(x: x, y: y))
281
281
  }
282
282
 
283
+ // スポーン時の向きはランダム。移動する行動を取るまでこの向きを保つ。
284
+ facingLeft = Bool.random()
285
+
283
286
  window.orderFrontRegardless()
284
287
  }
285
288
 
@@ -359,6 +362,7 @@ final class Pet {
359
362
  }
360
363
 
361
364
  // セッションが idle の間は、その場でうずくまって寝る(掴む/なでるはできる)。
365
+ // ただし待機クリップ自体のコマ送りは止めない。止めると単なる1枚絵で固まって見える。
362
366
  let sleeping = !sessionRunning
363
367
  if !sleeping {
364
368
  if feedTicksRemaining > 0 {
@@ -368,8 +372,8 @@ final class Pet {
368
372
  }
369
373
  }
370
374
  stepBehavior()
371
- advanceAnimationFrame()
372
375
  }
376
+ advanceAnimationFrame()
373
377
 
374
378
  petView.isAsleep = sleeping
375
379
  petView.facingLeft = facingLeft
package/tsconfig.json CHANGED
@@ -8,8 +8,9 @@
8
8
  "skipLibCheck": true,
9
9
  "types": ["node", "bun"],
10
10
  "paths": {
11
- "@/*": ["./cli/src/*"]
11
+ "@/*": ["./cli/src/*"],
12
+ "@lib/*": ["./lib/*"]
12
13
  }
13
14
  },
14
- "include": ["cli/src"]
15
+ "include": ["cli/src", "lib"]
15
16
  }
@@ -1,204 +0,0 @@
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}`)
@@ -1,204 +0,0 @@
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
- }