refactor: install.sh and xbctl

This commit is contained in:
xboard
2026-04-17 05:31:05 +08:00
parent 17823891a2
commit 680795cdfc
2 changed files with 440 additions and 307 deletions
+382 -38
View File
@@ -7,7 +7,9 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
@@ -179,6 +181,8 @@ func run(args []string) error {
case "version", "-v", "--version": case "version", "-v", "--version":
fmt.Printf("xbctl %s (built %s)\n", version, buildTime) fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
return nil return nil
case "config":
return runConfig(args[1:])
case "help", "-h", "--help": case "help", "-h", "--help":
printUsage() printUsage()
return nil return nil
@@ -194,6 +198,8 @@ func printUsage() {
xbctl list [--output text|json] xbctl list [--output text|json]
xbctl instance list [--output text|json] xbctl instance list [--output text|json]
xbctl instance get <id> [--output text|json] xbctl instance get <id> [--output text|json]
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
xbctl config health-port [--config PATH]
xbctl service status|start|stop|restart|logs xbctl service status|start|stop|restart|logs
xbctl health xbctl health
xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray] xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
@@ -624,46 +630,11 @@ func pruneCredentialKeys(path string, removed []config.Config) error {
} }
func writeInstallMeta(path string, root *config.RootConfig) error { func writeInstallMeta(path string, root *config.RootConfig) error {
versionValue := "unknown" ver := "unknown"
if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" { if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" {
versionValue = meta.Version ver = meta.Version
} }
instances := normalizeRootInstances(root) return writeInstallMetaVersioned(path, root, ver, "")
items := make([]instanceSummary, 0, len(instances))
latestID := ""
for _, inst := range instances {
id, err := inst.AutoInstanceID()
if err != nil {
return err
}
latestID = id
item := instanceSummary{
ID: id,
PanelURL: inst.Panel.URL,
Mode: instanceMode(inst),
NodeID: intPtr(inst.Panel.NodeID),
MachineID: machineIDPtr(&inst),
HealthPort: inst.HealthPort,
}
if item.Mode == "machine" {
item.NodeID = nil
}
items = append(items, item)
}
meta := installMeta{
ConfigMode: "instances",
Version: versionValue,
LatestInstanceID: latestID,
InstanceCount: len(items),
Instances: items,
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal install meta: %w", err)
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
} }
func instanceMode(inst config.Config) string { func instanceMode(inst config.Config) string {
@@ -792,3 +763,376 @@ func machineIDPtr(cfg *config.Config) *int {
vv := cfg.Machine.MachineID vv := cfg.Machine.MachineID
return &vv return &vv
} }
// ── config subcommand ─────────────────────────────────────────────────
func runConfig(args []string) error {
if len(args) == 0 {
return errors.New("usage: xbctl config <init|health-port>")
}
switch args[0] {
case "init":
return runConfigInit(args[1:])
case "health-port":
return runConfigHealthPort(args[1:])
default:
return fmt.Errorf("unknown config command: %s", args[0])
}
}
// runConfigInit generates/merges an instance into config.yml, writes
// credentials.env and install-meta.json. It replaces the Python PY_INST,
// PY_CFG, PY_ENV, and PY_META heredoc blocks in install.sh.
//
// Output (stdout, one per line):
//
// INSTANCE_ID=<generated-id>
// ENV_KEY=<credential-env-var-name>
func runConfigInit(args []string) error {
var (
configIn string
configOut string
credentialsIn string
credentialsOut string
metaPath string
mode string
panelURL string
nodeID int
nodeType string
machineID int
kernelType string
healthPort int
gomemlimit string
gogc int
installRoot string
token string
releaseVersion string
)
for i := 0; i < len(args); i++ {
if i+1 >= len(args) {
break
}
switch args[i] {
case "--config":
i++
configIn = args[i]
case "--output":
i++
configOut = args[i]
case "--credentials-in":
i++
credentialsIn = args[i]
case "--credentials-out":
i++
credentialsOut = args[i]
case "--meta":
i++
metaPath = args[i]
case "--mode":
i++
mode = args[i]
case "--panel-url":
i++
panelURL = args[i]
case "--node-id":
i++
v, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Errorf("invalid --node-id: %w", err)
}
nodeID = v
case "--node-type":
i++
nodeType = args[i]
case "--machine-id":
i++
v, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Errorf("invalid --machine-id: %w", err)
}
machineID = v
case "--kernel":
i++
kernelType = args[i]
case "--health-port":
i++
v, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Errorf("invalid --health-port: %w", err)
}
healthPort = v
case "--gomemlimit":
i++
gomemlimit = args[i]
case "--gogc":
i++
v, err := strconv.Atoi(args[i])
if err != nil {
return fmt.Errorf("invalid --gogc: %w", err)
}
gogc = v
case "--install-root":
i++
installRoot = args[i]
case "--token":
i++
token = args[i]
case "--version":
i++
releaseVersion = args[i]
}
}
if mode == "" {
return errors.New("--mode is required (node or machine)")
}
if panelURL == "" {
return errors.New("--panel-url is required")
}
if mode == "node" && nodeID <= 0 {
return errors.New("--node-id is required for node mode")
}
if mode == "machine" && machineID <= 0 {
return errors.New("--machine-id is required for machine mode")
}
// Build the new instance.
inst := config.Config{
Panel: config.PanelConfig{URL: panelURL},
Kernel: config.KernelConfig{
Type: kernelType,
LogLevel: "warn",
},
Log: config.LogConfig{Level: "info", Output: "stdout"},
HealthPort: healthPort,
}
if mode == "machine" {
inst.Machine = &config.MachineConfig{MachineID: machineID}
} else {
inst.Panel.NodeID = nodeID
if nodeType != "" {
inst.Panel.NodeType = nodeType
}
}
// Generate deterministic instance ID.
instanceID, err := inst.AutoInstanceID()
if err != nil {
return fmt.Errorf("generate instance ID: %w", err)
}
inst.InstanceID = instanceID
if installRoot == "" {
installRoot = "/etc/xboard-node"
}
inst.Kernel.ConfigDir = filepath.Join(installRoot, "instances", instanceID)
// Build credential env key.
envKey := "INSTANCE_" + strings.ToUpper(strings.ReplaceAll(instanceID, "-", "_"))
if mode == "machine" {
envKey += "_MACHINE_TOKEN"
inst.Machine.TokenEnv = envKey
} else {
envKey += "_API_KEY"
inst.Panel.TokenEnv = envKey
}
if gomemlimit != "" {
inst.Runtime.GoMemLimit = gomemlimit
}
if gogc > 0 {
inst.Runtime.GoGCPercent = gogc
}
// Load existing config (if any).
root := &config.RootConfig{}
hasExisting := false
if configIn != "" {
if loaded, loadErr := loadWritableRootConfig(configIn); loadErr == nil {
root = loaded
hasExisting = len(root.Instances) > 0 || root.Config.Panel.URL != "" || root.Config.Kernel.Type != ""
}
}
// Normalise existing instance IDs so dedup works correctly.
var instances []config.Config
if hasExisting {
instances = normalizeRootInstances(root)
seen := make(map[string]bool)
deduped := make([]config.Config, 0, len(instances))
for _, existing := range instances {
autoID, idErr := existing.AutoInstanceID()
if idErr == nil && autoID != "" {
existing.InstanceID = autoID
}
id := existing.InstanceID
if id != "" && seen[id] {
continue
}
if id != "" {
seen[id] = true
}
deduped = append(deduped, existing)
}
instances = deduped
}
// Merge: replace if same ID exists, otherwise append.
replaced := false
for i, existing := range instances {
if existing.InstanceID == instanceID {
instances[i] = inst
replaced = true
break
}
}
if !replaced {
instances = append(instances, inst)
}
root.Instances = instances
// Write config.
if configOut == "" {
configOut = configIn
}
if configOut == "" {
return errors.New("--output (or --config) is required")
}
if err := writeRootConfig(configOut, root); err != nil {
return err
}
// Write credentials.
if token != "" && credentialsOut != "" {
if err := mergeCredentials(credentialsIn, credentialsOut, envKey, token); err != nil {
return err
}
}
// Write install-meta.json.
if metaPath != "" {
if err := writeInstallMetaVersioned(metaPath, root, releaseVersion, instanceID); err != nil {
return err
}
}
// Output for bash capture.
fmt.Printf("INSTANCE_ID=%s\n", instanceID)
fmt.Printf("ENV_KEY=%s\n", envKey)
return nil
}
// mergeCredentials reads existing key=value credentials, adds/replaces
// the given key, and writes the result preserving insertion order.
func mergeCredentials(srcPath, dstPath, key, value string) error {
entries := make(map[string]string)
var order []string
if srcPath != "" {
data, err := os.ReadFile(srcPath)
if err == nil {
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
continue
}
k, v, _ := strings.Cut(line, "=")
if _, exists := entries[k]; !exists {
order = append(order, k)
}
entries[k] = v
}
}
}
if _, exists := entries[key]; !exists {
order = append(order, key)
}
entries[key] = value
var buf strings.Builder
for _, k := range order {
fmt.Fprintf(&buf, "%s=%s\n", k, entries[k])
}
return os.WriteFile(dstPath, []byte(buf.String()), 0o600)
}
// writeInstallMetaVersioned writes install-meta.json with an explicit
// version and latest-instance-ID. When latestID is empty, the last
// instance in the config is used (backward compat with writeInstallMeta).
func writeInstallMetaVersioned(path string, root *config.RootConfig, ver, latestID string) error {
if ver == "" {
ver = "unknown"
}
instances := normalizeRootInstances(root)
items := make([]instanceSummary, 0, len(instances))
for _, inst := range instances {
id, err := inst.AutoInstanceID()
if err != nil {
return err
}
if latestID == "" {
latestID = id
}
item := instanceSummary{
ID: id,
PanelURL: inst.Panel.URL,
Mode: instanceMode(inst),
NodeID: intPtr(inst.Panel.NodeID),
MachineID: machineIDPtr(&inst),
HealthPort: inst.HealthPort,
}
if item.Mode == "machine" {
item.NodeID = nil
}
items = append(items, item)
}
meta := installMeta{
ConfigMode: "instances",
Version: ver,
LatestInstanceID: latestID,
InstanceCount: len(items),
Instances: items,
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal install meta: %w", err)
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
// runConfigHealthPort reads health_port from an existing config file and
// prints it to stdout. Exits silently if the file does not exist or has
// no health_port.
func runConfigHealthPort(args []string) error {
cfgPath := defaultConfigPath
for i := 0; i < len(args); i++ {
if args[i] == "--config" && i+1 < len(args) {
i++
cfgPath = args[i]
}
}
data, err := os.ReadFile(cfgPath)
if err != nil {
return nil // file not found → no health port
}
root := &config.RootConfig{}
if err := yaml.Unmarshal(data, root); err != nil {
return nil
}
// Check instances first, then legacy top-level.
if len(root.Instances) > 0 {
for _, inst := range root.Instances {
if inst.HealthPort > 0 {
fmt.Println(inst.HealthPort)
return nil
}
}
}
if root.Config.HealthPort > 0 {
fmt.Println(root.Config.HealthPort)
}
return nil
}
+57 -268
View File
@@ -77,20 +77,12 @@ load_health_port_from_config() {
return return
fi fi
local parsed local parsed
parsed=$(python3 - "$cfg_path" <<'PYCFG' if [ -x "$CLI_PATH" ]; then
import sys parsed=$("$CLI_PATH" config health-port --config "$cfg_path" 2>/dev/null)
try: else
import yaml parsed=$(grep -m1 'health_port:' "$cfg_path" 2>/dev/null | sed 's/.*health_port:[[:space:]]*//' | tr -cd '0-9')
except Exception: fi
sys.exit(0) if [ -n "$parsed" ] && [ "$parsed" -ge 0 ] 2>/dev/null; then
with open(sys.argv[1], 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
port = data.get('health_port', 0)
if isinstance(port, int):
print(port)
PYCFG
)
if [ -n "$parsed" ] && [[ "$parsed" =~ ^[0-9]+$ ]]; then
HEALTH_PORT="$parsed" HEALTH_PORT="$parsed"
if [ "$HEALTH_PORT" -eq 0 ]; then if [ "$HEALTH_PORT" -eq 0 ]; then
HEALTH_ENABLED=0 HEALTH_ENABLED=0
@@ -449,17 +441,6 @@ validate_install_request() {
esac esac
} }
json_escape() {
python3 - <<'PY' "$1"
import json, sys
print(json.dumps(sys.argv[1]))
PY
}
current_mode() { :; }
current_target() { :; }
detect_current_state() { detect_current_state() {
local has_binary=0 has_config=0 has_service=0 local has_binary=0 has_config=0 has_service=0
[ -x "$BINARY_PATH" ] && has_binary=1 [ -x "$BINARY_PATH" ] && has_binary=1
@@ -562,229 +543,49 @@ stage_xbctl() {
fi fi
} }
render_runtime_block() {
local output=""
if [ -n "$RUNTIME_GOMEMLIMIT" ] || [ -n "$RUNTIME_GOGC" ]; then
output+=$'runtime:\n'
if [ -n "$RUNTIME_GOMEMLIMIT" ]; then
output+=" gomemlimit: \"${RUNTIME_GOMEMLIMIT}\"\n"
fi
if [ -n "$RUNTIME_GOGC" ]; then
output+=" gogc: ${RUNTIME_GOGC}\n"
fi
fi
printf '%b' "$output"
}
render_config() { render_config() {
INSTANCE_ID=$(python3 - "$MODE" "$PANEL_URL" "$NODE_ID" "$MACHINE_ID" <<'PY_INST' local init_args=(
import hashlib config init
import sys --mode "$MODE"
from urllib.parse import urlparse --panel-url "$PANEL_URL"
mode, panel_url, node_id, machine_id = sys.argv[1:] --kernel "${KERNEL_TYPE:-singbox}"
node_id = int(node_id or 0) --health-port "${HEALTH_PORT:-0}"
machine_id = int(machine_id or 0) --token "$TOKEN"
u = urlparse((panel_url or '').strip()) --version "$RELEASE_VERSION"
if not u.scheme or not u.netloc: --output "$TMP_DIR/config.yml"
raise SystemExit(f"invalid panel url: {panel_url}") --credentials-out "$TMP_DIR/credentials.env"
scheme = u.scheme.lower() --meta "$TMP_DIR/install-meta.json"
host = u.netloc.lower() --install-root "$INSTALL_ROOT"
path = u.path.rstrip('/')
normalized = f"{scheme}://{host}{path}"
slug_src = f"{host}{('-' + path.strip('/')) if path.strip('/') else ''}".lower()
slug = []
last_dash = False
for ch in slug_src:
if ch.isalnum():
slug.append(ch)
last_dash = False
elif not last_dash:
slug.append('-')
last_dash = True
slug = ''.join(slug).strip('-') or 'panel'
slug = (slug[:48].strip('-') or 'panel')
target_value = str(machine_id) if mode == "machine" else str(node_id)
target_label = f"machine-{machine_id}" if mode == "machine" else f"node-{node_id}"
short = hashlib.sha1(f"{normalized}|{mode}|{target_value}".encode()).hexdigest()[:6]
print(f"{slug}-{target_label}-{short}")
PY_INST
) )
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" "$TMP_DIR/config.yml" "$MODE" "$PANEL_URL" "$NODE_ID" "$NODE_TYPE" "$MACHINE_ID" "$KERNEL_TYPE" "$HEALTH_PORT" "$RUNTIME_GOMEMLIMIT" "$RUNTIME_GOGC" "$INSTALL_ROOT" "$INSTANCE_ID" <<'PY_CFG' init_args+=(--config "$CONFIG_FILE")
import os
import sys
from copy import deepcopy
import yaml
config_file, out_file, mode, panel_url, node_id, node_type, machine_id, kernel_type, health_port, gomemlimit, gogc, install_root, instance_id = sys.argv[1:]
node_id = int(node_id or 0)
machine_id = int(machine_id or 0)
health_port = int(health_port or 0)
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
else:
data = {}
legacy_keys = ["panel", "node", "kernel", "cert", "log", "runtime", "ws", "standalone", "health_port", "nodes", "machine"]
instances = data.get("instances")
if not isinstance(instances, list):
instances = []
if any(k in data for k in legacy_keys):
legacy = {k: deepcopy(data[k]) for k in legacy_keys if k in data}
if legacy:
instances.append(legacy)
for k in legacy_keys:
data.pop(k, None)
def normalize_existing_id(inst):
panel = inst.get("panel") or {}
machine = inst.get("machine") or {}
panel_url_existing = panel.get("url", "")
from urllib.parse import urlparse
u = urlparse((panel_url_existing or '').strip())
if not u.scheme or not u.netloc:
return inst.get("id", "")
normalized_existing = f"{u.scheme.lower()}://{u.netloc.lower()}{u.path.rstrip('/')}"
if machine and machine.get("machine_id"):
existing_mode = "machine"
target_value = str(machine.get("machine_id"))
target_label = f"machine-{machine.get('machine_id')}"
else:
existing_mode = "node"
target_value = str(panel.get("node_id", 0))
target_label = f"node-{panel.get('node_id', 0)}"
slug_src = f"{u.netloc.lower()}{('-' + u.path.rstrip('/').strip('/')) if u.path.rstrip('/').strip('/') else ''}".lower()
slug = []
last_dash = False
for ch in slug_src:
if ch.isalnum():
slug.append(ch)
last_dash = False
elif not last_dash:
slug.append('-')
last_dash = True
slug = ''.join(slug).strip('-') or 'panel'
slug = (slug[:48].strip('-') or 'panel')
short = __import__('hashlib').sha1(f"{normalized_existing}|{existing_mode}|{target_value}".encode()).hexdigest()[:6]
return f"{slug}-{target_label}-{short}"
normalized_instances = []
seen_ids = set()
for inst in instances:
inst = deepcopy(inst)
normalized_id = normalize_existing_id(inst)
if normalized_id:
inst["id"] = normalized_id
current_id = inst.get("id")
if current_id and current_id in seen_ids:
continue
if current_id:
seen_ids.add(current_id)
normalized_instances.append(inst)
instances = normalized_instances
env_key = f"INSTANCE_{instance_id.upper().replace('-', '_')}_{'MACHINE_TOKEN' if mode == 'machine' else 'API_KEY'}"
new_inst = {
"id": instance_id,
"panel": {"url": panel_url},
"kernel": {
"type": kernel_type,
"config_dir": f"{install_root}/instances/{instance_id}",
"log_level": "warn",
},
"log": {"level": "info", "output": "stdout"},
}
if health_port > 0:
new_inst["health_port"] = health_port
if gomemlimit or gogc:
new_inst["runtime"] = {}
if gomemlimit:
new_inst["runtime"]["gomemlimit"] = gomemlimit
if gogc:
new_inst["runtime"]["gogc"] = int(gogc)
if mode == 'machine':
new_inst["machine"] = {"machine_id": machine_id, "token_env": env_key}
else:
new_inst["panel"]["node_id"] = node_id
new_inst["panel"]["token_env"] = env_key
if node_type:
new_inst["panel"]["node_type"] = node_type
new_inst["node"] = {"push_interval": 0, "pull_interval": 0}
replaced = False
for i, inst in enumerate(instances):
if inst.get("id") == instance_id:
instances[i] = new_inst
replaced = True
break
if not replaced:
instances.append(new_inst)
data["instances"] = instances
with open(out_file, 'w', encoding='utf-8') as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=False)
PY_CFG
local env_key
if [ "$MODE" = "machine" ]; then
env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_MACHINE_TOKEN"
else
env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_API_KEY"
fi fi
python3 - "$CREDENTIALS_FILE" "$TMP_DIR/credentials.env" "$env_key" "$TOKEN" <<'PY_ENV' if [ -f "$CREDENTIALS_FILE" ]; then
import os init_args+=(--credentials-in "$CREDENTIALS_FILE")
import sys fi
src, out, key, value = sys.argv[1:] if [ "$MODE" = "machine" ]; then
entries = {} init_args+=(--machine-id "$MACHINE_ID")
order = [] else
if os.path.exists(src): init_args+=(--node-id "$NODE_ID")
with open(src, 'r', encoding='utf-8') as f: if [ -n "$NODE_TYPE" ]; then
for raw in f: init_args+=(--node-type "$NODE_TYPE")
line = raw.rstrip('\n') fi
if not line or line.startswith('#') or '=' not in line: fi
continue if [ -n "$RUNTIME_GOMEMLIMIT" ]; then
k, v = line.split('=', 1) init_args+=(--gomemlimit "$RUNTIME_GOMEMLIMIT")
if k not in entries: fi
order.append(k) if [ -n "$RUNTIME_GOGC" ] && [ "$RUNTIME_GOGC" -gt 0 ] 2>/dev/null; then
entries[k] = v init_args+=(--gogc "$RUNTIME_GOGC")
if key not in entries: fi
order.append(key)
entries[key] = value local output
with open(out, 'w', encoding='utf-8') as f: output=$("$TMP_DIR/xbctl" "${init_args[@]}") || {
for k in order: log_error "xbctl config init failed"
f.write(f"{k}={entries[k]}\n") exit 1
PY_ENV
chmod 600 "$TMP_DIR/credentials.env"
} }
render_install_meta() { INSTANCE_ID=$(echo "$output" | grep '^INSTANCE_ID=' | cut -d= -f2-)
python3 - "$TMP_DIR/config.yml" "$TMP_DIR/install-meta.json" "$RELEASE_VERSION" "$INSTANCE_ID" <<'PY_META' chmod 600 "$TMP_DIR/credentials.env"
import json
import sys
import yaml
from datetime import datetime, timezone
cfg_path, out_path, version, latest_id = sys.argv[1:]
with open(cfg_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f) or {}
instances = []
for inst in data.get('instances', []) or []:
item = {
'id': inst.get('id', ''),
'panel_url': ((inst.get('panel') or {}).get('url', '')),
'mode': 'machine' if inst.get('machine') else ('standalone' if inst.get('standalone') else 'node'),
'node_id': ((inst.get('panel') or {}).get('node_id')),
'machine_id': ((inst.get('machine') or {}).get('machine_id')),
'health_port': inst.get('health_port', 0),
}
instances.append(item)
meta = {
'config_mode': 'instances',
'version': version,
'latest_instance_id': latest_id,
'instance_count': len(instances),
'instances': instances,
'updated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
}
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(meta, f, indent=2)
PY_META
} }
render_service() { render_service() {
@@ -850,7 +651,7 @@ install_staged_files() {
install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE" install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE"
install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE" install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE"
install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META" install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META"
if [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then if [ -f "$0" ] && [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then
install -m 755 "$0" "$INSTALLER_COPY_PATH" install -m 755 "$0" "$INSTALLER_COPY_PATH"
fi fi
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
@@ -909,7 +710,6 @@ perform_install() {
stage_binary stage_binary
stage_xbctl stage_xbctl
render_config render_config
render_install_meta
render_service render_service
backup_existing_state backup_existing_state
install_staged_files install_staged_files
@@ -972,12 +772,11 @@ perform_uninstall() {
fi fi
rm -f "$BINARY_PATH" rm -f "$BINARY_PATH"
rm -f "$CLI_PATH" rm -f "$CLI_PATH"
rm -f "$INSTALL_META"
if [ "$PURGE" -eq 1 ]; then if [ "$PURGE" -eq 1 ]; then
rm -rf "$INSTALL_ROOT" rm -rf "$INSTALL_ROOT"
log_info "Removed ${INSTALL_ROOT}" log_info "Removed ${INSTALL_ROOT}"
else else
rm -f "$CONFIG_FILE" "$CREDENTIALS_FILE" rm -f "$INSTALL_META"
log_info "Config preserved under ${INSTALL_ROOT}" log_info "Config preserved under ${INSTALL_ROOT}"
fi fi
log_info "Uninstall complete" log_info "Uninstall complete"
@@ -990,27 +789,17 @@ perform_status() {
echo " state: ${CURRENT_STATE}" echo " state: ${CURRENT_STATE}"
if [ -f "$INSTALL_META" ]; then if [ -f "$INSTALL_META" ]; then
echo " meta: ${INSTALL_META}" echo " meta: ${INSTALL_META}"
python3 - "$INSTALL_META" <<'PY' if [ -x "$CLI_PATH" ]; then
import json, sys "$CLI_PATH" list 2>/dev/null || true
with open(sys.argv[1]) as f: else
data = json.load(f) # Simple key extraction from JSON (no Python needed)
config_mode = data.get("config_mode") local val
if config_mode: for key in config_mode version latest_instance_id instance_count updated_at; do
print(f" config_mode: {config_mode}") val=$(sed -n "s/.*\"${key}\": *\"\{0,1\}\([^\"]*\)\"\{0,1\}.*/\1/p" "$INSTALL_META" | head -1)
for key in ("mode", "panel_url", "kernel", "node_id", "machine_id", "health_port", "version", "updated_at", "latest_instance_id", "instance_count"): val="${val%,}" # strip trailing comma from numeric JSON values
if key in data: [ -n "$val" ] && echo " ${key}: ${val}"
print(f" {key}: {data[key]}") done
instances = data.get("instances") or [] fi
if instances:
print(" instances:")
for item in instances:
mode = item.get("mode", "")
panel_url = item.get("panel_url", "")
node_id = item.get("node_id")
machine_id = item.get("machine_id")
target = f"node_id={node_id}" if node_id else (f"machine_id={machine_id}" if machine_id else "")
print(f" - id={item.get('id','')} mode={mode} panel_url={panel_url} {target}".rstrip())
PY
fi fi
if [ -f "$SERVICE_PATH" ]; then if [ -f "$SERVICE_PATH" ]; then
echo " service: ${SERVICE_NAME}" echo " service: ${SERVICE_NAME}"