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 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
@@ -0,0 +1,10 @@
1
+ const { create, Updater } = require('./updater')
2
+ const { defaultCopy } = require('./updater')
3
+ const theme = require('./theme')
4
+
5
+ module.exports = {
6
+ create,
7
+ Updater,
8
+ theme,
9
+ defaultCopy
10
+ }
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
- {"name":"bare-tui-updater","version":"0.0.0"}
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 }