Skip to content

Commit eab3272

Browse files
committed
feat: show version in help overlay
Inject version at build time via globalThis.__remobiVersion, display as muted footer in the help overlay. Dev builds (from source checkout) show git hash suffix e.g. v0.2.7-dev+abc1234, npm installs show clean version. Detection uses src/index.ts presence (not published to npm) to distinguish source checkout from npm install, avoiding false positives from user repos.
1 parent 101d7f7 commit eab3272

9 files changed

Lines changed: 132 additions & 26 deletions

File tree

build.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ function findProjectRoot(): string {
1717
const PROJECT_ROOT = findProjectRoot()
1818

1919
/** Bundle the overlay JS + CSS into strings */
20-
export async function bundleOverlay(config: RemobiConfig): Promise<{ js: string; css: string }> {
20+
export async function bundleOverlay(
21+
config: RemobiConfig,
22+
version: string,
23+
): Promise<{ js: string; css: string }> {
2124
// Read CSS
2225
const cssPath = resolve(PROJECT_ROOT, 'styles/base.css')
2326
const css = readFileSync(cssPath, 'utf-8')
@@ -26,19 +29,21 @@ export async function bundleOverlay(config: RemobiConfig): Promise<{ js: string;
2629
const prebuiltPath = resolve(PROJECT_ROOT, 'dist/overlay.iife.js')
2730
if (existsSync(prebuiltPath)) {
2831
const overlayJs = readFileSync(prebuiltPath, 'utf-8')
29-
const js = `globalThis.__remobiConfig=${JSON.stringify(config)};${overlayJs}`
32+
const js = `globalThis.__remobiVersion=${JSON.stringify(version)};globalThis.__remobiConfig=${JSON.stringify(config)};${overlayJs}`
3033
return { js, css }
3134
}
3235

3336
// Dev fallback: bundle from source via esbuild (requires src/ and esbuild)
3437
const esbuild = await import('esbuild')
3538

3639
const configJson = JSON.stringify(config)
40+
const versionJson = JSON.stringify(version)
3741
const entryCode = `
3842
import { init, createHookRegistry } from './src/index.ts'
3943
const hooks = createHookRegistry()
4044
const config = ${configJson}
41-
;(function() { init(config, hooks) })()
45+
const version = ${versionJson}
46+
;(function() { init(config, hooks, version) })()
4247
`
4348

4449
const tmpEntry = resolve(PROJECT_ROOT, '.tmp-entry.ts')
@@ -132,16 +137,20 @@ export function injectOverlay(html: string, js: string, css: string, config: Rem
132137
}
133138

134139
/** Full build pipeline: bundle → fetch ttyd HTML → inject → write output */
135-
export async function build(config: RemobiConfig, outputPath: string): Promise<void> {
136-
const { js, css } = await bundleOverlay(config)
140+
export async function build(
141+
config: RemobiConfig,
142+
outputPath: string,
143+
version: string,
144+
): Promise<void> {
145+
const { js, css } = await bundleOverlay(config, version)
137146
const baseHtml = await fetchTtydHtml()
138147
const patched = injectOverlay(baseHtml, js, css, config)
139148
writeFileSync(outputPath, patched)
140149
}
141150

142151
/** Build from stdin HTML (pipe mode) */
143-
export async function injectFromStdin(config: RemobiConfig): Promise<string> {
144-
const { js, css } = await bundleOverlay(config)
152+
export async function injectFromStdin(config: RemobiConfig, version: string): Promise<string> {
153+
const { js, css } = await bundleOverlay(config, version)
145154
const stdin = await readStdin()
146155
if (stdin.trim().length === 0) {
147156
throw new Error('remobi inject expects piped ttyd HTML on stdin')

cli.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
import { execSync } from 'node:child_process'
23
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
34
import { homedir } from 'node:os'
45
import { dirname, join, resolve } from 'node:path'
@@ -14,22 +15,45 @@ import { serve } from './src/serve'
1415
import type { RemobiConfig, RemobiConfigOverrides } from './src/types'
1516
import { readStdin } from './src/util/node-compat'
1617

17-
// Walk up from module location to find package.json — works from both source and dist/
18-
function loadPackageVersion(): string {
18+
// Walk up from module location to find project root — works from both source and dist/
19+
function findProjectRoot(): string {
1920
let dir = import.meta.dirname
2021
for (let i = 0; i < 5; i++) {
21-
try {
22-
const content = readFileSync(resolve(dir, 'package.json'), 'utf-8')
23-
// oxlint-disable-next-line typescript/consistent-type-assertions -- JSON.parse returns unknown
24-
return (JSON.parse(content) as { version: string }).version
25-
} catch {
26-
dir = dirname(dir)
27-
}
22+
if (existsSync(resolve(dir, 'package.json'))) return dir
23+
dir = dirname(dir)
24+
}
25+
return import.meta.dirname
26+
}
27+
28+
function loadPackageVersion(root: string): string {
29+
try {
30+
const content = readFileSync(resolve(root, 'package.json'), 'utf-8')
31+
// oxlint-disable-next-line typescript/consistent-type-assertions -- JSON.parse returns unknown
32+
return (JSON.parse(content) as { version: string }).version
33+
} catch {
34+
return '0.0.0'
35+
}
36+
}
37+
38+
// Source checkout has src/index.ts (not published to npm per files array in package.json).
39+
// For dev builds, append git short hash so local vs npm is obvious.
40+
function resolveVersion(): string {
41+
const root = findProjectRoot()
42+
const pkgVersion = loadPackageVersion(root)
43+
if (!existsSync(resolve(root, 'src/index.ts'))) return pkgVersion
44+
try {
45+
const hash = execSync('git rev-parse --short HEAD', {
46+
cwd: root,
47+
encoding: 'utf-8',
48+
stdio: ['ignore', 'pipe', 'ignore'],
49+
}).trim()
50+
return `${pkgVersion}-dev+${hash}`
51+
} catch {
52+
return pkgVersion
2853
}
29-
return '0.0.0'
3054
}
3155

32-
const VERSION: string = loadPackageVersion()
56+
const VERSION: string = resolveVersion()
3357

3458
function usage(): void {
3559
console.log(`remobi v${VERSION} — mobile-friendly terminal overlay for ttyd + tmux
@@ -212,7 +236,14 @@ async function main(): Promise<void> {
212236
switch (command) {
213237
case 'serve': {
214238
const loaded = await loadConfig(configPath)
215-
await serve(loaded.config, port, command_.length > 0 ? command_ : undefined, noSleep, host)
239+
await serve(
240+
loaded.config,
241+
port,
242+
command_.length > 0 ? command_ : undefined,
243+
noSleep,
244+
host,
245+
VERSION,
246+
)
216247
break
217248
}
218249

@@ -233,7 +264,7 @@ async function main(): Promise<void> {
233264
// Ensure output directory exists
234265
mkdirSync(dirname(targetPath), { recursive: true })
235266

236-
await build(loaded.config, targetPath)
267+
await build(loaded.config, targetPath, VERSION)
237268
console.log(`Built: ${targetPath}`)
238269
break
239270
}
@@ -254,7 +285,7 @@ async function main(): Promise<void> {
254285
}
255286

256287
ensureInjectInputMode('remobi inject')
257-
const result = await injectFromStdin(loaded.config)
288+
const result = await injectFromStdin(loaded.config, VERSION)
258289
process.stdout.write(result)
259290
break
260291
}

src/controls/help.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function renderGestures(config: RemobiConfig): DocumentFragment {
5555
}
5656

5757
/** Build the help overlay content as a DocumentFragment — no innerHTML */
58-
function buildHelpContent(config: RemobiConfig): DocumentFragment {
58+
function buildHelpContent(config: RemobiConfig, version?: string): DocumentFragment {
5959
const topRightButtons: readonly ControlButton[] = [
6060
{
6161
id: 'font-size',
@@ -93,6 +93,10 @@ function buildHelpContent(config: RemobiConfig): DocumentFragment {
9393
}
9494
}
9595

96+
if (version !== undefined) {
97+
frag.appendChild(el('p', { class: 'wt-help-version' }, `remobi v${version}`))
98+
}
99+
96100
return frag
97101
}
98102

@@ -107,9 +111,10 @@ export function createHelpOverlay(
107111
term: XTerminal,
108112
helpButton: HTMLButtonElement,
109113
config: RemobiConfig,
114+
version?: string,
110115
): HelpOverlayResult {
111116
const overlay = el('div', { id: 'wt-help' })
112-
overlay.appendChild(buildHelpContent(config))
117+
overlay.appendChild(buildHelpContent(config, version))
113118

114119
function open(): void {
115120
overlay.style.display = 'block'

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function isMobile(): boolean {
4949
export function init(
5050
config: RemobiConfig = defaultConfig,
5151
hooks: HookRegistry = createHookRegistry(),
52+
version?: string,
5253
): void {
5354
void waitForTerm()
5455
.then(async (term) => {
@@ -195,7 +196,7 @@ export function init(
195196

196197
// Help overlay should never break core controls.
197198
try {
198-
const { element: helpOverlay } = createHelpOverlay(term, helpButton, config)
199+
const { element: helpOverlay } = createHelpOverlay(term, helpButton, config, version)
199200
document.body.appendChild(helpOverlay)
200201
} catch (error) {
201202
console.error('remobi: failed to initialise help overlay', error)

src/overlay-entry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createHookRegistry, init } from './index'
22
import type { RemobiConfig } from './types'
33

44
declare const __remobiConfig: RemobiConfig
5+
declare const __remobiVersion: string | undefined
56
const config = __remobiConfig
7+
const version = typeof __remobiVersion !== 'undefined' ? __remobiVersion : undefined
68
const hooks = createHookRegistry()
7-
init(config, hooks)
9+
init(config, hooks, version)

src/serve.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,10 @@ export async function serve(
199199
command: readonly string[] = DEFAULT_COMMAND,
200200
noSleep = false,
201201
host: string = DEFAULT_HOST,
202+
version = 'unknown',
202203
): Promise<void> {
203204
console.log('remobi: building overlay...')
204-
const { js, css } = await bundleOverlay(config)
205+
const { js, css } = await bundleOverlay(config, version)
205206

206207
const internalPort = randomInternalPort()
207208
const ttydArgs = buildTtydArgs(config, internalPort, command)

styles/base.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ body {
247247
width: 30%;
248248
}
249249

250+
#wt-help .wt-help-version {
251+
text-align: center;
252+
color: #6c7086;
253+
font-size: 12px;
254+
margin: 16px 0 0;
255+
}
256+
250257
#wt-help .wt-help-close {
251258
position: fixed;
252259
top: 12px;

tests/integration.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,34 @@ describe('help overlay integration', () => {
150150
expect(element.innerHTML).toContain('Gestures')
151151
})
152152

153+
test('shows version when provided', () => {
154+
const term = mockTerminal()
155+
const { helpButton } = createFontControls(term, defaultConfig.font)
156+
const { element } = createHelpOverlay(term, helpButton, defaultConfig, '1.2.3')
157+
158+
document.body.appendChild(element)
159+
160+
const versionEl = element.querySelector('.wt-help-version')
161+
expect(versionEl).not.toBeNull()
162+
expect(versionEl?.textContent).toBe('remobi v1.2.3')
163+
})
164+
165+
test('shows dev version with hash', () => {
166+
const term = mockTerminal()
167+
const { helpButton } = createFontControls(term, defaultConfig.font)
168+
const { element } = createHelpOverlay(term, helpButton, defaultConfig, '0.2.6-dev+abc1234')
169+
170+
expect(element.innerHTML).toContain('remobi v0.2.6-dev+abc1234')
171+
})
172+
173+
test('omits version when not provided', () => {
174+
const term = mockTerminal()
175+
const { helpButton } = createFontControls(term, defaultConfig.font)
176+
const { element } = createHelpOverlay(term, helpButton, defaultConfig)
177+
178+
expect(element.querySelector('.wt-help-version')).toBeNull()
179+
})
180+
153181
test('renders configured button descriptions and no stale Claude section', () => {
154182
const term = mockTerminal()
155183
const { helpButton } = createFontControls(term, defaultConfig.font)

tests/playwright/smoke.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,26 @@ test('remobi inject pipes ttyd HTML and produces patched output', async ({ reque
2525

2626
expect(stdout).toContain('<!DOCTYPE html>')
2727
expect(stdout.indexOf('</head>')).toBeGreaterThan(-1)
28+
expect(stdout).toContain('__remobiVersion')
29+
})
30+
31+
test('help overlay shows version', async ({ page }) => {
32+
await page.goto('/')
33+
await page.waitForSelector('#wt-toolbar', { timeout: 10_000 })
34+
35+
// Open help via touchend (same pattern as touch.spec.ts — tap() can miss on mobile viewports)
36+
const helpBtn = page.locator('#wt-font-controls button', { hasText: '?' })
37+
await expect(helpBtn).toBeVisible()
38+
await helpBtn.dispatchEvent('touchend', {
39+
touches: [],
40+
changedTouches: [],
41+
targetTouches: [],
42+
})
43+
44+
const overlay = page.locator('#wt-help')
45+
await expect(overlay).toBeVisible()
46+
47+
const versionEl = page.locator('#wt-help .wt-help-version')
48+
await expect(versionEl).toBeVisible()
49+
await expect(versionEl).toContainText(/remobi v\d+\.\d+\.\d+/)
2850
})

0 commit comments

Comments
 (0)