Skip to content

Commit dcb7b9d

Browse files
CharGrnmnclaude
andcommitted
feat: add @geometra/cli — view any Geometra site in the terminal
New CLI package provides the `geometra` command: geometra https://artemis-two.razroo.com/ geometra ws://localhost:8080/geometra-ws Connects to the Geometra WebSocket, receives layout+tree frames, and renders them using @geometra/renderer-terminal with Unicode box-drawing characters and ANSI 256-color output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b848f7 commit dcb7b9d

17 files changed

Lines changed: 253 additions & 22 deletions

File tree

package-lock.json

Lines changed: 50 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@geometra/cli",
3+
"version": "1.13.0",
4+
"description": "CLI to view any Geometra-powered site in the terminal",
5+
"license": "MIT",
6+
"type": "module",
7+
"repository": {
8+
"type": "git",
9+
"url": "https://github.com/razroo/geometra",
10+
"directory": "packages/cli"
11+
},
12+
"homepage": "https://razroo.github.io/geometra",
13+
"keywords": [
14+
"geometra",
15+
"cli",
16+
"terminal",
17+
"tui"
18+
],
19+
"bin": {
20+
"geometra": "./dist/bin.js"
21+
},
22+
"main": "./dist/index.js",
23+
"types": "./dist/index.d.ts",
24+
"exports": {
25+
".": {
26+
"types": "./dist/index.d.ts",
27+
"import": "./dist/index.js"
28+
}
29+
},
30+
"files": [
31+
"dist"
32+
],
33+
"scripts": {
34+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
35+
"check": "tsc --noEmit"
36+
},
37+
"dependencies": {
38+
"@geometra/core": "^1.12.0",
39+
"@geometra/renderer-terminal": "^1.12.0",
40+
"textura": "^1.11.0",
41+
"ws": "^8.18.0"
42+
},
43+
"devDependencies": {
44+
"@types/node": "^22.0.0",
45+
"@types/ws": "^8.5.0"
46+
}
47+
}

packages/cli/src/bin.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env node
2+
import { viewInTerminal } from './viewer.js'
3+
4+
const url = process.argv[2]
5+
6+
if (!url) {
7+
console.error('Usage: geometra <url>')
8+
console.error('')
9+
console.error(' View any Geometra-powered site in the terminal.')
10+
console.error('')
11+
console.error('Examples:')
12+
console.error(' geometra https://artemis-two.razroo.com/')
13+
console.error(' geometra http://localhost:5173/')
14+
console.error(' geometra ws://localhost:8080/geometra-ws')
15+
process.exit(1)
16+
}
17+
18+
// Resolve WebSocket URL from input
19+
let wsUrl: string
20+
21+
if (url.startsWith('ws://') || url.startsWith('wss://')) {
22+
// Direct WebSocket URL
23+
wsUrl = url
24+
} else {
25+
// HTTP(S) URL — derive WebSocket endpoint
26+
const parsed = new URL(url)
27+
const wsProto = parsed.protocol === 'https:' ? 'wss:' : 'ws:'
28+
// Default Geometra WS path
29+
wsUrl = `${wsProto}//${parsed.host}/geometra-ws`
30+
}
31+
32+
console.error(`Connecting to ${wsUrl}...`)
33+
34+
const viewer = viewInTerminal({ url: wsUrl })
35+
36+
// Clean exit on Ctrl+C
37+
process.on('SIGINT', () => {
38+
viewer.close()
39+
process.exit(0)
40+
})
41+
42+
process.on('SIGTERM', () => {
43+
viewer.close()
44+
process.exit(0)
45+
})

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { viewInTerminal } from './viewer.js'

