diff --git a/Makefile b/Makefile index 804f65790c..f0fbbcd0c1 100644 --- a/Makefile +++ b/Makefile @@ -344,7 +344,7 @@ clean: ## Clean project: remove created binaries and apps build-wasm: ## Compile-check every js/wasm binary (GOOS=js GOARCH=wasm), no run — mirrors the CI wasm lane @echo "compile-checking js/wasm binaries..." - @for p in ./pkg/tpviz/wasm ./cmd/dmsg-wasm ./cmd/wasm-visor ./cmd/wasm-visor-probe ./cmd/websh-probe ./cmd/skywire/commands/web/wasm; do \ + @for p in ./pkg/tpviz/wasm ./cmd/dmsg-wasm ./cmd/wasm-visor ./cmd/wasm-visor-probe ./cmd/websh-probe; do \ echo " GOOS=js GOARCH=wasm go build $$p"; \ GOOS=js GOARCH=wasm go build -mod=vendor -o /dev/null "$$p" || exit 1; \ done diff --git a/cmd/skywire-cli/commands/root.go b/cmd/skywire-cli/commands/root.go index 74c81d9e07..a14468ef86 100644 --- a/cmd/skywire-cli/commands/root.go +++ b/cmd/skywire-cli/commands/root.go @@ -42,7 +42,6 @@ import ( cliversion "github.com/skycoin/skywire/cmd/skywire-cli/commands/version" clivisor "github.com/skycoin/skywire/cmd/skywire-cli/commands/visor" clivpn "github.com/skycoin/skywire/cmd/skywire-cli/commands/vpn" - "github.com/skycoin/skywire/cmd/skywire/commands/web" "github.com/skycoin/skywire/pkg/buildinfo" "github.com/skycoin/skywire/pkg/calvin" "github.com/skycoin/skywire/pkg/cliout" @@ -110,7 +109,6 @@ func init() { cliutil.RootCmd.GroupID = groupUtil cligot.RootCmd.GroupID = groupUtil cliversion.RootCmd.GroupID = groupUtil - web.RootCmd.GroupID = groupUtil // Install flag-aware `help` command (supports -r/-t/-d). Covers // the case where `skywire cli` is invoked as a subcommand of the @@ -150,7 +148,6 @@ func init() { cliutil.RootCmd, cligot.RootCmd, cliversion.RootCmd, - web.RootCmd, // Top-level shortcuts: high-traffic verbs reachable without // the `visor` middle word. Long forms keep working at diff --git a/cmd/skywire/commands/web/Makefile b/cmd/skywire/commands/web/Makefile deleted file mode 100644 index e47289b5a7..0000000000 --- a/cmd/skywire/commands/web/Makefile +++ /dev/null @@ -1,24 +0,0 @@ -# Makefile for the `skywire web` WASM client. -# -# Targets: -# make build = build static/b.wasm via TinyGo -# make clean = remove static/b.wasm -# -# The Go server (web.go) embeds static/b.wasm via //go:embed. An -# empty b.wasm is acceptable — the page renders a "b.wasm not built" -# banner — but a production binary should ship a real bundle, so -# the top-level skywire Makefile should chain into this target as -# part of a release-binary build (TODO: wire into Makefile:build). - -WASM_OUT := static/b.wasm - -.PHONY: build clean - -build: $(WASM_OUT) - -$(WASM_OUT): wasm/main.go - tinygo build -target wasm -no-debug -o $@ ./wasm - @echo "built $@ ($(shell stat -c %s $@) bytes)" - -clean: - rm -f $(WASM_OUT) diff --git a/cmd/skywire/commands/web/procgroup_unix.go b/cmd/skywire/commands/web/procgroup_unix.go deleted file mode 100644 index 6079e53987..0000000000 --- a/cmd/skywire/commands/web/procgroup_unix.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build !windows - -// Package web cmd/skywire/commands/web/procgroup_unix.go c4-vis-cli -package web - -import ( - "os" - "os/exec" - "syscall" -) - -// setProcGroup puts the child in its own process group so the whole group can -// be signaled on cancel / client-disconnect (matters for visor halt, dmsg curl -// downloads, etc). -func setProcGroup(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} -} - -// killProcGroup sends SIGINT to the child's process group for graceful cleanup. -func killProcGroup(p *os.Process) { - if p == nil { - return - } - _ = syscall.Kill(-p.Pid, syscall.SIGINT) //nolint:errcheck -} diff --git a/cmd/skywire/commands/web/procgroup_windows.go b/cmd/skywire/commands/web/procgroup_windows.go deleted file mode 100644 index 94cc689ee4..0000000000 --- a/cmd/skywire/commands/web/procgroup_windows.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -// Package web cmd/skywire/commands/web/procgroup_windows.go c4-vis-cli -package web - -import ( - "os" - "os/exec" -) - -// setProcGroup is a no-op on Windows — there are no POSIX process groups, and -// the default CreateProcess behavior is sufficient for the web command's -// subprocess lifecycle. -func setProcGroup(_ *exec.Cmd) {} - -// killProcGroup terminates the child process. Windows has no SIGINT-to-group -// equivalent, so this is a best-effort hard kill of the subprocess. -func killProcGroup(p *os.Process) { - if p == nil { - return - } - _ = p.Kill() //nolint:errcheck -} diff --git a/cmd/skywire/commands/web/static/b.wasm b/cmd/skywire/commands/web/static/b.wasm deleted file mode 100755 index f73d103592..0000000000 Binary files a/cmd/skywire/commands/web/static/b.wasm and /dev/null differ diff --git a/cmd/skywire/commands/web/static/index.html b/cmd/skywire/commands/web/static/index.html deleted file mode 100644 index 1e3ef06854..0000000000 --- a/cmd/skywire/commands/web/static/index.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - -skywire web - - - -
-

Loading skywire web…

