@gravito/launchpad 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.
Files changed (38) hide show
  1. package/.turbo/turbo-test.log +82 -0
  2. package/.turbo/turbo-typecheck.log +1 -0
  3. package/Dockerfile +23 -0
  4. package/README.md +44 -0
  5. package/debug-launch.ts +66 -0
  6. package/package.json +26 -0
  7. package/server.ts +7 -0
  8. package/simulate-github.ts +55 -0
  9. package/src/Application/MissionControl.ts +59 -0
  10. package/src/Application/PayloadInjector.ts +60 -0
  11. package/src/Application/PoolManager.ts +81 -0
  12. package/src/Application/RefurbishUnit.ts +40 -0
  13. package/src/Domain/Events.ts +28 -0
  14. package/src/Domain/Interfaces.ts +62 -0
  15. package/src/Domain/Mission.ts +27 -0
  16. package/src/Domain/Rocket.ts +116 -0
  17. package/src/Domain/RocketStatus.ts +10 -0
  18. package/src/Infrastructure/Docker/DockerAdapter.ts +127 -0
  19. package/src/Infrastructure/Git/ShellGitAdapter.ts +22 -0
  20. package/src/Infrastructure/GitHub/OctokitGitHubAdapter.ts +42 -0
  21. package/src/Infrastructure/Persistence/CachedRocketRepository.ts +42 -0
  22. package/src/Infrastructure/Persistence/InMemoryRocketRepository.ts +32 -0
  23. package/src/Infrastructure/Router/BunProxyAdapter.ts +54 -0
  24. package/src/index.ts +223 -0
  25. package/tests/Deployment.test.ts +50 -0
  26. package/tests/Integration.test.ts +42 -0
  27. package/tests/PoolManager.test.ts +60 -0
  28. package/tests/Refurbishment.test.ts +43 -0
  29. package/tests/Rocket.test.ts +49 -0
  30. package/tests/Telemetry.test.ts +50 -0
  31. package/tests/docker-adapter.test.ts +110 -0
  32. package/tests/enterprise-coverage.test.ts +62 -0
  33. package/tests/mission-control.test.ts +79 -0
  34. package/tests/mission.test.ts +18 -0
  35. package/tests/payload-injector.test.ts +64 -0
  36. package/tests/pool-manager.test.ts +93 -0
  37. package/tests/repository.test.ts +31 -0
  38. package/tsconfig.json +8 -0
