@muhammedaksam/easiarr 0.7.0 → 0.7.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": "@muhammedaksam/easiarr",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -29,7 +29,8 @@ export const APPS: Record<AppId, AppDefinition> = {
29
29
  path: "/data/media/movies",
30
30
  apiVersion: "v3",
31
31
  },
32
- prowlarrCategoryIds: [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090],
32
+ prowlarrCategoryIds: [2000], // Movies
33
+ homepage: { icon: "radarr.png", widget: "radarr" },
33
34
  },
34
35
 
35
36
  sonarr: {
@@ -53,7 +54,8 @@ export const APPS: Record<AppId, AppDefinition> = {
53
54
  path: "/data/media/tv",
54
55
  apiVersion: "v3",
55
56
  },
56
- prowlarrCategoryIds: [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080, 5090],
57
+ prowlarrCategoryIds: [5000], // TV
58
+ homepage: { icon: "sonarr.png", widget: "sonarr" },
57
59
  },
58
60
 
59
61
  lidarr: {
@@ -75,7 +77,8 @@ export const APPS: Record<AppId, AppDefinition> = {
75
77
  path: "/data/media/music",
76
78
  apiVersion: "v1",
77
79
  },
78
- prowlarrCategoryIds: [3000, 3010, 3020, 3030, 3040, 3050, 3060],
80
+ prowlarrCategoryIds: [3000], // Audio
81
+ homepage: { icon: "lidarr.png", widget: "lidarr" },
79
82
  },
80
83
 
81
84
  readarr: {
@@ -97,11 +100,12 @@ export const APPS: Record<AppId, AppDefinition> = {
97
100
  path: "/data/media/books",
98
101
  apiVersion: "v1",
99
102
  },
100
- prowlarrCategoryIds: [7000, 7010, 7020, 7030, 7040, 7050, 7060],
103
+ prowlarrCategoryIds: [7000], // Books
101
104
  arch: {
102
105
  deprecated: ["arm64", "arm32"],
103
106
  warning: "Readarr is deprecated - no ARM64 support (project abandoned by upstream)",
104
107
  },
108
+ homepage: { icon: "readarr.png", widget: "readarr" },
105
109
  },
106
110
 
107
111
  bazarr: {
@@ -122,6 +126,7 @@ export const APPS: Record<AppId, AppDefinition> = {
122
126
  parser: "yaml",
123
127
  selector: "auth.apikey",
124
128
  },
129
+ homepage: { icon: "bazarr.png", widget: "bazarr" },
125
130
  },
126
131
 
127
132
  mylar3: {
@@ -166,7 +171,8 @@ export const APPS: Record<AppId, AppDefinition> = {
166
171
  path: "/data/media/adult",
167
172
  apiVersion: "v3",
168
173
  },
169
- prowlarrCategoryIds: [6000, 6010, 6020, 6030, 6040, 6045, 6050, 6060, 6070, 6080, 6090],
174
+ prowlarrCategoryIds: [6000], // XXX
175
+ homepage: { icon: "whisparr.png", widget: "sonarr" }, // Uses sonarr widget type
170
176
  },
171
177
 
172
178
  audiobookshelf: {
@@ -204,6 +210,7 @@ export const APPS: Record<AppId, AppDefinition> = {
204
210
  parser: "regex",
205
211
  selector: "<ApiKey>(.*?)</ApiKey>",
206
212
  },
213
+ homepage: { icon: "prowlarr.png", widget: "prowlarr" },
207
214
  },
208
215
 
209
216
  jackett: {
@@ -268,6 +275,7 @@ export const APPS: Record<AppId, AppDefinition> = {
268
275
  },
269
276
  ],
270
277
  trashGuide: "docs/Downloaders/qBittorrent/",
278
+ homepage: { icon: "qbittorrent.png", widget: "qbittorrent" },
271
279
  },
272
280
 
273
281
  sabnzbd: {
@@ -288,6 +296,7 @@ export const APPS: Record<AppId, AppDefinition> = {
288
296
  parser: "regex",
289
297
  selector: "api_key\\s*=\\s*(.+)",
290
298
  },
299
+ homepage: { icon: "sabnzbd.png", widget: "sabnzbd" },
291
300
  },
292
301
 
293
302
  // === MEDIA SERVERS ===
@@ -309,6 +318,7 @@ export const APPS: Record<AppId, AppDefinition> = {
309
318
  parser: "regex",
310
319
  selector: 'PlexOnlineToken="([^"]+)"',
311
320
  },
321
+ homepage: { icon: "plex.png", widget: "plex" },
312
322
  },
313
323
 
