flightdeck 0.2.0 → 0.2.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flightdeck",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jeremy Banka",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@t3-oss/env-core": "0.11.1",
25
+ "cron": "3.2.0",
25
26
  "zod": "3.23.8",
26
27
  "atom.io": "0.30.2",
27
28
  "comline": "0.1.6"
@@ -0,0 +1,67 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs"
10
+ import { resolve } from "node:path"
11
+
12
+ export type FilesystemStorageOptions = {
13
+ path: string
14
+ }
15
+
16
+ export class FilesystemStorage<
17
+ T extends Record<string, string> = Record<string, string>,
18
+ > implements Storage
19
+ {
20
+ public rootDir: string
21
+
22
+ public constructor(options: FilesystemStorageOptions) {
23
+ this.rootDir = options.path
24
+ if (!existsSync(this.rootDir)) {
25
+ mkdirSync(this.rootDir, { recursive: true })
26
+ }
27
+ }
28
+
29
+ public getItem<K extends string & keyof T>(key: K): T[K] | null {
30
+ const filePath = resolve(this.rootDir, key)
31
+ if (existsSync(filePath)) {
32
+ return readFileSync(filePath, `utf-8`) as T[K]
33
+ }
34
+ return null
35
+ }
36
+
37
+ public setItem<K extends string & keyof T>(key: K, value: T[K]): void {
38
+ const filePath = resolve(this.rootDir, key)
39
+ writeFileSync(filePath, value)
40
+ }
41
+
42
+ public removeItem<K extends string & keyof T>(key: K): void {
43
+ const filePath = resolve(this.rootDir, key)
44
+ if (existsSync(filePath)) {
45
+ rmSync(filePath)
46
+ }
47
+ }
48
+
49
+ public key(index: number): (string & keyof T) | null {
50
+ const filePaths = readdirSync(this.rootDir)
51
+ const filePathsByDateCreated = filePaths.sort((a, b) => {
52
+ const aStat = statSync(a)
53
+ const bStat = statSync(b)
54
+ return bStat.ctimeMs - aStat.ctimeMs
55
+ })
56
+ return (filePathsByDateCreated[index] as string & keyof T) ?? null
57
+ }
58
+
59
+ public clear(): void {
60
+ rmSync(this.rootDir, { recursive: true })
61
+ mkdirSync(this.rootDir, { recursive: true })
62
+ }
63
+
64
+ public get length(): number {
65
+ return readdirSync(this.rootDir).length
66
+ }
67
+ }
@@ -18,6 +18,7 @@ const FLIGHTDECK_MANUAL = {
18
18
  scripts: z.object({
19
19
  download: z.string(),
20
20
  install: z.string(),
21
+ checkAvailability: z.string(),
21
22
  }),
22
23
  }),