@@ -0,0 +1,116 @@
1
+ import { AggregateRoot } from '@gravito/enterprise'
2
+ import {
3
+ MissionAssigned,
4
+ RefurbishmentCompleted,
5
+ RocketIgnited,
6
+ RocketSplashedDown,
7
+ } from './Events'
8
+ import type { Mission } from './Mission'
9
+ import { RocketStatus } from './RocketStatus'
10
+
11
+ export class Rocket extends AggregateRoot<string> {
12
+ private _status: RocketStatus = RocketStatus.IDLE
13
+ private _currentMission: Mission | null = null
14
+ private _containerId: string
15
+ private _assignedDomain: string | null = null
16
+
17
+ constructor(id: string, containerId: string) {
18
+ super(id)
19
+ this._containerId = containerId
20
+ }
21
+
22
+ get status() {
23
+ return this._status
24
+ }
25
+ get currentMission() {
26
+ return this._currentMission
27
+ }
28
+ get containerId() {
29
+ return this._containerId
30
+ }
31
+ get assignedDomain() {
32
+ return this._assignedDomain
33
+ }
34
+
35
+ /**
36
+ * 分配域名
37
+ */
38
+ public assignDomain(domain: string): void {
39
+ this._assignedDomain = domain
40
+ }
41
+
42
+ /**
43
+ * 分配任務 (指派任務給 IDLE 的火箭)
44
+ */
45
+ public assignMission(mission: Mission): void {
46
+ if (this._status !== RocketStatus.IDLE) {
47
+ throw new Error(`無法指派任務:火箭 ${this.id} 狀態為 ${this._status},非 IDLE`)
48
+ }
49
+
50
+ this._status = RocketStatus.PREPARING
51
+ this._currentMission = mission
52
+ this.addDomainEvent(new MissionAssigned(this.id, mission.id))
53
+ }
54
+
55
+ /**
56
+ * 點火啟動 (應用程式啟動成功)
57
+ */
58
+ public ignite(): void {
59
+ if (this._status !== RocketStatus.PREPARING) {
60
+ throw new Error(`無法點火:火箭 ${this.id} 尚未進入 PREPARING 狀態`)
61
+ }
62
+
63
+ this._status = RocketStatus.ORBITING
64
+ this.addDomainEvent(new RocketIgnited(this.id))
65
+ }
66
+
67
+ /**
68
+ * 任務降落 (PR 合併或關閉,暫停服務)
69
+ */
70
+ public splashDown(): void {
71
+ if (this._status !== RocketStatus.ORBITING) {
72
+ throw new Error(`無法降落:火箭 ${this.id} 不在運行軌道上`)
73
+ }
74
+
75
+ this._status = RocketStatus.REFURBISHING
76
+ this.addDomainEvent(new RocketSplashedDown(this.id))
77
+ }
78
+
79
+ /**
80
+ * 翻新完成 (清理完畢,回歸池中)
81
+ */
82
+ public finishRefurbishment(): void {
83
+ if (this._status !== RocketStatus.REFURBISHING) {
84
+ throw new Error(`無法完成翻新:火箭 ${this.id} 不在 REFURBISHING 狀態`)
85
+ }
86
+
87
+ this._status = RocketStatus.IDLE
88
+ this._currentMission = null
89
+ this.addDomainEvent(new RefurbishmentCompleted(this.id))
90
+ }
91
+
92
+ /**
93
+ * 火箭除役 (移除容器)
94
+ */
95
+ public decommission(): void {
96
+ this._status = RocketStatus.DECOMMISSIONED
97
+ }
98
+
99
+ public toJSON() {
100
+ return {
101
+ id: this.id,
102
+ containerId: this.containerId,
103
+ status: this._status,
104
+ currentMission: this._currentMission,
105
+ assignedDomain: this._assignedDomain,
106
+ }
107
+ }
108
+
109
+ public static fromJSON(data: any): Rocket {
110
+ const rocket = new Rocket(data.id, data.containerId)
111
+ rocket._status = data.status
112
+ rocket._currentMission = data.currentMission
113
+ rocket._assignedDomain = data.assignedDomain
114
+ return rocket
115
+ }
116
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 火箭生命週期狀態
3
+ */
4
+ export enum RocketStatus {
5
+ IDLE = 'IDLE', // 待命:容器運行中,無任務
6
+ PREPARING = 'PREPARING', // 裝填:正在注入代碼 (Locked)
7
+ ORBITING = 'ORBITING', // 運行:應用服務中
8
+ REFURBISHING = 'REFURBISHING', // 翻新:正在清理環境
9
+ DECOMMISSIONED = 'DECOMMISSIONED', // 除役:容器已移除
10
+ }
@@ -0,0 +1,127 @@
1
+ import type { IDockerAdapter } from '../../Domain/Interfaces'
2
+
3
+ export class DockerAdapter implements IDockerAdapter {
4
+ private baseImage = 'oven/bun:1.0-slim'
5
+
6
+ async createBaseContainer(): Promise<string> {
7
+ const rocketId = `rocket-${Math.random().toString(36).substring(2, 9)}`
8
+
9
+ const proc = Bun.spawn([
10
+ 'docker',
11
+ 'run',
12
+ '-d',
13
+ '--name',
14
+ rocketId,
15
+ '--label',
16
+ 'gravito-origin=launchpad',
17
+ '-p',
18
+ '3000', // 讓 Docker 分配隨機宿主機埠
19
+ '-v',
20
+ `${process.env.HOME}/.bun/install/cache:/root/.bun/install/cache`,
21
+ '-v',
22
+ `${process.env.HOME}/.bun/install/cache:/home/bun/.bun/install/cache`,
23
+ this.baseImage,
24
+ 'tail',
25
+ '-f',
26
+ '/dev/null',
27
+ ])
28
+
29
+ const stdout = await new Response(proc.stdout).text()
30
+ const containerId = stdout.trim()
31
+
32
+ if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
33
+ return containerId
34
+ }
35
+
36
+ if (proc.exitCode !== 0) {
37
+ const stderr = await new Response(proc.stderr).text()
38
+ throw new Error(`Docker 容器建立失敗: ${stderr || stdout}`)
39
+ }
40
+
41
+ return containerId
42
+ }
43
+
44
+ async getExposedPort(containerId: string, containerPort = 3000): Promise<number> {
45
+ const proc = Bun.spawn(['docker', 'port', containerId, containerPort.toString()])
46
+ const stdout = await new Response(proc.stdout).text()
47
+ // 輸出格式可能包含多行,如 0.0.0.0:32768 和 [::]:32768
48
+ // 我們取第一行並提取端口
49
+ const firstLine = stdout.split('\n')[0] ?? ''
50
+ if (!firstLine) {
51
+ throw new Error(`無法獲取容器映射端口: ${stdout}`)
52
+ }
53
+ const match = firstLine.match(/:(\d+)$/)
54
+ if (!match?.[1]) {
55
+ throw new Error(`無法獲取容器映射端口: ${stdout}`)
56
+ }
57
+ return Number.parseInt(match[1], 10)
58
+ }
59
+
60
+ async copyFiles(containerId: string, sourcePath: string, targetPath: string): Promise<void> {
61
+ const proc = Bun.spawn(['docker', 'cp', sourcePath, `${containerId}:${targetPath}`])
62
+ await proc.exited
63
+ if (proc.exitCode && proc.exitCode !== 0) {
64
+ const stderr = await new Response(proc.stderr).text()
65
+ throw new Error(stderr || 'Docker copy failed')
66
+ }
67
+ }
68
+
69
+ async executeCommand(
70
+ containerId: string,
71
+ command: string[]
72
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
73
+ const proc = Bun.spawn(['docker', 'exec', '-u', '0', containerId, ...command])
74
+ const stdout = await new Response(proc.stdout).text()
75
+ const stderr = await new Response(proc.stderr).text()
76
+ await proc.exited
77
+ return { stdout, stderr, exitCode: proc.exitCode ?? -1 }
78
+ }
79
+
80
+ async removeContainer(containerId: string): Promise<void> {
81
+ await Bun.spawn(['docker', 'rm', '-f', containerId]).exited
82
+ }
83
+
84
+ async removeContainerByLabel(label: string): Promise<void> {
85
+ const listProc = Bun.spawn(['docker', 'ps', '-aq', '--filter', `label=${label}`])
86
+ const ids = await new Response(listProc.stdout).text()
87
+
88
+ if (ids.trim()) {
89
+ const idList = ids.trim().split('\n')
90
+ await Bun.spawn(['docker', 'rm', '-f', ...idList]).exited
91
+ }
92
+ }
93
+
94
+ streamLogs(containerId: string, onData: (data: string) => void): void {
95
+ const proc = Bun.spawn(['docker', 'logs', '-f', containerId], {
96
+ stdout: 'pipe',
97
+ stderr: 'pipe',
98
+ })
99
+ const read = async (stream: ReadableStream) => {
100
+ const reader = stream.getReader()
101
+ const decoder = new TextDecoder()
102
+ while (true) {
103
+ const { done, value } = await reader.read()
104
+ if (done) {
105
+ break
106
+ }
107
+ onData(decoder.decode(value))
108
+ }
109
+ }
110
+ read(proc.stdout)
111
+ read(proc.stderr)
112
+ }
113
+
114
+ async getStats(containerId: string): Promise<{ cpu: string; memory: string }> {
115
+ const proc = Bun.spawn([
116
+ 'docker',
117
+ 'stats',
118
+ containerId,
119
+ '--no-stream',
120
+ '--format',
121
+ '{{.CPUPerc}},{{.MemUsage}}',
122
+ ])
123
+ const stdout = await new Response(proc.stdout).text()
124
+ const [cpu, memory] = stdout.trim().split(',')
125
+ return { cpu: cpu || '0%', memory: memory || '0B / 0B' }
126
+ }
127
+ }
@@ -0,0 +1,22 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import type { IGitAdapter } from '../../Domain/Interfaces'
3
+
4
+ export class ShellGitAdapter implements IGitAdapter {
5
+ private baseDir = '/tmp/gravito-launchpad-git'
6
+
7
+ async clone(repoUrl: string, branch: string): Promise<string> {
8
+ const dirName = `${Date.now()}-${Math.random().toString(36).slice(2)}`
9
+ const targetDir = `${this.baseDir}/${dirName}`
10
+
11
+ await mkdir(this.baseDir, { recursive: true })
12
+
13
+ const proc = Bun.spawn(['git', 'clone', '--depth', '1', '--branch', branch, repoUrl, targetDir])
14
+
15
+ await proc.exited
16
+ if (proc.exitCode !== 0) {
17
+ throw new Error('Git Clone Failed')
18
+ }
19
+
20
+ return targetDir
21
+ }
22
+ }
@@ -0,0 +1,42 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { Octokit } from '@octokit/rest'
3
+ import type { IGitHubAdapter } from '../../Domain/Interfaces'
4
+
5
+ export class OctokitGitHubAdapter implements IGitHubAdapter {
6
+ private octokit: Octokit
7
+
8
+ constructor(token?: string) {
9
+ this.octokit = new Octokit({ auth: token })
10
+ }
11
+
12
+ verifySignature(payload: string, signature: string, secret: string): boolean {
13
+ if (!signature) {
14
+ return false
15
+ }
16
+
17
+ const hmac = createHmac('sha256', secret)
18
+ const digest = Buffer.from(`sha256=${hmac.update(payload).digest('hex')}`, 'utf8')
19
+ const checksum = Buffer.from(signature, 'utf8')
20
+
21
+ return digest.length === checksum.length && timingSafeEqual(digest, checksum)
22
+ }
23
+
24
+ async postComment(
25
+ repoOwner: string,
26
+ repoName: string,
27
+ prNumber: number,
28
+ comment: string
29
+ ): Promise<void> {
30
+ try {
31
+ await this.octokit.issues.createComment({
32
+ owner: repoOwner,
33
+ repo: repoName,
34
+ issue_number: prNumber,
35
+ body: comment,
36
+ })
37
+ console.log(`[GitHub] 已在 PR #${prNumber} 留下部署資訊`)
38
+ } catch (error: any) {
39
+ console.error(`[GitHub] 留言失敗: ${error.message}`)
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,42 @@
1
+ import type { CacheService } from 'gravito-core'
2
+ import type { IRocketRepository } from '../../Domain/Interfaces'
3
+ import { Rocket } from '../../Domain/Rocket'
4
+
5
+ export class CachedRocketRepository implements IRocketRepository {
6
+ private CACHE_KEY = 'launchpad:rockets'
7
+
8
+ constructor(private cache: CacheService) {}
9
+
10
+ private async getMap(): Promise<Map<string, any>> {
11
+ const data = await this.cache.get<Record<string, any>>(this.CACHE_KEY)
12
+ return new Map(Object.entries(data || {}))
13
+ }
14
+
15
+ async save(rocket: Rocket): Promise<void> {
16
+ const map = await this.getMap()
17
+ map.set(rocket.id, rocket.toJSON())
18
+ await this.cache.set(this.CACHE_KEY, Object.fromEntries(map))
19
+ }
20
+
21
+ async findById(id: string): Promise<Rocket | null> {
22
+ const map = await this.getMap()
23
+ const data = map.get(id)
24
+ return data ? Rocket.fromJSON(data) : null
25
+ }
26
+
27
+ async findIdle(): Promise<Rocket | null> {
28
+ const rockets = await this.findAll()
29
+ return rockets.find((r) => r.status === 'IDLE') || null
30
+ }
31
+
32
+ async findAll(): Promise<Rocket[]> {
33
+ const map = await this.getMap()
34
+ return Array.from(map.values()).map((data) => Rocket.fromJSON(data))
35
+ }
36
+
37
+ async delete(id: string): Promise<void> {
38
+ const map = await this.getMap()
39
+ map.delete(id)
40
+ await this.cache.set(this.CACHE_KEY, Object.fromEntries(map))
41
+ }
42
+ }
@@ -0,0 +1,32 @@
1
+ import type { IRocketRepository } from '../../Domain/Interfaces'
2
+ import type { Rocket } from '../../Domain/Rocket'
3
+ import { RocketStatus } from '../../Domain/RocketStatus'
4
+
5
+ export class InMemoryRocketRepository implements IRocketRepository {
6
+ private rockets = new Map<string, Rocket>()
7
+
8
+ async save(rocket: Rocket): Promise<void> {
9
+ this.rockets.set(rocket.id, rocket)
10
+ }
11
+
12
+ async findById(id: string): Promise<Rocket | null> {
13
+ return this.rockets.get(id) || null
14
+ }
15
+
16
+ async findIdle(): Promise<Rocket | null> {
17
+ for (const rocket of this.rockets.values()) {
18
+ if (rocket.status === RocketStatus.IDLE) {
19
+ return rocket
20
+ }
21
+ }
22
+ return null
23
+ }
24
+
25
+ async findAll(): Promise<Rocket[]> {
26
+ return Array.from(this.rockets.values())
27
+ }
28
+
29
+ async delete(id: string): Promise<void> {
30
+ this.rockets.delete(id)
31
+ }
32
+ }
@@ -0,0 +1,54 @@
1
+ import type { IRouterAdapter } from '../../Domain/Interfaces'
2
+
3
+ export class BunProxyAdapter implements IRouterAdapter {
4
+ private routes = new Map<string, string>() // domain -> targetUrl
5
+
6
+ register(domain: string, targetUrl: string): void {
7
+ console.log(`[Proxy] 註冊路由: ${domain} -> ${targetUrl}`)
8
+ this.routes.set(domain.toLowerCase(), targetUrl)
9
+ }
10
+
11
+ unregister(domain: string): void {
12
+ console.log(`[Proxy] 註銷路由: ${domain}`)
13
+ this.routes.delete(domain.toLowerCase())
14
+ }
15
+
16
+ start(port: number): void {
17
+ const self = this
18
+
19
+ Bun.serve({
20
+ port,
21
+ async fetch(request) {
22
+ const host = request.headers.get('host')?.split(':')[0]?.toLowerCase()
23
+
24
+ if (!host || !self.routes.has(host)) {
25
+ return new Response('Rocket Not Found or Mission Not Started', { status: 404 })
26
+ }
27
+
28
+ const targetBase = self.routes.get(host)!
29
+ const url = new URL(request.url)
30
+ const targetUrl = `${targetBase}${url.pathname}${url.search}`
31
+
32
+ console.log(`[Proxy] 轉發: ${host}${url.pathname} -> ${targetUrl}`)
33
+
34
+ // 建立轉發請求,克隆標頭與方法
35
+ try {
36
+ const proxyReq = new Request(targetUrl, {
37
+ method: request.method,
38
+ headers: request.headers,
39
+ body: request.body,
40
+ // @ts-expect-error Bun specific
41
+ duplex: 'half',
42
+ })
43
+
44
+ return await fetch(proxyReq)
45
+ } catch (error: any) {
46
+ console.error(`[Proxy] 轉發失敗: ${error.message}`)
47
+ return new Response('Proxy Error', { status: 502 })
48
+ }
49
+ },
50
+ })
51
+
52
+ console.log(`[Proxy] 動態路由伺服器已啟動於 Port: ${port}`)
53
+ }
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,223 @@
1
+ import { OrbitRipple } from '@gravito/ripple'
2
+ import { OrbitCache } from '@gravito/stasis'
3
+ import { type Container, type GravitoOrbit, PlanetCore, ServiceProvider } from 'gravito-core'
4
+ import { MissionControl } from './Application/MissionControl'
5
+ import { PayloadInjector } from './Application/PayloadInjector'
6
+ import { PoolManager } from './Application/PoolManager'
7
+ import { RefurbishUnit } from './Application/RefurbishUnit'
8
+ import { Mission } from './Domain/Mission'
9
+ import { DockerAdapter } from './Infrastructure/Docker/DockerAdapter'
10
+ import { ShellGitAdapter } from './Infrastructure/Git/ShellGitAdapter'
11
+ import { OctokitGitHubAdapter } from './Infrastructure/GitHub/OctokitGitHubAdapter'
12
+ import { CachedRocketRepository } from './Infrastructure/Persistence/CachedRocketRepository'
13
+ import { BunProxyAdapter } from './Infrastructure/Router/BunProxyAdapter'
14
+
15
+ export * from './Application/MissionControl'
16
+ export * from './Application/PayloadInjector'
17
+ export * from './Application/PoolManager'
18
+ export * from './Application/RefurbishUnit'
19
+ export * from './Domain/Mission'
20
+ export * from './Domain/Rocket'
21
+ export * from './Domain/RocketStatus'
22
+
23
+ /**
24
+ * Launchpad 內部的 Service Provider
25
+ */
26
+ class LaunchpadServiceProvider extends ServiceProvider {
27
+ register(container: Container): void {
28
+ if (!container.has('cache')) {
29
+ const cacheFromServices = this.core?.services?.get('cache')
30
+ if (cacheFromServices) {
31
+ container.instance('cache', cacheFromServices)
32
+ }
33
+ }
34
+
35
+ container.singleton('launchpad.docker', () => new DockerAdapter())
36
+ container.singleton('launchpad.git', () => new ShellGitAdapter())
37
+ container.singleton('launchpad.router', () => new BunProxyAdapter())
38
+ container.singleton(
39
+ 'launchpad.github',
40
+ () => new OctokitGitHubAdapter(process.env.GITHUB_TOKEN)
41
+ )
42
+
43
+ container.singleton('launchpad.repo', () => {
44
+ const cache = container.make<any>('cache')
45
+ return new CachedRocketRepository(cache)
46
+ })
47
+
48
+ container.singleton('launchpad.refurbish', () => {
49
+ return new RefurbishUnit(container.make('launchpad.docker'))
50
+ })
51
+
52
+ container.singleton('launchpad.pool', () => {
53
+ return new PoolManager(
54
+ container.make('launchpad.docker'),
55
+ container.make('launchpad.repo'),
56
+ container.make('launchpad.refurbish'),
57
+ container.make('launchpad.router')
58
+ )
59
+ })
60
+
61
+ container.singleton('launchpad.injector', () => {
62
+ return new PayloadInjector(
63
+ container.make('launchpad.docker'),
64
+ container.make('launchpad.git')
65
+ )
66
+ })
67
+
68
+ container.singleton('launchpad.ctrl', () => {
69
+ return new MissionControl(
70
+ container.make('launchpad.pool'),
71
+ container.make('launchpad.injector'),
72
+ container.make('launchpad.docker'),
73
+ container.make('launchpad.router')
74
+ )
75
+ })
76
+ }
77
+
78
+ override boot(): void {
79
+ const core = this.core
80
+ if (!core) {
81
+ return
82
+ }
83
+ const logger = core.logger
84
+ const router = core.container.make<BunProxyAdapter>('launchpad.router')
85
+ const docker = core.container.make<DockerAdapter>('launchpad.docker')
86
+
87
+ // 啟動路由代理
88
+ router.start(8080)
89
+
90
+ // [New] 啟動時自動清理殘留容器,維持系統純淨
91
+ logger.info('[Launchpad] 正在掃描並清理殘留資源...')
92
+ docker
93
+ .removeContainerByLabel('gravito-origin=launchpad')
94
+ .then(() => {
95
+ logger.info('[Launchpad] 資源清理完畢,系統就緒。')
96
+ })
97
+ .catch((err) => {
98
+ logger.warn('[Launchpad] 自動清理失敗 (可能是環境無容器):', err.message)
99
+ })
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Gravito Launchpad Orbit
105
+ */
106
+ export class LaunchpadOrbit implements GravitoOrbit {
107
+ constructor(private ripple: OrbitRipple) {}
108
+
109
+ async install(core: PlanetCore): Promise<void> {
110
+ core.register(new LaunchpadServiceProvider())
111
+
112
+ core.router.post('/launch', async (c) => {
113
+ const rawBody = await c.req.text()
114
+ const signature = c.req.header('x-hub-signature-256') || ''
115
+ const secret = process.env.GITHUB_WEBHOOK_SECRET
116
+
117
+ const github = core.container.make<OctokitGitHubAdapter>('launchpad.github')
118
+
119
+ if (secret && !github.verifySignature(rawBody, signature, secret)) {
120
+ core.logger.error('[GitHub] Webhook signature verification failed')
121
+ return c.json({ error: 'Invalid signature' }, 401)
122
+ }
123
+
124
+ const body = JSON.parse(rawBody)
125
+ const event = c.req.header('x-github-event')
126
+
127
+ if (event === 'pull_request') {
128
+ const action = body.action
129
+ const pr = body.pull_request
130
+ const missionId = `pr-${pr.number}`
131
+
132
+ if (['opened', 'synchronize', 'reopened'].includes(action)) {
133
+ const mission = Mission.create({
134
+ id: missionId,
135
+ repoUrl: pr.base.repo.clone_url,
136
+ branch: pr.head.ref,
137
+ commitSha: pr.head.sha,
138
+ })
139
+
140
+ const ctrl = core.container.make<MissionControl>('launchpad.ctrl')
141
+ const rocketId = await ctrl.launch(mission, (type, data) => {
142
+ // 修正:使用正確的 broadcast 方法
143
+ this.ripple.getServer().broadcast('telemetry', 'telemetry.data', { type, data })
144
+ })
145
+
146
+ if (action === 'opened' || action === 'reopened') {
147
+ const previewUrl = `http://${missionId}.dev.local:8080`
148
+ const dashboardUrl = `http://${c.req.header('host')?.split(':')[0]}:5173`
149
+
150
+ const comment =
151
+ `🚀 **Gravito Launchpad: Deployment Ready!**\n\n` +
152
+ `- **Preview URL**: [${previewUrl}](${previewUrl})\n` +
153
+ `- **Mission Dashboard**: [${dashboardUrl}](${dashboardUrl})\n\n` +
154
+ `*Rocket ID: ${rocketId} | Commit: ${pr.head.sha.slice(0, 7)}*`
155
+
156
+ await github.postComment(
157
+ pr.base.repo.owner.login,
158
+ pr.base.repo.name,
159
+ pr.number,
160
+ comment
161
+ )
162
+ }
163
+
164
+ return c.json({ success: true, missionId, rocketId })
165
+ }
166
+
167
+ if (action === 'closed') {
168
+ const pool = core.container.make<PoolManager>('launchpad.pool')
169
+ await pool.recycle(missionId)
170
+ return c.json({ success: true, action: 'recycled' })
171
+ }
172
+ }
173
+
174
+ return c.json({ success: true, message: 'Event ignored' })
175
+ })
176
+
177
+ core.router.post('/recycle', async (c) => {
178
+ const body = (await c.req.json()) as any
179
+ if (!body.missionId) {
180
+ return c.json({ error: 'missionId required' }, 400)
181
+ }
182
+
183
+ const pool = core.container.make<PoolManager>('launchpad.pool')
184
+ await pool.recycle(body.missionId)
185
+ return c.json({ success: true })
186
+ })
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 一鍵啟動 Launchpad 應用程式
192
+ */
193
+ export async function bootstrapLaunchpad() {
194
+ const ripple = new OrbitRipple({ path: '/ws' })
195
+
196
+ const core = await PlanetCore.boot({
197
+ config: {
198
+ APP_NAME: 'Gravito Launchpad',
199
+ PORT: 4000,
200
+ CACHE_DRIVER: 'file',
201
+ },
202
+ orbits: [
203
+ new OrbitCache(),
204
+ ripple,
205
+ new LaunchpadOrbit(ripple), // 傳入實例
206
+ ],
207
+ })
208
+
209
+ await core.bootstrap()
210
+
211
+ const liftoffConfig = core.liftoff()
212
+
213
+ return {
214
+ port: liftoffConfig.port,
215
+ websocket: ripple.getHandler(),
216
+ fetch: (req: Request, server: any) => {
217
+ if (ripple.getServer().upgrade(req, server)) {
218
+ return
219
+ }
220
+ return liftoffConfig.fetch(req, server)
221
+ },
222
+ }
223
+ }