314
324
  jellyfin: {
@@ -321,6 +331,7 @@ export const APPS: Record<AppId, AppDefinition> = {
321
331
  puid: 0,
322
332
  pgid: 13000,
323
333
  volumes: (root) => [`${root}/config/jellyfin:/config`, `${root}/data/media:/data/media`],
334
+ homepage: { icon: "jellyfin.png", widget: "jellyfin" },
324
335
  },
325
336
 
326
337
  tautulli: {
@@ -339,6 +350,7 @@ export const APPS: Record<AppId, AppDefinition> = {
339
350
  parser: "regex",
340
351
  selector: "api_key\\s*=\\s*(.+)",
341
352
  },
353
+ homepage: { icon: "tautulli.png", widget: "tautulli" },
342
354
  },
343
355
 
344
356
  tdarr: {
@@ -376,6 +388,7 @@ export const APPS: Record<AppId, AppDefinition> = {
376
388
  parser: "json",
377
389
  selector: "main.apiKey",
378
390
  },
391
+ homepage: { icon: "overseerr.png", widget: "overseerr" },
379
392
  },
380
393
 
381
394
  jellyseerr: {
@@ -436,6 +449,10 @@ export const APPS: Record<AppId, AppDefinition> = {
436
449
  puid: 0,
437
450
  pgid: 0,
438
451
  volumes: (root) => [`${root}/config/homepage:/app/config`, "/var/run/docker.sock:/var/run/docker.sock"],
452
+ environment: {
453
+ HOMEPAGE_ALLOWED_HOSTS:
454
+ "homepage,homepage.${CLOUDFLARE_DNS_ZONE},${CLOUDFLARE_DNS_ZONE},localhost,${LOCAL_DOCKER_IP}",
455
+ },
439
456
  },
440
457
 
441
458
  // === UTILITIES ===
@@ -450,6 +467,7 @@ export const APPS: Record<AppId, AppDefinition> = {
450
467
  pgid: 0,
451
468
  volumes: (root) => [`${root}/config/portainer:/data`, "/var/run/docker.sock:/var/run/docker.sock"],
452
469
  minPasswordLength: 12, // Portainer requires minimum 12 character password
470
+ homepage: { icon: "portainer.png", widget: "portainer" },
453
471
  },
454
472
 
455
473
  huntarr: {
@@ -562,6 +580,31 @@ export const APPS: Record<AppId, AppDefinition> = {
562
580
  volumes: (root) => [`${root}/config/ddns-updater:/data`],
563
581
  },
564
582
 
583
+ "easiarr-status": {
584
+ id: "easiarr-status",
585
+ name: "Easiarr Status",
586
+ description: "Exposes Easiarr version for Homepage dashboard",
587
+ category: "utility",
588
+ defaultPort: 3009,
589
+ image: "halverneus/static-file-server:latest",
590
+ puid: 0,
591
+ pgid: 0,
592
+ volumes: (root) => [`${root}/.easiarr:/web:ro`],
593
+ environment: {
594
+ FOLDER: "/web",
595
+ PORT: "3009",
596
+ CORS: "true",
597
+ },
598
+ homepage: {
599
+ icon: "mdi-docker",
600
+ widget: "customapi",
601
+ widgetFields: {
602
+ url: "http://easiarr-status:3009/config.json",
603
+ mappings: JSON.stringify([{ field: "version", label: "Installed" }]),
604
+ },
605
+ },
606
+ },
607
+
565
608
  // === VPN ===
566
609
  gluetun: {
567
610
  id: "gluetun",
@@ -608,6 +651,7 @@ export const APPS: Record<AppId, AppDefinition> = {
608
651
  mask: true,
609
652
  },
610
653
  ],
654
+ homepage: { icon: "gluetun.png", widget: "gluetun" },
611
655
  },
612
656
 
613
657
  // === MONITORING ===
@@ -621,6 +665,7 @@ export const APPS: Record<AppId, AppDefinition> = {
621
665
  puid: 0,
622
666
  pgid: 13000,
623
667
  volumes: (root) => [`${root}/config/grafana:/var/lib/grafana`],
668
+ homepage: { icon: "grafana.png", widget: "grafana" },
624
669
  },
625
670
 
626
671
  prometheus: {
@@ -633,6 +678,7 @@ export const APPS: Record<AppId, AppDefinition> = {
633
678
  puid: 0,
634
679
  pgid: 13000,
635
680
  volumes: (root) => [`${root}/config/prometheus:/prometheus`],
681
+ homepage: { icon: "prometheus.png" }, // No widget, just icon
636
682
  },
637
683
 
638
684
  dozzle: {
@@ -660,6 +706,7 @@ export const APPS: Record<AppId, AppDefinition> = {
660
706
  puid: 0,
661
707
  pgid: 0,
662
708
  volumes: (root) => [`${root}/config/uptime-kuma:/app/data`, "/var/run/docker.sock:/var/run/docker.sock"],
709
+ homepage: { icon: "uptime-kuma.png", widget: "uptimekuma" },
663
710
  },
664
711
 
665
712
  // === INFRASTRUCTURE ===
@@ -690,6 +737,7 @@ export const APPS: Record<AppId, AppDefinition> = {
690
737
  required: true,
691
738
  },
692
739
  ],
740
+ homepage: { icon: "traefik.png", widget: "traefik" },
693
741
  },
694
742
 
695
743
  "traefik-certs-dumper": {
@@ -715,6 +763,7 @@ export const APPS: Record<AppId, AppDefinition> = {
715
763
  puid: 0,
716
764
  pgid: 0,
717
765
  volumes: (root) => [`${root}/config/crowdsec:/etc/crowdsec`, "/var/run/docker.sock:/var/run/docker.sock:ro"],
766
+ homepage: { icon: "crowdsec.png", widget: "crowdsec" },
718
767
  },
719
768
 
720
769
  headscale: {
@@ -727,6 +776,7 @@ export const APPS: Record<AppId, AppDefinition> = {
727
776
  puid: 0,
728
777
  pgid: 0,
729
778
  volumes: (root) => [`${root}/config/headscale:/etc/headscale`, `${root}/config/headscale/data:/var/lib/headscale`],
779
+ homepage: { icon: "headscale.png", widget: "headscale" },
730
780
  },
731
781
 
732
782
  headplane: {
@@ -762,6 +812,7 @@ export const APPS: Record<AppId, AppDefinition> = {
762
812
  mask: true,
763
813
  },
764
814
  ],
815
+ homepage: { icon: "tailscale.png", widget: "tailscale" },
765
816
  },
766
817
 
767
818
  authentik: {
@@ -804,6 +855,7 @@ export const APPS: Record<AppId, AppDefinition> = {
804
855
  mask: true,
805
856
  },
806
857
  ],
858
+ homepage: { icon: "authentik.png", widget: "authentik" },
807
859
  },
808
860
 
809
861
  "authentik-worker": {
@@ -8,7 +8,7 @@ import type { EasiarrConfig, AppConfig, TraefikConfig, AppId } from "../config/s
8
8
  import { getComposePath } from "../config/manager"
9
9
  import { getApp } from "../apps/registry"
10
10
  import { generateServiceYaml } from "./templates"
11
- import { updateEnv } from "../utils/env"
11
+ import { updateEnv, getLocalIp } from "../utils/env"
12
12
 
13
13
  export interface ComposeService {
14
14
  image: string
@@ -212,5 +212,6 @@ async function updateEnvFile(config: EasiarrConfig) {
212
212
  PUID: config.uid.toString(),
213
213
  PGID: config.gid.toString(),
214
214
  UMASK: config.umask,
215
+ LOCAL_DOCKER_IP: getLocalIp(),
215
216
  })
216
217
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Homepage Configuration Generator
3
+ * Generates services.yaml and other config files for Homepage dashboard
4
+ */
5
+
6
+ import { writeFile, mkdir } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import type { EasiarrConfig, AppCategory } from "./schema"
10
+ import { APP_CATEGORIES } from "./schema"
11
+ import { getApp } from "../apps/registry"
12
+ import { CATEGORY_ORDER } from "../apps/categories"
13
+ import { readEnvSync, getLocalIp } from "../utils/env"
14
+
15
+ /**
16
+ * Get the Homepage config directory path
17
+ */
18
+ export function getHomepageConfigPath(config: EasiarrConfig): string {
19
+ return join(config.rootDir, "config", "homepage")
20
+ }
21
+
22
+ /**
23
+ * Homepage service entry
24
+ */
25
+ interface HomepageService {
26
+ href: string
27
+ icon?: string
28
+ description?: string
29
+ ping?: string
30
+ widget?: {
31
+ type: string
32
+ url: string
33
+ key?: string
34
+ [key: string]: unknown
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Generate services.yaml content from enabled apps
40
+ */
41
+ export async function generateServicesYaml(config: EasiarrConfig): Promise<string> {
42
+ const env = readEnvSync()
43
+ const localIp = getLocalIp()
44
+
45
+ // Group apps by category
46
+ const categoryGroups = new Map<AppCategory, Array<{ name: string; service: HomepageService }>>()
47
+
48
+ for (const appConfig of config.apps) {
49
+ if (!appConfig.enabled) continue
50
+
51
+ const appDef = getApp(appConfig.id)
52
+ if (!appDef) continue
53
+
54
+ // Skip homepage itself
55
+ if (appDef.id === "homepage") continue
56
+
57
+ const port = appConfig.port ?? appDef.defaultPort
58
+ const baseUrl = `http://${localIp}:${port}`
59
+
60
+ const service: HomepageService = {
61
+ href: baseUrl,
62
+ description: appDef.description,
63
+ }
64
+
65
+ // Add icon if defined in homepage meta
66
+ if (appDef.homepage?.icon) {
67
+ service.icon = appDef.homepage.icon
68
+ } else {
69
+ // Default to app ID as icon name
70
+ service.icon = `${appDef.id}.png`
71
+ }
72
+
73
+ // Add ping for monitoring
74
+ service.ping = baseUrl
75
+
76
+ // Add widget if defined and has API key
77
+ if (appDef.homepage?.widget) {
78
+ const apiKey = env[`API_KEY_${appDef.id.toUpperCase()}`]
79
+
80
+ service.widget = {
81
+ type: appDef.homepage.widget,
82
+ url: baseUrl,
83
+ }
84
+
85
+ if (apiKey) {
86
+ service.widget.key = apiKey
87
+ }
88
+
89
+ // Add any custom widget fields
90
+ if (appDef.homepage.widgetFields) {
91
+ Object.assign(service.widget, appDef.homepage.widgetFields)
92
+ }
93
+ }
94
+
95
+ // Add to category group
96
+ const category = appDef.category
97
+ if (!categoryGroups.has(category)) {
98
+ categoryGroups.set(category, [])
99
+ }
100
+ categoryGroups.get(category)!.push({ name: appDef.name, service })
101
+ }
102
+
103
+ // Build YAML output
104
+ let yaml = "---\n# Auto-generated by Easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
105
+
106
+ // Add Easiarr info section with two widgets - one for installed, one for latest
107
+ const localIpForEasiarr = localIp
108
+ yaml += `Easiarr:\n`
109
+ // Installed version from local easiarr-status container
110
+ yaml += ` - Installed:\n`
111
+ yaml += ` href: https://github.com/muhammedaksam/easiarr\n`
112
+ yaml += ` icon: mdi-docker\n`
113
+ yaml += ` description: Your current version\n`
114
+ yaml += ` widget:\n`
115
+ yaml += ` type: customapi\n`
116
+ yaml += ` url: http://${localIpForEasiarr}:3009/config.json\n`
117
+ yaml += ` refreshInterval: 3600000\n` // 1 hour
118
+ yaml += ` mappings:\n`
119
+ yaml += ` - field: version\n`
120
+ yaml += ` label: Version\n`
121
+ // Latest version from GitHub API
122
+ yaml += ` - Latest:\n`
123
+ yaml += ` href: https://github.com/muhammedaksam/easiarr/releases\n`
124
+ yaml += ` icon: mdi-github\n`
125
+ yaml += ` description: Check for updates\n`
126
+ yaml += ` widget:\n`
127
+ yaml += ` type: customapi\n`
128
+ yaml += ` url: https://api.github.com/repos/muhammedaksam/easiarr/releases/latest\n`
129
+ yaml += ` refreshInterval: 86400000\n` // 24 hours
130
+ yaml += ` mappings:\n`
131
+ yaml += ` - field: tag_name\n`
132
+ yaml += ` label: Version\n`
133
+ yaml += ` - field: published_at\n`
134
+ yaml += ` label: Released\n`
135
+ yaml += ` format: relativeDate\n`
136
+ yaml += `\n`
137
+
138
+ // Use CATEGORY_ORDER for consistent ordering
139
+ for (const { id: category } of CATEGORY_ORDER) {
140
+ const services = categoryGroups.get(category)
141
+ if (!services || services.length === 0) continue
142
+
143
+ const categoryName = APP_CATEGORIES[category]
144
+ yaml += `${categoryName}:\n`
145
+
146
+ for (const { name, service } of services) {
147
+ yaml += ` - ${name}:\n`
148
+ yaml += ` href: ${service.href}\n`
149
+
150
+ if (service.icon) {
151
+ yaml += ` icon: ${service.icon}\n`
152
+ }
153
+
154
+ if (service.description) {
155
+ yaml += ` description: ${service.description}\n`
156
+ }
157
+
158
+ if (service.ping) {
159
+ yaml += ` ping: ${service.ping}\n`
160
+ }
161
+
162
+ if (service.widget) {
163
+ yaml += ` widget:\n`
164
+ yaml += ` type: ${service.widget.type}\n`
165
+ yaml += ` url: ${service.widget.url}\n`
166
+
167
+ if (service.widget.key) {
168
+ yaml += ` key: ${service.widget.key}\n`
169
+ }
170
+
171
+ // Add any other widget fields
172
+ for (const [key, value] of Object.entries(service.widget)) {
173
+ if (key !== "type" && key !== "url" && key !== "key") {
174
+ yaml += ` ${key}: ${value}\n`
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ yaml += "\n"
181
+ }
182
+
183
+ return yaml
184
+ }
185
+
186
+ /**
187
+ * Generate settings.yaml with sensible defaults
188
+ */
189
+ export function generateSettingsYaml(): string {
190
+ return `---
191
+ # Auto-generated by Easiarr
192
+ # For configuration options: https://gethomepage.dev/configs/settings/
193
+
194
+ title: Easiarr Dashboard
195
+
196
+ # Background: "Close-up Photography of Leaves With Droplets"
197
+ # Photo by Sohail Nachiti: https://www.pexels.com/photo/close-up-photography-of-leaves-with-droplets-807598/
198
+ background:
199
+ image: https://images.pexels.com/photos/807598/pexels-photo-807598.jpeg?cs=srgb&fm=jpg&w=4128&h=3096
200
+ blur: sm
201
+ saturate: 50
202
+ brightness: 50
203
+ opacity: 50
204
+
205
+ cardBlur: md
206
+ theme: dark
207
+ color: slate
208
+
209
+ layout:
210
+ Media Management:
211
+ style: row
212
+ columns: 4
213
+ Media Servers:
214
+ style: row
215
+ columns: 2
216
+ Download Clients:
217
+ style: row
218
+ columns: 2
219
+ `
220
+ }
221
+
222
+ /**
223
+ * Save Homepage configuration files
224
+ */
225
+ export async function saveHomepageConfig(config: EasiarrConfig): Promise<{ services: string; settings: string }> {
226
+ const configPath = getHomepageConfigPath(config)
227
+
228
+ // Ensure directory exists
229
+ if (!existsSync(configPath)) {
230
+ await mkdir(configPath, { recursive: true })
231
+ }
232
+
233
+ const servicesPath = join(configPath, "services.yaml")
234
+ const settingsPath = join(configPath, "settings.yaml")
235
+
236
+ // Generate and save services.yaml
237
+ const servicesYaml = await generateServicesYaml(config)
238
+ await writeFile(servicesPath, servicesYaml, "utf-8")
239
+
240
+ // Generate and save settings.yaml (only if doesn't exist)
241
+ if (!existsSync(settingsPath)) {
242
+ const settingsYaml = generateSettingsYaml()
243
+ await writeFile(settingsPath, settingsYaml, "utf-8")
244
+ }
245
+
246
+ return { services: servicesPath, settings: settingsPath }
247
+ }
@@ -10,6 +10,7 @@ import { join } from "node:path"
10
10
  import type { EasiarrConfig } from "./schema"
11
11
  import { DEFAULT_CONFIG } from "./schema"
12
12
  import { detectTimezone, detectUid, detectGid } from "./defaults"
13
+ import { VersionInfo } from "../VersionInfo"
13
14
 
14
15
  const CONFIG_DIR_NAME = ".easiarr"
15
16
  const CONFIG_FILE_NAME = "config.json"
@@ -48,6 +49,26 @@ export async function configExists(): Promise<boolean> {
48
49
  return existsSync(getConfigPath())
49
50
  }
50
51
 
52
+ /**
53
+ * Migrate config to current version
54
+ * Preserves user settings while adding new fields with defaults
55
+ */
56
+ function migrateConfig(oldConfig: Partial<EasiarrConfig>): EasiarrConfig {
57
+ return {
58
+ // Start with defaults for new fields
59
+ ...DEFAULT_CONFIG,
60
+ // Preserve all user settings
61
+ ...oldConfig,
62
+ // Always update version to current
63
+ version: VersionInfo.version,
64
+ // Ensure required fields have values
65
+ umask: oldConfig.umask ?? "002",
66
+ apps: oldConfig.apps ?? [],
67
+ createdAt: oldConfig.createdAt ?? new Date().toISOString(),
68
+ updatedAt: new Date().toISOString(),
69
+ } as EasiarrConfig
70
+ }
71
+
51
72
  export async function loadConfig(): Promise<EasiarrConfig | null> {
52
73
  const configPath = getConfigPath()
53
74
 
@@ -57,7 +78,16 @@ export async function loadConfig(): Promise<EasiarrConfig | null> {
57
78
 
58
79
  try {
59
80
  const content = await readFile(configPath, "utf-8")
60
- return JSON.parse(content) as EasiarrConfig
81
+ let config = JSON.parse(content) as EasiarrConfig
82
+
83
+ // Auto-migrate if version differs from current package version
84
+ if (config.version !== VersionInfo.version) {
85
+ config = migrateConfig(config)
86
+ // Save migrated config (creates backup first)
87
+ await saveConfig(config)
88
+ }
89
+
90
+ return config
61
91
  } catch (error) {
62
92
  console.error("Failed to load config:", error)
63
93
  return null
@@ -120,6 +120,7 @@ export type AppId =
120
120
  | "guacamole"
121
121
  | "guacd"
122
122
  | "ddns-updater"
123
+ | "easiarr-status"
123
124
  // VPN
124
125
  | "gluetun"
125
126
  // Monitoring & Infra
@@ -190,6 +191,18 @@ export interface AppDefinition {
190
191
  arch?: ArchCompatibility
191
192
  /** Minimum password length requirement for user creation */
192
193
  minPasswordLength?: number
194
+ /** Homepage dashboard configuration */
195
+ homepage?: HomepageMeta
196
+ }
197
+
198
+ /** Homepage dashboard widget/service configuration */
199
+ export interface HomepageMeta {
200
+ /** Icon name from Dashboard Icons (e.g., "radarr.png", "mdi-web") */
201
+ icon?: string
202
+ /** Widget type for Homepage (e.g., "radarr", "sonarr", "qbittorrent") */
203
+ widget?: string
204
+ /** Custom widget fields if needed */
205
+ widgetFields?: Record<string, string>
193
206
  }
194
207
 
195
208
  export interface RootFolderMeta {
package/src/ui/App.ts CHANGED
@@ -12,6 +12,8 @@ import { QuickSetup } from "./screens/QuickSetup"
12
12
  import { AppManager } from "./screens/AppManager"
13
13
  import { ContainerControl } from "./screens/ContainerControl"
14
14
  import { AdvancedSettings } from "./screens/AdvancedSettings"
15
+ import { checkForUpdates } from "../utils/update-checker"
16
+ import { UpdateNotification } from "./components/UpdateNotification"
15
17
 
16
18
  export type Screen = "main" | "quickSetup" | "appManager" | "containerControl" | "advancedSettings"
17
19
 
@@ -40,7 +42,19 @@ export class App {
40
42
  if (!this.config) {
41
43
  this.navigateTo("quickSetup")
42
44
  } else {
43
- this.navigateTo("main")
45
+ // Check for updates before showing main menu
46
+ const updateInfo = await checkForUpdates()
47
+
48
+ if (updateInfo.updateAvailable) {
49
+ // Show update notification
50
+ const notification = new UpdateNotification(this.renderer, updateInfo, () => {
51
+ // After dismissing, show main menu
52
+ this.navigateTo("main")
53
+ })
54
+ this.renderer.root.add(notification)
55
+ } else {
56
+ this.navigateTo("main")
57
+ }
44
58
  }
45
59
 
46
60
  // Handle exit
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Update Notification Component
3
+ * Popup overlay showing new version available
4
+ */
5
+
6
+ import { BoxRenderable, TextRenderable, CliRenderer, KeyEvent } from "@opentui/core"
7
+ import type { UpdateInfo } from "../../utils/update-checker"
8
+
9
+ export class UpdateNotification extends BoxRenderable {
10
+ private cliRenderer: CliRenderer
11
+ private updateInfo: UpdateInfo
12
+ private onDismiss: () => void
13
+ private keyHandler: (key: KeyEvent) => void
14
+
15
+ constructor(renderer: CliRenderer, updateInfo: UpdateInfo, onDismiss: () => void) {
16
+ super(renderer, {
17
+ id: "update-notification",
18
+ position: "absolute",
19
+ top: "50%",
20
+ left: "50%",
21
+ width: 50,
22
+ height: 12,
23
+ marginTop: -6, // Center vertically
24
+ marginLeft: -25, // Center horizontally
25
+ flexDirection: "column",
26
+ borderStyle: "rounded",
27
+ borderColor: "#50fa7b",
28
+ padding: 1,
29
+ })
30
+
31
+ this.cliRenderer = renderer
32
+ this.updateInfo = updateInfo
33
+ this.onDismiss = onDismiss
34
+
35
+ this.buildContent()
36
+
37
+ // Key handler
38
+ this.keyHandler = (key: KeyEvent) => {
39
+ if (key.name === "return" || key.name === "escape" || key.name === "q") {
40
+ this.dismiss()
41
+ }
42
+ }
43
+ renderer.keyInput.on("keypress", this.keyHandler)
44
+ }
45
+
46
+ private buildContent(): void {
47
+ // Header
48
+ this.add(
49
+ new TextRenderable(this.cliRenderer, {
50
+ content: "🎉 Update Available!",
51
+ fg: "#50fa7b",
52
+ })
53
+ )
54
+
55
+ // Spacer
56
+ this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
57
+
58
+ // Version info
59
+ this.add(
60
+ new TextRenderable(this.cliRenderer, {
61
+ content: `Current: v${this.updateInfo.currentVersion}`,
62
+ fg: "#6272a4",
63
+ })
64
+ )
65
+
66
+ this.add(
67
+ new TextRenderable(this.cliRenderer, {
68
+ content: `Latest: ${this.updateInfo.latestVersion}`,
69
+ fg: "#f1fa8c",
70
+ })
71
+ )
72
+
73
+ // Spacer
74
+ this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
75
+
76
+ // Update command
77
+ this.add(
78
+ new TextRenderable(this.cliRenderer, {
79
+ content: "Run: bun update -g @muhammedaksam/easiarr",
80
+ fg: "#8be9fd",
81
+ })
82
+ )
83
+
84
+ // Spacer
85
+ this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
86
+
87
+ // Dismiss hint
88
+ this.add(
89
+ new TextRenderable(this.cliRenderer, {
90
+ content: "Press Enter or Esc to continue",
91
+ fg: "#6272a4",
92
+ })
93
+ )
94
+ }
95
+
96
+ private dismiss(): void {
97
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
98
+ this.destroy()
99
+ this.onDismiss()
100
+ }
101
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Homepage Setup Screen
3
+ * Configure Homepage dashboard with enabled apps
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import { EasiarrConfig } from "../../config/schema"
9
+ import { saveHomepageConfig, generateServicesYaml } from "../../config/homepage-config"
10
+ import { getApp } from "../../apps/registry"
11
+
12
+ interface SetupResult {
13
+ name: string
14
+ status: "pending" | "configuring" | "success" | "error" | "skipped"
15
+ message?: string
16
+ }
17
+
18
+ type Step = "menu" | "generating" | "preview" | "done"
19
+
20
+ export class HomepageSetup extends BoxRenderable {
21
+ private config: EasiarrConfig
22
+ private cliRenderer: CliRenderer
23
+ private onBack: () => void
24
+ private keyHandler!: (key: KeyEvent) => void
25
+ private results: SetupResult[] = []
26
+ private currentStep: Step = "menu"
27
+ private contentBox!: BoxRenderable
28
+ private pageContainer!: BoxRenderable
29
+ private menuIndex = 0
30
+ private previewContent = ""
31
+
32
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
33
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
34
+ title: "Homepage Setup",
35
+ stepInfo: "Configure Dashboard",
36
+ footerHint: [
37
+ { type: "key", key: "↑↓", value: "Navigate" },
38
+ { type: "key", key: "Enter", value: "Select" },
39
+ { type: "key", key: "Esc", value: "Back" },
40
+ ],
41
+ })
42
+ super(cliRenderer, { width: "100%", height: "100%" })
43
+ this.add(pageContainer)
44
+
45
+ this.config = config
46
+ this.cliRenderer = cliRenderer
47
+ this.onBack = onBack
48
+ this.contentBox = contentBox
49
+ this.pageContainer = pageContainer
50
+
51
+ this.initKeyHandler()
52
+ this.refreshContent()
53
+ }
54
+
55
+ private initKeyHandler(): void {
56
+ this.keyHandler = (key: KeyEvent) => {
57
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
58
+ if (this.currentStep === "menu") {
59
+ this.cleanup()
60
+ } else {
61
+ this.currentStep = "menu"
62
+ this.refreshContent()
63
+ }
64
+ return
65
+ }
66
+
67
+ if (this.currentStep === "menu") {
68
+ this.handleMenuKeys(key)
69
+ } else if (this.currentStep === "preview" || this.currentStep === "done") {
70
+ if (key.name === "return" || key.name === "escape") {
71
+ this.currentStep = "menu"
72
+ this.refreshContent()
73
+ }
74
+ }
75
+ }
76
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
77
+ }
78
+
79
+ private handleMenuKeys(key: KeyEvent): void {
80
+ const menuItems = this.getMenuItems()
81
+
82
+ if (key.name === "up" && this.menuIndex > 0) {
83
+ this.menuIndex--
84
+ this.refreshContent()
85
+ } else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
86
+ this.menuIndex++
87
+ this.refreshContent()
88
+ } else if (key.name === "return") {
89
+ this.executeMenuItem(this.menuIndex)
90
+ }
91
+ }
92
+
93
+ private getMenuItems(): { name: string; description: string; action: () => void }[] {
94
+ return [
95
+ {
96
+ name: "📊 Generate Services",
97
+ description: "Create services.yaml with all enabled apps",
98
+ action: () => this.generateServices(),
99
+ },
100
+ {
101
+ name: "👁️ Preview Config",
102
+ description: "Preview generated services.yaml",
103
+ action: () => this.previewServices(),
104
+ },
105
+ {
106
+ name: "📋 Show Enabled Apps",
107
+ description: "List apps that will be added to Homepage",
108
+ action: () => this.showEnabledApps(),
109
+ },
110
+ {
111
+ name: "↩️ Back",
112
+ description: "Return to main menu",
113
+ action: () => this.cleanup(),
114
+ },
115
+ ]
116
+ }
117
+
118
+ private executeMenuItem(index: number): void {
119
+ const items = this.getMenuItems()
120
+ if (index >= 0 && index < items.length) {
121
+ items[index].action()
122
+ }
123
+ }
124
+
125
+ private async generateServices(): Promise<void> {
126
+ this.currentStep = "generating"
127
+ this.results = [{ name: "services.yaml", status: "configuring" }]
128
+ this.refreshContent()
129
+
130
+ try {
131
+ const paths = await saveHomepageConfig(this.config)
132
+ this.results[0].status = "success"
133
+ this.results[0].message = `Saved to ${paths.services}`
134
+ } catch (error) {
135
+ this.results[0].status = "error"
136
+ this.results[0].message = error instanceof Error ? error.message : String(error)
137
+ }
138
+
139
+ this.currentStep = "done"
140
+ this.refreshContent()
141
+ }
142
+
143
+ private async previewServices(): Promise<void> {
144
+ this.previewContent = await generateServicesYaml(this.config)
145
+ this.currentStep = "preview"
146
+ this.refreshContent()
147
+ }
148
+
149
+ private showEnabledApps(): void {
150
+ const apps = this.config.apps.filter((a) => a.enabled && a.id !== "homepage")
151
+
152
+ this.results = apps.map((app) => {
153
+ const def = getApp(app.id)
154
+ const hasWidget = def?.homepage?.widget ? "📊" : "📌"
155
+ return {
156
+ name: `${hasWidget} ${def?.name || app.id}`,
157
+ status: "success" as const,
158
+ message: def?.description,
159
+ }
160
+ })
161
+
162
+ if (this.results.length === 0) {
163
+ this.results = [{ name: "No apps enabled", status: "skipped", message: "Enable apps first" }]
164
+ }
165
+
166
+ this.currentStep = "done"
167
+ this.refreshContent()
168
+ }
169
+
170
+ private refreshContent(): void {
171
+ this.contentBox.getChildren().forEach((child) => child.destroy())
172
+
173
+ if (this.currentStep === "menu") {
174
+ this.renderMenu()
175
+ } else if (this.currentStep === "preview") {
176
+ this.renderPreview()
177
+ } else {
178
+ this.renderResults()
179
+ }
180
+ }
181
+
182
+ private renderMenu(): void {
183
+ this.contentBox.add(
184
+ new TextRenderable(this.cliRenderer, {
185
+ content: "Configure Homepage dashboard with your enabled apps:\n\n",
186
+ fg: "#aaaaaa",
187
+ })
188
+ )
189
+
190
+ this.getMenuItems().forEach((item, idx) => {
191
+ const pointer = idx === this.menuIndex ? "→ " : " "
192
+ const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
193
+
194
+ this.contentBox.add(
195
+ new TextRenderable(this.cliRenderer, {
196
+ content: `${pointer}${item.name}\n`,
197
+ fg,
198
+ })
199
+ )
200
+ this.contentBox.add(
201
+ new TextRenderable(this.cliRenderer, {
202
+ content: ` ${item.description}\n\n`,
203
+ fg: "#6272a4",
204
+ })
205
+ )
206
+ })
207
+ }
208
+
209
+ private renderPreview(): void {
210
+ this.contentBox.add(
211
+ new TextRenderable(this.cliRenderer, {
212
+ content: "Preview: services.yaml\n",
213
+ fg: "#50fa7b",
214
+ })
215
+ )
216
+ this.contentBox.add(
217
+ new TextRenderable(this.cliRenderer, {
218
+ content: "─".repeat(40) + "\n",
219
+ fg: "#555555",
220
+ })
221
+ )
222
+
223
+ // Show preview (truncated)
224
+ const lines = this.previewContent.split("\n").slice(0, 30)
225
+ for (const line of lines) {
226
+ this.contentBox.add(
227
+ new TextRenderable(this.cliRenderer, {
228
+ content: line + "\n",
229
+ fg: line.startsWith("#") ? "#6272a4" : line.endsWith(":") ? "#8be9fd" : "#f8f8f2",
230
+ })
231
+ )
232
+ }
233
+
234
+ if (this.previewContent.split("\n").length > 30) {
235
+ this.contentBox.add(
236
+ new TextRenderable(this.cliRenderer, {
237
+ content: "\n... (truncated)\n",
238
+ fg: "#6272a4",
239
+ })
240
+ )
241
+ }
242
+
243
+ this.contentBox.add(
244
+ new TextRenderable(this.cliRenderer, {
245
+ content: "\nPress Enter or Esc to go back",
246
+ fg: "#6272a4",
247
+ })
248
+ )
249
+ }
250
+
251
+ private renderResults(): void {
252
+ const headerText = this.currentStep === "done" ? "Results:\n\n" : "Generating...\n\n"
253
+ this.contentBox.add(
254
+ new TextRenderable(this.cliRenderer, {
255
+ content: headerText,
256
+ fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
257
+ })
258
+ )
259
+
260
+ for (const result of this.results) {
261
+ let status = ""
262
+ let fg = "#aaaaaa"
263
+ switch (result.status) {
264
+ case "pending":
265
+ status = "⏳"
266
+ break
267
+ case "configuring":
268
+ status = "🔄"
269
+ fg = "#f1fa8c"
270
+ break
271
+ case "success":
272
+ status = "✓"
273
+ fg = "#50fa7b"
274
+ break
275
+ case "error":
276
+ status = "✗"
277
+ fg = "#ff5555"
278
+ break
279
+ case "skipped":
280
+ status = "⊘"
281
+ fg = "#6272a4"
282
+ break
283
+ }
284
+
285
+ let content = `${status} ${result.name}`
286
+ if (result.message) {
287
+ content += ` - ${result.message}`
288
+ }
289
+
290
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
291
+ }
292
+
293
+ this.contentBox.add(
294
+ new TextRenderable(this.cliRenderer, {
295
+ content: "\nPress Enter or Esc to continue...",
296
+ fg: "#6272a4",
297
+ })
298
+ )
299
+ }
300
+
301
+ private cleanup(): void {
302
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
303
+ this.destroy()
304
+ this.onBack()
305
+ }
306
+ }
@@ -16,6 +16,7 @@ import { ProwlarrSetup } from "./ProwlarrSetup"
16
16
  import { QBittorrentSetup } from "./QBittorrentSetup"
17
17
  import { FullAutoSetup } from "./FullAutoSetup"
18
18
  import { MonitorDashboard } from "./MonitorDashboard"
19
+ import { HomepageSetup } from "./HomepageSetup"
19
20
 
20
21
  export class MainMenu {
21
22
  private renderer: RenderContext
@@ -129,6 +130,10 @@ export class MainMenu {
129
130
  name: "📊 Monitor Dashboard",
130
131
  description: "Configure app health monitoring",
131
132
  },
133
+ {
134
+ name: "🏠 Homepage Setup",
135
+ description: "Generate Homepage dashboard config",
136
+ },
132
137
  { name: "❌ Exit", description: "Close easiarr" },
133
138
  ],
134
139
  })
@@ -227,7 +232,18 @@ export class MainMenu {
227
232
  this.container.add(monitor)
228
233
  break
229
234
  }
230
- case 11:
235
+ case 11: {
236
+ // Homepage Setup
237
+ this.menu.blur()
238
+ this.page.visible = false
239
+ const homepageSetup = new HomepageSetup(this.renderer as CliRenderer, this.config, () => {
240
+ this.page.visible = true
241
+ this.menu.focus()
242
+ })
243
+ this.container.add(homepageSetup)
244
+ break
245
+ }
246
+ case 12:
231
247
  process.exit(0)
232
248
  break
233
249
  }
@@ -40,6 +40,8 @@ export class QuickSetup {
40
40
  "jellyfin",
41
41
  "jellyseerr",
42
42
  "flaresolverr",
43
+ "homepage",
44
+ "easiarr-status",
43
45
  ])
44
46
 
45
47
  private rootDir: string = `${homedir()}/media`
package/src/utils/env.ts CHANGED
@@ -5,8 +5,31 @@
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs"
7
7
  import { writeFile, readFile } from "node:fs/promises"
8
+ import { networkInterfaces } from "node:os"
8
9
  import { getComposePath } from "../config/manager"
9
10
 
11
+ /**
12
+ * Get the local IP address of the Docker host
13
+ * Returns the first non-internal IPv4 address found
14
+ */
15
+ export function getLocalIp(): string {
16
+ const nets = networkInterfaces()
17
+
18
+ for (const name of Object.keys(nets)) {
19
+ const interfaces = nets[name]
20
+ if (!interfaces) continue
21
+
22
+ for (const net of interfaces) {
23
+ // Skip internal (loopback) and non-IPv4 addresses
24
+ if (net.family === "IPv4" && !net.internal) {
25
+ return net.address
26
+ }
27
+ }
28
+ }
29
+
30
+ return "localhost"
31
+ }
32
+
10
33
  /**
11
34
  * Get the path to the .env file
12
35
  */
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Update Checker
3
+ * Check GitHub releases for new easiarr versions
4
+ */
5
+
6
+ import { VersionInfo } from "../VersionInfo"
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { homedir } from "node:os"
10
+
11
+ const GITHUB_REPO = "muhammedaksam/easiarr"
12
+ const CACHE_FILE = join(homedir(), ".easiarr", ".update-cache.json")
13
+ const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
14
+
15
+ export interface UpdateInfo {
16
+ currentVersion: string
17
+ latestVersion: string
18
+ updateAvailable: boolean
19
+ releaseUrl: string
20
+ releaseNotes?: string
21
+ publishedAt?: string
22
+ }
23
+
24
+ interface CachedUpdate {
25
+ checkedAt: string
26
+ latestVersion: string
27
+ releaseUrl: string
28
+ releaseNotes?: string
29
+ publishedAt?: string
30
+ }
31
+
32
+ /**
33
+ * Get cached update info if still valid
34
+ */
35
+ function getCachedUpdate(): CachedUpdate | null {
36
+ if (!existsSync(CACHE_FILE)) return null
37
+
38
+ try {
39
+ const content = readFileSync(CACHE_FILE, "utf-8")
40
+ const cached = JSON.parse(content) as CachedUpdate
41
+
42
+ const checkedAt = new Date(cached.checkedAt).getTime()
43
+ const now = Date.now()
44
+
45
+ // Cache still valid
46
+ if (now - checkedAt < CACHE_DURATION_MS) {
47
+ return cached
48
+ }
49
+ } catch {
50
+ // Ignore cache errors
51
+ }
52
+
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Save update info to cache
58
+ */
59
+ function cacheUpdate(info: Omit<CachedUpdate, "checkedAt">): void {
60
+ try {
61
+ const cached: CachedUpdate = {
62
+ ...info,
63
+ checkedAt: new Date().toISOString(),
64
+ }
65
+ writeFileSync(CACHE_FILE, JSON.stringify(cached, null, 2), "utf-8")
66
+ } catch {
67
+ // Ignore cache write errors
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Compare semver versions
73
+ * Returns true if v2 is newer than v1
74
+ */
75
+ function isNewerVersion(v1: string, v2: string): boolean {
76
+ const normalize = (v: string) => v.replace(/^v/, "")
77
+ const parts1 = normalize(v1).split(".").map(Number)
78
+ const parts2 = normalize(v2).split(".").map(Number)
79
+
80
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
81
+ const p1 = parts1[i] || 0
82
+ const p2 = parts2[i] || 0
83
+ if (p2 > p1) return true
84
+ if (p2 < p1) return false
85
+ }
86
+
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * Fetch latest release from GitHub API
92
+ */
93
+ async function fetchLatestRelease(): Promise<{
94
+ version: string
95
+ url: string
96
+ notes?: string
97
+ publishedAt?: string
98
+ } | null> {
99
+ try {
100
+ const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
101
+ headers: {
102
+ Accept: "application/vnd.github.v3+json",
103
+ "User-Agent": "easiarr-update-checker",
104
+ },
105
+ })
106
+
107
+ if (!response.ok) {
108
+ return null
109
+ }
110
+
111
+ const data = (await response.json()) as {
112
+ tag_name: string
113
+ html_url: string
114
+ body?: string
115
+ published_at?: string
116
+ }
117
+
118
+ return {
119
+ version: data.tag_name,
120
+ url: data.html_url,
121
+ notes: data.body,
122
+ publishedAt: data.published_at,
123
+ }
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Check for updates
131
+ * Uses cache to avoid repeated API calls
132
+ */
133
+ export async function checkForUpdates(): Promise<UpdateInfo> {
134
+ const currentVersion = VersionInfo.version
135
+
136
+ // Check cache first
137
+ const cached = getCachedUpdate()
138
+ if (cached) {
139
+ return {
140
+ currentVersion,
141
+ latestVersion: cached.latestVersion,
142
+ updateAvailable: isNewerVersion(currentVersion, cached.latestVersion),
143
+ releaseUrl: cached.releaseUrl,
144
+ releaseNotes: cached.releaseNotes,
145
+ publishedAt: cached.publishedAt,
146
+ }
147
+ }
148
+
149
+ // Fetch from GitHub
150
+ const release = await fetchLatestRelease()
151
+
152
+ if (!release) {
153
+ return {
154
+ currentVersion,
155
+ latestVersion: currentVersion,
156
+ updateAvailable: false,
157
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
158
+ }
159
+ }
160
+
161
+ // Cache the result
162
+ cacheUpdate({
163
+ latestVersion: release.version,
164
+ releaseUrl: release.url,
165
+ releaseNotes: release.notes,
166
+ publishedAt: release.publishedAt,
167
+ })
168
+
169
+ return {
170
+ currentVersion,
171
+ latestVersion: release.version,
172
+ updateAvailable: isNewerVersion(currentVersion, release.version),
173
+ releaseUrl: release.url,
174
+ releaseNotes: release.notes,
175
+ publishedAt: release.publishedAt,
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Force refresh update check (ignores cache)
181
+ */
182
+ export async function forceCheckForUpdates(): Promise<UpdateInfo> {
183
+ const currentVersion = VersionInfo.version
184
+ const release = await fetchLatestRelease()
185
+
186
+ if (!release) {
187
+ return {
188
+ currentVersion,
189
+ latestVersion: currentVersion,
190
+ updateAvailable: false,
191
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
192
+ }
193
+ }
194
+
195
+ cacheUpdate({
196
+ latestVersion: release.version,
197
+ releaseUrl: release.url,
198
+ releaseNotes: release.notes,
199
+ publishedAt: release.publishedAt,
200
+ })
201
+
202
+ return {
203
+ currentVersion,
204
+ latestVersion: release.version,
205
+ updateAvailable: isNewerVersion(currentVersion, release.version),
206
+ releaseUrl: release.url,
207
+ releaseNotes: release.notes,
208
+ publishedAt: release.publishedAt,
209
+ }
210
+ }