dsh-miloco-bridge 0.1.0 → 0.1.2

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.
@@ -21,7 +21,8 @@
21
21
  */
22
22
 
23
23
  import { access } from 'node:fs/promises'
24
- import { join } from 'node:path'
24
+ import { dirname, join } from 'node:path'
25
+ import { fetchLatestRelease, inferDeployKind, isNewerRelease, normalizeReleaseVersion } from './github-release.js'
25
26
  import { MilocoError, readMilocoConfig } from './miloco-client.js'
26
27
  import { normalizePerceptionLevel, PERCEPTION_LEVELS } from './perception.js'
27
28
  import { AGENT_MODES, describeMode, readSessions } from './state.js'
@@ -212,6 +213,8 @@ export function registerSettingsApi(options) {
212
213
  const { ctx, config, client, overlay } = options
213
214
  const log = options.logger ?? ctx.logger
214
215
  const disposers = []
216
+ /** @type {null | {at: number, source: string, has_update: boolean, current: string, latest: string}} */
217
+ let lastUpgradeCheck = null
215
218
 
216
219
  /**
217
220
  * Register one exact route.
@@ -470,6 +473,172 @@ export function registerSettingsApi(options) {
470
473
  })
471
474
  })
472
475
 
476
+ route(`${SETTINGS_PREFIX}/version`, async (req, res) => {
477
+ if (req.method !== 'GET') {
478
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
479
+ return
480
+ }
481
+ const force = new URL(req.url, 'http://127.0.0.1').searchParams.get('refresh') === '1'
482
+ const endpoint = await client.endpoint()
483
+ const fileConfig = await readMilocoConfig(endpoint.home)
484
+ const pythonBin = String(fileConfig?.server?.python_bin ?? '')
485
+ // server.python_bin = <source>/backend/.venv/bin/python
486
+ const sourceRoot = pythonBin
487
+ ? dirname(dirname(dirname(dirname(pythonBin))))
488
+ : (config.deploy?.sourceDir || '')
489
+
490
+ try {
491
+ const [version, backendUpgrade, upgradeStatus] = await Promise.all([
492
+ client.get('/api/admin/version', { signal: AbortSignal.timeout(8_000) }),
493
+ client.get('/api/admin/upgrade/check', {
494
+ query: { force: force ? 'true' : 'false' },
495
+ signal: AbortSignal.timeout(15_000),
496
+ }).catch(() => null),
497
+ client.get('/api/admin/upgrade/status', { signal: AbortSignal.timeout(8_000) }).catch(() => null),
498
+ ])
499
+ let upgrade = backendUpgrade
500
+ if (!upgrade || upgrade.reachable === false) {
501
+ const current = String(upgrade?.current || version?.version || '')
502
+ const deployKind = upgrade?.deploy_kind || inferDeployKind(current)
503
+ try {
504
+ const latest = await fetchLatestRelease({
505
+ repo: config.deploy?.repo,
506
+ apiBase: config.deploy?.githubApi,
507
+ mirrors: config.deploy?.githubMirrors,
508
+ timeoutMs: 6_000,
509
+ })
510
+ upgrade = {
511
+ current,
512
+ latest: normalizeReleaseVersion(latest.tag),
513
+ has_update: Boolean(current) && deployKind === 'release'
514
+ && isNewerRelease(current, latest.tag),
515
+ deploy_kind: deployKind,
516
+ release_url: latest.release_url || latest.html_url,
517
+ reachable: true,
518
+ source: latest.source,
519
+ mirror: latest.mirror || null,
520
+ checked_at: Math.floor(Date.now() / 1000),
521
+ }
522
+ } catch (fallbackError) {
523
+ const message = fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
524
+ upgrade = {
525
+ ...(upgrade ?? {}),
526
+ current,
527
+ latest: null,
528
+ has_update: false,
529
+ deploy_kind: deployKind,
530
+ release_url: null,
531
+ reachable: false,
532
+ source: 'none',
533
+ error: message,
534
+ }
535
+ }
536
+ } else {
537
+ upgrade = { ...upgrade, source: upgrade.source || 'backend' }
538
+ }
539
+ if (upgrade) {
540
+ lastUpgradeCheck = {
541
+ at: Date.now(),
542
+ source: upgrade.source || 'backend',
543
+ has_update: Boolean(upgrade.has_update),
544
+ current: String(upgrade.current || ''),
545
+ latest: String(upgrade.latest || ''),
546
+ }
547
+ }
548
+ sendJson(res, 200, {
549
+ ok: true,
550
+ reachable: true,
551
+ sourceRoot,
552
+ version,
553
+ upgrade,
554
+ upgradeStatus,
555
+ })
556
+ } catch (error) {
557
+ sendJson(res, 200, {
558
+ ok: true,
559
+ reachable: false,
560
+ sourceRoot,
561
+ error: error instanceof MilocoError ? error.message : String(error),
562
+ version: null,
563
+ upgrade: null,
564
+ upgradeStatus: null,
565
+ })
566
+ }
567
+ })
568
+
569
+ route(`${SETTINGS_PREFIX}/upgrade`, async (req, res) => {
570
+ if (req.method !== 'POST') {
571
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
572
+ return
573
+ }
574
+ const body = await mutateBody(req, res)
575
+ if (body === null) return
576
+ const recent = lastUpgradeCheck && Date.now() - lastUpgradeCheck.at < 10 * 60_000
577
+ const pluginUpgrade = recent
578
+ && lastUpgradeCheck.source !== 'backend'
579
+ && lastUpgradeCheck.has_update
580
+ && typeof options.deploy?.startUpgrade === 'function'
581
+ if (pluginUpgrade) {
582
+ const proto = String(req.headers['x-forwarded-proto'] || 'http').split(',')[0].trim()
583
+ const origin = typeof req.headers.origin === 'string' && req.headers.origin
584
+ ? req.headers.origin
585
+ : `${proto}://${req.headers.host ?? '127.0.0.1:3080'}`
586
+ const outcome = await options.deploy.startUpgrade({ origin })
587
+ if (!outcome.started) {
588
+ sendJson(res, 409, { ok: false, error: outcome.reason || '升级未启动', ...outcome })
589
+ return
590
+ }
591
+ sendJson(res, 200, { ok: true, source: 'plugin', ...outcome })
592
+ return
593
+ }
594
+ // Upstream only allows /upgrade/run for release deployments. It performs
595
+ // its own has_update check; surface its 400 reason verbatim.
596
+ try {
597
+ const data = await client.post('/api/admin/upgrade/run', undefined, {
598
+ signal: AbortSignal.timeout(30_000),
599
+ })
600
+ sendJson(res, 200, { ok: true, data })
601
+ } catch (error) {
602
+ sendJson(res, 400, {
603
+ ok: false,
604
+ error: error instanceof MilocoError ? error.message : String(error),
605
+ })
606
+ }
607
+ })
608
+
609
+ route(`${SETTINGS_PREFIX}/upgrade/status`, async (req, res) => {
610
+ if (req.method !== 'GET') {
611
+ sendJson(res, 405, { ok: false, error: 'method not allowed' })
612
+ return
613
+ }
614
+ const pluginState = options.deploy?.upgradeState?.()
615
+ if (pluginState && pluginState.step !== 'idle') {
616
+ const phaseMap = {
617
+ 'downloading-installer': 'downloading',
618
+ 'starting-service': 'restarting',
619
+ wiring: 'installing',
620
+ }
621
+ sendJson(res, 200, {
622
+ ok: true,
623
+ source: 'plugin',
624
+ status: { ...pluginState, phase: phaseMap[pluginState.step] || pluginState.step },
625
+ })
626
+ return
627
+ }
628
+ try {
629
+ const status = await client.get('/api/admin/upgrade/status', {
630
+ signal: AbortSignal.timeout(8_000),
631
+ })
632
+ sendJson(res, 200, { ok: true, status })
633
+ } catch (error) {
634
+ sendJson(res, 200, {
635
+ ok: true,
636
+ status: null,
637
+ error: error instanceof MilocoError ? error.message : String(error),
638
+ })
639
+ }
640
+ })
641
+
473
642
  route(`${SETTINGS_PREFIX}/deploy`, async (req, res) => {
474
643
  if (!options.deploy) {
475
644
  sendJson(res, 503, { ok: false, error: '部署功能未启用' })