bare-tui-updater 0.0.0 → 0.0.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/README.md +172 -0
- package/index.js +10 -0
- package/mock.js +57 -0
- package/package.json +49 -1
- package/pear.js +30 -0
- package/theme.js +15 -0
- package/updater.js +247 -0
package/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# bare-tui-updater
|
|
2
|
+
|
|
3
|
+
A drop-in "update available" status-line widget for [bare-tui](https://github.com/holepunchto/bare-tui) apps using [pear-runtime](https://docs.pears.com/)'s OTA updater.
|
|
4
|
+
|
|
5
|
+
Provides an unobtrusive one-line banner notification styled after Claude Code's CLI update flow: a user-controlled confirm gate before applying an update, visual progress during download/apply, and a restart prompt.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install bare-tui-updater
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { Program, quit, key, style } = require('bare-tui')
|
|
17
|
+
const updater = require('bare-tui-updater')
|
|
18
|
+
const { wire } = require('bare-tui-updater/pear')
|
|
19
|
+
|
|
20
|
+
const pear = new PearRuntime({ ... })
|
|
21
|
+
|
|
22
|
+
class App {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.upd = updater.create({
|
|
25
|
+
mode: 'confirm',
|
|
26
|
+
onAccept: async () => {
|
|
27
|
+
await pear.updater.applyUpdate()
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
update(msg) {
|
|
32
|
+
if (key.matches(msg, 'ctrl+c')) return [this, quit]
|
|
33
|
+
const [u, cmd] = this.upd.update(msg)
|
|
34
|
+
this.upd = u
|
|
35
|
+
return [this, cmd]
|
|
36
|
+
}
|
|
37
|
+
view() {
|
|
38
|
+
return style.joinVertical(style.position.left,
|
|
39
|
+
'My app...',
|
|
40
|
+
this.upd.view()
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const program = new Program(new App())
|
|
46
|
+
wire(this.upd, { updater: pear.updater, send: program.send.bind(program) })
|
|
47
|
+
program.run()
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
### `updater.create(opts)`
|
|
53
|
+
|
|
54
|
+
Returns a bare-tui component (`{ update(msg), view() }`).
|
|
55
|
+
|
|
56
|
+
**Options:**
|
|
57
|
+
|
|
58
|
+
- `mode` — `'confirm'` (default) | `'silent'` | `'notify-only'`
|
|
59
|
+
- **confirm**: download happens silently; once staged, a banner appears and the user presses a key to apply.
|
|
60
|
+
- **silent**: apply immediately on ready, no user interaction for apply (still shows "restart" prompt after apply).
|
|
61
|
+
- **notify-only**: user must call `applyUpdate()` themselves; widget only narrates the lifecycle.
|
|
62
|
+
- `onAccept` — `async () => void` (required for `'confirm'`/`'silent'` modes)
|
|
63
|
+
- Called when the user accepts an update (or immediately in `'silent'` mode). Should call `await pear.updater.applyUpdate()` (or equivalent).
|
|
64
|
+
- `acceptKey` — string, default `'u'` — the key to press to apply a ready update.
|
|
65
|
+
- `dismissible` — boolean, default `true` — whether `esc` can dismiss the banner.
|
|
66
|
+
- `autoDismissMs` — number, default `0` (never auto-dismiss) — hide the banner after N milliseconds in `applied`/`error` states.
|
|
67
|
+
- `showProgress` — boolean, default `true` — show a spinner during download.
|
|
68
|
+
- `width` — number, default `60` — max width of the banner (truncates with `…`).
|
|
69
|
+
- `theme` — object — style overrides (see [theme.js](./theme.js)).
|
|
70
|
+
- `copy` — object — text overrides for every message.
|
|
71
|
+
|
|
72
|
+
**Component API:**
|
|
73
|
+
|
|
74
|
+
- `update(msg)` — `(model, cmd)` — processes a Msg and returns the updated model + a Cmd.
|
|
75
|
+
- `view()` — `string` — renders to a single line or `''` (empty when idle).
|
|
76
|
+
- `focus()` / `blur()` — gates key handling.
|
|
77
|
+
|
|
78
|
+
### `wire(widget, { updater, send })`
|
|
79
|
+
|
|
80
|
+
Connects a widget to a pear-runtime `updater` instance.
|
|
81
|
+
|
|
82
|
+
- `updater` — an EventEmitter with `.on('updating'|'updating-delta'|'updated'|'error')` (i.e., `pear.updater`).
|
|
83
|
+
- `send` — function to inject Msgs into the bare-tui Program (i.e., `program.send.bind(program)`).
|
|
84
|
+
|
|
85
|
+
Returns a `detach()` function to unsubscribe.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const detach = wire(this.upd, { updater: pear.updater, send: program.send })
|
|
89
|
+
// later...
|
|
90
|
+
detach()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `mock.mock()`
|
|
94
|
+
|
|
95
|
+
Returns a fake updater for demos and tests. Useful because `pear-runtime-updater.applyUpdate()` silently no-ops outside a bundled build.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const { mock } = require('bare-tui-updater/mock')
|
|
99
|
+
const mockUpd = mock()
|
|
100
|
+
await mockUpd.download({ version: '1.2.3' })
|
|
101
|
+
await mockUpd.apply()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Message Types
|
|
105
|
+
|
|
106
|
+
The widget consumes these Msg types (produced by the `wire()` adapter or emitted manually):
|
|
107
|
+
|
|
108
|
+
| Msg | Meaning |
|
|
109
|
+
| ------------------------------------ | ---------------------------------------------- |
|
|
110
|
+
| `{ type: 'update.downloading' }` | Update download started. |
|
|
111
|
+
| `{ type: 'update.progress', delta }` | One Hyperdrive mirror diff (raw pass-through). |
|
|
112
|
+
| `{ type: 'update.ready', version }` | New build staged on disk, awaiting apply. |
|
|
113
|
+
| `{ type: 'update.error', error }` | Download/apply error. |
|
|
114
|
+
| `{ type: 'update.apply' }` | Internal: user pressed accept key. |
|
|
115
|
+
| `{ type: 'update.applied', runId }` | Internal: apply finished. |
|
|
116
|
+
| `{ type: 'update.dismiss' }` | User pressed escape. |
|
|
117
|
+
| `{ type: 'key' }` | Keyboard input (from bare-tui). |
|
|
118
|
+
|
|
119
|
+
## Layout Behavior
|
|
120
|
+
|
|
121
|
+
The widget's `view()` returns either `''` (when idle) or a 3-line bordered block. This is non-intrusive by default: plan your layout for a **3-row insertion/removal when an update is available, and 0 rows the rest of the time**.
|
|
122
|
+
|
|
123
|
+
**Critical:** `style.joinVertical` reserves one row per argument regardless of content — an empty string still becomes a blank padded line. To prevent a permanent blank row at idle, **filter out falsy/empty segments before joining**:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
view() {
|
|
127
|
+
return style.joinVertical(
|
|
128
|
+
style.position.left,
|
|
129
|
+
...[
|
|
130
|
+
this._header(), // fixed height
|
|
131
|
+
this._body(), // fixed height
|
|
132
|
+
this.upd.view(), // 3 lines (active) or '' (idle)
|
|
133
|
+
this._footer() // fixed height
|
|
134
|
+
].filter(Boolean) // Drop empty strings so idle = 0 rows
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Without `.filter(Boolean)`, the widget reserves a permanent blank line in the layout — not intrusive once you know it's there, but not the zero-rows-at-idle behavior. With the filter, the update notification inserts/removes a 3-line block cleanly as needed.
|
|
140
|
+
|
|
141
|
+
## Restart Handling
|
|
142
|
+
|
|
143
|
+
**The widget does not restart the process automatically.** After applying an update, it displays "restart to use it" and the user/shell is responsible for relaunching the CLI.
|
|
144
|
+
|
|
145
|
+
This is intentional: there is no cross-platform restart primitive in `pear-runtime`, and safe restart logic depends heavily on whether the app is a standalone `bare-build` binary (re-exec `Bare.argv[0]`) or running from source (`bare bin.js`, cannot safely restart itself). The decision is yours.
|
|
146
|
+
|
|
147
|
+
A simple re-exec pattern (for standalone binaries):
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const { spawn } = require('bare-subprocess')
|
|
151
|
+
|
|
152
|
+
async function restart() {
|
|
153
|
+
const child = spawn(Bare.argv[0], Bare.argv.slice(1), {
|
|
154
|
+
detached: true,
|
|
155
|
+
stdio: 'inherit'
|
|
156
|
+
})
|
|
157
|
+
child.unref()
|
|
158
|
+
await new Promise((r) => setImmediate(r))
|
|
159
|
+
Bare.exit(0)
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Wire this into your app's menu or call it after `onAccept` if you want automatic restart. See [examples/pear.js](./examples/pear.js) for a complete integration.
|
|
164
|
+
|
|
165
|
+
## Examples
|
|
166
|
+
|
|
167
|
+
- [minimal.js](./examples/minimal.js) — Smallest runnable demo with the mock updater.
|
|
168
|
+
- [pear.js](./examples/pear.js) — Full integration with a real `pear-runtime` instance.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
Apache-2.0
|
package/index.js
ADDED
package/mock.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const { EventEmitter } = require('bare-events')
|
|
2
|
+
|
|
3
|
+
function mock() {
|
|
4
|
+
const emitter = new EventEmitter()
|
|
5
|
+
|
|
6
|
+
emitter.nextVersion = null
|
|
7
|
+
emitter.updated = false
|
|
8
|
+
emitter.updating = false
|
|
9
|
+
emitter.bundled = true
|
|
10
|
+
|
|
11
|
+
emitter.download = async (opts = {}) => {
|
|
12
|
+
if (emitter.updated) return emitter
|
|
13
|
+
emitter.updating = true
|
|
14
|
+
emitter.emit('updating')
|
|
15
|
+
|
|
16
|
+
const delayMs = opts.delayMs || 1000
|
|
17
|
+
const deltaCount = opts.deltaCount || 3
|
|
18
|
+
const deltaDelayMs = delayMs / deltaCount
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < deltaCount; i++) {
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, deltaDelayMs))
|
|
22
|
+
emitter.emit('updating-delta', { op: 'add', bytesAdded: 1024 * 100 })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
emitter.updating = false
|
|
26
|
+
emitter.updated = true
|
|
27
|
+
emitter.nextVersion = opts.version || '1.2.3'
|
|
28
|
+
emitter.emit('updated')
|
|
29
|
+
return emitter
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
emitter.apply = (opts = {}) => {
|
|
33
|
+
if (!emitter.updated) throw new Error('No update to apply')
|
|
34
|
+
if (opts.shouldFail) throw new Error('Mock applyUpdate failed')
|
|
35
|
+
emitter.applied = true
|
|
36
|
+
return Promise.resolve(emitter)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
emitter.error = (error) => {
|
|
40
|
+
emitter.updating = false
|
|
41
|
+
emitter.updated = false
|
|
42
|
+
emitter.emit('error', error || new Error('Mock updater error'))
|
|
43
|
+
return Promise.resolve(emitter)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
emitter.reset = () => {
|
|
47
|
+
emitter.nextVersion = null
|
|
48
|
+
emitter.updated = false
|
|
49
|
+
emitter.updating = false
|
|
50
|
+
emitter.applied = false
|
|
51
|
+
return emitter
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return emitter
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { mock }
|
package/package.json
CHANGED
|
@@ -1 +1,49 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "bare-tui-updater",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A drop-in 'update available' banner widget for bare-tui apps using pear-runtime's OTA updater",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./package": "./package.json",
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./pear": "./pear.js",
|
|
10
|
+
"./mock": "./mock.js",
|
|
11
|
+
"./theme": "./theme.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"package.json",
|
|
15
|
+
"index.js",
|
|
16
|
+
"updater.js",
|
|
17
|
+
"theme.js",
|
|
18
|
+
"pear.js",
|
|
19
|
+
"mock.js",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"bare-tui": "^0.0.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"bare-fs": "^4.7.1",
|
|
27
|
+
"bare-path": "^3.0.0",
|
|
28
|
+
"bare-stream": "^2.13.1",
|
|
29
|
+
"brittle": "^3.19.0",
|
|
30
|
+
"lunte": "^1.2.0",
|
|
31
|
+
"prettier": "^3.6.2",
|
|
32
|
+
"prettier-config-holepunch": "^2.0.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"format": "prettier --write . && lunte --fix",
|
|
36
|
+
"lint": "prettier --check . && lunte",
|
|
37
|
+
"test": "brittle-bare --coverage test/index.js"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/holepunchto/bare-tui-updater.git"
|
|
42
|
+
},
|
|
43
|
+
"author": "Holepunch",
|
|
44
|
+
"license": "Apache-2.0",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/holepunchto/bare-tui-updater/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/holepunchto/bare-tui-updater"
|
|
49
|
+
}
|
package/pear.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function wire(widget, { updater, send }) {
|
|
2
|
+
if (typeof updater?.on !== 'function') {
|
|
3
|
+
throw new TypeError('bare-tui-updater/pear: updater must have an .on() method')
|
|
4
|
+
}
|
|
5
|
+
if (typeof send !== 'function') {
|
|
6
|
+
throw new TypeError('bare-tui-updater/pear: send must be a function')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const onUpdating = () => send({ type: 'update.downloading' })
|
|
10
|
+
const onDelta = (delta) => send({ type: 'update.progress', delta })
|
|
11
|
+
const onUpdated = () => send({ type: 'update.ready', version: updater.nextVersion })
|
|
12
|
+
const onError = (error) => send({ type: 'update.error', error })
|
|
13
|
+
|
|
14
|
+
updater.on('updating', onUpdating)
|
|
15
|
+
updater.on('updating-delta', onDelta)
|
|
16
|
+
updater.on('updated', onUpdated)
|
|
17
|
+
updater.on('error', onError)
|
|
18
|
+
|
|
19
|
+
if (updater.updated) onUpdated()
|
|
20
|
+
else if (updater.updating) onUpdating()
|
|
21
|
+
|
|
22
|
+
return function () {
|
|
23
|
+
updater.off('updating', onUpdating)
|
|
24
|
+
updater.off('updating-delta', onDelta)
|
|
25
|
+
updater.off('updated', onUpdated)
|
|
26
|
+
updater.off('error', onError)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { wire }
|
package/theme.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { style } = require('bare-tui')
|
|
2
|
+
|
|
3
|
+
const defaultTheme = {
|
|
4
|
+
accent: (s) => style().foreground('cyan').render(s),
|
|
5
|
+
success: (s) => style().foreground('green').render(s),
|
|
6
|
+
error: (s) => style().foreground('red').render(s),
|
|
7
|
+
hint: (s) => style().faint(true).render(s),
|
|
8
|
+
border: style.borders.rounded
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function merge(user) {
|
|
12
|
+
return { ...defaultTheme, ...(user || {}) }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { defaultTheme, merge }
|
package/updater.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
const { style } = require('bare-tui')
|
|
2
|
+
const spinner = require('bare-tui').spinner
|
|
3
|
+
const theme = require('./theme')
|
|
4
|
+
|
|
5
|
+
let nextId = 1
|
|
6
|
+
|
|
7
|
+
class Updater {
|
|
8
|
+
constructor(opts = {}) {
|
|
9
|
+
this.id = nextId++
|
|
10
|
+
this.tag = 0
|
|
11
|
+
this.width = opts.width || 60
|
|
12
|
+
this.themeOverrides = theme.merge(opts.theme)
|
|
13
|
+
this.onAccept = opts.onAccept || null
|
|
14
|
+
this.autoDismissMs = opts.autoDismissMs ?? 0
|
|
15
|
+
this.acceptKey = opts.acceptKey || 'u'
|
|
16
|
+
this.focused = opts.focused !== false
|
|
17
|
+
this.mode = opts.mode || 'confirm'
|
|
18
|
+
this.showProgress = opts.showProgress !== false
|
|
19
|
+
this.dismissible = opts.dismissible !== false
|
|
20
|
+
this.copy = { ...defaultCopy, ...(opts.copy || {}) }
|
|
21
|
+
|
|
22
|
+
this.state = 'idle'
|
|
23
|
+
this.version = null
|
|
24
|
+
this.error = null
|
|
25
|
+
this.progressCount = 0
|
|
26
|
+
this.progressBytesAdded = 0
|
|
27
|
+
this.spinner = spinner.create({ fps: 8 })
|
|
28
|
+
|
|
29
|
+
if (this.mode === 'silent' && this.onAccept) {
|
|
30
|
+
this._applyTimer = null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
focus() {
|
|
35
|
+
this.focused = true
|
|
36
|
+
return this
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
blur() {
|
|
40
|
+
this.focused = false
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
visible() {
|
|
45
|
+
return this.state !== 'idle'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
update(msg) {
|
|
49
|
+
if (!msg) return [this, null]
|
|
50
|
+
|
|
51
|
+
switch (msg.type) {
|
|
52
|
+
case 'update.downloading':
|
|
53
|
+
if (this.state === 'applied' || this.state === 'applying') return [this, null]
|
|
54
|
+
this.state = 'downloading'
|
|
55
|
+
this.progressCount = 0
|
|
56
|
+
this.progressBytesAdded = 0
|
|
57
|
+
this.tag++
|
|
58
|
+
return [this, this.spinner.init()]
|
|
59
|
+
|
|
60
|
+
case 'update.progress':
|
|
61
|
+
if (this.state !== 'downloading') return [this, null]
|
|
62
|
+
if (msg.delta) {
|
|
63
|
+
this.progressCount++
|
|
64
|
+
if (msg.delta.bytesAdded) this.progressBytesAdded += msg.delta.bytesAdded
|
|
65
|
+
}
|
|
66
|
+
return [this, null]
|
|
67
|
+
|
|
68
|
+
case 'update.ready':
|
|
69
|
+
if (this.state === 'applied' || this.state === 'applying') return [this, null]
|
|
70
|
+
this.state = 'ready'
|
|
71
|
+
this.version = msg.version || this.version
|
|
72
|
+
this.tag++
|
|
73
|
+
if (this.mode === 'silent' && this.onAccept) {
|
|
74
|
+
return [this, this._acceptCmd()]
|
|
75
|
+
}
|
|
76
|
+
return [this, this._autoDismissCmd()]
|
|
77
|
+
|
|
78
|
+
case 'update.apply':
|
|
79
|
+
if (this.state !== 'ready') return [this, null]
|
|
80
|
+
this.state = 'applying'
|
|
81
|
+
this.tag++
|
|
82
|
+
return [this, this._acceptCmd()]
|
|
83
|
+
|
|
84
|
+
case 'update.applied':
|
|
85
|
+
if (msg.runId !== this.runId) return [this, null]
|
|
86
|
+
this.state = 'applied'
|
|
87
|
+
return [this, this._autoDismissCmd()]
|
|
88
|
+
|
|
89
|
+
case 'update.error':
|
|
90
|
+
if (this.state === 'applied') return [this, null]
|
|
91
|
+
this.state = 'error'
|
|
92
|
+
this.error = msg.error
|
|
93
|
+
this.tag++
|
|
94
|
+
return [this, this._autoDismissCmd()]
|
|
95
|
+
|
|
96
|
+
case 'update.dismiss':
|
|
97
|
+
if (!this.dismissible) return [this, null]
|
|
98
|
+
this.state = 'idle'
|
|
99
|
+
this.tag++
|
|
100
|
+
return [this, null]
|
|
101
|
+
|
|
102
|
+
case 'update.hide':
|
|
103
|
+
if (msg.id === this.id && msg.tag === this.tag) this.state = 'idle'
|
|
104
|
+
return [this, null]
|
|
105
|
+
|
|
106
|
+
case 'spinner.tick': {
|
|
107
|
+
if (this.state !== 'downloading' && this.state !== 'applying') return [this, null]
|
|
108
|
+
const [s, cmd] = this.spinner.update(msg)
|
|
109
|
+
this.spinner = s
|
|
110
|
+
return [this, cmd]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
case 'key':
|
|
114
|
+
return this._onKey(msg)
|
|
115
|
+
|
|
116
|
+
default:
|
|
117
|
+
return [this, null]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_onKey(msg) {
|
|
122
|
+
if (!this.focused) return [this, null]
|
|
123
|
+
|
|
124
|
+
// Check if msg is a KeyMsg-like object or check the chord directly
|
|
125
|
+
const matches = (chord) => {
|
|
126
|
+
if (typeof msg.is === 'function') return msg.is(chord)
|
|
127
|
+
if (msg.chord === chord) return true
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.state === 'ready' && (matches(this.acceptKey) || matches('enter'))) {
|
|
132
|
+
this.state = 'applying'
|
|
133
|
+
this.tag++
|
|
134
|
+
return [this, this._acceptCmd()]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
this.dismissible &&
|
|
139
|
+
(this.state === 'ready' || this.state === 'applied' || this.state === 'error') &&
|
|
140
|
+
matches('esc')
|
|
141
|
+
) {
|
|
142
|
+
this.state = 'idle'
|
|
143
|
+
this.tag++
|
|
144
|
+
return [this, null]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [this, null]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_acceptCmd() {
|
|
151
|
+
if (!this.onAccept) {
|
|
152
|
+
return () => ({ type: 'update.apply' })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const runId = (this.runId = {})
|
|
156
|
+
return () =>
|
|
157
|
+
Promise.resolve(this.onAccept())
|
|
158
|
+
.then(() => ({ type: 'update.applied', runId }))
|
|
159
|
+
.catch((error) => ({ type: 'update.error', error, runId }))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_autoDismissCmd() {
|
|
163
|
+
if (!this.autoDismissMs) return null
|
|
164
|
+
const id = this.id
|
|
165
|
+
const tag = ++this.tag
|
|
166
|
+
const ms = this.autoDismissMs
|
|
167
|
+
return () =>
|
|
168
|
+
new Promise((resolve) => setTimeout(() => resolve({ type: 'update.hide', id, tag }), ms))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
view() {
|
|
172
|
+
const t = this.themeOverrides
|
|
173
|
+
let text
|
|
174
|
+
|
|
175
|
+
switch (this.state) {
|
|
176
|
+
case 'idle':
|
|
177
|
+
case 'disabled':
|
|
178
|
+
return ''
|
|
179
|
+
|
|
180
|
+
case 'downloading':
|
|
181
|
+
text =
|
|
182
|
+
(this.showProgress ? t.accent(this.spinner.view()) + ' ' : '') +
|
|
183
|
+
'Downloading update' +
|
|
184
|
+
(this.progressCount > 0
|
|
185
|
+
? ` (${this.progressCount} file${this.progressCount === 1 ? '' : 's'})`
|
|
186
|
+
: '…')
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
case 'ready':
|
|
190
|
+
text =
|
|
191
|
+
t.success('✓') +
|
|
192
|
+
' Update' +
|
|
193
|
+
(this.version ? ` v${this.version}` : '') +
|
|
194
|
+
' ready — ' +
|
|
195
|
+
t.hint(`press ${this.acceptKey} to update, esc to dismiss`)
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
case 'applying':
|
|
199
|
+
text =
|
|
200
|
+
t.accent(this.spinner.view()) +
|
|
201
|
+
' Applying update' +
|
|
202
|
+
(this.version ? ` v${this.version}` : '') +
|
|
203
|
+
'…'
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
case 'applied':
|
|
207
|
+
text =
|
|
208
|
+
t.success('✓') +
|
|
209
|
+
' Updated' +
|
|
210
|
+
(this.version ? ` to v${this.version}` : '') +
|
|
211
|
+
' — restart to use it'
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
case 'error':
|
|
215
|
+
text =
|
|
216
|
+
t.error('✗') +
|
|
217
|
+
' Update failed' +
|
|
218
|
+
(this.error?.message ? `: ${this.error.message}` : this.error ? `: ${this.error}` : '')
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
default:
|
|
222
|
+
return ''
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return style()
|
|
226
|
+
.border(t.border)
|
|
227
|
+
.borderForeground('cyan')
|
|
228
|
+
.padding(0, 1)
|
|
229
|
+
.width(Math.max(20, this.width))
|
|
230
|
+
.render(style.truncate(text, Math.max(20, this.width - 4)))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const defaultCopy = {
|
|
235
|
+
checking: 'Checking for updates…',
|
|
236
|
+
downloading: 'Downloading update',
|
|
237
|
+
ready: 'Update ready',
|
|
238
|
+
applying: 'Applying update',
|
|
239
|
+
applied: 'Updated',
|
|
240
|
+
error: 'Update failed'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function create(opts) {
|
|
244
|
+
return new Updater(opts)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = { create, Updater, defaultCopy }
|