From 680795cdfc5c69eb42c05c7825b41e2f65c4830c Mon Sep 17 00:00:00 2001 From: xboard Date: Fri, 17 Apr 2026 05:31:05 +0800 Subject: [PATCH] refactor: install.sh and xbctl --- cmd/xbctl/main.go | 420 +++++++++++++++++++++++++++++++++++++++++----- install.sh | 327 +++++++----------------------------- 2 files changed, 440 insertions(+), 307 deletions(-) diff --git a/cmd/xbctl/main.go b/cmd/xbctl/main.go index e5e05a3..01acc58 100644 --- a/cmd/xbctl/main.go +++ b/cmd/xbctl/main.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" + "strconv" "strings" "text/tabwriter" "time" @@ -179,6 +181,8 @@ func run(args []string) error { case "version", "-v", "--version": fmt.Printf("xbctl %s (built %s)\n", version, buildTime) return nil + case "config": + return runConfig(args[1:]) case "help", "-h", "--help": printUsage() return nil @@ -194,6 +198,8 @@ func printUsage() { xbctl list [--output text|json] xbctl instance list [--output text|json] xbctl instance get [--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 health 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 { - versionValue := "unknown" + ver := "unknown" if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" { - versionValue = meta.Version + ver = meta.Version } - instances := normalizeRootInstances(root) - 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) + return writeInstallMetaVersioned(path, root, ver, "") } func instanceMode(inst config.Config) string { @@ -792,3 +763,376 @@ func machineIDPtr(cfg *config.Config) *int { vv := cfg.Machine.MachineID return &vv } + +// ── config subcommand ───────────────────────────────────────────────── + +func runConfig(args []string) error { + if len(args) == 0 { + return errors.New("usage: xbctl config ") + } + 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= +// ENV_KEY= +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 +} diff --git a/install.sh b/install.sh index 2639b32..fe94101 100755 --- a/install.sh +++ b/install.sh @@ -77,20 +77,12 @@ load_health_port_from_config() { return fi local parsed - parsed=$(python3 - "$cfg_path" <<'PYCFG' -import sys -try: - import yaml -except Exception: - sys.exit(0) -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 + if [ -x "$CLI_PATH" ]; then + parsed=$("$CLI_PATH" config health-port --config "$cfg_path" 2>/dev/null) + else + parsed=$(grep -m1 'health_port:' "$cfg_path" 2>/dev/null | sed 's/.*health_port:[[:space:]]*//' | tr -cd '0-9') + fi + if [ -n "$parsed" ] && [ "$parsed" -ge 0 ] 2>/dev/null; then HEALTH_PORT="$parsed" if [ "$HEALTH_PORT" -eq 0 ]; then HEALTH_ENABLED=0 @@ -449,17 +441,6 @@ validate_install_request() { esac } -json_escape() { - python3 - <<'PY' "$1" -import json, sys -print(json.dumps(sys.argv[1])) -PY -} - -current_mode() { :; } - -current_target() { :; } - detect_current_state() { local has_binary=0 has_config=0 has_service=0 [ -x "$BINARY_PATH" ] && has_binary=1 @@ -562,229 +543,49 @@ stage_xbctl() { 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() { - INSTANCE_ID=$(python3 - "$MODE" "$PANEL_URL" "$NODE_ID" "$MACHINE_ID" <<'PY_INST' -import hashlib -import sys -from urllib.parse import urlparse -mode, panel_url, node_id, machine_id = sys.argv[1:] -node_id = int(node_id or 0) -machine_id = int(machine_id or 0) -u = urlparse((panel_url or '').strip()) -if not u.scheme or not u.netloc: - raise SystemExit(f"invalid panel url: {panel_url}") -scheme = u.scheme.lower() -host = u.netloc.lower() -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 -) - - 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' -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" + local init_args=( + config init + --mode "$MODE" + --panel-url "$PANEL_URL" + --kernel "${KERNEL_TYPE:-singbox}" + --health-port "${HEALTH_PORT:-0}" + --token "$TOKEN" + --version "$RELEASE_VERSION" + --output "$TMP_DIR/config.yml" + --credentials-out "$TMP_DIR/credentials.env" + --meta "$TMP_DIR/install-meta.json" + --install-root "$INSTALL_ROOT" + ) + if [ -f "$CONFIG_FILE" ]; then + init_args+=(--config "$CONFIG_FILE") + fi + if [ -f "$CREDENTIALS_FILE" ]; then + init_args+=(--credentials-in "$CREDENTIALS_FILE") + fi + if [ "$MODE" = "machine" ]; then + init_args+=(--machine-id "$MACHINE_ID") + else + init_args+=(--node-id "$NODE_ID") + if [ -n "$NODE_TYPE" ]; then + init_args+=(--node-type "$NODE_TYPE") + fi + fi + if [ -n "$RUNTIME_GOMEMLIMIT" ]; then + init_args+=(--gomemlimit "$RUNTIME_GOMEMLIMIT") + fi + if [ -n "$RUNTIME_GOGC" ] && [ "$RUNTIME_GOGC" -gt 0 ] 2>/dev/null; then + init_args+=(--gogc "$RUNTIME_GOGC") fi - python3 - "$CREDENTIALS_FILE" "$TMP_DIR/credentials.env" "$env_key" "$TOKEN" <<'PY_ENV' -import os -import sys -src, out, key, value = sys.argv[1:] -entries = {} -order = [] -if os.path.exists(src): - with open(src, 'r', encoding='utf-8') as f: - for raw in f: - line = raw.rstrip('\n') - if not line or line.startswith('#') or '=' not in line: - continue - k, v = line.split('=', 1) - if k not in entries: - order.append(k) - entries[k] = v -if key not in entries: - order.append(key) -entries[key] = value -with open(out, 'w', encoding='utf-8') as f: - for k in order: - f.write(f"{k}={entries[k]}\n") -PY_ENV - chmod 600 "$TMP_DIR/credentials.env" -} -render_install_meta() { - python3 - "$TMP_DIR/config.yml" "$TMP_DIR/install-meta.json" "$RELEASE_VERSION" "$INSTANCE_ID" <<'PY_META' -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), + local output + output=$("$TMP_DIR/xbctl" "${init_args[@]}") || { + log_error "xbctl config init failed" + exit 1 } - 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 + + INSTANCE_ID=$(echo "$output" | grep '^INSTANCE_ID=' | cut -d= -f2-) + chmod 600 "$TMP_DIR/credentials.env" } render_service() { @@ -850,7 +651,7 @@ install_staged_files() { install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE" install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE" 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" fi install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH" @@ -909,7 +710,6 @@ perform_install() { stage_binary stage_xbctl render_config - render_install_meta render_service backup_existing_state install_staged_files @@ -972,12 +772,11 @@ perform_uninstall() { fi rm -f "$BINARY_PATH" rm -f "$CLI_PATH" - rm -f "$INSTALL_META" if [ "$PURGE" -eq 1 ]; then rm -rf "$INSTALL_ROOT" log_info "Removed ${INSTALL_ROOT}" else - rm -f "$CONFIG_FILE" "$CREDENTIALS_FILE" + rm -f "$INSTALL_META" log_info "Config preserved under ${INSTALL_ROOT}" fi log_info "Uninstall complete" @@ -990,27 +789,17 @@ perform_status() { echo " state: ${CURRENT_STATE}" if [ -f "$INSTALL_META" ]; then echo " meta: ${INSTALL_META}" - python3 - "$INSTALL_META" <<'PY' -import json, sys -with open(sys.argv[1]) as f: - data = json.load(f) -config_mode = data.get("config_mode") -if config_mode: - print(f" config_mode: {config_mode}") -for key in ("mode", "panel_url", "kernel", "node_id", "machine_id", "health_port", "version", "updated_at", "latest_instance_id", "instance_count"): - if key in data: - print(f" {key}: {data[key]}") -instances = data.get("instances") or [] -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 + if [ -x "$CLI_PATH" ]; then + "$CLI_PATH" list 2>/dev/null || true + else + # Simple key extraction from JSON (no Python needed) + local val + for key in config_mode version latest_instance_id instance_count updated_at; do + val=$(sed -n "s/.*\"${key}\": *\"\{0,1\}\([^\"]*\)\"\{0,1\}.*/\1/p" "$INSTALL_META" | head -1) + val="${val%,}" # strip trailing comma from numeric JSON values + [ -n "$val" ] && echo " ${key}: ${val}" + done + fi fi if [ -f "$SERVICE_PATH" ]; then echo " service: ${SERVICE_NAME}"