-
- - - - diff --git a/cmd/skywire/commands/web/static/wasm_exec.js b/cmd/skywire/commands/web/static/wasm_exec.js deleted file mode 100644 index 3d926ce524..0000000000 --- a/cmd/skywire/commands/web/static/wasm_exec.js +++ /dev/null @@ -1,559 +0,0 @@ -// Copyright 2018 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. -// -// This file has been modified for use by the TinyGo compiler. - -(() => { - // Map multiple JavaScript environments to a single common API, - // preferring web standards over Node.js API. - // - // Environments considered: - // - Browsers - // - Node.js - // - Electron - // - Parcel - - if (typeof global !== "undefined") { - // global already exists - } else if (typeof window !== "undefined") { - window.global = window; - } else if (typeof self !== "undefined") { - self.global = self; - } else { - throw new Error("cannot export Go (neither global, window nor self is defined)"); - } - - if (!global.require && typeof require !== "undefined") { - global.require = require; - } - - if (!global.fs && global.require) { - global.fs = require("node:fs"); - } - - const enosys = () => { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; - }; - - if (!global.fs) { - let outputBuf = ""; - global.fs = { - constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused - writeSync(fd, buf) { - outputBuf += decoder.decode(buf); - const nl = outputBuf.lastIndexOf("\n"); - if (nl != -1) { - console.log(outputBuf.substr(0, nl)); - outputBuf = outputBuf.substr(nl + 1); - } - return buf.length; - }, - write(fd, buf, offset, length, position, callback) { - if (offset !== 0 || length !== buf.length || position !== null) { - callback(enosys()); - return; - } - const n = this.writeSync(fd, buf); - callback(null, n); - }, - chmod(path, mode, callback) { callback(enosys()); }, - chown(path, uid, gid, callback) { callback(enosys()); }, - close(fd, callback) { callback(enosys()); }, - fchmod(fd, mode, callback) { callback(enosys()); }, - fchown(fd, uid, gid, callback) { callback(enosys()); }, - fstat(fd, callback) { callback(enosys()); }, - fsync(fd, callback) { callback(null); }, - ftruncate(fd, length, callback) { callback(enosys()); }, - lchown(path, uid, gid, callback) { callback(enosys()); }, - link(path, link, callback) { callback(enosys()); }, - lstat(path, callback) { callback(enosys()); }, - mkdir(path, perm, callback) { callback(enosys()); }, - open(path, flags, mode, callback) { callback(enosys()); }, - read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, - readdir(path, callback) { callback(enosys()); }, - readlink(path, callback) { callback(enosys()); }, - rename(from, to, callback) { callback(enosys()); }, - rmdir(path, callback) { callback(enosys()); }, - stat(path, callback) { callback(enosys()); }, - symlink(path, link, callback) { callback(enosys()); }, - truncate(path, length, callback) { callback(enosys()); }, - unlink(path, callback) { callback(enosys()); }, - utimes(path, atime, mtime, callback) { callback(enosys()); }, - }; - } - - if (!global.process) { - global.process = { - getuid() { return -1; }, - getgid() { return -1; }, - geteuid() { return -1; }, - getegid() { return -1; }, - getgroups() { throw enosys(); }, - pid: -1, - ppid: -1, - umask() { throw enosys(); }, - cwd() { throw enosys(); }, - chdir() { throw enosys(); }, - } - } - - if (!global.crypto) { - const nodeCrypto = require("node:crypto"); - global.crypto = { - getRandomValues(b) { - nodeCrypto.randomFillSync(b); - }, - }; - } - - if (!global.performance) { - global.performance = { - now() { - const [sec, nsec] = process.hrtime(); - return sec * 1000 + nsec / 1000000; - }, - }; - } - - if (!global.TextEncoder) { - global.TextEncoder = require("node:util").TextEncoder; - } - - if (!global.TextDecoder) { - global.TextDecoder = require("node:util").TextDecoder; - } - - // End of polyfills for common API. - - const encoder = new TextEncoder("utf-8"); - const decoder = new TextDecoder("utf-8"); - let reinterpretBuf = new DataView(new ArrayBuffer(8)); - var logLine = []; - const wasmExit = {}; // thrown to exit via proc_exit (not an error) - - global.Go = class { - constructor() { - this._callbackTimeouts = new Map(); - this._nextCallbackTimeoutID = 1; - - const mem = () => { - // The buffer may change when requesting more memory. - return new DataView(this._inst.exports.memory.buffer); - } - - const unboxValue = (v_ref) => { - reinterpretBuf.setBigInt64(0, v_ref, true); - const f = reinterpretBuf.getFloat64(0, true); - if (f === 0) { - return undefined; - } - if (!isNaN(f)) { - return f; - } - - const id = v_ref & 0xffffffffn; - return this._values[id]; - } - - - const loadValue = (addr) => { - let v_ref = mem().getBigUint64(addr, true); - return unboxValue(v_ref); - } - - const boxValue = (v) => { - const nanHead = 0x7FF80000n; - - if (typeof v === "number") { - if (isNaN(v)) { - return nanHead << 32n; - } - if (v === 0) { - return (nanHead << 32n) | 1n; - } - reinterpretBuf.setFloat64(0, v, true); - return reinterpretBuf.getBigInt64(0, true); - } - - switch (v) { - case undefined: - return 0n; - case null: - return (nanHead << 32n) | 2n; - case true: - return (nanHead << 32n) | 3n; - case false: - return (nanHead << 32n) | 4n; - } - - let id = this._ids.get(v); - if (id === undefined) { - id = this._idPool.pop(); - if (id === undefined) { - id = BigInt(this._values.length); - } - this._values[id] = v; - this._goRefCounts[id] = 0; - this._ids.set(v, id); - } - this._goRefCounts[id]++; - let typeFlag = 1n; - switch (typeof v) { - case "string": - typeFlag = 2n; - break; - case "symbol": - typeFlag = 3n; - break; - case "function": - typeFlag = 4n; - break; - } - return id | ((nanHead | typeFlag) << 32n); - } - - const storeValue = (addr, v) => { - let v_ref = boxValue(v); - mem().setBigUint64(addr, v_ref, true); - } - - const loadSlice = (array, len, cap) => { - return new Uint8Array(this._inst.exports.memory.buffer, array, len); - } - - const loadSliceOfValues = (array, len, cap) => { - const a = new Array(len); - for (let i = 0; i < len; i++) { - a[i] = loadValue(array + i * 8); - } - return a; - } - - const loadString = (ptr, len) => { - return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len)); - } - - const timeOrigin = Date.now() - performance.now(); - const wasi_EBADF = 8; - const wasi_ENOSYS = 52; - this.importObject = { - wasi_snapshot_preview1: { - // https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md - fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) { - let nwritten = 0; - if (fd == 1) { - for (let iovs_i=0; iovs_i wasi_ENOSYS, - fd_close: () => wasi_ENOSYS, - fd_fdstat_get: () => wasi_ENOSYS, - fd_prestat_get: () => wasi_EBADF, // wasi-libc relies on this errno value - fd_prestat_dir_name: () => wasi_ENOSYS, - fd_seek: () => wasi_ENOSYS, - path_open: () => wasi_ENOSYS, - proc_exit: (code) => { - this.exited = true; - this.exitCode = code; - this._resolveExitPromise(); - throw wasmExit; - }, - random_get: (bufPtr, bufLen) => { - crypto.getRandomValues(loadSlice(bufPtr, bufLen)); - return 0; - }, - }, - gojs: { - // func ticks() int64 - "runtime.ticks": () => { - return BigInt((timeOrigin + performance.now()) * 1e6); - }, - - // func sleepTicks(timeout int64) - "runtime.sleepTicks": (timeout) => { - // Do not sleep, only reactivate scheduler after the given timeout. - setTimeout(() => { - if (this.exited) return; - try { - this._inst.exports.go_scheduler(); - } catch (e) { - if (e !== wasmExit) throw e; - } - }, Number(timeout)/1e6); - }, - - // func finalizeRef(v ref) - "syscall/js.finalizeRef": (v_ref) => { - // Note: TinyGo does not support finalizers so this is only called - // for one specific case, by js.go:jsString. and can/might leak memory. - const id = v_ref & 0xffffffffn; - if (this._goRefCounts?.[id] !== undefined) { - this._goRefCounts[id]--; - if (this._goRefCounts[id] === 0) { - const v = this._values[id]; - this._values[id] = null; - this._ids.delete(v); - this._idPool.push(id); - } - } else { - console.error("syscall/js.finalizeRef: unknown id", id); - } - }, - - // func stringVal(value string) ref - "syscall/js.stringVal": (value_ptr, value_len) => { - value_ptr >>>= 0; - const s = loadString(value_ptr, value_len); - return boxValue(s); - }, - - // func valueGet(v ref, p string) ref - "syscall/js.valueGet": (v_ref, p_ptr, p_len) => { - let prop = loadString(p_ptr, p_len); - let v = unboxValue(v_ref); - let result = Reflect.get(v, prop); - return boxValue(result); - }, - - // func valueSet(v ref, p string, x ref) - "syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => { - const v = unboxValue(v_ref); - const p = loadString(p_ptr, p_len); - const x = unboxValue(x_ref); - Reflect.set(v, p, x); - }, - - // func valueDelete(v ref, p string) - "syscall/js.valueDelete": (v_ref, p_ptr, p_len) => { - const v = unboxValue(v_ref); - const p = loadString(p_ptr, p_len); - Reflect.deleteProperty(v, p); - }, - - // func valueIndex(v ref, i int) ref - "syscall/js.valueIndex": (v_ref, i) => { - return boxValue(Reflect.get(unboxValue(v_ref), i)); - }, - - // valueSetIndex(v ref, i int, x ref) - "syscall/js.valueSetIndex": (v_ref, i, x_ref) => { - Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref)); - }, - - // func valueCall(v ref, m string, args []ref) (ref, bool) - "syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => { - const v = unboxValue(v_ref); - const name = loadString(m_ptr, m_len); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - try { - const m = Reflect.get(v, name); - storeValue(ret_addr, Reflect.apply(m, v, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr + 8, 0); - } - }, - - // func valueInvoke(v ref, args []ref) (ref, bool) - "syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { - try { - const v = unboxValue(v_ref); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - storeValue(ret_addr, Reflect.apply(v, undefined, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr + 8, 0); - } - }, - - // func valueNew(v ref, args []ref) (ref, bool) - "syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { - const v = unboxValue(v_ref); - const args = loadSliceOfValues(args_ptr, args_len, args_cap); - try { - storeValue(ret_addr, Reflect.construct(v, args)); - mem().setUint8(ret_addr + 8, 1); - } catch (err) { - storeValue(ret_addr, err); - mem().setUint8(ret_addr+ 8, 0); - } - }, - - // func valueLength(v ref) int - "syscall/js.valueLength": (v_ref) => { - return unboxValue(v_ref).length; - }, - - // valuePrepareString(v ref) (ref, int) - "syscall/js.valuePrepareString": (ret_addr, v_ref) => { - const s = String(unboxValue(v_ref)); - const str = encoder.encode(s); - storeValue(ret_addr, str); - mem().setInt32(ret_addr + 8, str.length, true); - }, - - // valueLoadString(v ref, b []byte) - "syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => { - const str = unboxValue(v_ref); - loadSlice(slice_ptr, slice_len, slice_cap).set(str); - }, - - // func valueInstanceOf(v ref, t ref) bool - "syscall/js.valueInstanceOf": (v_ref, t_ref) => { - return unboxValue(v_ref) instanceof unboxValue(t_ref); - }, - - // func copyBytesToGo(dst []byte, src ref) (int, bool) - "syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => { - let num_bytes_copied_addr = ret_addr; - let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable - - const dst = loadSlice(dest_addr, dest_len); - const src = unboxValue(src_ref); - if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { - mem().setUint8(returned_status_addr, 0); // Return "not ok" status - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - mem().setUint32(num_bytes_copied_addr, toCopy.length, true); - mem().setUint8(returned_status_addr, 1); // Return "ok" status - }, - - // copyBytesToJS(dst ref, src []byte) (int, bool) - // Originally copied from upstream Go project, then modified: - // https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416 - "syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => { - let num_bytes_copied_addr = ret_addr; - let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable - - const dst = unboxValue(dst_ref); - const src = loadSlice(src_addr, src_len); - if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { - mem().setUint8(returned_status_addr, 0); // Return "not ok" status - return; - } - const toCopy = src.subarray(0, dst.length); - dst.set(toCopy); - mem().setUint32(num_bytes_copied_addr, toCopy.length, true); - mem().setUint8(returned_status_addr, 1); // Return "ok" status - }, - } - }; - - // Go 1.20 uses 'env'. Go 1.21 uses 'gojs'. - // For compatibility, we use both as long as Go 1.20 is supported. - this.importObject.env = this.importObject.gojs; - } - - async run(instance) { - this._inst = instance; - this._values = [ // JS values that Go currently has references to, indexed by reference id - NaN, - 0, - null, - true, - false, - global, - this, - ]; - this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id - this._ids = new Map(); // mapping from JS values to reference ids - this._idPool = []; // unused ids that have been garbage collected - this.exited = false; // whether the Go program has exited - this.exitCode = 0; - - if (this._inst.exports._start) { - let exitPromise = new Promise((resolve, reject) => { - this._resolveExitPromise = resolve; - }); - - // Run program, but catch the wasmExit exception that's thrown - // to return back here. - try { - this._inst.exports._start(); - } catch (e) { - if (e !== wasmExit) throw e; - } - - await exitPromise; - return this.exitCode; - } else { - this._inst.exports._initialize(); - } - } - - _resume() { - if (this.exited) { - throw new Error("Go program has already exited"); - } - try { - this._inst.exports.resume(); - } catch (e) { - if (e !== wasmExit) throw e; - } - if (this.exited) { - this._resolveExitPromise(); - } - } - - _makeFuncWrapper(id) { - const go = this; - return function () { - const event = { id: id, this: this, args: arguments }; - go._pendingEvent = event; - go._resume(); - return event.result; - }; - } - } - - if ( - global.require && - global.require.main === module && - global.process && - global.process.versions && - !global.process.versions.electron - ) { - if (process.argv.length != 3) { - console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); - process.exit(1); - } - - const go = new Go(); - WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => { - let exitCode = await go.run(result.instance); - process.exit(exitCode); - }).catch((err) => { - console.error(err); - process.exit(1); - }); - } -})(); diff --git a/cmd/skywire/commands/web/wasm/main.go b/cmd/skywire/commands/web/wasm/main.go deleted file mode 100644 index c617761ab9..0000000000 --- a/cmd/skywire/commands/web/wasm/main.go +++ /dev/null @@ -1,909 +0,0 @@ -//go:build js && wasm - -// Package main cmd/skywire/commands/web/wasm/main.go c4-vis-cli -// -// Renders a single shell-like prompt: `skywire $ `. As the -// operator types, the WASM client parses the line into command path -// + flags + args, looks up the matching cobra node in /api/tree, -// and shows its help (Short, Long, Flags, Example) below the input. -// Pressing Enter executes the line via POST /api/run + SSE stream. -// Tab autocompletes the current token (subcommand or flag name). -// Up/Down recall previous lines. -// -// All DOM manipulation via syscall/js — no JS framework, no -// client-side state outside this module. -// -// Build: tinygo build -target wasm -no-debug -o ../static/b.wasm . -package main - -import ( - "sort" - "strings" - "syscall/js" -) - -var ( - tree map[string]node - history []string - histIdx int // -1 = current input -) - -func main() { - d := js.Global().Get("document") - if d.Get("readyState").String() == "loading" { - var ready js.Func - ready = js.FuncOf(func(_ js.Value, _ []js.Value) interface{} { - boot() - ready.Release() - return nil - }) - d.Call("addEventListener", "DOMContentLoaded", ready) - } else { - boot() - } - select {} -} - -func boot() { - loadTree() -} - -type node struct { - Path string - Name string - Short string - Long string - Example string - Children []string - Flags []flag - Runnable bool -} - -type flag struct { - Name string - Shorthand string - Type string - Default string - Usage string -} - -func loadTree() { - then := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - args[0].Call("text").Call("then", js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - tree = parseTree(args[0].String()) - renderShell() - update("") - return nil - })) - return nil - }) - js.Global().Call("fetch", "/api/tree").Call("then", then) -} - -// renderShell paints the single-column shell layout: prompt input -// on top, help/completion panel mid, output panel bottom. Input -// focus is captured on load + every click anywhere in the page. -func renderShell() { - d := js.Global().Get("document") - app := d.Call("getElementById", "app") - if app.IsUndefined() || app.IsNull() { - d.Get("body").Set("innerHTML", `
missing #app in index.html
`) - return - } - app.Set("innerHTML", ` -
-
- skywire $ - -
-
-
- -
`) - - input := d.Call("getElementById", "sh-input") - input.Call("focus") - - // Refocus on any click outside an interactive element. Keeps the - // keyboard captured for the prompt without explicit Ctrl+L style - // gestures. - bodyClick := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - target := args[0].Get("target") - tag := target.Get("tagName").String() - if tag != "BUTTON" && tag != "A" { - input.Call("focus") - } - return nil - }) - d.Get("body").Call("addEventListener", "click", bodyClick) - - inputCb := js.FuncOf(func(_ js.Value, _ []js.Value) interface{} { - update(input.Get("value").String()) - return nil - }) - input.Call("addEventListener", "input", inputCb) - - keyCb := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - ev := args[0] - key := ev.Get("key").String() - switch key { - case "Enter": - ev.Call("preventDefault") - line := strings.TrimSpace(input.Get("value").String()) - if line == "" { - return nil - } - history = append(history, line) - histIdx = -1 - runLine(line) - input.Set("value", "") - update("") - case "Tab": - ev.Call("preventDefault") - completed := autocomplete(input.Get("value").String()) - if completed != "" { - input.Set("value", completed) - update(completed) - } - case "ArrowUp": - if len(history) == 0 { - return nil - } - ev.Call("preventDefault") - if histIdx == -1 { - histIdx = len(history) - } - if histIdx > 0 { - histIdx-- - } - input.Set("value", history[histIdx]) - update(history[histIdx]) - case "ArrowDown": - if histIdx == -1 { - return nil - } - ev.Call("preventDefault") - histIdx++ - if histIdx >= len(history) { - histIdx = -1 - input.Set("value", "") - update("") - } else { - input.Set("value", history[histIdx]) - update(history[histIdx]) - } - } - return nil - }) - input.Call("addEventListener", "keydown", keyCb) -} - -// update re-renders the help + completions panel based on the -// current input line. Called on every keystroke and on history -// navigation. -func update(line string) { - d := js.Global().Get("document") - - path, finishedTokens, lastToken := resolvePath(line) - n, ok := tree[path] - if !ok { - n = tree[""] - path = "" - } - - // Help panel: name + Short + Long + Example + Flags table. - help := d.Call("getElementById", "sh-help") - help.Set("innerHTML", "") - - header := d.Call("createElement", "div") - header.Set("className", "sh-help-header") - cmdSoFar := "skywire" - if path != "" { - cmdSoFar += " " + strings.ReplaceAll(path, ".", " ") - } - header.Set("textContent", cmdSoFar) - help.Call("appendChild", header) - - if n.Short != "" { - s := d.Call("createElement", "div") - s.Set("className", "sh-help-short") - s.Set("textContent", n.Short) - help.Call("appendChild", s) - } - if n.Long != "" { - pre := d.Call("createElement", "pre") - pre.Set("className", "sh-help-long") - pre.Set("textContent", n.Long) - help.Call("appendChild", pre) - } - if n.Runnable && len(n.Flags) > 0 { - flagsH := d.Call("createElement", "div") - flagsH.Set("className", "sh-help-section") - flagsH.Set("textContent", "Flags") - help.Call("appendChild", flagsH) - table := d.Call("createElement", "table") - table.Set("className", "sh-flags") - for _, fl := range n.Flags { - tr := d.Call("createElement", "tr") - tdName := d.Call("createElement", "td") - tdName.Set("className", "sh-flag-name") - name := "--" + fl.Name - if fl.Shorthand != "" { - name = "-" + fl.Shorthand + ", " + name - } - tdName.Set("textContent", name) - tdType := d.Call("createElement", "td") - tdType.Set("className", "sh-flag-type") - tdType.Set("textContent", fl.Type) - tdUsage := d.Call("createElement", "td") - tdUsage.Set("className", "sh-flag-usage") - usage := fl.Usage - if fl.Default != "" && fl.Default != "false" && fl.Default != "[]" { - usage += " (default " + fl.Default + ")" - } - tdUsage.Set("textContent", usage) - tr.Call("appendChild", tdName) - tr.Call("appendChild", tdType) - tr.Call("appendChild", tdUsage) - table.Call("appendChild", tr) - } - help.Call("appendChild", table) - } - if n.Example != "" { - eh := d.Call("createElement", "div") - eh.Set("className", "sh-help-section") - eh.Set("textContent", "Examples") - help.Call("appendChild", eh) - pre := d.Call("createElement", "pre") - pre.Set("className", "sh-help-example") - pre.Set("textContent", n.Example) - help.Call("appendChild", pre) - } - - // Completions panel: list of subcommands (if non-leaf) or flag - // suggestions (if last token starts with "-"). - compPanel := d.Call("getElementById", "sh-completions") - compPanel.Set("innerHTML", "") - suggestions := []string{} - if strings.HasPrefix(lastToken, "-") && !finishedTokens { - // Flag completion at the current path. - flagPrefix := strings.TrimLeft(lastToken, "-") - for _, fl := range n.Flags { - if flagPrefix == "" || strings.HasPrefix(fl.Name, flagPrefix) { - suggestions = append(suggestions, "--"+fl.Name) - } - } - } else { - // Subcommand completion. - for _, childPath := range n.Children { - c := tree[childPath] - if lastToken == "" || strings.HasPrefix(c.Name, lastToken) { - suggestions = append(suggestions, c.Name) - } - } - } - sort.Strings(suggestions) - if len(suggestions) > 0 { - hdr := d.Call("createElement", "div") - hdr.Set("className", "sh-comp-header") - hdr.Set("textContent", "↹ Tab — completions") - compPanel.Call("appendChild", hdr) - row := d.Call("createElement", "div") - row.Set("className", "sh-comp-row") - for _, s := range suggestions { - pill := d.Call("createElement", "span") - pill.Set("className", "sh-comp-pill") - pill.Set("textContent", s) - row.Call("appendChild", pill) - } - compPanel.Call("appendChild", row) - } -} - -// resolvePath walks the input line token-by-token, advancing through -// the cobra tree until a token isn't a known subcommand name. Returns -// the dot-path matched, whether the last token is "finished" (a -// trailing space means yes), and the last (possibly partial) token -// for completion logic. -func resolvePath(line string) (string, bool, string) { - finished := strings.HasSuffix(line, " ") - tokens := strings.Fields(line) - path := "" - consumed := 0 - for i, t := range tokens { - if strings.HasPrefix(t, "-") { - // Hit a flag — stop walking the path. - break - } - candidate := path - if candidate != "" { - candidate += "." - } - candidate += t - if _, ok := tree[candidate]; !ok { - break - } - path = candidate - consumed = i + 1 - } - lastToken := "" - if !finished && len(tokens) > 0 { - lastToken = tokens[len(tokens)-1] - if consumed == len(tokens) { - // Last token was a complete subcommand → empty partial. - lastToken = "" - } - } - return path, finished, lastToken -} - -// autocomplete returns the input line with the last token expanded -// to the unique completion, or empty if no unique match. Common -// prefix expansion isn't done — yet. -func autocomplete(line string) string { - path, finished, lastToken := resolvePath(line) - n, ok := tree[path] - if !ok { - return "" - } - - var candidates []string - isFlag := strings.HasPrefix(lastToken, "-") - if isFlag { - prefix := strings.TrimLeft(lastToken, "-") - for _, fl := range n.Flags { - if strings.HasPrefix(fl.Name, prefix) { - candidates = append(candidates, "--"+fl.Name) - } - } - } else if !finished { - for _, childPath := range n.Children { - c := tree[childPath] - if strings.HasPrefix(c.Name, lastToken) { - candidates = append(candidates, c.Name) - } - } - } - if len(candidates) == 0 { - // Trailing space: list children of current path → noop here, - // the completions panel shows them; tab doesn't change input. - return "" - } - if len(candidates) == 1 { - // Replace the last token with the unique candidate + space. - fields := strings.Fields(line) - if !finished && len(fields) > 0 { - fields = fields[:len(fields)-1] - } - fields = append(fields, candidates[0]) - return strings.Join(fields, " ") + " " - } - // Multi-candidate: extend to common prefix. - common := candidates[0] - for _, c := range candidates[1:] { - common = commonPrefix(common, c) - } - if len(common) > len(lastToken) { - fields := strings.Fields(line) - if !finished && len(fields) > 0 { - fields = fields[:len(fields)-1] - } - fields = append(fields, common) - return strings.Join(fields, " ") - } - return "" -} - -func commonPrefix(a, b string) string { - n := len(a) - if len(b) < n { - n = len(b) - } - i := 0 - for i < n && a[i] == b[i] { - i++ - } - return a[:i] -} - -// runLine parses a full input line into command path + flags + args -// and POSTs to /api/run. Output streams via SSE into #sh-output. -func runLine(line string) { - path, flags, args := parseLine(line) - if _, ok := tree[path]; !ok { - appendOutput("error: unknown command\n") - return - } - - body := `{"path":` + jsonString(path) - if len(flags) > 0 { - body += `,"flags":{` - first := true - for k, v := range flags { - if !first { - body += "," - } - body += jsonString(k) + ":" + jsonString(v) - first = false - } - body += `}` - } - if len(args) > 0 { - body += `,"args":[` - for i, a := range args { - if i > 0 { - body += "," - } - body += jsonString(a) - } - body += `]` - } - body += `}` - - d := js.Global().Get("document") - out := d.Call("getElementById", "sh-output") - wrap := d.Call("getElementById", "sh-output-wrap") - wrap.Set("hidden", false) - label := d.Call("getElementById", "sh-output-label") - label.Set("textContent", "$ skywire "+strings.ReplaceAll(path, ".", " ")) - // Append history separator instead of clearing — operator - // expects a transcript view, not a single-shot REPL. - if cur := out.Get("textContent").String(); cur != "" { - out.Set("textContent", cur+"\n") - } - out.Set("textContent", out.Get("textContent").String()+"$ "+line+"\n") - - cancelBtn := d.Call("getElementById", "sh-cancel") - cancelBtn.Set("hidden", true) - - headers := js.Global().Get("Object").New() - headers.Set("Content-Type", "application/json") - opts := js.Global().Get("Object").New() - opts.Set("method", "POST") - opts.Set("body", body) - opts.Set("headers", headers) - then := js.FuncOf(func(_ js.Value, fetchArgs []js.Value) interface{} { - fetchArgs[0].Call("json").Call("then", js.FuncOf(func(_ js.Value, jsonArgs []js.Value) interface{} { - id := jsonArgs[0].Get("id").String() - subscribe(id) - return nil - })) - return nil - }) - js.Global().Call("fetch", "/api/run", opts).Call("then", then) -} - -// parseLine breaks a typed line into (command-path, flag map, -// positional args). Tokens with --name or --name=value are flags; -// the next-token-after-flag-name (when no =) is the flag's value -// unless it itself starts with --. Bool flags accept --name or -// --name=true. Single-dash shorthand isn't handled yet (operator -// types --long-name for now). -func parseLine(line string) (string, map[string]string, []string) { - tokens := strings.Fields(line) - // Find path prefix. - path := "" - i := 0 - for ; i < len(tokens); i++ { - t := tokens[i] - if strings.HasPrefix(t, "-") { - break - } - candidate := path - if candidate != "" { - candidate += "." - } - candidate += t - if _, ok := tree[candidate]; !ok { - break - } - path = candidate - } - flags := map[string]string{} - args := []string{} - for i < len(tokens) { - t := tokens[i] - if strings.HasPrefix(t, "--") { - name := strings.TrimPrefix(t, "--") - if eq := strings.Index(name, "="); eq >= 0 { - flags[name[:eq]] = name[eq+1:] - i++ - continue - } - // Bool detection — if the flag's typed-tree type is bool - // and the next token starts with -- (or doesn't exist), - // treat as standalone --flag (true). - if isBoolFlag(path, name) { - if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { - // Operator wrote --bool value — accept the value. - flags[name] = tokens[i+1] - i += 2 - } else { - flags[name] = "true" - i++ - } - continue - } - if i+1 < len(tokens) { - flags[name] = tokens[i+1] - i += 2 - } else { - flags[name] = "" - i++ - } - continue - } - args = append(args, t) - i++ - } - return path, flags, args -} - -func isBoolFlag(path, flagName string) bool { - n, ok := tree[path] - if !ok { - return false - } - for _, fl := range n.Flags { - if fl.Name == flagName { - return fl.Type == "bool" - } - } - return false -} - -func subscribe(id string) { - d := js.Global().Get("document") - out := d.Call("getElementById", "sh-output") - cancelBtn := d.Call("getElementById", "sh-cancel") - cancelBtn.Set("hidden", false) - - es := js.Global().Get("EventSource").New("/api/sse/" + id) - stdout := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - data := args[0].Get("data").String() - out.Set("textContent", out.Get("textContent").String()+data+"\n") - out.Set("scrollTop", out.Get("scrollHeight")) - return nil - }) - exitCB := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - code := args[0].Get("data").String() - out.Set("textContent", out.Get("textContent").String()+"[exit "+code+"]\n") - es.Call("close") - cancelBtn.Set("hidden", true) - return nil - }) - es.Call("addEventListener", "stdout", stdout) - es.Call("addEventListener", "exit", exitCB) - - cancelClick := js.FuncOf(func(_ js.Value, _ []js.Value) interface{} { - opts := js.Global().Get("Object").New() - opts.Set("method", "POST") - js.Global().Call("fetch", "/api/cancel/"+id, opts) - return nil - }) - cancelBtn.Call("addEventListener", "click", cancelClick) -} - -func appendOutput(s string) { - out := js.Global().Get("document").Call("getElementById", "sh-output") - wrap := js.Global().Get("document").Call("getElementById", "sh-output-wrap") - wrap.Set("hidden", false) - out.Set("textContent", out.Get("textContent").String()+s) -} - -// jsonString escapes s for inclusion in a JSON document. Hand-rolled -// to avoid encoding/json's reflect dependency. -func jsonString(s string) string { - b := strings.Builder{} - b.WriteByte('"') - for _, r := range s { - switch r { - case '"': - b.WriteString(`\"`) - case '\\': - b.WriteString(`\\`) - case '\n': - b.WriteString(`\n`) - case '\r': - b.WriteString(`\r`) - case '\t': - b.WriteString(`\t`) - default: - if r < 0x20 { - b.WriteString("\\u00") - const hex = "0123456789abcdef" - b.WriteByte(hex[r>>4]) - b.WriteByte(hex[r&0xF]) - } else { - b.WriteRune(r) - } - } - } - b.WriteByte('"') - return b.String() -} - -// parseTree hand-rolled JSON decoder — see equivalent in the -// install-page generator's WASM client. Doesn't use encoding/json -// (drags reflect) and we don't need a general decoder here. -func parseTree(s string) map[string]node { - p := &parser{s: s} - out := map[string]node{} - p.expect('{') - for { - p.ws() - if p.peek() == '}' { - p.next() - break - } - k := p.parseString() - p.ws() - p.expect(':') - v := p.parseNode() - out[k] = v - p.ws() - if p.peek() == ',' { - p.next() - continue - } - } - return out -} - -type parser struct { - s string - i int -} - -func (p *parser) ws() { - for p.i < len(p.s) { - c := p.s[p.i] - if c == ' ' || c == '\t' || c == '\n' || c == '\r' { - p.i++ - continue - } - break - } -} - -func (p *parser) peek() byte { - if p.i >= len(p.s) { - return 0 - } - return p.s[p.i] -} - -func (p *parser) next() byte { - c := p.peek() - p.i++ - return c -} - -func (p *parser) expect(c byte) { - p.ws() - if p.peek() != c { - return - } - p.next() -} - -func (p *parser) parseString() string { - p.ws() - if p.peek() != '"' { - return "" - } - p.next() - b := strings.Builder{} - for p.i < len(p.s) { - c := p.next() - if c == '"' { - return b.String() - } - if c == '\\' { - esc := p.next() - switch esc { - case '"', '\\', '/': - b.WriteByte(esc) - case 'n': - b.WriteByte('\n') - case 'r': - b.WriteByte('\r') - case 't': - b.WriteByte('\t') - case 'u': - if p.i+4 > len(p.s) { - return b.String() - } - h := p.s[p.i : p.i+4] - p.i += 4 - r := 0 - for _, c := range h { - r <<= 4 - switch { - case c >= '0' && c <= '9': - r |= int(c - '0') - case c >= 'a' && c <= 'f': - r |= int(c-'a') + 10 - case c >= 'A' && c <= 'F': - r |= int(c-'A') + 10 - } - } - b.WriteRune(rune(r)) - default: - b.WriteByte(esc) - } - continue - } - b.WriteByte(c) - } - return b.String() -} - -func (p *parser) parseBool() bool { - p.ws() - if p.i+4 <= len(p.s) && p.s[p.i:p.i+4] == "true" { - p.i += 4 - return true - } - if p.i+5 <= len(p.s) && p.s[p.i:p.i+5] == "false" { - p.i += 5 - } - return false -} - -func (p *parser) skipValue() { - p.ws() - c := p.peek() - switch c { - case '"': - p.parseString() - case '{', '[': - open := c - closeB := byte('}') - if open == '[' { - closeB = ']' - } - depth := 1 - p.next() - for p.i < len(p.s) && depth > 0 { - c = p.next() - if c == '"' { - p.i-- - p.parseString() - continue - } - if c == open { - depth++ - } else if c == closeB { - depth-- - } - } - default: - for p.i < len(p.s) { - c = p.peek() - if c == ',' || c == '}' || c == ']' || c == ' ' || c == '\n' || c == '\t' || c == '\r' { - return - } - p.next() - } - } -} - -func (p *parser) parseStringArray() []string { - out := []string{} - p.ws() - if p.peek() != '[' { - return out - } - p.next() - for { - p.ws() - if p.peek() == ']' { - p.next() - return out - } - out = append(out, p.parseString()) - p.ws() - if p.peek() == ',' { - p.next() - continue - } - } -} - -func (p *parser) parseFlags() []flag { - out := []flag{} - p.ws() - if p.peek() != '[' { - return out - } - p.next() - for { - p.ws() - if p.peek() == ']' { - p.next() - return out - } - f := flag{} - p.expect('{') - for { - p.ws() - if p.peek() == '}' { - p.next() - break - } - k := p.parseString() - p.ws() - p.expect(':') - switch k { - case "name": - f.Name = p.parseString() - case "shorthand": - f.Shorthand = p.parseString() - case "type": - f.Type = p.parseString() - case "default": - f.Default = p.parseString() - case "usage": - f.Usage = p.parseString() - default: - p.skipValue() - } - p.ws() - if p.peek() == ',' { - p.next() - } - } - out = append(out, f) - p.ws() - if p.peek() == ',' { - p.next() - } - } -} - -func (p *parser) parseNode() node { - n := node{} - p.ws() - p.expect('{') - for { - p.ws() - if p.peek() == '}' { - p.next() - return n - } - k := p.parseString() - p.ws() - p.expect(':') - switch k { - case "path": - n.Path = p.parseString() - case "name": - n.Name = p.parseString() - case "short": - n.Short = p.parseString() - case "long": - n.Long = p.parseString() - case "example": - n.Example = p.parseString() - case "children": - n.Children = p.parseStringArray() - case "flags": - n.Flags = p.parseFlags() - case "runnable": - n.Runnable = p.parseBool() - default: - p.skipValue() - } - p.ws() - if p.peek() == ',' { - p.next() - } - } -} diff --git a/cmd/skywire/commands/web/web.go b/cmd/skywire/commands/web/web.go deleted file mode 100644 index 92591ca823..0000000000 --- a/cmd/skywire/commands/web/web.go +++ /dev/null @@ -1,505 +0,0 @@ -// Package web cmd/skywire/commands/web/web.go c4-vis-cli -// -// Serves a browser-based UI for the entire skywire CLI tree. The -// page is a TinyGo-compiled WASM bundle (see ./wasm/main.go); the -// server walks the live cobra tree at startup, exposes it as JSON, -// and proxies command execution via subprocess + Server-Sent Events. -// -// Why a separate binary subcommand and not nested under `cli`: this -// command spawns `./skywire ` as a subprocess, so it's a -// PEER of cli, not a child — clearer in the help tree this way. -// -// Static assets (index.html, wasm_exec.js, b.wasm) are embedded via -// //go:embed so a release binary ships self-contained. b.wasm is -// produced by `make build` in this directory; absent that, the -// server still starts and serves the index page with a build-me -// banner. -package web - -import ( - "bufio" - "context" - _ "embed" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -//go:embed static/index.html -var indexHTML []byte - -//go:embed static/wasm_exec.js -var wasmExecJS []byte - -// b.wasm is optional — embedded if produced by `make build` in this -// dir, otherwise the embed will be empty and the loader page shows -// a "rebuild me" banner. The empty default keeps the package -// buildable from a fresh checkout without TinyGo installed. -// -//go:embed static/b.wasm -var bWasm []byte - -var ( - flagAddr string - flagToken string - flagAllow []string - flagSkywire string -) - -func init() { - RootCmd.Flags().StringVar(&flagAddr, "addr", "127.0.0.1:8088", - "HTTP bind address (use 'host:port'; default loopback-only)") - RootCmd.Flags().StringVar(&flagToken, "token", "", - "require ?token=... or Authorization: Bearer for every request (empty = no auth, only safe on loopback)") - RootCmd.Flags().StringSliceVar(&flagAllow, "allow", nil, - "allowlist of subcommand paths (dot-separated, e.g. 'cli.skychat.send'); empty = allow all non-hidden subcommands") - RootCmd.Flags().StringVar(&flagSkywire, "skywire", "", - "path to the skywire binary used for subprocess execution (empty = use the running binary)") -} - -// RootCmd is the cobra entry point. Mounted from -// cmd/skywire/commands/root.go. -var RootCmd = &cobra.Command{ - Use: "web", - Short: "Serve the skywire CLI as a browser-based UI", - Long: `Start a local HTTP server that renders the entire skywire -cobra command tree as a navigable web page. Click any subcommand -to see its help, fill in flags, and execute — output streams back -to the browser via Server-Sent Events. - -The page is a TinyGo-compiled WASM bundle (no JavaScript framework, -no client-side state outside the WASM module). The server walks -the live cobra tree at startup so the UI always reflects the -current binary's actual capabilities. - -Default binding is loopback-only. For LAN/remote access, pair ---addr 0.0.0.0:8088 with --token and require clients to -provide the token via ?token= or Authorization: Bearer. - -The --allow flag scopes which subcommands the UI exposes — useful -for hosted instances. Example: --allow cli.skychat,cli.dmsg.curl -exposes only chat send + dmsg curl. Empty allowlist = everything -non-hidden. - -Examples: - skywire web # localhost:8088, no auth - skywire web --addr 0.0.0.0:8088 --token foo # LAN, token-gated - skywire web --allow cli.skychat # only chat subcommands`, - SilenceErrors: true, - SilenceUsage: true, - DisableFlagsInUseLine: true, - RunE: func(cmd *cobra.Command, _ []string) error { - return serve(cmd.Context(), cmd.Root()) - }, -} - -func serve(ctx context.Context, root *cobra.Command) error { - skywireBin := flagSkywire - if skywireBin == "" { - exe, err := os.Executable() - if err != nil { - return fmt.Errorf("resolve own binary path: %w", err) - } - skywireBin = exe - } - tree := buildTree(root, flagAllow) - - mux := http.NewServeMux() - mux.Handle("GET /", authMiddleware(http.HandlerFunc(handleIndex))) - mux.Handle("GET /b.wasm", authMiddleware(http.HandlerFunc(handleWasm))) - mux.Handle("GET /wasm_exec.js", authMiddleware(http.HandlerFunc(handleWasmExec))) - mux.Handle("GET /api/tree", authMiddleware(handleTree(tree))) - - runs := newRunRegistry() - mux.Handle("POST /api/run", authMiddleware(handleRun(runs, skywireBin))) - mux.Handle("GET /api/sse/{id}", authMiddleware(handleSSE(runs))) - mux.Handle("POST /api/cancel/{id}", authMiddleware(handleCancel(runs))) - - srv := &http.Server{ - Addr: flagAddr, - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - } - - // Shutdown on ctx cancel — outer command's signal handler - // (cobra's default) plumbs SIGINT through. - go func() { //nolint:gosec // G118: shutdown timeout deliberately uses Background — the request ctx is being canceled - <-ctx.Done() - shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = srv.Shutdown(shutCtx) //nolint:errcheck - runs.cancelAll() - }() - - fmt.Fprintf(os.Stderr, "skywire web: serving on http://%s/\n", flagAddr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -// authMiddleware is the optional token gate. No-op when flagToken is -// empty — operator's responsibility to keep the bind loopback in -// that case. Loopback bindings (127.0.0.0/8, ::1) bypass the gate -// because the operator running localhost obviously authenticates -// themselves via OS access. -func authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if flagToken == "" { - next.ServeHTTP(w, r) - return - } - // Loopback bypass — RemoteAddr is "host:port". - host, _, _ := strings.Cut(r.RemoteAddr, ":") - if host == "127.0.0.1" || host == "::1" || host == "localhost" { - next.ServeHTTP(w, r) - return - } - got := r.URL.Query().Get("token") - if got == "" { - auth := r.Header.Get("Authorization") - if strings.HasPrefix(auth, "Bearer ") { - got = strings.TrimPrefix(auth, "Bearer ") - } - } - if got != flagToken { - http.Error(w, "missing or invalid token", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -func handleIndex(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(indexHTML) //nolint:errcheck -} - -func handleWasm(w http.ResponseWriter, _ *http.Request) { - if len(bWasm) == 0 { - http.Error(w, "b.wasm not built — run `make build` in cmd/skywire/commands/web/", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/wasm") - _, _ = w.Write(bWasm) //nolint:errcheck -} - -func handleWasmExec(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/javascript; charset=utf-8") - _, _ = w.Write(wasmExecJS) //nolint:errcheck -} - -// CommandNode is the JSON-friendly shape of one cobra command in the -// tree. Flat children list (not nested) so the WASM client can -// navigate via path lookups without recursing JSON. flags include -// only non-hidden user-facing flags. -type CommandNode struct { - Path string `json:"path"` // dot-separated, "" for root - Name string `json:"name"` - Short string `json:"short,omitempty"` - Long string `json:"long,omitempty"` - Example string `json:"example,omitempty"` - Use string `json:"use,omitempty"` // raw Use string from cobra - Children []string `json:"children,omitempty"` - Flags []FlagInfo `json:"flags,omitempty"` - Runnable bool `json:"runnable"` // has Run / RunE - Hidden bool `json:"hidden,omitempty"` -} - -// FlagInfo carries enough flag metadata for the WASM side to render -// an appropriate . Type strings match pflag's Value.Type() -// output ("string", "bool", "int", "duration", etc.). -type FlagInfo struct { - Name string `json:"name"` - Shorthand string `json:"shorthand,omitempty"` - Type string `json:"type"` - Default string `json:"default,omitempty"` - Usage string `json:"usage,omitempty"` -} - -// buildTree walks the cobra subcommand graph rooted at `root` and -// returns a path→node map. The empty-string key is the root itself. -// Hidden commands are included only when the operator's --allow -// list names them explicitly; the WASM client decides whether to -// surface them in the sidebar. -func buildTree(root *cobra.Command, allow []string) map[string]CommandNode { - allowSet := make(map[string]struct{}, len(allow)) - for _, a := range allow { - allowSet[a] = struct{}{} - } - out := make(map[string]CommandNode) - var walk func(c *cobra.Command, path string) - walk = func(c *cobra.Command, path string) { - // Non-allowlisted nodes are still walked so allowed descendants can - // mount under an implicit breadcrumb; the node itself is emitted with - // its flags/runnable bit regardless. - children := make([]string, 0, len(c.Commands())) - for _, sub := range c.Commands() { - if sub.Hidden && len(allow) == 0 { - continue - } - subPath := sub.Name() - if path != "" { - subPath = path + "." + sub.Name() - } - children = append(children, subPath) - walk(sub, subPath) - } - flags := []FlagInfo{} - c.Flags().VisitAll(func(f *pflag.Flag) { - if f.Hidden { - return - } - flags = append(flags, FlagInfo{ - Name: f.Name, - Shorthand: f.Shorthand, - Type: f.Value.Type(), - Default: f.DefValue, - Usage: f.Usage, - }) - }) - runnable := c.Run != nil || c.RunE != nil - out[path] = CommandNode{ - Path: path, - Name: c.Name(), - Short: c.Short, - Long: strings.TrimSpace(c.Long), - Example: strings.TrimSpace(c.Example), - Use: c.Use, - Children: children, - Flags: flags, - Runnable: runnable, - Hidden: c.Hidden, - } - } - walk(root, "") - return out -} - -func handleTree(tree map[string]CommandNode) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(tree) //nolint:errcheck - }) -} - -// runRegistry tracks running subprocesses so /api/sse/ can find -// their stdout stream and /api/cancel/ can SIGINT them. -type runRegistry struct { - mu sync.Mutex - runs map[string]*run -} - -type run struct { - cmd *exec.Cmd - output chan string // line-buffered stdout+stderr - done chan int // exit code - cancel context.CancelFunc - started time.Time -} - -func newRunRegistry() *runRegistry { return &runRegistry{runs: map[string]*run{}} } - -func (rr *runRegistry) put(id string, r *run) { - rr.mu.Lock() - defer rr.mu.Unlock() - rr.runs[id] = r -} - -func (rr *runRegistry) get(id string) (*run, bool) { - rr.mu.Lock() - defer rr.mu.Unlock() - r, ok := rr.runs[id] - return r, ok -} - -func (rr *runRegistry) delete(id string) { - rr.mu.Lock() - defer rr.mu.Unlock() - delete(rr.runs, id) -} - -func (rr *runRegistry) cancelAll() { - rr.mu.Lock() - defer rr.mu.Unlock() - for _, r := range rr.runs { - r.cancel() - } -} - -// RunRequest is the POST /api/run body — subpath plus user-entered -// flag values + positional args. -type RunRequest struct { - Path string `json:"path"` // dot-separated, mapped to space-separated argv - Flags map[string]string `json:"flags,omitempty"` // key = long flag name, value = string repr - Args []string `json:"args,omitempty"` // positional args -} - -func handleRun(runs *runRegistry, skywireBin string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req RunRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "decode body: "+err.Error(), http.StatusBadRequest) - return - } - argv := append([]string{}, strings.Split(req.Path, ".")...) - for k, v := range req.Flags { - argv = append(argv, "--"+k, v) - } - argv = append(argv, req.Args...) - - runCtx, cancel := context.WithCancel(context.Background()) //nolint:gosec // cancel routed to run - cmd := exec.CommandContext(runCtx, skywireBin, argv...) //nolint:gosec // skywireBin is the resolved self-binary path; argv is trusted CLI input - // Combined stdout+stderr to one pipe; we don't distinguish - // in the SSE stream (the wasm client just renders sequential - // lines, like a terminal would). - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - http.Error(w, "stdout pipe: "+err.Error(), http.StatusInternalServerError) - return - } - cmd.Stderr = cmd.Stdout - // SIGINT on cancel so the subprocess gets a chance to clean - // up (matters for visor halt, dmsg curl downloads, etc). - setProcGroup(cmd) - - if err := cmd.Start(); err != nil { - cancel() - http.Error(w, "start: "+err.Error(), http.StatusInternalServerError) - return - } - - id := uuid.NewString() - runEntry := &run{ - cmd: cmd, - output: make(chan string, 256), - done: make(chan int, 1), - cancel: cancel, - started: time.Now(), - } - runs.put(id, runEntry) - - // Reader goroutine: line-split stdout into the channel. - go func() { - defer close(runEntry.output) - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - select { - case runEntry.output <- sc.Text(): - case <-runCtx.Done(): - return - } - } - }() - // Wait goroutine: emit exit code, then drop from registry - // after a grace window so late SSE consumers can read it. - go func() { - err := cmd.Wait() - code := 0 - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - code = exitErr.ExitCode() - } else { - code = -1 - } - } - runEntry.done <- code - close(runEntry.done) - // Grace: keep the entry around for 30s so a slow SSE - // reconnect can still pull the exit code. - time.AfterFunc(30*time.Second, func() { - runs.delete(id) - cancel() - }) - }() - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) //nolint:errcheck - }) -} - -func handleSSE(runs *runRegistry) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - runEntry, ok := runs.get(id) - if !ok { - http.Error(w, "no such run", http.StatusNotFound) - return - } - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.WriteHeader(http.StatusOK) - flusher.Flush() - - // Browser disconnect → SIGINT to subprocess. The Setpgid - // above lets us kill the whole process group, catching any - // children the subprocess spawned (e.g., `skywire visor`'s - // app subprocesses). - notify := r.Context().Done() - - for { - select { - case line, ok := <-runEntry.output: - if !ok { - // Output channel closed → wait for exit code + - // emit it as a final event, then close. - select { - case code := <-runEntry.done: - fmt.Fprintf(w, "event: exit\ndata: %d\n\n", code) //nolint:errcheck - flusher.Flush() - case <-time.After(5 * time.Second): - fmt.Fprintf(w, "event: exit\ndata: -1\n\n") //nolint:errcheck - flusher.Flush() - } - return - } - writeSSE(w, "stdout", line) - flusher.Flush() - case <-notify: - // Client gone — kill the subprocess group. - killProcGroup(runEntry.cmd.Process) - return - } - } - }) -} - -func writeSSE(w io.Writer, event, data string) { - for _, line := range strings.Split(data, "\n") { - fmt.Fprintf(w, "event: %s\ndata: %s\n", event, line) //nolint:errcheck - } - fmt.Fprint(w, "\n") //nolint:errcheck -} - -func handleCancel(runs *runRegistry) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - runEntry, ok := runs.get(id) - if !ok { - http.Error(w, "no such run", http.StatusNotFound) - return - } - killProcGroup(runEntry.cmd.Process) - w.WriteHeader(http.StatusNoContent) - }) -} diff --git a/docs/skywire/README.md b/docs/skywire/README.md index 7d81bbd0aa..148ed5b2a3 100644 --- a/docs/skywire/README.md +++ b/docs/skywire/README.md @@ -22,7 +22,6 @@ skywire - [dmsg](dmsg/README.md) — DMSG services & utilities - [svc](svc/README.md) — Skywire services - [visor](visor/README.md) — Skywire Visor -- [web](web/README.md) — Serve the skywire CLI as a browser-based UI ## Flags diff --git a/docs/skywire/web/README.md b/docs/skywire/web/README.md deleted file mode 100644 index de6833d00b..0000000000 --- a/docs/skywire/web/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# skywire web - -[← skywire](../README.md) - -Start a local HTTP server that renders the entire skywire -cobra command tree as a navigable web page. Click any subcommand -to see its help, fill in flags, and execute — output streams back -to the browser via Server-Sent Events. - -The page is a TinyGo-compiled WASM bundle (no JavaScript framework, -no client-side state outside the WASM module). The server walks -the live cobra tree at startup so the UI always reflects the -current binary's actual capabilities. - -Default binding is loopback-only. For LAN/remote access, pair ---addr 0.0.0.0:8088 with --token and require clients to -provide the token via ?token= or Authorization: Bearer. - -The --allow flag scopes which subcommands the UI exposes — useful -for hosted instances. Example: --allow cli.skychat,cli.dmsg.curl -exposes only chat send + dmsg curl. Empty allowlist = everything -non-hidden. - -Examples: - skywire web # localhost:8088, no auth - skywire web --addr 0.0.0.0:8088 --token foo # LAN, token-gated - skywire web --allow cli.skychat # only chat subcommands - -## Usage - -``` -skywire web -``` - -## Flags - -``` - --addr string HTTP bind address (use 'host:port'; default loopback-only) (default "127.0.0.1:8088") - --allow strings allowlist of subcommand paths (dot-separated, e.g. 'cli.skychat.send'); empty = allow all non-hidden subcommands - --skywire string path to the skywire binary used for subprocess execution (empty = use the running binary) - --token string require ?token=... or Authorization: Bearer for every request (empty = no auth, only safe on loopback) -``` - -## Global Flags - -``` - -h, --help show help menu -``` - ---- -_Generated by `skywire doc` — do not edit by hand._