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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1321,6 +1321,8 @@ kcap daemon service uninstall # stop and remove the service, ending
kcap daemon service uninstall --under DIR # stop and remove every unit whose daemon binary is inside DIR, ending their hosted agents
```

On **Linux**, to run the daemon as a service requires systemd. Without systemd, as in most containers and sandboxes, every `kcap daemon service` verb exits 1 saying so, `kcap daemon status` and `stop` skip the service, and setup tells you to run `kcap daemon start -d`. Nothing starts that daemon again after a restart: run it again, and it resumes the agents it was hosting that were alive within `KCAP_RESUME_MAX_AGE_SECONDS` (default 24 hours). Uninstall leaves a unit installed while systemd ran, and keeps the config directory, until you delete the unit file it names.

`install` pins the active profile via `KCAP_PROFILE` and captures your current `PATH` into the unit, so the supervised daemon resolves the same server URL, `claude`/`codex` binaries, and profile settings it would from your shell. Pass `--profile P` to pin a different profile, `--max-agents N` to bake an override (`0` = unlimited), or `--no-start` to register without starting (`--no-start` cannot be combined with `--verify`, whose whole job is to prove the *started* daemon is ready). The service restarts the daemon on crash/`SIGKILL` but **not** on a clean stop. Over an installed service, a plain `install` stops it and installs it again. On macOS it rewrites the plist only once launchd has unloaded the old job, waiting up to 15 seconds; on Linux and Windows a refused registration puts the previous unit file back. A failed install says whether the service is still loaded or now stopped, which daemon its unit names, and the command that reinstalls it: `install --replace --verify`. `stop` unloads it from the OS supervisor (launchd `bootout` / equivalent; the unit file is retained) rather than merely signaling the process.

#### Windows service
Expand Down
23 changes: 21 additions & 2 deletions src/Capacitor.Cli/Commands/OutgoingRecorders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ sealed class OutgoingRecorders(
StandardStreams console) : IOutgoingRecorders {
static readonly TimeSpan ProbeBudget = TimeSpan.FromSeconds(5);

internal Func<bool> SystemdRunning { get; init; } = ServiceManagerFactory.SystemdRunning;

/// <summary>A launch still starting is counted too: it is not in the agent table until its runtime answers. A
/// daemon that cannot give that count, including one too old to report it, counts as busy.</summary>
public async Task<IReadOnlyList<string>> BusyAsync() {
Expand All @@ -38,7 +40,7 @@ public async Task<bool> StopAsync() {
// The service goes first: `daemon stop` leaves a service-managed daemon alone, since a raw kill would be
// restarted.
try {
var services = ServiceManagerFactory.ForCurrentOs(config, home, time);
var services = ServiceManagerFactory.ForCurrentOs(config, home, time, SystemdRunning);

foreach (var id in services.ListInstalled()) {
var ended = await ServiceRemoval.EndAgentsAsync(store, id, time);
Expand All @@ -52,7 +54,24 @@ public async Task<bool> StopAsync() {
}
}
} catch (PlatformNotSupportedException) {
// No service backend on this OS.
// Linux without systemd: a unit installed while it ran stays on disk, and would start a deleted binary
// once systemd runs here again.
if (OperatingSystem.IsLinux()) {
IReadOnlyList<string> units;
try {
units = new SystemdServiceManager(home).ListInstalled();
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
await console.Error.WriteLineAsync($" • Could not list daemon services: {ex.Message}");
stopped = false;
units = [];
}

foreach (var id in units) {
await console.Error.WriteLineAsync(
$" • Could not remove daemon service '{id}': systemd is not running. Delete {SystemdUnit.UnitPath(home, id)} to finish.");
stopped = false;
}
}
}

if (await new DaemonCommands(store, config, profiles, home, harnesses, binaries, time, console).HandleAsync(["daemon", "stop", "--yes"]) != 0)
Expand Down
4 changes: 4 additions & 0 deletions src/Capacitor.Cli/LocalEdition/LocalInstallerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.Http;
using Capacitor.Cli.Core.LocalEdition;
using Capacitor.Cli.Services;

namespace Capacitor.Cli.LocalEdition;

Expand All @@ -12,6 +13,9 @@ sealed class LocalInstallerFactory(
public LocalInstaller Create() {
if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsLinux())
throw new LocalInstallFailure(LocalInstallFailureCode.UnsupportedPlatform, "The local edition runs on macOS for now.");
if (OperatingSystem.IsLinux() && !ServiceManagerFactory.SystemdRunning())
throw new LocalInstallFailure(LocalInstallFailureCode.UnsupportedPlatform,
"The local edition on Linux needs systemd, which is not running on this machine.");

var service = LocalServerServices.ForCurrentOs(home, time);
var local = http.CreateClient(CapacitorClients.LocalInstallation);
Expand Down
14 changes: 12 additions & 2 deletions src/Capacitor.Cli/LocalEdition/LocalServerServices.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Services;

namespace Capacitor.Cli.LocalEdition;

static class LocalServerServices {
public static ILocalServerService ForCurrentOs(UserHome home, TimeProvider time) {
/// <summary>Without systemd every <c>systemctl</c> call fails as an unreachable systemd does, so an
/// installation on disk still reads as installed: uninstall must not take it for none and delete its data.</summary>
/// <param name="systemdRunning">Test seam; production checks <see cref="ServiceManagerFactory.SystemdRunning"/>.</param>
public static ILocalServerService ForCurrentOs(UserHome home, TimeProvider time, Func<bool>? systemdRunning = null) {
if (OperatingSystem.IsMacOS()) return new LocalServerLaunchdService(home, time);
if (OperatingSystem.IsLinux()) return new LocalServerSystemdService(home, time);
if (OperatingSystem.IsLinux())
return (systemdRunning ?? ServiceManagerFactory.SystemdRunning)()
? new LocalServerSystemdService(home, time)
: new LocalServerSystemdService(home, time, run: NoSystemd);

throw new PlatformNotSupportedException("The local edition runs on macOS and Linux.");
}

static (int ExitCode, string StdOut, string StdErr) NoSystemd(string file, string[] args) =>
(127, "", "systemd is not running on this machine");
}
19 changes: 17 additions & 2 deletions src/Capacitor.Cli/Services/ServiceManagerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ public static ServicePlatform CurrentPlatform() {
throw new PlatformNotSupportedException("kcap daemon service is not supported on this OS.");
}

public static IServiceManager ForCurrentOs(ConfigRoot config, UserHome home, TimeProvider time) =>
ForPlatform(CurrentPlatform(), config, home, time);
/// <summary>Throws <see cref="PlatformNotSupportedException"/> where no supported service manager runs,
/// Linux without systemd included: every caller reads that as "no service", where a systemd manager
/// would fail on its first <c>systemctl</c>.</summary>
/// <param name="systemdRunning">Test seam; production checks <see cref="SystemdRunning"/>.</param>
public static IServiceManager ForCurrentOs(ConfigRoot config, UserHome home, TimeProvider time, Func<bool>? systemdRunning = null) {
var platform = CurrentPlatform();
if (platform == ServicePlatform.Systemd && !(systemdRunning ?? SystemdRunning)())
throw new PlatformNotSupportedException(
"kcap daemon service needs systemd, which is not running on this machine. Start the daemon with "
+ "`kcap daemon start -d`; nothing starts it again after a restart.");

return ForPlatform(platform, config, home, time);
}

/// <summary>systemd's own <c>sd_booted</c> test. A container or sandbox without it often has no
/// <c>systemctl</c> at all.</summary>
public static bool SystemdRunning() => Directory.Exists("/run/systemd/system");

/// <summary>The word <c>service status --json</c> reports for the manager in charge</summary>
public static string SupervisorWord(IServiceManager manager) => manager switch {
Expand Down
42 changes: 40 additions & 2 deletions test/Capacitor.Cli.Tests.Unit/Commands/UninstallCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Capacitor.Cli.Core.Mcp;
using Capacitor.Cli.Core.Setup;
using Capacitor.Cli.LocalEdition;
using Capacitor.Cli.Services;
using Capacitor.Cli.Tests.Unit.LocalEdition.Fakes;
using TUnit.Core.Enums;

Expand Down Expand Up @@ -781,7 +782,42 @@ public async Task A_watcher_the_stop_step_cannot_end_is_reported_on_the_commands
await Assert.That(fixture.Err.ToString()).Contains("Watcher s-a is still running.");
}

UninstallCommand NewCommand(Fixture f, DaemonStore? store = null, string? workdir = null, UserHome? home = null, IOutgoingRecorders? recorders = null, ILocalStackFactory? stacks = null, KcapLinkRemover? links = null, string stdin = "") {
[Test]
[RunOn(OS.Linux)]
public async Task Uninstall_without_systemd_keeps_the_config_while_a_daemon_unit_is_left() {
await using var fixture = await Fixture.CreateAsync();
var unit = SystemdUnit.UnitPath(fixture.UserHome, "laptop");
Directory.CreateDirectory(Path.GetDirectoryName(unit)!);
await File.WriteAllTextAsync(unit, "[Service]\nExecStart=/opt/kcap/kcap-daemon\n");

var exit = await NewCommand(fixture, systemdRunning: () => false).HandleAsync(["uninstall", "--yes"]);

await Assert.That(exit).IsEqualTo(1);
await Assert.That(fixture.Err.ToString()).Contains($"Could not remove daemon service 'laptop': systemd is not running. Delete {unit}");
await Assert.That(File.Exists(unit)).IsTrue();
await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue();
}

[Test]
[RunOn(OS.Linux), SupportedOSPlatform("linux")]
public async Task Uninstall_without_systemd_keeps_the_config_when_the_unit_directory_is_unreadable() {
await using var fixture = await Fixture.CreateAsync();
var dir = Path.GetDirectoryName(SystemdUnit.UnitPath(fixture.UserHome, "laptop"))!;
Directory.CreateDirectory(dir);
File.SetUnixFileMode(dir, UnixFileMode.None);

try {
var exit = await NewCommand(fixture, systemdRunning: () => false).HandleAsync(["uninstall", "--yes"]);

await Assert.That(exit).IsEqualTo(1);
await Assert.That(fixture.Err.ToString()).Contains("Could not list daemon services");
await Assert.That(Directory.Exists(fixture.ConfigDir)).IsTrue();
} finally {
File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}

UninstallCommand NewCommand(Fixture f, DaemonStore? store = null, string? workdir = null, UserHome? home = null, IOutgoingRecorders? recorders = null, ILocalStackFactory? stacks = null, KcapLinkRemover? links = null, Func<bool>? systemdRunning = null, string stdin = "") {
var userHome = home ?? f.UserHome;
var profiles = Resolutions.None(f.Root);
var daemons = store ?? Daemons.Store;
Expand All @@ -792,7 +828,9 @@ UninstallCommand NewCommand(Fixture f, DaemonStore? store = null, string? workdi
return new(
daemons, f.Root, userHome, harness, TestBinaries.None, new AgentsPaths(userHome),
new WorkingDirectory(workdir ?? AppContext.BaseDirectory),
recorders ?? new OutgoingRecorders(daemons, f.Root, profiles, userHome, harness, TestBinaries.None, watchers, TimeProvider.System, console),
recorders ?? new OutgoingRecorders(daemons, f.Root, profiles, userHome, harness, TestBinaries.None, watchers, TimeProvider.System, console) {
SystemdRunning = systemdRunning ?? (() => true),
},
stacks ?? new EmptyStackFactory(userHome, f.Root),
links ?? new KcapLinkRemover([], ""),
console);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Capacitor.Cli.Core;
using Capacitor.Cli.Core.LocalEdition;
using Capacitor.Cli.LocalEdition;
using TUnit.Core.Enums;

namespace Capacitor.Cli.Tests.Unit.LocalEdition;

[RunOn(OS.Linux)]
public class LocalServerServicesTests {
[TempDir] public required TempDir Tmp { get; init; }

UserHome Home => new(Tmp.PathTo("home"));

/// <summary>Uninstall reads an installation it cannot query as installed, so it keeps its data instead of
/// deleting it with the config directory.</summary>
[Test]
public async Task Linux_without_systemd_reads_the_unit_as_unknown_and_refuses_to_change_it() {
var service = LocalServerServices.ForCurrentOs(Home, TimeProvider.System, systemdRunning: () => false);
var unit = LocalServerUnit.ForInstallation(LocalRoot.Resolve(Tmp.PathTo("local"), Home), 4100);

await Assert.That(service.Owner(unit).Loaded).IsTypeOf<OwnerReading.Unreadable>();
await Assert.That(service.Activity(unit)).IsTypeOf<UnitActivity.Unknown>();
await Assert.That(() => service.Start(unit)).Throws<LocalLifecycleException>().WithMessageContaining("systemd is not running");
}

[Test]
public async Task Linux_with_systemd_is_the_systemd_service() {
await Assert.That(LocalServerServices.ForCurrentOs(Home, TimeProvider.System, systemdRunning: () => true))
.IsTypeOf<LocalServerSystemdService>();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Capacitor.Cli.Services;
using TUnit.Core.Enums;

namespace Capacitor.Cli.Tests.Unit.Services;

Expand All @@ -25,10 +26,24 @@ public async Task The_supervisor_word_names_each_manager() {

[Test]
public async Task ForCurrentOs_does_not_throw_on_this_host() {
var mgr = ServiceManagerFactory.ForCurrentOs(Config.Root, Home, TimeProvider.System);
var mgr = ServiceManagerFactory.ForCurrentOs(Config.Root, Home, TimeProvider.System, systemdRunning: () => true);
await Assert.That(mgr.Describe()).IsNotNull();
}

[Test]
[RunOn(OS.Linux)]
public async Task ForCurrentOs_on_Linux_without_systemd_has_no_service_manager() {
await Assert.That(() => ServiceManagerFactory.ForCurrentOs(Config.Root, Home, TimeProvider.System, systemdRunning: () => false))
.Throws<PlatformNotSupportedException>().WithMessageContaining("kcap daemon start -d");
}

[Test]
[RunOn(OS.Linux)]
public async Task ForCurrentOs_on_Linux_with_systemd_is_the_systemd_manager() {
await Assert.That(ServiceManagerFactory.ForCurrentOs(Config.Root, Home, TimeProvider.System, systemdRunning: () => true))
.IsTypeOf<SystemdServiceManager>();
}

[Test]
public async Task Launchd_GenerateFiles_returns_one_file() {
var spec = new ServiceSpec("laptop", "/opt/kcap/kcap-daemon", "/tmp/daemon-laptop.log",
Expand Down
Loading