23
24
  options: {
@@ -1,22 +1,39 @@
1
1
  import { execSync, spawn } from "node:child_process"
2
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
3
2
  import type { Server } from "node:http"
4
3
  import { createServer } from "node:http"
5
4
  import { homedir } from "node:os"
6
5
  import { resolve } from "node:path"
7
6
 
8
7
  import { Future } from "atom.io/internal"
8
+ import { discoverType } from "atom.io/introspection"
9
9
  import { fromEntries, toEntries } from "atom.io/json"
10
10
  import { ChildSocket } from "atom.io/realtime-server"
11
+ import { CronJob } from "cron"
11
12
 
13
+ import { FilesystemStorage } from "./filesystem-storage"
12
14
  import { env } from "./flightdeck.env"
13
15
 
16
+ export const FLIGHTDECK_SETUP_PHASES = [`downloaded`, `installed`] as const
17
+
18
+ export type FlightDeckSetupPhase = (typeof FLIGHTDECK_SETUP_PHASES)[number]
19
+
20
+ export const FLIGHTDECK_UPDATE_PHASES = [`notified`, `confirmed`] as const
21
+
22
+ export type FlightDeckUpdatePhase = (typeof FLIGHTDECK_UPDATE_PHASES)[number]
23
+
24
+ export function isVersionNumber(version: string): boolean {
25
+ return (
26
+ /^\d+\.\d+\.\d+$/.test(version) || !Number.isNaN(Number.parseFloat(version))
27
+ )
28
+ }
29
+
14
30
  export type FlightDeckOptions<S extends string = string> = {
15
31
  packageName: string
16
32
  services: { [service in S]: { run: string; waitFor: boolean } }
17
33
  scripts: {
18
34
  download: string
19
35
  install: string
36
+ checkAvailability?: string
20
37
  }
21
38
  port?: number | undefined
22
39
  flightdeckRootDir?: string | undefined
@@ -25,6 +42,11 @@ export type FlightDeckOptions<S extends string = string> = {
25
42
  export class FlightDeck<S extends string = string> {
26
43
  protected safety = 0
27
44
 
45
+ protected storage: FilesystemStorage<{
46
+ setupPhase: FlightDeckSetupPhase
47
+ updatePhase: FlightDeckUpdatePhase
48
+ updateAwaitedVersion: string
49
+ }>
28
50
  protected webhookServer: Server
29
51
  protected services: {
30
52
  [service in S]: ChildSocket<
@@ -35,13 +57,15 @@ export class FlightDeck<S extends string = string> {
35
57
  protected serviceIdx: { readonly [service in S]: number }
36
58
  public defaultServicesReadyToUpdate: { readonly [service in S]: boolean }
37
59
  public servicesReadyToUpdate: { [service in S]: boolean }
38
- public servicesShouldRestart: boolean
60
+ public autoRespawnDeadServices: boolean
39
61
 
40
62
  protected logger: Pick<Console, `error` | `info` | `warn`>
41
63
  protected serviceLoggers: {
42
64
  readonly [service in S]: Pick<Console, `error` | `info` | `warn`>
43
65
  }
44
66
 
67
+ protected updateAvailabilityChecker: CronJob | null = null
68
+
45
69
  public servicesLive: Future<void>[]
46
70
  public servicesDead: Future<void>[]
47
71
  public live = new Future(() => {})
@@ -49,11 +73,9 @@ export class FlightDeck<S extends string = string> {
49
73
 
50
74
  protected restartTimes: number[] = []
51
75
 
52
- protected persistentStateDir: string
53
-
54
76
  public constructor(public readonly options: FlightDeckOptions<S>) {
55
77
  const { FLIGHTDECK_SECRET } = env
56
- const { flightdeckRootDir = resolve(homedir(), `services`) } = options
78
+ const { flightdeckRootDir = resolve(homedir(), `.flightdeck`) } = options
57
79
  const port = options.port ?? 8080
58
80
  const origin = `http://localhost:${port}`
59
81
 
@@ -71,7 +93,7 @@ export class FlightDeck<S extends string = string> {
71
93
  ]),
72
94
  )
73
95
  this.servicesReadyToUpdate = { ...this.defaultServicesReadyToUpdate }
74
- this.servicesShouldRestart = true
96
+ this.autoRespawnDeadServices = true
75
97
 
76
98
  this.logger = {
77
99
  info: (...args: any[]) => {
@@ -109,14 +131,9 @@ export class FlightDeck<S extends string = string> {
109
131
  this.live.use(Promise.all(this.servicesLive))
110
132
  this.dead.use(Promise.all(this.servicesDead))
111
133
 
112
- this.persistentStateDir = resolve(
113
- flightdeckRootDir,
114
- `.state`,
115
- options.packageName,
116
- )
117
- if (!existsSync(this.persistentStateDir)) {
118
- mkdirSync(this.persistentStateDir, { recursive: true })
119
- }
134
+ this.storage = new FilesystemStorage({
135
+ path: resolve(flightdeckRootDir, `storage`, options.packageName),
136
+ })
120
137
 
121
138
  if (FLIGHTDECK_SECRET === undefined) {
122
139
  this.logger.warn(
@@ -142,62 +159,34 @@ export class FlightDeck<S extends string = string> {
142
159
  }
143
160
  const url = new URL(req.url, origin)
144
161
  this.logger.info(req.method, url.pathname)
145
- switch (req.method) {
146
- case `POST`:
147
- {
148
- switch (url.pathname) {
149
- case `/`:
150
- {
151
- res.writeHead(200)
152
- res.end()
153
- const installFile = resolve(
154
- this.persistentStateDir,
155
- `install`,
156
- )
157
- const readyFile = resolve(
158
- this.persistentStateDir,
159
- `ready`,
160
- )
161
- if (!existsSync(installFile)) {
162
- this.logger.info(
163
- `Install file does not exist yet. Creating...`,
164
- )
165
- writeFileSync(installFile, ``)
166
- }
167
- if (existsSync(readyFile)) {
168
- this.logger.info(`Ready file exists. Removing...`)
169
- rmSync(readyFile)
170
- }
171
- this.getLatestRelease()
172
- if (
173
- toEntries(this.servicesReadyToUpdate).every(
174
- ([, isReady]) => isReady,
175
- )
176
- ) {
177
- this.tryUpdate()
178
- return
179
- }
180
- for (const entry of toEntries(this.services)) {
181
- const [serviceName, service] = entry
182
- if (service) {
183
- if (this.options.services[serviceName].waitFor) {
184
- service.emit(`updatesReady`)
185
- }
186
- } else {
187
- this.startService(serviceName)
188
- }
189
- }
190
- }
191
- break
192
-
193
- default:
194
- throw 404
195
- }
196
- }
197
- break
198
-
199
- default:
200
- throw 405
162
+
163
+ const versionForeignInput = Buffer.concat(data).toString()
164
+ if (!isVersionNumber(versionForeignInput)) {
165
+ throw 400
166
+ }
167
+
168
+ res.writeHead(200)
169
+ res.end()
170
+
171
+ this.storage.setItem(`updatePhase`, `notified`)
172
+ this.storage.setItem(`updateAwaitedVersion`, versionForeignInput)
173
+ const { checkAvailability } = options.scripts
174
+ if (checkAvailability) {
175
+ this.updateAvailabilityChecker?.stop()
176
+ this.seekUpdate(versionForeignInput)
177
+ const updatePhase = this.storage.getItem(`updatePhase`)
178
+ this.logger.info(`> storage("updatePhase") >`, updatePhase)
179
+ if (updatePhase === `notified`) {
180
+ this.updateAvailabilityChecker = new CronJob(
181
+ `30 * * * * *`,
182
+ () => {
183
+ this.seekUpdate(versionForeignInput)
184
+ },
185
+ )
186
+ this.updateAvailabilityChecker.start()
187
+ }
188
+ } else {
189
+ this.downloadPackage()
201
190
  }
202
191
  } catch (thrown) {
203
192
  this.logger.error(thrown, req.url)
@@ -225,6 +214,43 @@ export class FlightDeck<S extends string = string> {
225
214
  })
226
215
  }
227
216
 
217
+ protected seekUpdate(version: string): void {
218
+ this.logger.info(`Checking for updates...`)
219
+ const { checkAvailability } = this.options.scripts
220
+ if (!checkAvailability) {
221
+ this.logger.info(`No checkAvailability script found.`)
222
+ return
223
+ }
224
+ try {
225
+ const out = execSync(`${checkAvailability} ${version}`)
226
+ this.logger.info(`Check stdout:`, out.toString())
227
+ this.updateAvailabilityChecker?.stop()
228
+ this.storage.setItem(`updatePhase`, `confirmed`)
229
+ this.downloadPackage()
230
+ this.announceUpdate()
231
+ } catch (thrown) {
232
+ if (thrown instanceof Error) {
233
+ this.logger.error(`Check failed:`, thrown.message)
234
+ } else {
235
+ const thrownType = discoverType(thrown)
236
+ this.logger.error(`Check threw`, thrownType, thrown)
237
+ }
238
+ }
239
+ }
240
+
241
+ protected announceUpdate(): void {
242
+ for (const entry of toEntries(this.services)) {
243
+ const [serviceName, service] = entry
244
+ if (service) {
245
+ if (this.options.services[serviceName].waitFor) {
246
+ service.emit(`updatesReady`)
247
+ }
248
+ } else {
249
+ this.startService(serviceName)
250
+ }
251
+ }
252
+ }
253
+
228
254
  protected tryUpdate(): void {
229
255
  if (toEntries(this.servicesReadyToUpdate).every(([, isReady]) => isReady)) {
230
256
  this.logger.info(`All services are ready to update.`)
@@ -254,11 +280,26 @@ export class FlightDeck<S extends string = string> {
254
280
 
255
281
  protected startAllServices(): Future<unknown> {
256
282
  this.logger.info(`Starting all services...`)
257
- this.servicesShouldRestart = true
258
- for (const [serviceName] of toEntries(this.services)) {
259
- this.startService(serviceName)
283
+ this.autoRespawnDeadServices = true
284
+ const setupPhase = this.storage.getItem(`setupPhase`)
285
+ this.logger.info(`> storage("setupPhase") >`, setupPhase)
286
+ switch (setupPhase) {
287
+ case null:
288
+ this.logger.info(`Starting from scratch.`)
289
+ this.downloadPackage()
290
+ this.installPackage()
291
+ return this.startAllServices()
292
+ case `downloaded`:
293
+ this.logger.info(`Found package downloaded but not installed.`)
294
+ this.installPackage()
295
+ return this.startAllServices()
296
+ case `installed`: {
297
+ for (const [serviceName] of toEntries(this.services)) {
298
+ this.startService(serviceName)
299
+ }
300
+ return this.live
301
+ }
260
302
  }
261
- return this.live
262
303
  }
263
304
 
264
305
  protected startService(serviceName: S): void {
@@ -269,17 +310,6 @@ export class FlightDeck<S extends string = string> {
269
310
  throw new Error(`Out of tries...`)
270
311
  }
271
312
  this.safety++
272
- const readyFile = resolve(this.persistentStateDir, `ready`)
273
- if (!existsSync(readyFile)) {
274
- this.logger.info(
275
- `Tried to start service but failed: could not find readyFile: ${readyFile}`,
276
- )
277
- this.getLatestRelease()
278
- this.applyUpdate()
279
- this.startService(serviceName)
280
-
281
- return
282
- }
283
313
 
284
314
  const [exe, ...args] = this.options.services[serviceName].run.split(` `)
285
315
  const serviceProcess = spawn(exe, args, {
@@ -292,10 +322,10 @@ export class FlightDeck<S extends string = string> {
292
322
  console,
293
323
  )
294
324
  this.services[serviceName].onAny((...messages) => {
295
- this.logger.info(`💬`, ...messages)
325
+ this.serviceLoggers[serviceName].info(`💬`, ...messages)
296
326
  })
297
327
  this.services[serviceName].on(`readyToUpdate`, () => {
298
- this.serviceLoggers[serviceName].info(`Ready to update.`)
328
+ this.logger.info(`Service "${serviceName}" is ready to update.`)
299
329
  this.servicesReadyToUpdate[serviceName] = true
300
330
  this.tryUpdate()
301
331
  })
@@ -308,18 +338,21 @@ export class FlightDeck<S extends string = string> {
308
338
  this.dead.use(Promise.all(this.servicesDead))
309
339
  })
310
340
  this.services[serviceName].process.once(`close`, (exitCode) => {
311
- this.serviceLoggers[serviceName].info(`Exited with code ${exitCode}`)
341
+ this.logger.info(
342
+ `Auto-respawn saw "${serviceName}" exit with code ${exitCode}`,
343
+ )
312
344
  this.services[serviceName] = null
313
- if (!this.servicesShouldRestart) {
314
- this.serviceLoggers[serviceName].info(`Will not be restarted.`)
345
+ if (!this.autoRespawnDeadServices) {
346
+ this.logger.info(`Auto-respawn is off; "${serviceName}" rests.`)
315
347
  return
316
348
  }
317
- const installFile = resolve(this.persistentStateDir, `install`)
318
- const updatesAreReady = existsSync(installFile)
349
+ const updatePhase = this.storage.getItem(`updatePhase`)
350
+ this.logger.info(`> storage("updatePhase") >`, updatePhase)
351
+ const updatesAreReady = updatePhase === `confirmed`
319
352
  if (updatesAreReady) {
320
353
  this.serviceLoggers[serviceName].info(`Updating before startup...`)
321
354
  this.restartTimes = []
322
- this.applyUpdate()
355
+ this.installPackage()
323
356
  this.startService(serviceName)
324
357
  } else {
325
358
  const now = Date.now()
@@ -342,18 +375,13 @@ export class FlightDeck<S extends string = string> {
342
375
  this.safety = 0
343
376
  }
344
377
 
345
- protected applyUpdate(): void {
346
- this.logger.info(`Installing...`)
347
-
378
+ protected downloadPackage(): void {
379
+ this.logger.info(`Downloading...`)
348
380
  try {
349
- execSync(this.options.scripts.install)
350
- const installFile = resolve(this.persistentStateDir, `install`)
351
- if (existsSync(installFile)) {
352
- rmSync(installFile)
353
- }
354
- const readyFile = resolve(this.persistentStateDir, `ready`)
355
- writeFileSync(readyFile, ``)
356
- this.logger.info(`Installed!`)
381
+ const out = execSync(this.options.scripts.download)
382
+ this.logger.info(`Download stdout:`, out.toString())
383
+ this.storage.setItem(`setupPhase`, `downloaded`)
384
+ this.logger.info(`Downloaded!`)
357
385
  } catch (thrown) {
358
386
  if (thrown instanceof Error) {
359
387
  this.logger.error(`Failed to get the latest release: ${thrown.message}`)
@@ -362,12 +390,14 @@ export class FlightDeck<S extends string = string> {
362
390
  }
363
391
  }
364
392
 
365
- protected getLatestRelease(): void {
366
- this.logger.info(`Downloading...`)
393
+ protected installPackage(): void {
394
+ this.logger.info(`Installing...`)
367
395
 
368
396
  try {
369
- execSync(this.options.scripts.download)
370
- this.logger.info(`Downloaded!`)
397
+ const out = execSync(this.options.scripts.install)
398
+ this.logger.info(`Install stdout:`, out.toString())
399
+ this.storage.setItem(`setupPhase`, `installed`)
400
+ this.logger.info(`Installed!`)
371
401
  } catch (thrown) {
372
402
  if (thrown instanceof Error) {
373
403
  this.logger.error(`Failed to get the latest release: ${thrown.message}`)
@@ -377,8 +407,8 @@ export class FlightDeck<S extends string = string> {
377
407
  }
378
408
 
379
409
  public stopAllServices(): Future<unknown> {
380
- this.logger.info(`Stopping all services...`)
381
- this.servicesShouldRestart = false
410
+ this.logger.info(`Stopping all services... auto-respawn disabled.`)
411
+ this.autoRespawnDeadServices = false
382
412
  for (const [serviceName] of toEntries(this.services)) {
383
413
  this.stopService(serviceName)
384
414
  }
@@ -388,13 +418,13 @@ export class FlightDeck<S extends string = string> {
388
418
  public stopService(serviceName: S): void {
389
419
  const service = this.services[serviceName]
390
420
  if (service) {
391
- this.serviceLoggers[serviceName].info(`Stopping service...`)
421
+ this.logger.info(`Stopping service "${serviceName}"...`)
392
422
  this.servicesDead[this.serviceIdx[serviceName]].use(
393
423
  new Promise((pass) => {
394
424
  service.emit(`timeToStop`)
395
425
  service.process.once(`close`, (exitCode) => {
396
426
  this.logger.info(
397
- `🛬 service ${serviceName} exited with code ${exitCode}`,
427
+ `Stopped service "${serviceName}"; exited with code ${exitCode}`,
398
428
  )
399
429
  this.services[serviceName] = null
400
430
  pass()
package/src/klaxon.lib.ts CHANGED
@@ -1,18 +1,21 @@
1
1
  export type AlertOptions = {
2
2
  secret: string
3
3
  endpoint: string
4
+ version: string
4
5
  }
5
6
 
6
7
  export async function alert({
7
8
  secret,
8
9
  endpoint,
10
+ version,
9
11
  }: AlertOptions): Promise<Response> {
10
12
  const response = await fetch(endpoint, {
11
13
  method: `POST`,
12
14
  headers: {
13
- "Content-Type": `application/json`,
15
+ "Content-Type": `text/plain;charset=UTF-8`,
14
16
  Authorization: `Bearer ${secret}`,
15
17
  },
18
+ body: version,
16
19
  })
17
20
 
18
21
  return response
@@ -64,7 +67,8 @@ export async function scramble<K extends string = string>({
64
67
  const name = publishedPackage.name as K
65
68
  const { endpoint } = packageConfig[name]
66
69
  const secret = secretsConfig[name]
67
- const alertResultPromise = alert({ secret, endpoint }).then(
70
+ const version = publishedPackage.version
71
+ const alertResultPromise = alert({ secret, endpoint, version }).then(
68
72
  (alertResult) => [name, alertResult] as const,
69
73
  )
70
74
  alertResults.push(alertResultPromise)
package/src/lib.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./filesystem-storage"
1
2
  export * from "./flightdeck.lib"
2
3
  import * as Klaxon from "./klaxon.lib"
3
4