-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): add service subcommand for systemd user service management #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| /** | ||
| * CLI command handlers for systemd user service management | ||
| */ | ||
|
|
||
| import { execFileSync, execSync, spawn } from "node:child_process"; | ||
| import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; | ||
| import { homedir, userInfo } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import * as p from "@clack/prompts"; | ||
| import pc from "picocolors"; | ||
| import { getConfig } from "../config/index.js"; | ||
|
|
||
| const PROJECT_DIR = process.cwd(); | ||
| const ADK_CLAW_DIR = join(PROJECT_DIR, ".adk-claw"); | ||
| const CONFIG_PATH = join(ADK_CLAW_DIR, "config.json"); | ||
|
|
||
| const SERVICE_NAME = "adk-claw"; | ||
| const SERVICE_DIR = join(homedir(), ".config", "systemd", "user"); | ||
| const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`); | ||
|
|
||
| /** | ||
| * Main service command router | ||
| */ | ||
| export async function serviceCommand(args: string[]) { | ||
| const subcommand = args[0]; | ||
|
|
||
| try { | ||
| execFileSync("which", ["systemctl"], { stdio: "pipe" }); | ||
| } catch { | ||
| p.log.error( | ||
| "Service management requires systemd, which was not found on this system.", | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!existsSync(CONFIG_PATH)) { | ||
| p.log.error("ADK Claw is not initialized. Run 'adk-claw init' first."); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| switch (subcommand) { | ||
| case "install": | ||
| await installService(); | ||
| break; | ||
| case "uninstall": | ||
| case "remove": | ||
| await uninstallService(); | ||
| break; | ||
| case "start": | ||
| runServiceCommand("start"); | ||
| break; | ||
| case "stop": | ||
| runServiceCommand("stop"); | ||
| break; | ||
| case "restart": | ||
| runServiceCommand("restart"); | ||
| break; | ||
| case "status": | ||
| streamServiceCommand("status"); | ||
| break; | ||
| case "logs": | ||
| streamLogs(); | ||
| break; | ||
| case "help": | ||
| case "--help": | ||
| case "-h": | ||
| case undefined: | ||
| printServiceHelp(); | ||
| break; | ||
| default: | ||
| p.log.error(`Unknown service command: ${subcommand}`); | ||
| printServiceHelp(); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Install the systemd user service | ||
| */ | ||
| async function installService() { | ||
| const s = p.spinner(); | ||
|
|
||
| try { | ||
| // Step 1: Resolve binary path | ||
| s.start("Resolving adk-claw binary..."); | ||
| let adkClawBin: string; | ||
| try { | ||
| adkClawBin = execSync("which adk-claw", { encoding: "utf-8" }).trim(); | ||
| } catch { | ||
| s.stop("Failed to resolve binary"); | ||
| p.log.error( | ||
| "Could not find 'adk-claw' in PATH. Make sure it is installed globally.\n" + | ||
| " pnpm link --global or npm install -g adk-claw", | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Step 2: Get agent name from config | ||
| const config = getConfig(); | ||
| const agentName = config.agentName; | ||
| const cwd = PROJECT_DIR; | ||
|
|
||
| // Step 3: Generate service file content | ||
| // Capture PATH now so systemd (which has a minimal environment) can find node/nvm binaries | ||
| const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; | ||
| s.message("Generating service file..."); | ||
| const serviceContent = generateServiceFile( | ||
| agentName, | ||
| cwd, | ||
| adkClawBin, | ||
| currentPath, | ||
| ); | ||
|
|
||
| // Step 4: Write service file | ||
| mkdirSync(SERVICE_DIR, { recursive: true }); | ||
| writeFileSync(SERVICE_FILE, serviceContent, "utf-8"); | ||
|
|
||
| // Step 5: daemon-reload | ||
| s.message("Reloading systemd daemon..."); | ||
| execSync("systemctl --user daemon-reload", { stdio: "pipe" }); | ||
|
|
||
| // Step 6: enable | ||
| s.message("Enabling service..."); | ||
| execSync(`systemctl --user enable ${SERVICE_NAME}`, { stdio: "pipe" }); | ||
|
|
||
| // Step 7: enable-linger so service survives SSH logout | ||
| s.message("Enabling linger..."); | ||
| const username = userInfo().username; | ||
| try { | ||
| execFileSync("loginctl", ["enable-linger", username], { stdio: "pipe" }); | ||
| } catch { | ||
| // Non-fatal — linger may require sudo on some systems | ||
| p.log.warn( | ||
| `Could not enable linger for user ${username}. ` + | ||
| `Run: sudo loginctl enable-linger ${username}`, | ||
| ); | ||
| } | ||
|
|
||
| s.stop("Service installed and enabled"); | ||
|
|
||
| // Step 8: Ask if they want to start now | ||
| const shouldStart = await p.confirm({ | ||
| message: "Start the service now?", | ||
| initialValue: true, | ||
| }); | ||
|
|
||
| if (p.isCancel(shouldStart)) { | ||
| p.log.info("Skipped starting service."); | ||
| } else if (shouldStart) { | ||
| execSync(`systemctl --user start ${SERVICE_NAME}`, { stdio: "pipe" }); | ||
| p.log.success("Service started!"); | ||
| } | ||
|
|
||
| p.note( | ||
| `Service file: ${pc.dim(SERVICE_FILE)} | ||
| Binary: ${pc.dim(adkClawBin)} | ||
| Working dir: ${pc.dim(cwd)} | ||
|
|
||
| ${pc.bold("Useful commands:")} | ||
| adk-claw service status # Check status | ||
| adk-claw service logs # Follow logs | ||
| adk-claw service stop # Stop service | ||
| adk-claw service restart # Restart service | ||
| adk-claw service uninstall # Remove service`, | ||
| "Service installed", | ||
| ); | ||
| } catch (error) { | ||
| s.stop("Installation failed"); | ||
| p.log.error( | ||
| error instanceof Error ? error.message : "Unknown error occurred", | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Uninstall the systemd user service | ||
| */ | ||
| async function uninstallService() { | ||
| const s = p.spinner(); | ||
|
|
||
| try { | ||
| s.start("Stopping service..."); | ||
| try { | ||
| execSync(`systemctl --user stop ${SERVICE_NAME}`, { stdio: "pipe" }); | ||
| } catch { | ||
| // Service may not be running — fine | ||
| } | ||
|
|
||
| s.message("Disabling service..."); | ||
| try { | ||
| execSync(`systemctl --user disable ${SERVICE_NAME}`, { stdio: "pipe" }); | ||
| } catch { | ||
| // Service may not be enabled — fine | ||
| } | ||
|
|
||
| s.message("Removing service file..."); | ||
| if (existsSync(SERVICE_FILE)) { | ||
| unlinkSync(SERVICE_FILE); | ||
| } | ||
|
|
||
| execSync("systemctl --user daemon-reload", { stdio: "pipe" }); | ||
|
|
||
| s.stop("Service uninstalled"); | ||
| p.log.success("adk-claw service has been removed."); | ||
| } catch (error) { | ||
| s.stop("Uninstall failed"); | ||
| p.log.error( | ||
| error instanceof Error ? error.message : "Unknown error occurred", | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Run a simple one-shot systemctl command | ||
| */ | ||
| function runServiceCommand(action: "start" | "stop" | "restart") { | ||
| try { | ||
| execSync(`systemctl --user ${action} ${SERVICE_NAME}`, { | ||
| stdio: "inherit", | ||
| }); | ||
| p.log.success(`Service ${action}ed.`); | ||
| } catch (error) { | ||
| p.log.error( | ||
| error instanceof Error ? error.message : `Failed to ${action} service`, | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Stream systemctl status output to terminal | ||
| */ | ||
| function streamServiceCommand(action: "status") { | ||
| const child = spawn("systemctl", ["--user", action, SERVICE_NAME], { | ||
| stdio: "inherit", | ||
| }); | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 0); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Stream journalctl logs to terminal | ||
| */ | ||
| function streamLogs() { | ||
| const child = spawn("journalctl", ["--user", "-u", SERVICE_NAME, "-f"], { | ||
| stdio: "inherit", | ||
| }); | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 0); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Generate systemd service file content | ||
| */ | ||
| function generateServiceFile( | ||
| agentName: string, | ||
| cwd: string, | ||
| adkClawBin: string, | ||
| envPath: string, | ||
| ): string { | ||
| return `[Unit] | ||
| Description=ADK Claw Bot (${agentName}) | ||
| After=network-online.target | ||
| Wants=network-online.target | ||
|
|
||
| [Service] | ||
| Type=simple | ||
| WorkingDirectory=${cwd} | ||
| Environment=PATH=${envPath} | ||
| ExecStart=${adkClawBin} start | ||
| Restart=on-failure | ||
| RestartSec=10 | ||
| EnvironmentFile=-${cwd}/.env | ||
| StandardOutput=journal | ||
| StandardError=journal | ||
| SyslogIdentifier=adk-claw | ||
|
|
||
| [Install] | ||
| WantedBy=default.target | ||
| `; | ||
| } | ||
|
|
||
| /** | ||
| * Print service command help | ||
| */ | ||
| function printServiceHelp() { | ||
| console.log(` | ||
| ${pc.bold("adk-claw service")} - Manage the systemd user service | ||
|
|
||
| ${pc.bold("Usage:")} | ||
| adk-claw service <command> | ||
|
|
||
| ${pc.bold("Commands:")} | ||
| install Install and enable the systemd service | ||
| uninstall Stop, disable, and remove the service | ||
| start Start the service | ||
| stop Stop the service | ||
| restart Restart the service | ||
| status Show service status | ||
| logs Follow live service logs | ||
|
|
||
| ${pc.bold("Examples:")} | ||
| ${pc.dim("# Install the service (runs on boot, survives SSH logout)")} | ||
| adk-claw service install | ||
|
|
||
| ${pc.dim("# Check if service is running")} | ||
| adk-claw service status | ||
|
|
||
| ${pc.dim("# Follow live logs")} | ||
| adk-claw service logs | ||
|
|
||
| ${pc.dim("# Remove the service entirely")} | ||
| adk-claw service uninstall | ||
| `); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The service management commands are specific to
systemd, which is typically found on Linux. Running these commands on other operating systems like macOS or Windows will fail with a potentially confusing error message (e.g.,systemctlnot found). It would improve the user experience to add a check at the beginning of this function to ensuresystemdis available and provide a clear error message if it's not.For example, you could check for the existence of the
systemctlbinary before proceeding: