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/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"