dsh-autorestart 0.3.6 → 0.5.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/README.md +147 -7
- package/client.js +2362 -147
- package/cordis.patch.yml +16 -0
- package/docs/README.zh.md +98 -10
- package/index.js +896 -67
- package/lib/checkpoint.js +38 -0
- package/lib/sleep.js +828 -0
- package/locale/en.json +101 -1
- package/locale/zh.json +101 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -73,6 +73,71 @@ import {
|
|
|
73
73
|
runCommandNow,
|
|
74
74
|
scheduleDetachedRestart,
|
|
75
75
|
} from './lib/restart.js'
|
|
76
|
+
import {
|
|
77
|
+
createSleepController,
|
|
78
|
+
COUNTDOWN_CHOICES,
|
|
79
|
+
DEFAULT_COUNTDOWN_SECONDS,
|
|
80
|
+
detectShutdownPlan,
|
|
81
|
+
planInvocation,
|
|
82
|
+
runShutdown,
|
|
83
|
+
snapCountdownChoice,
|
|
84
|
+
} from './lib/sleep.js'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The version of this plugin, as a fallback.
|
|
88
|
+
*
|
|
89
|
+
* The real version is read from the plugin's own package.json (see
|
|
90
|
+
* `pluginVersion()`), but a bundled installation may not ship that file — so
|
|
91
|
+
* the constant below is the last resort. A test keeps the two in sync.
|
|
92
|
+
*/
|
|
93
|
+
const PLUGIN_VERSION_FALLBACK = '0.5.0'
|
|
94
|
+
|
|
95
|
+
/** The cached version of this plugin (read once per process). */
|
|
96
|
+
let pluginVersionCache = null
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The version of this plugin.
|
|
100
|
+
*
|
|
101
|
+
* It is the version of the package itself: the feature announcements are
|
|
102
|
+
* bound to it, so a new version of the plugin can announce a new feature (and
|
|
103
|
+
* the announcement of an old version never comes back).
|
|
104
|
+
*
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function pluginVersion() {
|
|
108
|
+
if (pluginVersionCache !== null) return pluginVersionCache
|
|
109
|
+
let version = PLUGIN_VERSION_FALLBACK
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')
|
|
112
|
+
const parsed = JSON.parse(raw)
|
|
113
|
+
if (parsed && typeof parsed.version === 'string' && parsed.version.trim() !== '') {
|
|
114
|
+
version = parsed.version.trim()
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// The package.json is not readable (a bundled build): the fallback is the
|
|
118
|
+
// version that was shipped with this build.
|
|
119
|
+
}
|
|
120
|
+
pluginVersionCache = version
|
|
121
|
+
return version
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The features that are announced ONCE, after an upgrade.
|
|
126
|
+
*
|
|
127
|
+
* The install prompt only ever reaches a fresh installation, so an
|
|
128
|
+
* installation that already existed would never learn about a new feature.
|
|
129
|
+
* Every entry of this list is therefore announced once per version: the
|
|
130
|
+
* announcement is shown until the user presses "知道了", and the Host records
|
|
131
|
+
* the version it has shown, so it never comes back.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{ feature: string, version: string }[]}
|
|
134
|
+
*/
|
|
135
|
+
function featureAnnouncements() {
|
|
136
|
+
return [
|
|
137
|
+
// The graceful shutdown ("收工关机") and its moon button.
|
|
138
|
+
{ feature: 'sleep', version: pluginVersion() },
|
|
139
|
+
]
|
|
140
|
+
}
|
|
76
141
|
|
|
77
142
|
/**
|
|
78
143
|
* `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
|
|
@@ -241,6 +306,20 @@ function buildConfigSchema() {
|
|
|
241
306
|
.description('How many snapshots are kept; older ones are deleted automatically.'),
|
|
242
307
|
rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
|
|
243
308
|
.description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
|
|
309
|
+
// graceful shutdown ("收工关机"): wait for every conversation, count down, then
|
|
310
|
+
// switch the REAL host machine off (a WSL host shuts Windows down, not the distro)
|
|
311
|
+
showSleepButton: schemastery.boolean().default(true)
|
|
312
|
+
.description('是否在左侧栏最底部、设置按钮旁边显示「收工关机」按钮:关掉之后界面上不再有这个按钮,关机接口也不再被界面调用。'),
|
|
313
|
+
// The countdown length is a DROPDOWN: a union of the offered choices
|
|
314
|
+
// projects into an enum (the settings page renders a select, not a
|
|
315
|
+
// number field). `loose()` keeps an old profile value from breaking
|
|
316
|
+
// the boot — the value is snapped onto a choice in normalizeConfig.
|
|
317
|
+
sleepCountdownSeconds: schemastery.union(COUNTDOWN_CHOICES.map((value) => schemastery.const(value)))
|
|
318
|
+
.default(DEFAULT_COUNTDOWN_SECONDS)
|
|
319
|
+
.loose()
|
|
320
|
+
.description('收工关机的倒计时秒数(下拉框:0 / 10 / 20 / 30 / 60 秒,默认 20;0 表示不倒数、立即关机,但仍会先等所有会话收工)。'),
|
|
321
|
+
sleepCommand: schemastery.string().default('')
|
|
322
|
+
.description('自定义关机命令:留空表示按系统自动识别(Windows / WSL / Linux / macOS),填了就用这条命令代替自动识别。'),
|
|
244
323
|
})
|
|
245
324
|
} catch (error) {
|
|
246
325
|
schemasterySource = `construction-failed: ${String(error)}`
|
|
@@ -280,6 +359,12 @@ const DEFAULTS = {
|
|
|
280
359
|
backupIncludePlugins: true,
|
|
281
360
|
maxBackups: 2,
|
|
282
361
|
rollbackTimeoutMs: 90000,
|
|
362
|
+
// Graceful shutdown ("收工关机"): the button at the foot of the left sidebar,
|
|
363
|
+
// the countdown before the machine is switched off and the optional custom
|
|
364
|
+
// shutdown command (empty = the host is detected automatically).
|
|
365
|
+
showSleepButton: true,
|
|
366
|
+
sleepCountdownSeconds: DEFAULT_COUNTDOWN_SECONDS,
|
|
367
|
+
sleepCommand: '',
|
|
283
368
|
}
|
|
284
369
|
|
|
285
370
|
/** Merge a loader row config over {@link DEFAULTS}, dropping wrong-typed values. */
|
|
@@ -304,6 +389,12 @@ function normalizeConfig(raw) {
|
|
|
304
389
|
const rollbackTimeout = Number(config.rollbackTimeoutMs)
|
|
305
390
|
config.rollbackTimeoutMs =
|
|
306
391
|
Number.isFinite(rollbackTimeout) && rollbackTimeout >= 1000 ? Math.trunc(rollbackTimeout) : 90000
|
|
392
|
+
// The countdown of the graceful shutdown is one of the offered choices: a
|
|
393
|
+
// profile that still carries an old value (15, 45, …) is snapped onto the
|
|
394
|
+
// nearest one instead of breaking, and 0 means "no countdown at all".
|
|
395
|
+
config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
|
|
396
|
+
if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
|
|
397
|
+
if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
|
|
307
398
|
return config
|
|
308
399
|
}
|
|
309
400
|
|
|
@@ -370,6 +461,36 @@ function resolveChoicePath(config) {
|
|
|
370
461
|
return `${prefix}/choice`
|
|
371
462
|
}
|
|
372
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Resolve the sleep-route path: a sibling of the health route, under the same
|
|
466
|
+
* prefix (`GET /sleep` reports the state of the graceful sleep, `POST /sleep`
|
|
467
|
+
* arms or cancels it).
|
|
468
|
+
*
|
|
469
|
+
* @param {object} config
|
|
470
|
+
* @returns {string}
|
|
471
|
+
*/
|
|
472
|
+
function resolveSleepPath(config) {
|
|
473
|
+
const healthPath = resolveHealthPath(config)
|
|
474
|
+
const index = healthPath.lastIndexOf('/')
|
|
475
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
476
|
+
return `${prefix}/sleep`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Resolve the announcements-route path: a sibling of the health route, under
|
|
481
|
+
* the same prefix (`GET /announcements` reports the feature announcements the
|
|
482
|
+
* user has not seen yet, `POST /announcements` records that it saw one).
|
|
483
|
+
*
|
|
484
|
+
* @param {object} config
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
function resolveAnnouncementsPath(config) {
|
|
488
|
+
const healthPath = resolveHealthPath(config)
|
|
489
|
+
const index = healthPath.lastIndexOf('/')
|
|
490
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
491
|
+
return `${prefix}/announcements`
|
|
492
|
+
}
|
|
493
|
+
|
|
373
494
|
/**
|
|
374
495
|
* The blocking sessions with the reason each one blocks: a session that is
|
|
375
496
|
* held by an armed goal reports `goal-active` (that goal can be disarmed), a
|
|
@@ -552,14 +673,21 @@ const INSTALL_CHOICE_FILE = 'install-choice.json'
|
|
|
552
673
|
* Read the recorded install choice from the fallback file.
|
|
553
674
|
*
|
|
554
675
|
* @param {string} stateDir
|
|
555
|
-
* @returns {boolean|null
|
|
676
|
+
* @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null }|null}
|
|
556
677
|
*/
|
|
557
678
|
function readInstallChoice(stateDir) {
|
|
558
679
|
try {
|
|
559
680
|
const raw = fs.readFileSync(path.join(stateDir, INSTALL_CHOICE_FILE), 'utf8')
|
|
560
681
|
const parsed = JSON.parse(raw)
|
|
561
682
|
if (!parsed || typeof parsed !== 'object') return null
|
|
562
|
-
return
|
|
683
|
+
return {
|
|
684
|
+
includePlugins: typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null,
|
|
685
|
+
backupOnRestart: typeof parsed.backupOnRestart === 'boolean' ? parsed.backupOnRestart : null,
|
|
686
|
+
showSleepButton: typeof parsed.showSleepButton === 'boolean' ? parsed.showSleepButton : null,
|
|
687
|
+
sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
|
|
688
|
+
? parsed.sleepCountdownSeconds
|
|
689
|
+
: null,
|
|
690
|
+
}
|
|
563
691
|
} catch {
|
|
564
692
|
return null
|
|
565
693
|
}
|
|
@@ -568,18 +696,32 @@ function readInstallChoice(stateDir) {
|
|
|
568
696
|
/**
|
|
569
697
|
* Write the install choice into the fallback file.
|
|
570
698
|
*
|
|
699
|
+
* The file is written ADDITIVELY: a choice about the sleep button must not
|
|
700
|
+
* erase the recorded backup scope (and the other way round).
|
|
701
|
+
*
|
|
571
702
|
* @param {string} stateDir
|
|
572
|
-
* @param {boolean}
|
|
703
|
+
* @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} values
|
|
573
704
|
* @returns {string} the file that was written
|
|
574
705
|
*/
|
|
575
|
-
function writeInstallChoice(stateDir,
|
|
706
|
+
function writeInstallChoice(stateDir, values) {
|
|
576
707
|
const file = path.join(stateDir, INSTALL_CHOICE_FILE)
|
|
708
|
+
const current = readInstallChoice(stateDir) || {
|
|
709
|
+
includePlugins: null,
|
|
710
|
+
backupOnRestart: null,
|
|
711
|
+
showSleepButton: null,
|
|
712
|
+
sleepCountdownSeconds: null,
|
|
713
|
+
}
|
|
714
|
+
const payload = {
|
|
715
|
+
includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
|
|
716
|
+
backupOnRestart: typeof values.backupOnRestart === 'boolean' ? values.backupOnRestart : current.backupOnRestart,
|
|
717
|
+
showSleepButton: typeof values.showSleepButton === 'boolean' ? values.showSleepButton : current.showSleepButton,
|
|
718
|
+
sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
|
|
719
|
+
? values.sleepCountdownSeconds
|
|
720
|
+
: current.sleepCountdownSeconds,
|
|
721
|
+
at: new Date().toISOString(),
|
|
722
|
+
}
|
|
577
723
|
fs.mkdirSync(stateDir, { recursive: true })
|
|
578
|
-
fs.writeFileSync(
|
|
579
|
-
file,
|
|
580
|
-
JSON.stringify({ includePlugins: includePlugins === true, at: new Date().toISOString() }, null, 2),
|
|
581
|
-
'utf8',
|
|
582
|
-
)
|
|
724
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
|
|
583
725
|
return file
|
|
584
726
|
}
|
|
585
727
|
|
|
@@ -602,10 +744,60 @@ export function resolveBackupIncludePlugins(rawConfig, stateDir) {
|
|
|
602
744
|
return { includePlugins: rawConfig.backupIncludePlugins, source: 'config' }
|
|
603
745
|
}
|
|
604
746
|
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
605
|
-
if (fallback !== null
|
|
747
|
+
if (fallback !== null && fallback.includePlugins !== null) {
|
|
748
|
+
return { includePlugins: fallback.includePlugins, source: 'fallback-file' }
|
|
749
|
+
}
|
|
606
750
|
return { includePlugins: DEFAULTS.backupIncludePlugins, source: 'default' }
|
|
607
751
|
}
|
|
608
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Resolve the effective `backupOnRestart` value and where it came from.
|
|
755
|
+
*
|
|
756
|
+
* The same rule as the backup scope: the profile row config wins, the degraded
|
|
757
|
+
* fallback file fills the gap, and the built-in default applies when neither
|
|
758
|
+
* says anything.
|
|
759
|
+
*
|
|
760
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
761
|
+
* @param {string} stateDir
|
|
762
|
+
* @returns {{ backupOnRestart: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
763
|
+
*/
|
|
764
|
+
export function resolveBackupOnRestart(rawConfig, stateDir) {
|
|
765
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
766
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'backupOnRestart')
|
|
767
|
+
&& typeof rawConfig.backupOnRestart === 'boolean') {
|
|
768
|
+
return { backupOnRestart: rawConfig.backupOnRestart, source: 'config' }
|
|
769
|
+
}
|
|
770
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
771
|
+
if (fallback !== null && fallback.backupOnRestart !== null) {
|
|
772
|
+
return { backupOnRestart: fallback.backupOnRestart, source: 'fallback-file' }
|
|
773
|
+
}
|
|
774
|
+
return { backupOnRestart: DEFAULTS.backupOnRestart, source: 'default' }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve the effective `showSleepButton` value and where it came from.
|
|
779
|
+
*
|
|
780
|
+
* The same rule as the backup scope: the profile row config wins, the degraded
|
|
781
|
+
* fallback file fills the gap, and the built-in default applies when neither
|
|
782
|
+
* says anything.
|
|
783
|
+
*
|
|
784
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
785
|
+
* @param {string} stateDir
|
|
786
|
+
* @returns {{ showSleepButton: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
787
|
+
*/
|
|
788
|
+
export function resolveShowSleepButton(rawConfig, stateDir) {
|
|
789
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
790
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'showSleepButton')
|
|
791
|
+
&& typeof rawConfig.showSleepButton === 'boolean') {
|
|
792
|
+
return { showSleepButton: rawConfig.showSleepButton, source: 'config' }
|
|
793
|
+
}
|
|
794
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
795
|
+
if (fallback !== null && fallback.showSleepButton !== null) {
|
|
796
|
+
return { showSleepButton: fallback.showSleepButton, source: 'fallback-file' }
|
|
797
|
+
}
|
|
798
|
+
return { showSleepButton: DEFAULTS.showSleepButton, source: 'default' }
|
|
799
|
+
}
|
|
800
|
+
|
|
609
801
|
/**
|
|
610
802
|
* Resolve the profile directory the running host was started from.
|
|
611
803
|
*
|
|
@@ -1657,6 +1849,130 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1657
1849
|
}
|
|
1658
1850
|
}
|
|
1659
1851
|
|
|
1852
|
+
/**
|
|
1853
|
+
* Persist one or more user choices into the profile row of this plugin.
|
|
1854
|
+
*
|
|
1855
|
+
* The official `configEditor` service is the primary path: the choice goes
|
|
1856
|
+
* into the profile row of this plugin, so the settings page and the one-time
|
|
1857
|
+
* prompts read and write the same key (one source of truth). When the service
|
|
1858
|
+
* is unavailable the choice degrades to a file in the plugin's state
|
|
1859
|
+
* directory and a warning is logged, so the choice is never silently lost.
|
|
1860
|
+
*
|
|
1861
|
+
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
|
|
1862
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file' }>}
|
|
1863
|
+
*/
|
|
1864
|
+
const persistConfigChoice = async (changes) => {
|
|
1865
|
+
// The profile row and the degraded fallback file use different key names for
|
|
1866
|
+
// the backup scope: the row carries the plugin's own key
|
|
1867
|
+
// (`backupIncludePlugins`), the file the short one (`includePlugins`).
|
|
1868
|
+
const wanted = {}
|
|
1869
|
+
const fallback = {}
|
|
1870
|
+
if (typeof changes.backupIncludePlugins === 'boolean') {
|
|
1871
|
+
wanted.backupIncludePlugins = changes.backupIncludePlugins
|
|
1872
|
+
fallback.includePlugins = changes.backupIncludePlugins
|
|
1873
|
+
}
|
|
1874
|
+
if (typeof changes.backupOnRestart === 'boolean') {
|
|
1875
|
+
wanted.backupOnRestart = changes.backupOnRestart
|
|
1876
|
+
fallback.backupOnRestart = changes.backupOnRestart
|
|
1877
|
+
}
|
|
1878
|
+
if (typeof changes.showSleepButton === 'boolean') {
|
|
1879
|
+
wanted.showSleepButton = changes.showSleepButton
|
|
1880
|
+
fallback.showSleepButton = changes.showSleepButton
|
|
1881
|
+
}
|
|
1882
|
+
if (typeof changes.sleepCountdownSeconds === 'number') {
|
|
1883
|
+
wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1884
|
+
fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1885
|
+
}
|
|
1886
|
+
const editor = resolveConfigEditor(ctx)
|
|
1887
|
+
if (editor !== null) {
|
|
1888
|
+
try {
|
|
1889
|
+
const entry = findOwnEntry(editor)
|
|
1890
|
+
if (entry !== null) {
|
|
1891
|
+
await editor.edit(entry, (current) => {
|
|
1892
|
+
const base = current && typeof current === 'object' ? { ...current } : {}
|
|
1893
|
+
return { ...base, ...wanted }
|
|
1894
|
+
})
|
|
1895
|
+
// The Loader reconciles the row, but the running instance must not
|
|
1896
|
+
// keep the old value until that happens.
|
|
1897
|
+
for (const [key, value] of Object.entries(wanted)) config[key] = value
|
|
1898
|
+
log.info(`the choice was written into the profile configuration: ${JSON.stringify(wanted)}`)
|
|
1899
|
+
return { applied: 'config' }
|
|
1900
|
+
}
|
|
1901
|
+
log.warn(`the configEditor service has no profile row for ${name}; the choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
log.warn(`could not write the choice into the profile configuration: ${describeError(error)}`)
|
|
1904
|
+
}
|
|
1905
|
+
} else {
|
|
1906
|
+
log.warn(`the configEditor service is unavailable; the choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1907
|
+
}
|
|
1908
|
+
const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
|
|
1909
|
+
try {
|
|
1910
|
+
writeInstallChoice(path.dirname(stateFile), fallback)
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
throw new Error(`could not write the choice to ${file}: ${describeError(error)}`)
|
|
1913
|
+
}
|
|
1914
|
+
for (const [key, value] of Object.entries(wanted)) config[key] = value
|
|
1915
|
+
log.warn(`the choice was written to ${file} instead of the profile configuration`)
|
|
1916
|
+
return { applied: 'fallback-file' }
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* The feature announcements the user has not seen yet.
|
|
1921
|
+
*
|
|
1922
|
+
* A feature is pending when the recorded version is not the current one: a
|
|
1923
|
+
* user who has seen the announcement of version 0.5.0 never sees it again,
|
|
1924
|
+
* and a user who upgrades to a newer version sees the announcement of that
|
|
1925
|
+
* version. A feature without a record is always pending — a missing record
|
|
1926
|
+
* must never swallow an announcement.
|
|
1927
|
+
*
|
|
1928
|
+
* @returns {{ feature: string, version: string }[]}
|
|
1929
|
+
*/
|
|
1930
|
+
const pendingAnnouncements = () => {
|
|
1931
|
+
const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
|
|
1932
|
+
? state.seenFeatureVersions
|
|
1933
|
+
: {}
|
|
1934
|
+
return featureAnnouncements().filter((entry) => seen[entry.feature] !== entry.version)
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Record that the user has seen the announcement of one feature.
|
|
1939
|
+
*
|
|
1940
|
+
* The record lives in the checkpoint file, so it survives a restart of the
|
|
1941
|
+
* Host: an announcement that was shown once does not come back with the
|
|
1942
|
+
* next boot.
|
|
1943
|
+
*
|
|
1944
|
+
* @param {string} feature the id of the feature
|
|
1945
|
+
* @returns {string[]} the features that are recorded now
|
|
1946
|
+
*/
|
|
1947
|
+
const markFeatureSeen = (feature) => {
|
|
1948
|
+
const id = typeof feature === 'string' ? feature.trim() : ''
|
|
1949
|
+
if (id === '') return []
|
|
1950
|
+
const known = featureAnnouncements().find((entry) => entry.feature === id)
|
|
1951
|
+
if (!known) return []
|
|
1952
|
+
if (!state.seenFeatureVersions || typeof state.seenFeatureVersions !== 'object') {
|
|
1953
|
+
state.seenFeatureVersions = {}
|
|
1954
|
+
}
|
|
1955
|
+
state.seenFeatureVersions[id] = known.version
|
|
1956
|
+
persist()
|
|
1957
|
+
return Object.keys(state.seenFeatureVersions).sort()
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Record that the user has seen the announcement of EVERY feature.
|
|
1962
|
+
*
|
|
1963
|
+
* This is what the one-time install prompt does: it already explains the
|
|
1964
|
+
* features to a fresh installation, so no announcement may follow it.
|
|
1965
|
+
*
|
|
1966
|
+
* @returns {string[]} the features that are recorded now
|
|
1967
|
+
*/
|
|
1968
|
+
const markAllFeaturesSeen = () => {
|
|
1969
|
+
const seen = new Set()
|
|
1970
|
+
for (const entry of featureAnnouncements()) {
|
|
1971
|
+
for (const id of markFeatureSeen(entry.feature)) seen.add(id)
|
|
1972
|
+
}
|
|
1973
|
+
return Array.from(seen).sort()
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1660
1976
|
const runtime = {
|
|
1661
1977
|
get state() {
|
|
1662
1978
|
return state
|
|
@@ -1706,52 +2022,52 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1706
2022
|
stateFile,
|
|
1707
2023
|
log,
|
|
1708
2024
|
persist,
|
|
2025
|
+
/**
|
|
2026
|
+
* Persist one or more user choices into the profile row of this plugin.
|
|
2027
|
+
*
|
|
2028
|
+
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
|
|
2029
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file' }>}
|
|
2030
|
+
*/
|
|
2031
|
+
applyConfigChoice: (changes) => persistConfigChoice(changes),
|
|
2032
|
+
/**
|
|
2033
|
+
* The feature announcements the user has not seen yet.
|
|
2034
|
+
*
|
|
2035
|
+
* @returns {{ feature: string, version: string }[]}
|
|
2036
|
+
*/
|
|
2037
|
+
pendingAnnouncements,
|
|
2038
|
+
/**
|
|
2039
|
+
* Record that the user has seen the announcement of one feature.
|
|
2040
|
+
*
|
|
2041
|
+
* @param {string} feature
|
|
2042
|
+
* @returns {string[]} the features that are recorded now
|
|
2043
|
+
*/
|
|
2044
|
+
markFeatureSeen,
|
|
2045
|
+
/**
|
|
2046
|
+
* Record that the user has seen the announcement of every feature.
|
|
2047
|
+
*
|
|
2048
|
+
* @returns {string[]} the features that are recorded now
|
|
2049
|
+
*/
|
|
2050
|
+
markAllFeaturesSeen,
|
|
1709
2051
|
/**
|
|
1710
2052
|
* Persist the user's one-time install choice: whether the pre-restart
|
|
1711
2053
|
* backup also snapshots the installed plugin tree.
|
|
1712
2054
|
*
|
|
1713
|
-
* The official `configEditor` service is the primary path: the choice goes
|
|
1714
|
-
* into the profile row of this plugin, so the settings page and this prompt
|
|
1715
|
-
* read and write the same key (one source of truth). When the service is
|
|
1716
|
-
* unavailable the choice degrades to a file in the plugin's state directory
|
|
1717
|
-
* and a warning is logged, so the choice is never silently lost.
|
|
1718
|
-
*
|
|
1719
2055
|
* @param {boolean} includePlugins
|
|
1720
2056
|
* @returns {Promise<{ applied: 'config'|'fallback-file', includePlugins: boolean }>}
|
|
1721
2057
|
*/
|
|
1722
2058
|
async applyBackupChoice(includePlugins) {
|
|
1723
|
-
const
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
config.backupIncludePlugins = value
|
|
1736
|
-
log.info(`the install choice was written into the profile configuration: backupIncludePlugins=${value}`)
|
|
1737
|
-
return { applied: 'config', includePlugins: value }
|
|
1738
|
-
}
|
|
1739
|
-
log.warn(`the configEditor service has no profile row for ${name}; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1740
|
-
} catch (error) {
|
|
1741
|
-
log.warn(`could not write the install choice into the profile configuration: ${describeError(error)}`)
|
|
1742
|
-
}
|
|
1743
|
-
} else {
|
|
1744
|
-
log.warn(`the configEditor service is unavailable; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1745
|
-
}
|
|
1746
|
-
const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
|
|
1747
|
-
try {
|
|
1748
|
-
writeInstallChoice(path.dirname(stateFile), value)
|
|
1749
|
-
} catch (error) {
|
|
1750
|
-
throw new Error(`could not write the install choice to ${file}: ${describeError(error)}`)
|
|
1751
|
-
}
|
|
1752
|
-
config.backupIncludePlugins = value
|
|
1753
|
-
log.warn(`the install choice was written to ${file} instead of the profile configuration`)
|
|
1754
|
-
return { applied: 'fallback-file', includePlugins: value }
|
|
2059
|
+
const result = await persistConfigChoice({ backupIncludePlugins: includePlugins === true })
|
|
2060
|
+
return { applied: result.applied, includePlugins: includePlugins === true }
|
|
2061
|
+
},
|
|
2062
|
+
/**
|
|
2063
|
+
* Persist whether the graceful-sleep button is shown in the main UI.
|
|
2064
|
+
*
|
|
2065
|
+
* @param {boolean} showSleepButton
|
|
2066
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file', showSleepButton: boolean }>}
|
|
2067
|
+
*/
|
|
2068
|
+
async applySleepButtonChoice(showSleepButton) {
|
|
2069
|
+
const result = await persistConfigChoice({ showSleepButton: showSleepButton === true })
|
|
2070
|
+
return { applied: result.applied, showSleepButton: showSleepButton === true }
|
|
1755
2071
|
},
|
|
1756
2072
|
toolErrors,
|
|
1757
2073
|
startPolling,
|
|
@@ -3233,18 +3549,107 @@ function buildHandoffRoute(ctx, runtime, config) {
|
|
|
3233
3549
|
}
|
|
3234
3550
|
}
|
|
3235
3551
|
|
|
3552
|
+
/**
|
|
3553
|
+
* Build the announcements route: the one-time announcement of a new feature.
|
|
3554
|
+
*
|
|
3555
|
+
* It sits next to the health route and uses the same authentication rule
|
|
3556
|
+
* (loopback OR a request the connection service admits).
|
|
3557
|
+
*
|
|
3558
|
+
* - `GET` answers `{ ok: true, pending: [{ feature, version }] }`: the
|
|
3559
|
+
* announcements the user has not seen yet (an empty list when there is
|
|
3560
|
+
* nothing new — a fresh installation is asked at the install prompt, so
|
|
3561
|
+
* it never gets an announcement);
|
|
3562
|
+
* - `POST` with `{ "feature": "sleep" }` records that the user has seen
|
|
3563
|
+
* that announcement and answers `{ ok: true, seen: [...] }`.
|
|
3564
|
+
*
|
|
3565
|
+
* The record lives in the checkpoint file, so an announcement that was shown
|
|
3566
|
+
* once never comes back — not with the next boot and not with the next
|
|
3567
|
+
* version of the plugin.
|
|
3568
|
+
*/
|
|
3569
|
+
function buildAnnouncementsRoute(ctx, runtime, config) {
|
|
3570
|
+
const routePath = resolveAnnouncementsPath(config)
|
|
3571
|
+
return {
|
|
3572
|
+
kind: 'exact',
|
|
3573
|
+
path: routePath,
|
|
3574
|
+
handler: (req, res) => {
|
|
3575
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
3576
|
+
const loopback = remote === undefined
|
|
3577
|
+
|| remote === '127.0.0.1'
|
|
3578
|
+
|| remote === '::1'
|
|
3579
|
+
|| remote === '::ffff:127.0.0.1'
|
|
3580
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
3581
|
+
res.statusCode = 403
|
|
3582
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3583
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
3584
|
+
return
|
|
3585
|
+
}
|
|
3586
|
+
const answer = (payload, status = 200) => {
|
|
3587
|
+
res.statusCode = status
|
|
3588
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3589
|
+
res.setHeader('cache-control', 'no-store')
|
|
3590
|
+
res.end(JSON.stringify(payload))
|
|
3591
|
+
}
|
|
3592
|
+
const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
|
|
3593
|
+
if (method === 'GET') {
|
|
3594
|
+
answer({ ok: true, pending: runtime.pendingAnnouncements() })
|
|
3595
|
+
return
|
|
3596
|
+
}
|
|
3597
|
+
if (method !== 'POST') {
|
|
3598
|
+
res.statusCode = 405
|
|
3599
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3600
|
+
res.setHeader('allow', 'GET, POST')
|
|
3601
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
3602
|
+
return
|
|
3603
|
+
}
|
|
3604
|
+
void readJsonBody(req).then((payload) => {
|
|
3605
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
3606
|
+
const feature = typeof body.feature === 'string' ? body.feature.trim() : ''
|
|
3607
|
+
if (feature === '') {
|
|
3608
|
+
answer({
|
|
3609
|
+
ok: false,
|
|
3610
|
+
error: 'feature-required',
|
|
3611
|
+
instruction: 'Send { "feature": "sleep" }.',
|
|
3612
|
+
}, 400)
|
|
3613
|
+
return
|
|
3614
|
+
}
|
|
3615
|
+
const known = featureAnnouncements().some((entry) => entry.feature === feature)
|
|
3616
|
+
if (!known) {
|
|
3617
|
+
answer({
|
|
3618
|
+
ok: false,
|
|
3619
|
+
error: 'unknown-feature',
|
|
3620
|
+
instruction: `Send { "feature": ${featureAnnouncements().map((entry) => `"${entry.feature}"`).join(' | ')} }.`,
|
|
3621
|
+
}, 400)
|
|
3622
|
+
return
|
|
3623
|
+
}
|
|
3624
|
+
answer({ ok: true, seen: runtime.markFeatureSeen(feature) })
|
|
3625
|
+
}).catch((error) => {
|
|
3626
|
+
answer({ ok: false, error: describeError(error) }, 400)
|
|
3627
|
+
})
|
|
3628
|
+
},
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3236
3632
|
/**
|
|
3237
3633
|
* Build the POST `/choice` route: persist the one-time install choice of the
|
|
3238
|
-
* user (whether the pre-restart backup also snapshots the installed plugins)
|
|
3634
|
+
* user (whether the pre-restart backup also snapshots the installed plugins),
|
|
3635
|
+
* the answer to the one-time question whether the graceful-sleep button is
|
|
3636
|
+
* shown, and the settings of the configuration page (how long the countdown
|
|
3637
|
+
* runs, whether the pre-restart backup is taken at all and whether that
|
|
3638
|
+
* backup includes the installed plugins).
|
|
3239
3639
|
*
|
|
3240
3640
|
* It sits next to the health route and uses the same authentication rule
|
|
3241
3641
|
* (loopback OR a request the connection service admits). Body:
|
|
3242
|
-
* `{ "includePlugins"
|
|
3243
|
-
*
|
|
3642
|
+
* `{ "includePlugins"?: boolean, "backupIncludePlugins"?: boolean,
|
|
3643
|
+
* "backupOnRestart"?: boolean, "showSleepButton"?: boolean,
|
|
3644
|
+
* "sleepCountdownSeconds"?: 0 | 10 | 20 | 30 | 60 }` — at least one
|
|
3645
|
+
* of the keys must be present, and a key that IS present must carry a valid
|
|
3646
|
+
* value. Anything else is refused with a 400 and NOTHING is written.
|
|
3244
3647
|
*
|
|
3245
|
-
* The answer is `{ ok: true, applied: 'config' | 'fallback-file',
|
|
3648
|
+
* The answer is `{ ok: true, applied: 'config' | 'fallback-file', ... }`:
|
|
3246
3649
|
* `applied` says where the choice landed — the profile configuration (the
|
|
3247
|
-
* official path) or the degraded fallback file.
|
|
3650
|
+
* official path) or the degraded fallback file. Only the keys that were asked
|
|
3651
|
+
* for are echoed back, plus the effective `countdownSeconds` so the settings
|
|
3652
|
+
* page can read them back at once.
|
|
3248
3653
|
*/
|
|
3249
3654
|
function buildChoiceRoute(ctx, runtime, config) {
|
|
3250
3655
|
const routePath = resolveChoicePath(config)
|
|
@@ -3272,8 +3677,24 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3272
3677
|
}
|
|
3273
3678
|
void readJsonBody(req).then(async (payload) => {
|
|
3274
3679
|
const body = payload && typeof payload === 'object' ? payload : {}
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3680
|
+
const hasInclude = Object.prototype.hasOwnProperty.call(body, 'includePlugins')
|
|
3681
|
+
const hasBackupInclude = Object.prototype.hasOwnProperty.call(body, 'backupIncludePlugins')
|
|
3682
|
+
const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
|
|
3683
|
+
const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
|
|
3684
|
+
const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
|
|
3685
|
+
if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds) {
|
|
3686
|
+
// No key was asked for: there is nothing to persist.
|
|
3687
|
+
res.statusCode = 400
|
|
3688
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3689
|
+
res.setHeader('cache-control', 'no-store')
|
|
3690
|
+
res.end(JSON.stringify({
|
|
3691
|
+
ok: false,
|
|
3692
|
+
error: 'no-choice',
|
|
3693
|
+
instruction: 'Send { "includePlugins": true | false } and/or { "backupIncludePlugins": true | false } and/or { "backupOnRestart": true | false } and/or { "showSleepButton": true | false } and/or { "sleepCountdownSeconds": 0 | 10 | 20 | 30 | 60 }.',
|
|
3694
|
+
}))
|
|
3695
|
+
return
|
|
3696
|
+
}
|
|
3697
|
+
if (hasInclude && typeof body.includePlugins !== 'boolean') {
|
|
3277
3698
|
// Nothing is written for a malformed choice: the caller's answer
|
|
3278
3699
|
// must not change the profile configuration.
|
|
3279
3700
|
res.statusCode = 400
|
|
@@ -3286,15 +3707,82 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3286
3707
|
}))
|
|
3287
3708
|
return
|
|
3288
3709
|
}
|
|
3289
|
-
|
|
3710
|
+
if (hasBackupInclude && typeof body.backupIncludePlugins !== 'boolean') {
|
|
3711
|
+
res.statusCode = 400
|
|
3712
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3713
|
+
res.setHeader('cache-control', 'no-store')
|
|
3714
|
+
res.end(JSON.stringify({
|
|
3715
|
+
ok: false,
|
|
3716
|
+
error: 'backupIncludePlugins-must-be-boolean',
|
|
3717
|
+
instruction: 'Send { "backupIncludePlugins": true | false }.',
|
|
3718
|
+
}))
|
|
3719
|
+
return
|
|
3720
|
+
}
|
|
3721
|
+
if (hasBackup && typeof body.backupOnRestart !== 'boolean') {
|
|
3722
|
+
res.statusCode = 400
|
|
3723
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3724
|
+
res.setHeader('cache-control', 'no-store')
|
|
3725
|
+
res.end(JSON.stringify({
|
|
3726
|
+
ok: false,
|
|
3727
|
+
error: 'backupOnRestart-must-be-boolean',
|
|
3728
|
+
instruction: 'Send { "backupOnRestart": true | false }.',
|
|
3729
|
+
}))
|
|
3730
|
+
return
|
|
3731
|
+
}
|
|
3732
|
+
if (hasShow && typeof body.showSleepButton !== 'boolean') {
|
|
3733
|
+
res.statusCode = 400
|
|
3734
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3735
|
+
res.setHeader('cache-control', 'no-store')
|
|
3736
|
+
res.end(JSON.stringify({
|
|
3737
|
+
ok: false,
|
|
3738
|
+
error: 'showSleepButton-must-be-boolean',
|
|
3739
|
+
instruction: 'Send { "showSleepButton": true | false }.',
|
|
3740
|
+
}))
|
|
3741
|
+
return
|
|
3742
|
+
}
|
|
3743
|
+
if (hasSeconds && COUNTDOWN_CHOICES.indexOf(body.sleepCountdownSeconds) === -1) {
|
|
3744
|
+
res.statusCode = 400
|
|
3745
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3746
|
+
res.setHeader('cache-control', 'no-store')
|
|
3747
|
+
res.end(JSON.stringify({
|
|
3748
|
+
ok: false,
|
|
3749
|
+
error: 'sleepCountdownSeconds-must-be-offered',
|
|
3750
|
+
instruction: `Send { "sleepCountdownSeconds": ${COUNTDOWN_CHOICES.join(' | ')} }.`,
|
|
3751
|
+
}))
|
|
3752
|
+
return
|
|
3753
|
+
}
|
|
3754
|
+
const changes = {}
|
|
3755
|
+
// The route field is `includePlugins`, the configuration key is
|
|
3756
|
+
// `backupIncludePlugins` (the profile row keeps the plugin's own name).
|
|
3757
|
+
if (hasInclude) changes.backupIncludePlugins = body.includePlugins === true
|
|
3758
|
+
if (hasBackupInclude) changes.backupIncludePlugins = body.backupIncludePlugins === true
|
|
3759
|
+
if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
|
|
3760
|
+
if (hasShow) changes.showSleepButton = body.showSleepButton === true
|
|
3761
|
+
if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
3762
|
+
const result = await runtime.applyConfigChoice(changes)
|
|
3763
|
+
// The one-time install prompt explained the features to the user, so
|
|
3764
|
+
// the announcement of a fresh installation must not follow it.
|
|
3765
|
+
//
|
|
3766
|
+
// Only the install prompt sends `includePlugins` and `showSleepButton`
|
|
3767
|
+
// (the settings page writes `backupIncludePlugins`,
|
|
3768
|
+
// `backupOnRestart` and `sleepCountdownSeconds`), so these two keys
|
|
3769
|
+
// are exactly the "the install question was answered" moment.
|
|
3770
|
+
if (hasInclude || hasShow) {
|
|
3771
|
+
const seen = runtime.markAllFeaturesSeen()
|
|
3772
|
+
if (seen.length > 0) {
|
|
3773
|
+
runtime.log.info(`the install question marked the feature announcements as seen: ${seen.join(', ')}`)
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
const answer = { ok: true, applied: result.applied }
|
|
3777
|
+
if (hasInclude) answer.includePlugins = body.includePlugins === true
|
|
3778
|
+
if (hasBackupInclude) answer.backupIncludePlugins = body.backupIncludePlugins === true
|
|
3779
|
+
if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
|
|
3780
|
+
if (hasShow) answer.showSleepButton = body.showSleepButton === true
|
|
3781
|
+
if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
3290
3782
|
res.statusCode = 200
|
|
3291
3783
|
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3292
3784
|
res.setHeader('cache-control', 'no-store')
|
|
3293
|
-
res.end(JSON.stringify(
|
|
3294
|
-
ok: true,
|
|
3295
|
-
applied: result.applied,
|
|
3296
|
-
includePlugins: result.includePlugins === true,
|
|
3297
|
-
}))
|
|
3785
|
+
res.end(JSON.stringify(answer))
|
|
3298
3786
|
}).catch((error) => {
|
|
3299
3787
|
res.statusCode = 500
|
|
3300
3788
|
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
@@ -3304,8 +3792,290 @@ function buildChoiceRoute(ctx, runtime, config) {
|
|
|
3304
3792
|
}
|
|
3305
3793
|
}
|
|
3306
3794
|
|
|
3795
|
+
/**
|
|
3796
|
+
* Is the graceful sleep a dry run?
|
|
3797
|
+
*
|
|
3798
|
+
* With `DSH_AUTORESTART_SLEEP_DRYRUN=1` the plugin reports the command it
|
|
3799
|
+
* WOULD run and never runs it. That is what makes the feature testable on a
|
|
3800
|
+
* live machine.
|
|
3801
|
+
*
|
|
3802
|
+
* @returns {boolean}
|
|
3803
|
+
*/
|
|
3804
|
+
function sleepDryRun() {
|
|
3805
|
+
return process.env.DSH_AUTORESTART_SLEEP_DRYRUN === '1'
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
/**
|
|
3809
|
+
* The gate of the graceful sleep: the ONE question is "is any conversation
|
|
3810
|
+
* still working?".
|
|
3811
|
+
*
|
|
3812
|
+
* It is deliberately NOT the restart gate: that one also demands a quiet
|
|
3813
|
+
* settle window, and a settle window longer than the countdown would push the
|
|
3814
|
+
* power-off back by whole countdown periods (every restart of the countdown
|
|
3815
|
+
* would also claim that somebody started working again, which is not what
|
|
3816
|
+
* happened). The sleep feature counts down on screen itself, so it only needs
|
|
3817
|
+
* the busy check — and it keeps `settleMs` out of the picture entirely.
|
|
3818
|
+
*
|
|
3819
|
+
* The restart gate itself is untouched: the restart feature keeps its quiet
|
|
3820
|
+
* window exactly as it was.
|
|
3821
|
+
*
|
|
3822
|
+
* @param {object} runtime
|
|
3823
|
+
* @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
|
|
3824
|
+
*/
|
|
3825
|
+
function sleepGate(runtime) {
|
|
3826
|
+
let busy = []
|
|
3827
|
+
try {
|
|
3828
|
+
busy = runtime.busyAgents()
|
|
3829
|
+
} catch {
|
|
3830
|
+
busy = []
|
|
3831
|
+
}
|
|
3832
|
+
const list = Array.isArray(busy) ? busy : []
|
|
3833
|
+
if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
|
|
3834
|
+
return { allow: true, reason: 'quiet', blockedBy: [] }
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
/**
|
|
3838
|
+
* The FINAL plan of the graceful sleep: what will really be executed, and how
|
|
3839
|
+
* the host describes itself in the status JSON.
|
|
3840
|
+
*
|
|
3841
|
+
* Two rules:
|
|
3842
|
+
* 1. `supported` follows the plan that will be executed, NOT the detected
|
|
3843
|
+
* one: a machine whose platform could not be detected is still perfectly
|
|
3844
|
+
* usable when the operator configured a command of their own.
|
|
3845
|
+
* 2. the reported `kind`/`label` belong to that same plan, so the status
|
|
3846
|
+
* never says "this machine has no reliable way to switch off" while a
|
|
3847
|
+
* command is about to run.
|
|
3848
|
+
*
|
|
3849
|
+
* @param {object} plan the detected plan
|
|
3850
|
+
* @param {object} config
|
|
3851
|
+
* @returns {object} the effective plan
|
|
3852
|
+
*/
|
|
3853
|
+
function effectiveSleepPlan(plan, config) {
|
|
3854
|
+
const invocation = planInvocation(plan, {
|
|
3855
|
+
sleepCommand: config.sleepCommand,
|
|
3856
|
+
platform: plan.platform,
|
|
3857
|
+
})
|
|
3858
|
+
const custom = invocation.source === 'sleepCommand'
|
|
3859
|
+
const detectedKind = typeof plan.kind === 'string' ? plan.kind : 'unsupported'
|
|
3860
|
+
const detected = detectedKind !== 'unsupported'
|
|
3861
|
+
const command = typeof invocation.command === 'string' && invocation.command !== '' ? invocation.command : null
|
|
3862
|
+
const label = custom
|
|
3863
|
+
? (detected ? `执行你指定的关机命令(${plan.label})` : '执行你指定的关机命令')
|
|
3864
|
+
: plan.label
|
|
3865
|
+
const kind = custom
|
|
3866
|
+
// A custom command on a platform we could not classify: the machine is
|
|
3867
|
+
// real, the mechanism is the operator's own — it is NOT "unsupported".
|
|
3868
|
+
? (detected ? detectedKind : 'custom')
|
|
3869
|
+
: detectedKind
|
|
3870
|
+
return {
|
|
3871
|
+
...invocation,
|
|
3872
|
+
kind,
|
|
3873
|
+
label,
|
|
3874
|
+
// The short phrase the countdown message talks about.
|
|
3875
|
+
shortLabel: custom ? '执行你指定的关机命令' : plan.label,
|
|
3876
|
+
// The final plan decides whether the feature may run at all.
|
|
3877
|
+
supported: command !== null,
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
/**
|
|
3882
|
+
* Build the graceful-sleep service: the detected host plan plus the state
|
|
3883
|
+
* machine that waits for the conversations, counts down and switches the
|
|
3884
|
+
* machine off.
|
|
3885
|
+
*
|
|
3886
|
+
* The busy check is the runtime's own `busyAgents()`, so the sleep feature and
|
|
3887
|
+
* the restart feature agree on what "every conversation is idle" means. The
|
|
3888
|
+
* gate itself is the sleep-specific one (no settle window): the sleep feature
|
|
3889
|
+
* counts down on screen itself.
|
|
3890
|
+
*
|
|
3891
|
+
* `options.sleepNow` and `options.sleepSpawn` replace the clock and the
|
|
3892
|
+
* process spawner, so the whole flow can be driven without waiting and
|
|
3893
|
+
* without ever touching the machine. Production never passes them.
|
|
3894
|
+
*
|
|
3895
|
+
* @param {object} ctx
|
|
3896
|
+
* @param {object} runtime
|
|
3897
|
+
* @param {object} config
|
|
3898
|
+
* @param {{ sleepNow?: () => number, sleepSpawn?: Function }} [options]
|
|
3899
|
+
* @returns {{ plan: object, controller: object, invocation: () => object }}
|
|
3900
|
+
*/
|
|
3901
|
+
function createSleepService(ctx, runtime, config, options = {}) {
|
|
3902
|
+
const plan = detectShutdownPlan({ platform: process.platform, env: process.env })
|
|
3903
|
+
// The configured command (if any) replaces the detected one; the final plan
|
|
3904
|
+
// is what decides whether the feature may run and what the UI is told.
|
|
3905
|
+
const invocation = () => effectiveSleepPlan(plan, config)
|
|
3906
|
+
const gate = () => sleepGate(runtime)
|
|
3907
|
+
const final = invocation()
|
|
3908
|
+
const now = typeof options.sleepNow === 'function' ? options.sleepNow : () => Date.now()
|
|
3909
|
+
const spawn = typeof options.sleepSpawn === 'function' ? options.sleepSpawn : undefined
|
|
3910
|
+
const buildController = (seconds) => createSleepController({
|
|
3911
|
+
now,
|
|
3912
|
+
busyAgents: () => runtime.busyAgents(),
|
|
3913
|
+
gate,
|
|
3914
|
+
titleOf: (sessionId) => runtime.sessionTitle(sessionId),
|
|
3915
|
+
countdownSeconds: seconds,
|
|
3916
|
+
hostLabel: final.shortLabel,
|
|
3917
|
+
supported: final.supported,
|
|
3918
|
+
log: runtime.log,
|
|
3919
|
+
// The timer belongs to the plugin: `ctx.interval` is disposed with it.
|
|
3920
|
+
setInterval: (fn, ms) => ctx.interval(fn, ms),
|
|
3921
|
+
clearInterval: (handle) => {
|
|
3922
|
+
try {
|
|
3923
|
+
if (typeof handle === 'function') handle()
|
|
3924
|
+
} catch {}
|
|
3925
|
+
},
|
|
3926
|
+
shutdown: () => runShutdown(invocation(), {
|
|
3927
|
+
spawn,
|
|
3928
|
+
dryRun: sleepDryRun(),
|
|
3929
|
+
log: runtime.log,
|
|
3930
|
+
// The gate is checked ONE more time right before the machine is closed —
|
|
3931
|
+
// with the SAME sleep gate (a busy conversation still stops it).
|
|
3932
|
+
gate,
|
|
3933
|
+
}),
|
|
3934
|
+
})
|
|
3935
|
+
// The countdown length belongs to the CONFIGURATION, and the configuration
|
|
3936
|
+
// page can change it while the plugin runs. The controller is rebuilt as
|
|
3937
|
+
// soon as the new length is known — but never while a countdown is running:
|
|
3938
|
+
// that one keeps the length it was started with.
|
|
3939
|
+
let active = buildController(config.sleepCountdownSeconds)
|
|
3940
|
+
let activeSeconds = config.sleepCountdownSeconds
|
|
3941
|
+
const current = () => {
|
|
3942
|
+
const seconds = config.sleepCountdownSeconds
|
|
3943
|
+
if (seconds === activeSeconds) return active
|
|
3944
|
+
if (active.status().armed) return active
|
|
3945
|
+
active.dispose()
|
|
3946
|
+
active = buildController(seconds)
|
|
3947
|
+
activeSeconds = seconds
|
|
3948
|
+
return active
|
|
3949
|
+
}
|
|
3950
|
+
const liveController = {
|
|
3951
|
+
arm: (...args) => current().arm(...args),
|
|
3952
|
+
cancel: (...args) => current().cancel(...args),
|
|
3953
|
+
tick: (...args) => current().tick(...args),
|
|
3954
|
+
status: (...args) => current().status(...args),
|
|
3955
|
+
dispose: (...args) => current().dispose(...args),
|
|
3956
|
+
}
|
|
3957
|
+
return {
|
|
3958
|
+
plan,
|
|
3959
|
+
get controller() {
|
|
3960
|
+
return liveController
|
|
3961
|
+
},
|
|
3962
|
+
invocation,
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
/**
|
|
3967
|
+
* The state of the graceful sleep, in the shape the client renders it.
|
|
3968
|
+
*
|
|
3969
|
+
* @param {{ plan: object, controller: object, invocation: () => object }} sleep
|
|
3970
|
+
* @param {object} config
|
|
3971
|
+
* @returns {object}
|
|
3972
|
+
*/
|
|
3973
|
+
function sleepStatusPayload(sleep, config) {
|
|
3974
|
+
const status = sleep.controller.status()
|
|
3975
|
+
const invocation = sleep.invocation()
|
|
3976
|
+
return {
|
|
3977
|
+
ok: true,
|
|
3978
|
+
phase: status.phase,
|
|
3979
|
+
armed: status.armed,
|
|
3980
|
+
showSleepButton: config.showSleepButton !== false,
|
|
3981
|
+
countdownSeconds: status.countdownSeconds,
|
|
3982
|
+
/**
|
|
3983
|
+
* The pre-restart backup scope, straight from the effective configuration:
|
|
3984
|
+
* the settings page reads its two switches from here.
|
|
3985
|
+
*/
|
|
3986
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
3987
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3988
|
+
remainingMs: status.remainingMs,
|
|
3989
|
+
blockers: status.blockers,
|
|
3990
|
+
host: {
|
|
3991
|
+
platform: invocation.platform || process.platform,
|
|
3992
|
+
kind: invocation.kind,
|
|
3993
|
+
label: invocation.label,
|
|
3994
|
+
source: invocation.source,
|
|
3995
|
+
// Machine-readable: the host was only recognized as WSL because the
|
|
3996
|
+
// Windows drive is mounted (a hint, not a proof). The UI and the tests
|
|
3997
|
+
// read this instead of guessing from the Chinese label.
|
|
3998
|
+
suspect: invocation.suspect === true,
|
|
3999
|
+
},
|
|
4000
|
+
dryRun: sleepDryRun(),
|
|
4001
|
+
message: status.message,
|
|
4002
|
+
lastRun: status.lastRun,
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
/**
|
|
4007
|
+
* Build the `/sleep` route: `GET` reports the state of the graceful sleep,
|
|
4008
|
+
* `POST` arms or cancels it.
|
|
4009
|
+
*
|
|
4010
|
+
* It sits next to the health route and uses the same authentication rule
|
|
4011
|
+
* (loopback OR a request the connection service admits). Body:
|
|
4012
|
+
* `{ "action": "arm" | "cancel" }`; anything else is refused with a 400.
|
|
4013
|
+
*
|
|
4014
|
+
* There is NO ticket and no extra header: the desktop client and the hosted
|
|
4015
|
+
* web client drive the very same route, so a request carries nothing but the
|
|
4016
|
+
* action itself.
|
|
4017
|
+
*/
|
|
4018
|
+
function buildSleepRoute(ctx, runtime, config, sleep) {
|
|
4019
|
+
const routePath = resolveSleepPath(config)
|
|
4020
|
+
return {
|
|
4021
|
+
kind: 'exact',
|
|
4022
|
+
path: routePath,
|
|
4023
|
+
handler: (req, res) => {
|
|
4024
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
4025
|
+
const loopback = remote === undefined
|
|
4026
|
+
|| remote === '127.0.0.1'
|
|
4027
|
+
|| remote === '::1'
|
|
4028
|
+
|| remote === '::ffff:127.0.0.1'
|
|
4029
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
4030
|
+
res.statusCode = 403
|
|
4031
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4032
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
4033
|
+
return
|
|
4034
|
+
}
|
|
4035
|
+
const answer = (payload, status = 200) => {
|
|
4036
|
+
res.statusCode = status
|
|
4037
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4038
|
+
res.setHeader('cache-control', 'no-store')
|
|
4039
|
+
res.end(JSON.stringify(payload))
|
|
4040
|
+
}
|
|
4041
|
+
const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
|
|
4042
|
+
if (method === 'GET') {
|
|
4043
|
+
answer(sleepStatusPayload(sleep, config))
|
|
4044
|
+
return
|
|
4045
|
+
}
|
|
4046
|
+
if (method !== 'POST') {
|
|
4047
|
+
res.statusCode = 405
|
|
4048
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4049
|
+
res.setHeader('allow', 'GET, POST')
|
|
4050
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
4051
|
+
return
|
|
4052
|
+
}
|
|
4053
|
+
void readJsonBody(req).then((payload) => {
|
|
4054
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
4055
|
+
const action = typeof body.action === 'string' ? body.action.trim() : ''
|
|
4056
|
+
if (action !== 'arm' && action !== 'cancel') {
|
|
4057
|
+
answer({
|
|
4058
|
+
ok: false,
|
|
4059
|
+
error: 'unknown-action',
|
|
4060
|
+
instruction: 'Send { "action": "arm" | "cancel" }.',
|
|
4061
|
+
}, 400)
|
|
4062
|
+
return
|
|
4063
|
+
}
|
|
4064
|
+
if (action === 'arm') {
|
|
4065
|
+
sleep.controller.arm()
|
|
4066
|
+
} else {
|
|
4067
|
+
sleep.controller.cancel()
|
|
4068
|
+
}
|
|
4069
|
+
answer(sleepStatusPayload(sleep, config))
|
|
4070
|
+
}).catch((error) => {
|
|
4071
|
+
answer({ ok: false, error: describeError(error) }, 400)
|
|
4072
|
+
})
|
|
4073
|
+
},
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
3307
4077
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
3308
|
-
function registerHealthRoute(ctx, runtime, config) {
|
|
4078
|
+
function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
3309
4079
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
3310
4080
|
? config.healthPath
|
|
3311
4081
|
: '/__dsh_autorestart/health'
|
|
@@ -3417,6 +4187,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3417
4187
|
// The route that persists the one-time install choice, plus the
|
|
3418
4188
|
// value that is effective right now.
|
|
3419
4189
|
choiceUrl: resolveChoicePath(config),
|
|
4190
|
+
// The route that reports (and records) the one-time feature
|
|
4191
|
+
// announcements, so the client never has to guess the prefix.
|
|
4192
|
+
announcementsUrl: resolveAnnouncementsPath(config),
|
|
4193
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
3420
4194
|
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3421
4195
|
parkedGoals: parkedGoalList(runtime),
|
|
3422
4196
|
recovery: recoveryList(live),
|
|
@@ -3433,6 +4207,8 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3433
4207
|
parkUrl: resolveParkPath(config),
|
|
3434
4208
|
recoverUrl: resolveRecoverPath(config),
|
|
3435
4209
|
choiceUrl: resolveChoicePath(config),
|
|
4210
|
+
announcementsUrl: resolveAnnouncementsPath(config),
|
|
4211
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
3436
4212
|
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3437
4213
|
parkedGoals: [],
|
|
3438
4214
|
recovery: [],
|
|
@@ -3491,6 +4267,8 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3491
4267
|
const recoverRoute = buildRecoverRoute(ctx, runtime, config)
|
|
3492
4268
|
const handoffRoute = buildHandoffRoute(ctx, runtime, config)
|
|
3493
4269
|
const choiceRoute = buildChoiceRoute(ctx, runtime, config)
|
|
4270
|
+
const announcementsRoute = buildAnnouncementsRoute(ctx, runtime, config)
|
|
4271
|
+
const sleepRoute = sleep ? buildSleepRoute(ctx, runtime, config, sleep) : null
|
|
3494
4272
|
try {
|
|
3495
4273
|
const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
|
|
3496
4274
|
const portMatch = /:(\d+)\/?$/.exec(origin)
|
|
@@ -3522,6 +4300,18 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3522
4300
|
} catch (error) {
|
|
3523
4301
|
runtime.log.warn('could not register the choice route:', String(error))
|
|
3524
4302
|
}
|
|
4303
|
+
try {
|
|
4304
|
+
target.effect(() => webServer.register(announcementsRoute), 'dsh-autorestart: announcements route')
|
|
4305
|
+
} catch (error) {
|
|
4306
|
+
runtime.log.warn('could not register the announcements route:', String(error))
|
|
4307
|
+
}
|
|
4308
|
+
if (sleepRoute !== null) {
|
|
4309
|
+
try {
|
|
4310
|
+
target.effect(() => webServer.register(sleepRoute), 'dsh-autorestart: sleep route')
|
|
4311
|
+
} catch (error) {
|
|
4312
|
+
runtime.log.warn('could not register the sleep route:', String(error))
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
3525
4315
|
if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
|
|
3526
4316
|
try {
|
|
3527
4317
|
target.effect(
|
|
@@ -3652,16 +4442,36 @@ function trackAgents(ctx, runtime, config) {
|
|
|
3652
4442
|
* Plugin entry point.
|
|
3653
4443
|
* @param ctx - Cordis plugin context.
|
|
3654
4444
|
* @param rawConfig - the loader row config (merged over the built-in defaults).
|
|
4445
|
+
* @param {{
|
|
4446
|
+
* sleepNow?: () => number,
|
|
4447
|
+
* sleepSpawn?: Function,
|
|
4448
|
+
* }} [options] - the injectable primitives of the graceful sleep: they replace
|
|
4449
|
+
* the clock and the process spawner, so the whole flow can be driven without
|
|
4450
|
+
* waiting and without ever touching the machine. Production never passes them.
|
|
3655
4451
|
*/
|
|
3656
|
-
export function apply(ctx, rawConfig) {
|
|
4452
|
+
export function apply(ctx, rawConfig, options = {}) {
|
|
3657
4453
|
const config = normalizeConfig(rawConfig)
|
|
3658
4454
|
// The one-time install choice lives in the profile row config (the single
|
|
3659
4455
|
// source of truth). Only when the row does not set the key at all does the
|
|
3660
4456
|
// degraded fallback file get a say.
|
|
3661
|
-
const
|
|
4457
|
+
const stateDir = resolveStateDir(config)
|
|
4458
|
+
const choice = resolveBackupIncludePlugins(rawConfig, stateDir)
|
|
3662
4459
|
config.backupIncludePlugins = choice.includePlugins
|
|
3663
|
-
|
|
4460
|
+
// The same rule for the pre-restart backup itself: the profile row decides,
|
|
4461
|
+
// the fallback file fills the gap, the built-in default applies otherwise.
|
|
4462
|
+
const backupChoice = resolveBackupOnRestart(rawConfig, stateDir)
|
|
4463
|
+
config.backupOnRestart = backupChoice.backupOnRestart
|
|
4464
|
+
// The same rule for the graceful-sleep button: the profile row decides, the
|
|
4465
|
+
// fallback file fills the gap, the built-in default applies otherwise.
|
|
4466
|
+
const sleepChoice = resolveShowSleepButton(rawConfig, stateDir)
|
|
4467
|
+
config.showSleepButton = sleepChoice.showSleepButton
|
|
4468
|
+
const runtime = createRuntime(ctx, config, {
|
|
4469
|
+
backupChoiceSource: choice.source,
|
|
4470
|
+
})
|
|
3664
4471
|
const log = runtime.log
|
|
4472
|
+
// The graceful sleep: the host plan and the state machine that waits for the
|
|
4473
|
+
// conversations, counts down and switches the machine off.
|
|
4474
|
+
const sleep = createSleepService(ctx, runtime, config, options)
|
|
3665
4475
|
|
|
3666
4476
|
log.info('activated', {
|
|
3667
4477
|
stateFile: runtime.stateFile,
|
|
@@ -3671,8 +4481,15 @@ export function apply(ctx, rawConfig) {
|
|
|
3671
4481
|
desktopApp: runtime.env.isDesktopApp,
|
|
3672
4482
|
appExePath: runtime.env.appExePath,
|
|
3673
4483
|
configSchema: Config !== undefined,
|
|
4484
|
+
backupOnRestart: config.backupOnRestart,
|
|
3674
4485
|
backupIncludePlugins: config.backupIncludePlugins,
|
|
3675
4486
|
backupChoiceSource: choice.source,
|
|
4487
|
+
showSleepButton: config.showSleepButton,
|
|
4488
|
+
sleepChoiceSource: sleepChoice.source,
|
|
4489
|
+
// The FINAL plan: what the sleep feature would really do.
|
|
4490
|
+
sleepHost: sleep.invocation().kind,
|
|
4491
|
+
sleepSource: sleep.invocation().source,
|
|
4492
|
+
sleepSupported: sleep.invocation().supported,
|
|
3676
4493
|
})
|
|
3677
4494
|
if (Config === undefined) {
|
|
3678
4495
|
log.warn('Config schema is unavailable in this build (schemastery could not be loaded); configuration still works through cordis.patch.yml and the built-in defaults')
|
|
@@ -3702,13 +4519,25 @@ export function apply(ctx, rawConfig) {
|
|
|
3702
4519
|
registerTools(ctx, runtime, config)
|
|
3703
4520
|
registerManualRestartGuard(ctx, runtime, config)
|
|
3704
4521
|
registerSystemPrompt(ctx, runtime)
|
|
3705
|
-
registerHealthRoute(ctx, runtime, config)
|
|
4522
|
+
registerHealthRoute(ctx, runtime, config, sleep)
|
|
3706
4523
|
|
|
3707
4524
|
ctx.effect(() => () => {
|
|
3708
4525
|
runtime.markDisposed()
|
|
3709
4526
|
runtime.stopPolling()
|
|
4527
|
+
// The countdown timer belongs to the plugin: it must not survive it.
|
|
4528
|
+
sleep.controller.dispose()
|
|
3710
4529
|
persistSafely(runtime)
|
|
3711
4530
|
}, 'dsh-autorestart lifecycle')
|
|
3712
4531
|
}
|
|
3713
4532
|
|
|
3714
|
-
export {
|
|
4533
|
+
export {
|
|
4534
|
+
DEFAULTS,
|
|
4535
|
+
normalizeConfig,
|
|
4536
|
+
resolveStateFile,
|
|
4537
|
+
resolveStateDir,
|
|
4538
|
+
resolveChoicePath,
|
|
4539
|
+
resolveSleepPath,
|
|
4540
|
+
resolveAnnouncementsPath,
|
|
4541
|
+
featureAnnouncements,
|
|
4542
|
+
pluginVersion,
|
|
4543
|
+
}
|