packages/cli/src/viewer.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { TerminalRenderer } from '@geometra/renderer-terminal'
2+
import type { ComputedLayout } from 'textura'
3+
import type { UIElement } from '@geometra/core'
4+
import WebSocket from 'ws'
5+
6+
interface ServerFrame {
7+
type: 'frame'
8+
layout: ComputedLayout
9+
tree: UIElement
10+
}
11+
12+
interface ServerPatch {
13+
type: 'patch'
14+
patches: Array<{ path: number[]; x?: number; y?: number; width?: number; height?: number }>
15+
}
16+
17+
type ServerMessage = ServerFrame | ServerPatch | { type: string }
18+
19+
function applyPatches(layout: ComputedLayout, patches: ServerPatch['patches']): void {
20+
for (const patch of patches) {
21+
let node: ComputedLayout = layout
22+
for (const idx of patch.path) {
23+
node = node.children[idx]!
24+
}
25+
if (patch.x !== undefined) node.x = patch.x
26+
if (patch.y !== undefined) node.y = patch.y
27+
if (patch.width !== undefined) node.width = patch.width
28+
if (patch.height !== undefined) node.height = patch.height
29+
}
30+
}
31+
32+
export interface ViewOptions {
33+
/** WebSocket URL to connect to. */
34+
url: string
35+
/** Terminal columns (default: stdout columns). */
36+
width?: number
37+
/** Terminal rows (default: stdout rows). */
38+
height?: number
39+
}
40+
41+
/**
42+
* Connect to a Geometra server WebSocket and render in the terminal.
43+
* Returns a cleanup function to close the connection.
44+
*/
45+
export function viewInTerminal(options: ViewOptions): { close: () => void } {
46+
const renderer = new TerminalRenderer({
47+
width: options.width,
48+
height: options.height,
49+
})
50+
51+
let layout: ComputedLayout | null = null
52+
let tree: UIElement | null = null
53+
54+
const ws = new WebSocket(options.url)
55+
56+
ws.on('message', (data) => {
57+
try {
58+
const msg: ServerMessage = JSON.parse(data.toString())
59+
60+
if (msg.type === 'frame') {
61+
const frame = msg as ServerFrame
62+
layout = frame.layout
63+
tree = frame.tree
64+
renderer.render(layout, tree)
65+
} else if (msg.type === 'patch' && layout && tree) {
66+
applyPatches(layout, (msg as ServerPatch).patches)
67+
renderer.render(layout, tree)
68+
}
69+
} catch {
70+
// ignore parse errors
71+
}
72+
})
73+
74+
ws.on('error', (err) => {
75+
process.stderr.write(`\x1b[31mConnection error: ${err.message}\x1b[0m\n`)
76+
})
77+
78+
ws.on('close', () => {
79+
renderer.destroy()
80+
process.stderr.write('Disconnected.\n')
81+
})
82+
83+
return {
84+
close() {
85+
ws.close()
86+
renderer.destroy()
87+
},
88+
}
89+
}

packages/cli/tsconfig.build.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src",
6+
"types": ["node"]
7+
},
8+
"include": ["src"],
9+
"exclude": ["src/**/__tests__/**"]
10+
}

packages/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@geometra/client",
3-
"version": "1.12.0",
3+
"version": "1.13.0",
44
"description": "Thin client that receives server-computed geometry and paints via renderer",
55
"license": "MIT",
66
"type": "module",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@geometra/core",
3-
"version": "1.12.0",
3+
"version": "1.13.0",
44
"description": "DOM-free UI framework core: components, signals, reconciler",
55
"license": "MIT",
66
"type": "module",

packages/renderer-canvas/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@geometra/renderer-canvas",
3-
"version": "1.12.0",
3+
"version": "1.13.0",
44
"description": "Canvas2D renderer for Geometra — the singularity frontend framework",
55
"license": "MIT",
66
"type": "module",

packages/renderer-terminal/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@geometra/renderer-terminal",
3-
"version": "1.12.0",
3+
"version": "1.13.0",
44
"description": "Terminal/TUI renderer for Geometra — the singularity frontend framework",
55
"license": "MIT",
66
"type": "module",

0 commit comments

Comments
 (0)