flightdeck 0.0.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/dist/bin.js +5285 -0
- package/dist/flightdeck.$configPath.schema.json +39 -0
- package/dist/flightdeck.main.schema.json +39 -0
- package/dist/flightdeck.schema.schema.json +10 -0
- package/dist/lib.d.ts +36 -0
- package/dist/lib.js +3865 -0
- package/package.json +49 -0
- package/src/bin.ts +113 -0
- package/src/flightdeck.ts +235 -0
- package/src/lib.ts +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flightdeck",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Jeremy Banka",
|
|
7
|
+
"email": "hello@jeremybanka.com"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"files": ["dist", "src"],
|
|
14
|
+
"main": "dist/lib.js",
|
|
15
|
+
"types": "dist/lib.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"flightdeck": "./dist/bin.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"atom.io": "workspace:*",
|
|
21
|
+
"comline": "workspace:*",
|
|
22
|
+
"zod": "3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "rimraf dist && concurrently \"bun:build:*\" && bun run schema",
|
|
26
|
+
"build:lib": "bun build --outdir dist --target node --external flightdeck -- src/lib.ts ",
|
|
27
|
+
"build:bin": "bun build --outdir dist --target node --external flightdeck -- src/bin.ts",
|
|
28
|
+
"build:dts": "tsup",
|
|
29
|
+
"schema": "bun ./src/bin.ts --outdir=dist -- schema",
|
|
30
|
+
"lint:biome": "biome check -- .",
|
|
31
|
+
"lint:eslint": "eslint --flag unstable_ts_config -- .",
|
|
32
|
+
"lint:types": "tsc --noEmit",
|
|
33
|
+
"lint:types:watch": "tsc --watch --noEmit",
|
|
34
|
+
"lint": "bun run lint:biome && bun run lint:eslint && bun run lint:types",
|
|
35
|
+
"test": "vitest",
|
|
36
|
+
"test:once": "vitest run",
|
|
37
|
+
"test:coverage": "echo no test coverage yet"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "1.1.8",
|
|
41
|
+
"@types/node": "20.16.3",
|
|
42
|
+
"@types/tmp": "0.2.6",
|
|
43
|
+
"concurrently": "8.2.2",
|
|
44
|
+
"tmp": "0.2.3",
|
|
45
|
+
"tsup": "8.2.4",
|
|
46
|
+
"rimraf": "6.0.1",
|
|
47
|
+
"vitest": "2.0.5"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
|
|
5
|
+
import type { OptionsGroup } from "comline"
|
|
6
|
+
import { cli, optional, parseArrayOption } from "comline"
|
|
7
|
+
import type { FlightDeckOptions } from "flightdeck"
|
|
8
|
+
import { FlightDeck } from "flightdeck"
|
|
9
|
+
import { z } from "zod"
|
|
10
|
+
|
|
11
|
+
const FLIGHTDECK_MANUAL = {
|
|
12
|
+
optionsSchema: z.object({
|
|
13
|
+
secret: z.string(),
|
|
14
|
+
repo: z.string(),
|
|
15
|
+
app: z.string(),
|
|
16
|
+
runCmd: z.array(z.string()),
|
|
17
|
+
serviceDir: z.string(),
|
|
18
|
+
updateCmd: z.array(z.string()),
|
|
19
|
+
}),
|
|
20
|
+
options: {
|
|
21
|
+
secret: {
|
|
22
|
+
flag: `s`,
|
|
23
|
+
required: true,
|
|
24
|
+
description: `Secret used to authenticate with the service.`,
|
|
25
|
+
example: `--secret=\"secret\"`,
|
|
26
|
+
},
|
|
27
|
+
repo: {
|
|
28
|
+
flag: `r`,
|
|
29
|
+
required: true,
|
|
30
|
+
description: `Name of the repository.`,
|
|
31
|
+
example: `--repo=\"sample/repo\"`,
|
|
32
|
+
},
|
|
33
|
+
app: {
|
|
34
|
+
flag: `a`,
|
|
35
|
+
required: true,
|
|
36
|
+
description: `Name of the application.`,
|
|
37
|
+
example: `--app=\"my-app\"`,
|
|
38
|
+
},
|
|
39
|
+
runCmd: {
|
|
40
|
+
flag: `r`,
|
|
41
|
+
required: true,
|
|
42
|
+
description: `Command to run the application.`,
|
|
43
|
+
example: `--runCmd=\"./app\"`,
|
|
44
|
+
parse: parseArrayOption,
|
|
45
|
+
},
|
|
46
|
+
serviceDir: {
|
|
47
|
+
flag: `d`,
|
|
48
|
+
required: true,
|
|
49
|
+
description: `Directory where the service is stored.`,
|
|
50
|
+
example: `--serviceDir=\"./services/sample/repo/my-app/current\"`,
|
|
51
|
+
},
|
|
52
|
+
updateCmd: {
|
|
53
|
+
flag: `u`,
|
|
54
|
+
required: true,
|
|
55
|
+
description: `Command to update the service.`,
|
|
56
|
+
example: `--updateCmd=\"./app\"`,
|
|
57
|
+
parse: parseArrayOption,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
} satisfies OptionsGroup<FlightDeckOptions>
|
|
61
|
+
|
|
62
|
+
const SCHEMA_MANUAL = {
|
|
63
|
+
optionsSchema: z.object({
|
|
64
|
+
outdir: z.string().optional(),
|
|
65
|
+
}),
|
|
66
|
+
options: {
|
|
67
|
+
outdir: {
|
|
68
|
+
flag: `o`,
|
|
69
|
+
required: false,
|
|
70
|
+
description: `Directory to write the schema to.`,
|
|
71
|
+
example: `--outdir=./dist`,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
} satisfies OptionsGroup<{ outdir?: string | undefined }>
|
|
75
|
+
|
|
76
|
+
const parse = cli(
|
|
77
|
+
{
|
|
78
|
+
cliName: `flightdeck`,
|
|
79
|
+
routes: optional({ schema: null, $configPath: null }),
|
|
80
|
+
routeOptions: {
|
|
81
|
+
"": FLIGHTDECK_MANUAL,
|
|
82
|
+
$configPath: FLIGHTDECK_MANUAL,
|
|
83
|
+
schema: SCHEMA_MANUAL,
|
|
84
|
+
},
|
|
85
|
+
discoverConfigPath: (args) => {
|
|
86
|
+
if (args[0] === `schema`) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const configPath =
|
|
90
|
+
args[0] ?? path.join(process.cwd(), `flightdeck.config.json`)
|
|
91
|
+
return configPath
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
console,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const { inputs, writeJsonSchema } = parse(process.argv)
|
|
98
|
+
|
|
99
|
+
switch (inputs.case) {
|
|
100
|
+
case `schema`:
|
|
101
|
+
{
|
|
102
|
+
const { outdir } = inputs.opts
|
|
103
|
+
writeJsonSchema(outdir ?? `.`)
|
|
104
|
+
}
|
|
105
|
+
break
|
|
106
|
+
default: {
|
|
107
|
+
const flightDeck = new FlightDeck(inputs.opts)
|
|
108
|
+
process.on(`close`, async () => {
|
|
109
|
+
flightDeck.stopService()
|
|
110
|
+
await flightDeck.dead
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execSync, spawn } from "node:child_process"
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, rmSync, unlinkSync } from "node:fs"
|
|
3
|
+
import type { Http2Server } from "node:http2"
|
|
4
|
+
import { createServer } from "node:http2"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { resolve } from "node:path"
|
|
7
|
+
|
|
8
|
+
import { Future } from "atom.io/internal"
|
|
9
|
+
import { ChildSocket } from "atom.io/realtime-server"
|
|
10
|
+
|
|
11
|
+
export type FlightDeckOptions = {
|
|
12
|
+
secret: string
|
|
13
|
+
repo: string
|
|
14
|
+
app: string
|
|
15
|
+
runCmd: string[]
|
|
16
|
+
updateCmd: string[]
|
|
17
|
+
serviceDir?: string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let safety = 0
|
|
21
|
+
const PORT = process.env.PORT ?? 8080
|
|
22
|
+
const ORIGIN = `http://localhost:${PORT}`
|
|
23
|
+
export class FlightDeck {
|
|
24
|
+
public get serviceName(): string {
|
|
25
|
+
return `${this.options.repo}/${this.options.app}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
protected webhookServer: Http2Server
|
|
29
|
+
protected service: ChildSocket<
|
|
30
|
+
{ updatesReady: [] },
|
|
31
|
+
{ readyToUpdate: []; alive: [] }
|
|
32
|
+
> | null = null
|
|
33
|
+
protected restartTimes: number[] = []
|
|
34
|
+
|
|
35
|
+
public alive = new Future(() => {})
|
|
36
|
+
public dead = new Future(() => {})
|
|
37
|
+
|
|
38
|
+
public readonly currentServiceDir: string
|
|
39
|
+
public readonly updateServiceDir: string
|
|
40
|
+
public readonly backupServiceDir: string
|
|
41
|
+
|
|
42
|
+
public constructor(public readonly options: FlightDeckOptions) {
|
|
43
|
+
const {
|
|
44
|
+
secret,
|
|
45
|
+
serviceDir = resolve(
|
|
46
|
+
homedir(),
|
|
47
|
+
`services`,
|
|
48
|
+
`sample/repo`,
|
|
49
|
+
`my-app`,
|
|
50
|
+
`current`,
|
|
51
|
+
),
|
|
52
|
+
} = options
|
|
53
|
+
|
|
54
|
+
this.currentServiceDir = resolve(serviceDir, `current`)
|
|
55
|
+
this.backupServiceDir = resolve(serviceDir, `backup`)
|
|
56
|
+
this.updateServiceDir = resolve(serviceDir, `update`)
|
|
57
|
+
|
|
58
|
+
createServer((req, res) => {
|
|
59
|
+
let data: Uint8Array[] = []
|
|
60
|
+
req
|
|
61
|
+
.on(`data`, (chunk) => {
|
|
62
|
+
data.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk))
|
|
63
|
+
})
|
|
64
|
+
.on(`end`, () => {
|
|
65
|
+
console.log(req.headers)
|
|
66
|
+
const authHeader = req.headers.authorization
|
|
67
|
+
try {
|
|
68
|
+
if (authHeader !== `Bearer ${secret}`) throw 401
|
|
69
|
+
const url = new URL(req.url, ORIGIN)
|
|
70
|
+
console.log(req.method, url.pathname)
|
|
71
|
+
switch (req.method) {
|
|
72
|
+
case `POST`:
|
|
73
|
+
{
|
|
74
|
+
console.log(`received post, url is ${url.pathname}`)
|
|
75
|
+
switch (url.pathname) {
|
|
76
|
+
case `/`:
|
|
77
|
+
{
|
|
78
|
+
res.writeHead(200)
|
|
79
|
+
res.end()
|
|
80
|
+
this.fetchLatestRelease()
|
|
81
|
+
if (this.service) {
|
|
82
|
+
this.service.emit(`updatesReady`)
|
|
83
|
+
} else {
|
|
84
|
+
this.applyUpdate()
|
|
85
|
+
this.startService()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
default:
|
|
91
|
+
throw 404
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
default:
|
|
97
|
+
throw 405
|
|
98
|
+
}
|
|
99
|
+
} catch (thrown) {
|
|
100
|
+
console.error(thrown, req.url)
|
|
101
|
+
if (typeof thrown === `number`) {
|
|
102
|
+
res.writeHead(thrown)
|
|
103
|
+
res.end()
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
data = []
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}).listen(PORT, () => {
|
|
110
|
+
console.log(`Server started on port ${PORT}`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
this.startService()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
protected startService(): void {
|
|
117
|
+
safety++
|
|
118
|
+
if (safety > 10) {
|
|
119
|
+
throw new Error(`safety exceeded`)
|
|
120
|
+
}
|
|
121
|
+
if (!existsSync(this.currentServiceDir)) {
|
|
122
|
+
console.log(
|
|
123
|
+
`Tried to start service but failed: Service ${this.serviceName} is not yet installed.`,
|
|
124
|
+
)
|
|
125
|
+
this.fetchLatestRelease()
|
|
126
|
+
this.applyUpdate()
|
|
127
|
+
this.startService()
|
|
128
|
+
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const [executable, ...args] = this.options.runCmd
|
|
133
|
+
const program = executable.startsWith(`./`)
|
|
134
|
+
? resolve(this.currentServiceDir, executable)
|
|
135
|
+
: executable
|
|
136
|
+
const serviceProcess = spawn(program, args, {
|
|
137
|
+
cwd: this.currentServiceDir,
|
|
138
|
+
env: import.meta.env,
|
|
139
|
+
})
|
|
140
|
+
this.service = new ChildSocket(serviceProcess, this.serviceName, console)
|
|
141
|
+
this.service.onAny((...messages) => {
|
|
142
|
+
console.log(`🛰 `, ...messages)
|
|
143
|
+
})
|
|
144
|
+
this.service.on(`readyToUpdate`, () => {
|
|
145
|
+
this.stopService()
|
|
146
|
+
})
|
|
147
|
+
this.service.on(`alive`, () => {
|
|
148
|
+
this.alive.use(Promise.resolve())
|
|
149
|
+
this.dead = new Future(() => {})
|
|
150
|
+
})
|
|
151
|
+
this.service.process.on(`close`, (exitCode) => {
|
|
152
|
+
console.log(`Service ${this.serviceName} exited with code ${exitCode}`)
|
|
153
|
+
this.service = null
|
|
154
|
+
const updatesAreReady = existsSync(this.updateServiceDir)
|
|
155
|
+
if (updatesAreReady) {
|
|
156
|
+
console.log(`Updates are ready; applying and restarting...`)
|
|
157
|
+
this.restartTimes = []
|
|
158
|
+
this.applyUpdate()
|
|
159
|
+
this.startService()
|
|
160
|
+
} else {
|
|
161
|
+
if (exitCode !== 0) {
|
|
162
|
+
const now = Date.now()
|
|
163
|
+
const fiveMinutesAgo = now - 5 * 60 * 1000
|
|
164
|
+
this.restartTimes = this.restartTimes.filter(
|
|
165
|
+
(time) => time > fiveMinutesAgo,
|
|
166
|
+
)
|
|
167
|
+
this.restartTimes.push(now)
|
|
168
|
+
|
|
169
|
+
if (this.restartTimes.length < 5) {
|
|
170
|
+
console.log(`Service ${this.serviceName} crashed. Restarting...`)
|
|
171
|
+
this.startService()
|
|
172
|
+
} else {
|
|
173
|
+
console.log(
|
|
174
|
+
`Service ${this.serviceName} crashed too many times. Not restarting.`,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
protected applyUpdate(): void {
|
|
183
|
+
console.log(`Installing latest version of service ${this.serviceName}...`)
|
|
184
|
+
|
|
185
|
+
if (existsSync(this.updateServiceDir)) {
|
|
186
|
+
if (this.service) {
|
|
187
|
+
console.log(
|
|
188
|
+
`Tried to apply update but failed: Service ${this.serviceName} is currently running.`,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (existsSync(this.currentServiceDir)) {
|
|
194
|
+
if (!existsSync(this.backupServiceDir)) {
|
|
195
|
+
mkdirSync(this.backupServiceDir, { recursive: true })
|
|
196
|
+
} else {
|
|
197
|
+
rmSync(this.backupServiceDir, { recursive: true })
|
|
198
|
+
}
|
|
199
|
+
renameSync(this.currentServiceDir, this.backupServiceDir)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
renameSync(this.updateServiceDir, this.currentServiceDir)
|
|
203
|
+
this.restartTimes = []
|
|
204
|
+
} else {
|
|
205
|
+
console.log(`Service ${this.serviceName} is already up to date.`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
protected fetchLatestRelease(): void {
|
|
210
|
+
console.log(`Downloading latest version of service ${this.serviceName}...`)
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
execSync(this.options.updateCmd.join(` `))
|
|
214
|
+
} catch (thrown) {
|
|
215
|
+
if (thrown instanceof Error) {
|
|
216
|
+
console.error(`Failed to fetch the latest release: ${thrown.message}`)
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public stopService(): void {
|
|
223
|
+
if (this.service) {
|
|
224
|
+
console.log(`Stopping service ${this.serviceName}...`)
|
|
225
|
+
this.service.process.kill()
|
|
226
|
+
this.service = null
|
|
227
|
+
this.dead.use(Promise.resolve())
|
|
228
|
+
this.alive = new Future(() => {})
|
|
229
|
+
} else {
|
|
230
|
+
console.error(
|
|
231
|
+
`Failed to stop service ${this.serviceName}: Service is not running.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./flightdeck"
|