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._