Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -593,8 +593,10 @@ async function main (testOptions) {
* Log the common setup start message to the console
*/
async function logSetupStart () {
console.clear()
console.log(`${chalk.bold('Welcome to the')} ${chalk.cyan('FlowFuse Device Agent')} ${chalk.gray(`v${pkg.version}`)}`)
if (!installerMode) {
console.clear()
console.log(`${chalk.bold('Welcome to the')} ${chalk.cyan('FlowFuse Device Agent')} ${chalk.gray(`v${pkg.version}`)}`)
}
}

/**
Expand Down
16 changes: 8 additions & 8 deletions installer/go/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,10 @@ func Uninstall(customWorkDir string) error {
s := cfg.ServiceName
logger.Debug("Loaded service name from config: %s", s)
switch s {
case "":
serviceName = "flowfuse-device-agent"
default:
serviceName = s
case "":
serviceName = "flowfuse-device-agent"
default:
serviceName = s
}
}

Expand Down Expand Up @@ -377,10 +377,10 @@ func Update(agentVersion, nodeVersion, customWorkDir string, updateAgent, update
s := cfg.ServiceName
logger.Debug("Loaded service name from config: %s", s)
switch s {
case "":
serviceName = "flowfuse-device-agent"
default:
serviceName = s
case "":
serviceName = "flowfuse-device-agent"
default:
serviceName = s
}
}

Expand Down
42 changes: 21 additions & 21 deletions installer/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package main
import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/flowfuse/device-agent-installer/cmd"
"github.com/flowfuse/device-agent-installer/pkg/logger"
"github.com/flowfuse/device-agent-installer/pkg/style"
"github.com/flowfuse/device-agent-installer/pkg/utils"
"github.com/spf13/pflag"
)
Expand All @@ -33,7 +36,7 @@ func init() {
pflag.StringVarP(&nodeVersion, "nodejs-version", "n", "22.23.0", "Node.js version to install (minimum)")
pflag.StringVarP(&agentVersion, "agent-version", "a", "latest", "Device agent version to install/update to")
pflag.StringVarP(&serviceUsername, "service-user", "s", "flowfuse", "Username for the service account")
pflag.StringVarP(&flowfuseURL, "url", "u", "https://app.flowfuse.com", "FlowFuse URL")
pflag.StringVarP(&flowfuseURL, "url", "u", "", "FlowFuse URL")
pflag.StringVarP(&flowfuseOneTimeCode, "otc", "o", "", "FlowFuse one time code for authentication (optional for interactive installation)")
pflag.StringVarP(&installDir, "dir", "d", "", "Custom installation directory (default: /opt/flowfuse-device on Unix, c:\\opt\\flowfuse-device on Windows)")
pflag.IntVarP(&port, "port", "p", 1880, "TCP port for the device agent (1-65535)")
Expand Down Expand Up @@ -95,43 +98,40 @@ func main() {
defer logger.Close()
}

// Handle Ctrl-C (and SIGTERM) gracefully: if the user interrupts while a
// prompt is on screen, restore the terminal so no dangling cursor-save state
// is left behind (which would otherwise break cursor handling until reset).
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
style.CancelPrompt()
fmt.Fprintln(os.Stdout, "\nCancelled.")
logger.Close()
os.Exit(130)
}()

// Log startup information
logger.Debug("Command line arguments: node=%s, agent=%s, user=%s, url=%s, debug=%v, customInstallDir=%s, port=%d, caCert=%s",
nodeVersion, agentVersion, serviceUsername, flowfuseURL, debugMode, installDir, port, caCertPath)
operatingSystem, architecture := utils.GetOSDetails()
logger.Debug("Detected system: %s, detected architecture: %s", operatingSystem, architecture)

logger.Info("****************************************************************")
logger.Info("* FlowFuse Device Agent Installer *")
logger.Info("* *")
logger.Info("* This installer will set up the FlowFuse Device Agent on your *")
logger.Info("* system and configure it to run as a system service. *")
logger.Info("* *")
logger.Info("****************************************************************")
logger.Info("%s %s", style.Bold("Welcome to the"), style.Cyan("FlowFuse Device Agent Installer"))

if debugMode {
logger.Info("Debug mode enabled. Logs will be written to: %s", logger.GetLogFilePath())
logger.Debug("FlowFuse Device Agent Installer version: %s", instVersion)
}

if flowfuseOneTimeCode == "" && !uninstall && !updateNode && !updateAgent {
fmt.Println("One time code has not been provided. The Device Agent automatic configuration is not possible.")
response := utils.PromptYesNo("Do you want to continue with the installation?", false)
if !response {
fmt.Println("Installation aborted by user.")
os.Exit(1)
} else {
fmt.Println("Continuing with installation in interactive mode...")
}
}

if uninstall {
err = cmd.Uninstall(installDir)
} else if updateNode || updateAgent {
logger.Info("Updating FlowFuse Device Agent...")
err = cmd.Update(agentVersion, nodeVersion, installDir, updateAgent, updateNode)
} else {
logger.Info("Installing FlowFuse Device Agent...")
logger.Info("")
logger.Info("Let's get your connected to FlowFuse.")
logger.Info("")
err = cmd.Install(nodeVersion, agentVersion, flowfuseURL, flowfuseOneTimeCode, installDir, false, port, caCertPath)
}

Expand Down
21 changes: 17 additions & 4 deletions installer/go/pkg/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"sync"
"time"
)

// ansiEscapeRE matches ANSI SGR escape sequences (e.g. those produced by the
// style package). They are stripped from file output so the log file stays
// plain text while the console keeps its styling.
var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)

// stripANSI removes ANSI escape sequences from s.
func stripANSI(s string) string {
return ansiEscapeRE.ReplaceAllString(s, "")
}


var (
// Global debug flag
Expand Down Expand Up @@ -122,12 +133,14 @@ func Debug(format string, v ...interface{}) {
mutex.Lock()
defer mutex.Unlock()

message := fmt.Sprintf(format, v...)

if fileDebugLogger != nil {
fileDebugLogger.Output(2, fmt.Sprintf(format, v...))
fileDebugLogger.Output(2, stripANSI(message))
}

if consoleDebugLogger != nil {
consoleDebugLogger.Output(2, fmt.Sprintf(format, v...))
consoleDebugLogger.Output(2, message)
}
}

Expand All @@ -146,7 +159,7 @@ func Info(format string, v ...interface{}) {
message := fmt.Sprintf(format, v...)

if fileInfoLogger != nil {
fileInfoLogger.Output(2, message)
fileInfoLogger.Output(2, stripANSI(message))
}

if consoleInfoLogger != nil {
Expand All @@ -172,7 +185,7 @@ func Error(format string, v ...interface{}) {
message := fmt.Sprintf(format, v...)

if fileErrorLogger != nil {
fileErrorLogger.Output(2, message)
fileErrorLogger.Output(2, stripANSI(message))
}

if consoleErrorLogger != nil {
Expand Down
152 changes: 53 additions & 99 deletions installer/go/pkg/nodejs/deviceagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,113 +312,67 @@ func ConfigureDeviceAgent(url, token, baseDir string, port int) (string, bool, e
deviceAgentPath = filepath.Join(nodeBinDirPath, "flowfuse-device-agent.cmd")
}

// Create configure command.
// Build the arguments shared across platforms first, adding the optional
// -o/-u flags only when a value is provided, then layer the OS-specific
// flags on top inside each case.
agentArgs := []string{"--otc-no-start", "--installer-mode"}
if token != "" {
// Create configure command
var configureCmd *exec.Cmd
switch runtime.GOOS {
case "linux", "darwin":
configureCmd = exec.Command("sudo", preserveEnv, deviceAgentPath, "-o", token, "-u", url, "--dir", baseDir, "--port", fmt.Sprintf("%d", port), "--otc-no-start", "--installer-mode")
env := os.Environ()
configureCmd.Dir = baseDir
configureCmd.Env = append(env, newPath)
case "windows":
configureCmd = exec.Command("powershell", "-Command", "&", fmt.Sprintf(`'%s'`, deviceAgentPath), "-o", token, "-u", url, "--dir", fmt.Sprintf(`'%s'`, baseDir), "--port", fmt.Sprintf(`'%d'`, port), "--otc-no-start", "--installer-mode", "-v")
env := os.Environ()
configureCmd.Dir = baseDir
configureCmd.Env = append(env, newPath)
default:
return "", false, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
agentArgs = append(agentArgs, "-o", token)
}
if url != "" {
agentArgs = append(agentArgs, "-u", url)
}

logger.Debug("Configure command: %s", configureCmd.String())
var configureCmd *exec.Cmd
switch runtime.GOOS {
case "linux", "darwin":
args := append([]string{preserveEnv, deviceAgentPath}, agentArgs...)
args = append(args, "--dir", baseDir, "--port", fmt.Sprintf("%d", port))
configureCmd = exec.Command("sudo", args...)
env := os.Environ()
configureCmd.Dir = baseDir
configureCmd.Env = append(env, newPath)
case "windows":
args := append([]string{"-Command", "&", fmt.Sprintf(`'%s'`, deviceAgentPath)}, agentArgs...)
args = append(args, "--dir", fmt.Sprintf(`'%s'`, baseDir), "--port", fmt.Sprintf(`'%d'`, port))
configureCmd = exec.Command("powershell", args...)
env := os.Environ()
configureCmd.Dir = baseDir
configureCmd.Env = append(env, newPath)
default:
return "", false, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}

// Connect stdin, stdout, and stderr for interactive processes
configureCmd.Stdin = os.Stdin
configureCmd.Stdout = os.Stdout
configureCmd.Stderr = os.Stderr
logger.Debug("Configure command: %s", configureCmd.String())

logger.Debug("Starting device agent configuration")
// Connect stdin, stdout, and stderr for interactive processes
configureCmd.Stdin = os.Stdin
configureCmd.Stdout = os.Stdout
configureCmd.Stderr = os.Stderr

// Run the command interactively
if err := configureCmd.Run(); err != nil {
return "", false, fmt.Errorf("failed to configure the device agent: %w", err)
}
logger.Debug("Starting device agent configuration")

var chownCmd *exec.Cmd
switch runtime.GOOS {
case "linux":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir)
case "darwin":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir)
case "windows":
logger.Info("Configuration completed successfully!")
return "otc", true, nil
}
// Set permissions for the working directory
if output, err := chownCmd.CombinedOutput(); err != nil {
return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output)
}
// Run the command interactively
if err := configureCmd.Run(); err != nil {
return "", false, fmt.Errorf("failed to configure the device agent: %w", err)
}

var chownCmd *exec.Cmd
switch runtime.GOOS {
case "linux":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir)
case "darwin":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir)
case "windows":
logger.Info("Configuration completed successfully!")
return "otc", true, nil
} else {

logger.Info("No OTC (One-Time Code) provided. Automatic configuration is not possible.")
options := []string{
"Provide a device configuration file now",
"Install the device agent only (you'll need to configure it manually later)",
}
choice, err := utils.PromptOption("You can either:", options, 0)
if err != nil {
logger.Error("Failed to get user selection: %v", err)
return "", false, fmt.Errorf("failed to get user selection: %w", err)
}
configProvided := choice == 0

if configProvided {
// Manual configuration mode
logger.Info("Please paste your device configuration below.")
logger.Info("The configuration should be in YAML format with all required fields.")
logger.Info("Enter an empty line when done:")

configContent, err := utils.PromptMultilineInput()
if err != nil {
logger.Error("Failed to read configuration input: %v", err)
return "", false, fmt.Errorf("failed to read configuration input: %w", err)
}

// Validate configuration
if err := utils.ValidateDeviceConfiguration(configContent); err != nil {
logger.Error("Invalid device configuration: %v", err)
return "", false, fmt.Errorf("invalid device configuration: %w", err)
}

// Save configuration to device.yml
if err := utils.SaveDeviceConfiguration(configContent, deviceConfigPath); err != nil {
logger.Error("Failed to save device configuration: %v", err)
return "", false, fmt.Errorf("failed to save device configuration: %w", err)
}

var chownCmd *exec.Cmd
switch runtime.GOOS {
case "linux":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser+":"+serviceUser, baseDir)
case "darwin":
chownCmd = exec.Command("sudo", "chown", "-R", serviceUser, baseDir)
case "windows":
logger.Info("Configuration completed successfully!")
return "manual", true, nil
}
// Set permissions for the working directory
if output, err := chownCmd.CombinedOutput(); err != nil {
return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output)
}

logger.Info("Configuration completed successfully!")
return "manual", true, nil
}

logger.Info("Configuration completed successfully!")
return "install-only", false, nil
}
// Set permissions for the working directory
if output, err := chownCmd.CombinedOutput(); err != nil {
return "", false, fmt.Errorf("failed to set directory ownership: %w\nOutput: %s", err, output)
}

logger.Info("Configuration completed successfully!")
return "otc", true, nil
}
21 changes: 17 additions & 4 deletions installer/go/pkg/service/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,28 @@ type ServiceConfig struct {
NodeExtraCACerts string // Optional custom CA bundle path (NODE_EXTRA_CA_CERTS)
}

// IsSystemd returns true if the system uses systemd, false otherwise
// This is determined by checking if the "systemctl" command is available
// IsSystemd returns true if the system was booted with systemd as its init
// system, false otherwise.
//
// This requires both that systemd is actually running as PID 1 (detected via the
// /run/systemd/system directory, the same check libsystemd's sd_booted() uses)
// and that the "systemctl" command is available. Checking only for the systemctl
// binary is insufficient: distributions such as Ubuntu ship the systemd package
// (and therefore systemctl) even in environments where systemd is not the running
// init - e.g. containers - causing systemctl calls to fail with "System has not
// been booted with systemd as init system (PID 1)".
//
// Returns:
// - true if systemd is found, false otherwise
// - true if systemd is the running init system, false otherwise
func IsSystemd() bool {
logger.LogFunctionEntry("IsSystemd", nil)
defer logger.LogFunctionExit("IsSystemd", nil, nil)

// systemd is only the active init system if it has populated /run/systemd/system.
if _, err := os.Stat("/run/systemd/system"); err != nil {
return false
}
_, err := exec.LookPath("systemctl")
logger.LogFunctionExit("IsSystemd", nil, nil)
return err == nil
}

Expand Down
Loading