diff --git a/.changeset/sour-pandas-serve.md b/.changeset/sour-pandas-serve.md new file mode 100644 index 00000000..567f4346 --- /dev/null +++ b/.changeset/sour-pandas-serve.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": minor +--- + +Move daemon lifecycle management from `caplets serve ...` to `caplets daemon ...`, with native per-user service manager support, install-time HTTP configuration, environment overrides, status, logs, and uninstall behavior. diff --git a/CONCEPTS.md b/CONCEPTS.md index f54092c6..6debeae8 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -2,12 +2,32 @@ Shared domain vocabulary for this project -- entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. -## Code Mode +## Caplets Runtime ### Caplet A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations. +### Caplets Daemon + +A per-user native service managed by `caplets daemon` that runs local HTTP `caplets serve` through the operating system service manager. + +The Caplets Daemon is installed and updated through an install-time service contract. Runtime lifecycle commands operate on the installed service rather than changing its persisted serve or environment configuration. + +### Install-Time Service Contract + +The persisted daemon agreement that defines what command runs, which environment model applies, which native service identity owns it, and how updates become active. + +### Native Service Manager + +The operating system facility that owns per-user service registration and lifecycle for the Caplets Daemon. + +### Service Descriptor + +The native service-manager artifact that declares how the Caplets Daemon should be launched and supervised. + +## Code Mode + ### Code Mode The Caplets execution surface where an agent runs a bounded TypeScript workflow against generated Caplet handles and receives compact structured output. diff --git a/docs/architecture.md b/docs/architecture.md index 68b63cb2..07870903 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,12 @@ The HTTP server in `packages/core/src/serve/http.ts` exposes versioned MCP, atta `/v1/attach` is the Caplets runtime attach API. Attached clients read `/v1/attach/manifest`, subscribe to `/v1/attach/events`, and invoke revision-scoped exports through `/v1/attach/invoke` before merging remote projections with local/project overlays. +### Caplets Daemon + +`packages/core/src/daemon/` owns the default per-user daemon lifecycle. `caplets daemon install` persists HTTP `caplets serve` configuration, explicit service environment variables, optional shell inheritance intent, user-only log paths, and native service descriptors under the `daemon/default` identity. Runtime lifecycle commands (`start`, `restart`, `stop`, `status`, `logs`, and `uninstall`) read that installed service state instead of accepting serve flags. + +The daemon uses the native per-user service manager for the host platform: launchd UserAgents on macOS, `systemd --user` services on Linux, and current-user Windows Scheduled Tasks on Windows. There is no detached-process fallback when a native manager is unavailable. Foreground `caplets serve` remains stdio/HTTP serving only. + ### Code Mode Code Mode is implemented under `packages/core/src/code-mode/`. diff --git a/docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md b/docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md new file mode 100644 index 00000000..767a949c --- /dev/null +++ b/docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md @@ -0,0 +1,222 @@ +--- +date: 2026-06-19 +topic: caplets-daemon-service +--- + +# Caplets Daemon Service Requirements + +## Summary + +Caplets should expose a top-level `caplets daemon` command for managing a per-user native service that runs local HTTP `caplets serve`. Daemon lifecycle moves out of `caplets serve` entirely and uses the operating system service manager for install, runtime control, status, and logs. + +--- + +## Problem Frame + +Users need a local Caplets HTTP service that survives terminal exits and login restarts without hand-written launchd, systemd, or Scheduled Task setup. The command surface should describe the user's goal: managing a daemon, not invoking a nested variant of foreground `serve`. + +Environment propagation is part of the product problem. Caplet configs and backend commands can depend on PATH entries, tokens, and toolchain variables that are available in an interactive shell but missing from GUI-launched or service-launched processes. The daemon command must give users an explicit way to inherit shell setup and a separate way to persist service-specific environment variables. + +--- + +## Key Decisions + +- **Daemon is a top-level CLI surface.** `caplets daemon ...` is the canonical service lifecycle command, and daemon subcommands are removed from `caplets serve`. +- **The service is per-user and native-manager-owned.** Caplets uses launchd UserAgents, `systemctl --user`, or per-user Windows Scheduled Tasks and does not fall back to detached process management. +- **The managed process is local HTTP serve only.** Stdio serving, remote-backed `attach`, project-bound services, and arbitrary Caplets commands are outside the first version. +- **Install owns persistent service configuration.** Runtime commands do not mutate installed serve flags, environment settings, or shell-inheritance settings. +- **Validation proves the service command can start.** Install validates by launching a temporary Caplets HTTP server with the generated command and probing health unless the user passes `--no-validate`. +- **Logs are Caplets-managed files.** The service writes stdout and stderr under daemon state, and `caplets daemon logs` reads those files even if the service is later stopped or uninstalled. + +```mermaid +flowchart TB + A["caplets daemon install"] --> B["Write and register per-user native service"] + B --> C{"Start requested or restart accepted?"} + C -->|yes| D["Native service manager starts or restarts service"] + C -->|no| E["Installed config waits for next lifecycle command"] + D --> F["caplets daemon status checks native manager"] + F --> G["Probe Caplets HTTP health when running"] + D --> H["caplets daemon logs reads Caplets log files"] +``` + +--- + +## Actors + +- A1. **User.** Installs, updates, starts, stops, inspects, and uninstalls the local Caplets daemon. +- A2. **Caplets CLI.** Resolves daemon configuration, validates startup, renders service descriptors, invokes native service managers, and reports status. +- A3. **Native service manager.** Owns service registration and runtime state through launchd, systemd user services, or Windows Scheduled Tasks. +- A4. **Caplets HTTP daemon.** Runs local HTTP `caplets serve` with the installed configuration and writes stdout/stderr logs. + +--- + +## Requirements + +**Command surface** + +- R1. `caplets daemon` provides `install`, `uninstall`, `start`, `restart`, `stop`, `status`, and `logs` subcommands. +- R2. `caplets serve` does not expose daemon lifecycle subcommands. +- R3. `enable` and `disable` are not aliases for `install` and `uninstall`. +- R4. Every `caplets daemon` subcommand supports `--json` with structured action and state details. +- R5. Daemon commands manage only local HTTP `caplets serve`; stdio, `attach`, and arbitrary commands are rejected or absent from the daemon surface. +- R6. Daemon commands manage per-user services only; unsupported platforms fail with actionable messaging. + +**Install and configuration** + +- R7. `caplets daemon install` writes and registers the per-user native service descriptor for the default daemon instance. +- R8. Installed service identity and artifacts use `daemon/default` naming, such as `dev.caplets.daemon.default` and `caplets-daemon-default.service`. +- R9. Existing `serve/default` daemon artifacts are ignored by `caplets daemon` commands. +- R10. The installed service uses the user's home directory as its working directory. +- R11. `install` accepts the persistent HTTP serve configuration flags from `caplets serve`, including `--host`, `--port`, `--path`, `--user`, `--password`, `--allow-unauthenticated-http`, and `--trust-proxy`; `--transport` is not accepted because the daemon is always HTTP. +- R12. On an existing installation, omitted install flags preserve current persisted values. +- R13. `install --reset` rebuilds persisted service configuration from defaults plus the flags supplied in that command. +- R14. `install --dry-run` previews descriptor content and native-manager actions without writing files, registering the service, purging state, or starting validation. +- R15. `install --no-validate` skips startup validation for advanced cases. +- R16. `install --start`, `install --restart`, and `install --no-restart` are mutually exclusive. +- R17. If `install` updates a running service, Caplets updates the installed config first and then asks whether to restart in interactive mode. +- R18. If `install` updates a running service in non-interactive mode, Caplets fails unless `--start`, `--restart`, or `--no-restart` resolves the restart decision. +- R19. `install --start` starts the service when stopped and restarts it when already running so the installed config becomes active. +- R20. `install --restart` restarts the service after updating the installed config. +- R21. `install --no-restart` updates installed config and leaves the running process unchanged. + +**Environment model** + +- R22. `--inherit-env` is opt-in on `install` and runs the daemon through a user shell wrapper rather than storing an environment snapshot. +- R23. On macOS and Linux, shell inheritance falls back from `SHELL`, to the account shell when discoverable, to `/bin/sh`, and then to an actionable failure if no shell can be found. +- R24. On Windows, shell inheritance falls back from `SHELL`, to PowerShell, to `ComSpec` or `cmd.exe` as the last resort. +- R25. `--no-inherit-env` turns off shell inheritance on an existing installation. +- R26. Repeatable `--env KEY=VALUE` persists service-specific environment variables. +- R27. `--env` requires a strict environment variable name, requires `=`, allows an empty value, and preserves everything after the first `=`. +- R28. Explicit `--env` values override inherited shell environment values for the Caplets process. +- R29. On update, `--env` merges by key and preserves existing persisted environment values not mentioned in the command. +- R30. Repeatable `--unset-env KEY` removes persisted service environment keys. +- R31. When `--unset-env` and `--env` name the same key, unsets apply first and `--env` wins. + +**Validation** + +- R32. `install` validates by launching a temporary Caplets HTTP server with the generated service command, probing the Caplets health endpoint, and stopping the temporary server before registration. +- R33. When `--inherit-env` is enabled, validation uses the same shell-wrapper path as the installed service. +- R34. If an already-running daemon occupies the requested address, validation uses a temporary free loopback port while preserving the generated command, environment model, and config path as closely as possible. + +**Runtime lifecycle** + +- R35. `start`, `restart`, and `stop` require an installed native service and fail with the relevant `caplets daemon install` guidance when the service is not installed. +- R36. Runtime lifecycle commands operate through the native service manager. +- R37. `start` succeeds if the installed service is already running. +- R38. `restart` starts the installed service if it is stopped. +- R39. `stop` succeeds if the installed service is already stopped. +- R40. `uninstall` stops a running service through the native manager before unregistering and removing service artifacts. +- R41. `uninstall` preserves logs and historical state by default. +- R42. `uninstall --purge` removes service artifacts, daemon state, daemon logs, and daemon config. +- R43. `uninstall --dry-run` previews unregister and removal actions without changing service, state, log, or config files. +- R44. `uninstall --dry-run --purge` lists every service, config, state, and log path that would be removed. + +**Status and logs** + +- R45. `status` uses the native service manager as the source of truth for installed and running state. +- R46. When the native manager reports the service running, `status` probes the Caplets HTTP health endpoint and reports health separately from manager state. +- R47. `status` reports Caplets-managed stdout and stderr log paths. +- R48. The service writes stdout and stderr to Caplets-managed log files under daemon state. +- R49. `logs` reads existing log files and does not require the service to be installed. +- R50. `logs` defaults to the last 10 lines and supports `--tail `, `--follow`, and `--stream stdout|stderr|all`. +- R51. `logs --tail 0 --follow` shows only new log lines. +- R52. `logs` shows stdout and stderr together by default and can show either stream alone. +- R53. If selected log files do not exist, `logs` prints a clear empty-state message with the expected log paths. + +--- + +## Key Flows + +- F1. Install a new daemon + - **Trigger:** The user wants Caplets to run as a user service. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The user runs `caplets daemon install`; Caplets resolves service config, validates temporary startup unless bypassed, writes service artifacts, and registers the native service. + - **Covered by:** R7, R10, R11, R14, R15, R32 + +- F2. Update a running daemon + - **Trigger:** The user changes persisted service config while the daemon is running. + - **Actors:** A1, A2, A3, A4 + - **Steps:** Caplets updates installed config, then restarts only when the user confirms interactively or passes an explicit restart/start flag; non-interactive runs must resolve the restart decision with a flag. + - **Covered by:** R12, R16, R17, R18, R19, R20, R21 + +- F3. Install with shell-inherited environment + - **Trigger:** The daemon needs PATH or variables normally initialized by the user's shell. + - **Actors:** A1, A2, A3, A4 + - **Steps:** The user runs `install --inherit-env`; Caplets resolves the platform shell fallback, validates through the same wrapper, and registers the wrapped service command. + - **Covered by:** R22, R23, R24, R32, R33 + +- F4. Control installed service + - **Trigger:** The user wants to start, restart, or stop an installed daemon. + - **Actors:** A1, A2, A3 + - **Steps:** Caplets verifies installation, delegates lifecycle to the native service manager, and treats already-running or already-stopped states as successful when the command intent is already satisfied. + - **Covered by:** R35, R36, R37, R38, R39 + +- F5. Inspect status and logs + - **Trigger:** The user needs to know whether the daemon is running or why it failed. + - **Actors:** A1, A2, A3, A4 + - **Steps:** `status` reads native-manager state and health; `logs` tails Caplets-managed stdout/stderr files with tail-like flags. + - **Covered by:** R45, R46, R47, R48, R49, R50, R51, R52, R53 + +- F6. Uninstall or purge + - **Trigger:** The user wants to remove the native service registration. + - **Actors:** A1, A2, A3 + - **Steps:** Caplets stops the service if needed, unregisters it, removes service artifacts, and preserves logs/state unless `--purge` is present. + - **Covered by:** R40, R41, R42, R43, R44 + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R2, R3.** Given the CLI is installed, when the user asks for `caplets daemon --help`, then daemon lifecycle commands are listed; when the user asks for `caplets serve --help`, then daemon lifecycle commands are absent. +- AE2. **Covers R11, R14, R22, R26.** Given no daemon is installed, when the user runs `caplets daemon install --dry-run --host 127.0.0.1 --port 5388 --path /caplets --inherit-env --env FOO=bar`, then Caplets prints the generated service plan without writing or registering anything. +- AE3. **Covers R5, R11.** Given no daemon is installed, when the user runs `caplets daemon install --transport http`, then Caplets rejects the command without installing or updating service artifacts. +- AE4. **Covers R17, R18, R19, R20, R21.** Given a running installed daemon, when `install` changes config, then interactive runs ask about restart, non-interactive runs require a restart decision flag, and `--start` or `--restart` makes the new config active immediately. +- AE5. **Covers R22, R23, R24, R28, R33.** Given `--inherit-env` and `--env PATH=/custom/bin`, when validation runs, then it uses the service shell wrapper and the explicit PATH value wins for the Caplets process. +- AE6. **Covers R32, R34.** Given the current daemon already uses the configured port, when install validates an update, then validation uses a temporary loopback port instead of failing only because the live daemon owns the target port. +- AE7. **Covers R35, R36, R37, R38, R39.** Given no service is installed, runtime lifecycle commands fail with install guidance; given a service is installed, `start`, `restart`, and `stop` are idempotent around already-running or already-stopped state. +- AE8. **Covers R41, R42, R49, R53.** Given the daemon has been uninstalled without purge, when the user runs `caplets daemon logs`, then Caplets reads preserved logs or prints the expected log paths if no logs exist. +- AE9. **Covers R42, R43, R44.** Given the user runs `caplets daemon uninstall --dry-run --purge`, then Caplets lists all unregister and removal actions without stopping, unregistering, or deleting anything. + +--- + +## Success Criteria + +- Users can discover the daemon lifecycle from `caplets daemon --help` without seeing daemon behavior under `caplets serve`. +- Users can configure the managed HTTP service from `caplets daemon install` with the same HTTP configuration flags they would use for foreground `caplets serve --transport http`, without exposing a daemon `--transport` option. +- A first install on macOS, Linux with systemd user services, and Windows registers a per-user service through the native manager. +- `install --inherit-env` catches shell-wrapper startup failures during validation before service registration. +- `status --json` distinguishes installed state, native running state, Caplets HTTP health, and log paths. +- `logs --tail 0 --follow` behaves like tailing new output from the managed daemon logs. + +--- + +## Scope Boundaries + +- Daemonizing stdio transport is out of scope. +- Daemonizing `caplets attach` is out of scope. +- Project-bound daemon instances are out of scope. +- System-wide or privileged service installation is out of scope. +- Detached process fallback is out of scope. +- Migrating or cleaning old `serve/default` daemon artifacts is out of scope. +- New secret storage, redaction, or auth mechanisms are out of scope for this feature. + +--- + +## Dependencies / Assumptions + +- The local HTTP server has a health endpoint suitable for install validation and status checks. +- Supported platforms have a usable per-user native service manager available to the current user. +- Shell inheritance on Windows cannot promise Unix login-shell parity; PowerShell and `cmd.exe` fallbacks are compatibility paths, not exact behavioral matches. +- Service logs are file-backed so Caplets can read them independently of native-manager-specific log tools. + +--- + +## Sources / Research + +- `STRATEGY.md` for the product emphasis on runtime diagnosability and reliable local/remote/native setup. +- `docs/product/caplets-code-mode-prd.md` for current CLI surface descriptions around `serve`, `attach`, and `doctor`. +- `docs/architecture.md` for local MCP serving, HTTP serving, and health/control surface context. +- `packages/core/src/cli/commands.ts` for the current absence of a top-level `daemon` command and current `serve` daemon subcommands. +- `packages/core/src/cli.ts` for the current `serve start|stop|status|restart|enable|disable` wiring. +- `packages/core/src/serve/daemon/` for the existing daemon config, path, process, and platform descriptor foundation. +- `packages/core/test/serve-daemon.test.ts` for current daemon behavior and the tests that will need to move from `serve` to `daemon`. diff --git a/docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md b/docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md new file mode 100644 index 00000000..e121c4f4 --- /dev/null +++ b/docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md @@ -0,0 +1,417 @@ +--- +title: "feat: Move daemon management to native Caplets services" +type: feat +date: 2026-06-19 +origin: docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md +deepened: 2026-06-19 +--- + +# feat: Move daemon management to native Caplets services + +## Summary + +Move daemon lifecycle from `caplets serve ...` to a top-level `caplets daemon ...` command that installs and controls a per-user native service for local HTTP `caplets serve`. The implementation should preserve existing HTTP serve option behavior, add install-time service configuration and environment controls, and route runtime lifecycle through launchd, systemd user services, or Windows Scheduled Tasks. + +--- + +## Problem Frame + +The current daemon implementation is exposed as nested `serve` subcommands and is mostly a detached Node process with descriptor generation bolted on. The requirements call for a daemon product surface with native service manager ownership, durable install-time configuration, shell/env inheritance, validation, status health probing, and tail-like logs (see origin: `docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md`). + +This matters to the Caplets strategy because reliable local runtime setup and diagnosability are part of making Code Mode and native agent surfaces dependable without forcing users into hosted Cloud. + +--- + +## Requirements + +**CLI surface** + +- R1. `caplets daemon` exposes `install`, `uninstall`, `start`, `restart`, `stop`, `status`, and `logs`, with `--json` on every daemon subcommand. +- R2. `caplets serve` keeps foreground stdio/HTTP serving only and no longer exposes daemon lifecycle commands. +- R3. `caplets daemon install` accepts HTTP serve configuration flags except `--transport`, and Commander rejects any daemon `--transport` usage. + +**Persistent daemon configuration** + +- R4. Install writes a default per-user service identity under `daemon/default`, uses the user's home directory as the service working directory, and ignores old `serve/default` state. +- R5. Install updates preserve omitted persisted values, `--reset` rebuilds from defaults, and only install can mutate service config. +- R6. Install supports repeatable `--env KEY=VALUE`, repeatable `--unset-env KEY`, `--inherit-env`, and `--no-inherit-env` with deterministic merge precedence. + +**Native service management** + +- R7. Caplets registers, starts, restarts, stops, queries, and unregisters the daemon through the native per-user service manager only. +- R8. Unsupported platforms and unavailable per-user service managers fail with actionable messages and no detached-process fallback. +- R9. Runtime lifecycle commands fail with install guidance when the service is not installed, then behave idempotently once it is installed. + +**Validation, status, and logs** + +- R10. Install validates by starting the generated command temporarily and probing HTTP health unless `--no-validate` or `--dry-run` applies. +- R11. Status succeeds even before install by reporting uninstalled state, and reports native running state, HTTP health when running, installed config, and stdout/stderr log paths when available. +- R12. Logs are Caplets-managed user-only files with `logs --tail `, `--follow`, and `--stream stdout|stderr|all`; logs remain readable after uninstall unless purged. + +**Uninstall and cleanup** + +- R13. Uninstall stops a running service before unregistering and removing service artifacts. +- R14. Uninstall preserves logs/state by default, while `--purge` removes daemon config, state, and logs. +- R15. Dry-run modes preview install, uninstall, and purge actions without registration, file mutation, process startup, or validation. + +--- + +## Key Technical Decisions + +- KTD1. **Move daemon orchestration out of `serve`:** Keep HTTP server option resolution in `serve/options.ts`, but move daemon orchestration and exports into a daemon-focused module. This preserves the existing foreground server contract while making the CLI surface match the product concept. +- KTD2. **Model install separately from runtime lifecycle:** `install` owns persisted serve flags, env settings, shell inheritance, descriptor content, validation, and optional start/restart decisions. `start`, `restart`, `stop`, and `status` read the installed service and do not apply new config. +- KTD3. **Introduce a native manager seam:** Platform descriptors alone are not enough because install/start/status/uninstall semantics differ across launchd, systemd, and Scheduled Tasks. A manager interface with injectable command execution lets tests cover native behavior without registering live OS services. +- KTD4. **Generate an internal HTTP serve command without daemon `--transport`:** The public daemon CLI never accepts `--transport`, but the managed process can still invoke foreground `caplets serve --transport http` internally. This keeps the daemon fail-closed while reusing the existing HTTP server entrypoint. +- KTD5. **Persist env intent, not ambient snapshots:** `--inherit-env` stores shell-wrapper intent and explicit `--env` entries store service env overrides. This avoids capturing stale terminal environment values while letting Caplets validate the same wrapper path it will install. +- KTD6. **Validate command preflight before registration:** Install should prove that the generated command starts and responds on the configured health route derived from `servicePaths(resolved.path).health` before registering the native service. `--dry-run` remains pure preview and `--no-validate` is the explicit escape hatch. +- KTD7. **Make logs file-backed and manager-independent:** Service descriptors or platform wrappers redirect stdout/stderr to Caplets-managed files. `caplets daemon logs` reads those files rather than depending on `journalctl`, `log stream`, or Event Viewer. +- KTD8. **Treat stdout and stderr as selected log streams:** `logs --stream all` should behave like tailing multiple files with stream labels or headers, not promise perfect chronological interleaving across two independent files. +- KTD9. **Normalize native state without hiding platform details:** The daemon manager should return normalized states for CLI behavior while preserving raw launchd, systemd, or Scheduled Task details for status JSON and troubleshooting. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + CLI["caplets daemon CLI"] --> API["daemon operations API"] + API --> Config["daemon/default config and state"] + API --> Command["HTTP serve command planner"] + Command --> Shell["optional shell/env wrapper"] + API --> Validate["temporary validation runner"] + API --> Manager{"native manager"} + Manager --> Launchd["launchd UserAgent"] + Manager --> Systemd["systemd --user service"] + Manager --> Task["Windows Scheduled Task"] + Manager --> Logs["stdout/stderr log files"] + API --> Health["configured HTTP health probe"] +``` + +```mermaid +stateDiagram-v2 + [*] --> NotInstalled + NotInstalled --> InstalledStopped: install + NotInstalled --> InstalledRunning: install --start + InstalledStopped --> InstalledRunning: start or restart + InstalledRunning --> InstalledRunning: install --start or install --restart + InstalledRunning --> InstalledStopped: stop + InstalledStopped --> NotInstalled: uninstall + InstalledRunning --> NotInstalled: uninstall stops then removes + InstalledStopped --> [*]: uninstall --purge + InstalledRunning --> [*]: uninstall --purge +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Config + participant Validator + participant Manager + User->>CLI: daemon install flags + CLI->>Config: load existing config + CLI->>CLI: stage candidate config + CLI->>Validator: start generated HTTP command + Validator-->>CLI: health result + CLI->>Manager: write/register service + CLI->>Config: write installed config + alt start requested + CLI->>Manager: start or restart service + else running update without flag + CLI->>User: prompt for restart decision + end +``` + +--- + +## Output Structure + +The implementation may adjust exact file names, but the plan expects daemon-specific code to live outside the foreground `serve` module while reusing HTTP serve option resolution. + +```text +packages/core/src/daemon/ + config.ts + env.ts + index.ts + logs.ts + manager.ts + paths.ts + platform-darwin.ts + platform-linux.ts + platform-windows.ts + process.ts + types.ts + validation.ts +``` + +## Native Manager Contract + +The implementation should keep platform command details behind the daemon manager interface, but the plan expects the following contracts to be explicit and test-covered. + +| Platform | Install/Register | Start/Restart | Stop | Status | Uninstall | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| macOS launchd | Write `~/Library/LaunchAgents/dev.caplets.daemon.default.plist`, then `launchctl bootstrap gui/$UID ` or a reload sequence when already bootstrapped. | `launchctl kickstart -k gui/$UID/dev.caplets.daemon.default` for start/restart. | Stop the loaded job without unregistering it, then treat already-stopped as success. | `launchctl print gui/$UID/dev.caplets.daemon.default`; parse PID, exit status, and missing-service errors. | Stop if needed, `launchctl bootout gui/$UID `, then remove descriptor artifacts. | +| Linux systemd user | Write `~/.config/systemd/user/caplets-daemon-default.service`, `systemctl --user daemon-reload`, then `systemctl --user enable caplets-daemon-default.service`. | `systemctl --user start` or `restart caplets-daemon-default.service`. | `systemctl --user stop caplets-daemon-default.service`, treating inactive as success. | `systemctl --user show caplets-daemon-default.service` and `is-active`; preserve raw state fields. | Stop if needed, `systemctl --user disable`, remove unit, then `daemon-reload`. | +| Windows Scheduled Tasks | Register a current-user, non-elevated `\Caplets\daemon-default` task with an ONLOGON trigger, no execution time limit, wrapper-enforced cwd/log redirection, and restart-on-failure settings when supported by XML registration. | `schtasks /Run /TN \Caplets\daemon-default`; restart ends the task first when running. | `schtasks /End /TN \Caplets\daemon-default`, treating not-running as success. | `schtasks /Query /TN \Caplets\daemon-default /FO LIST /V`; map Ready, Running, Disabled, Last Run Result, and missing-task states. | End if needed, then `schtasks /Delete /TN \Caplets\daemon-default /F`. | + +The normalized status model should include at least `not_installed`, `installed_stopped`, `running`, `failed`, `unavailable`, and `unknown`, plus raw native state details. Runtime lifecycle commands may use the normalized state for idempotency, but status JSON must retain the raw details so platform-specific failures stay actionable. + +--- + +## Implementation Units + +### U1. Move the CLI surface to `caplets daemon` + +- **Goal:** Add the top-level daemon command and remove daemon lifecycle subcommands from `serve`. +- **Requirements:** R1, R2, R3; origin R1-R6, AE1, AE3. +- **Dependencies:** None. +- **Files:** `packages/core/src/cli/commands.ts`, `packages/core/src/cli.ts`, `packages/core/src/cli/completion.ts`, `packages/core/test/daemon-cli.test.ts`, `packages/core/test/cli-completion.test.ts`, `packages/core/test/serve-daemon.test.ts`. +- **Approach:** Add `daemon` to the command registry and static completion tables. Replace the current `addServeDaemonCommand` wiring with daemon-specific subcommand wiring where only `install` accepts HTTP serve flags. Do not register `enable` or `disable`, and do not register aliases. Leave `serve` with only foreground `--transport stdio|http` behavior. Removed `serve` daemon subcommands should fail with command-specific migration guidance pointing to `caplets daemon ...`, without mutating daemon state. +- **Patterns to follow:** Existing Commander setup in `packages/core/src/cli.ts`; static subcommand completion in `packages/core/src/cli/commands.ts`; focused CLI assertions in `packages/core/test/cli-completion.test.ts`. +- **Test scenarios:** + - Covers AE1. Running daemon help lists `install`, `uninstall`, `start`, `restart`, `stop`, `status`, and `logs`. + - Covers AE1. Running serve help does not list daemon lifecycle subcommands. + - Covers AE3. `caplets daemon install --transport http` fails before writing service artifacts. + - `caplets daemon enable` and `caplets daemon disable` fail as unknown commands. + - `caplets serve start|stop|status|restart|enable|disable` fails with migration guidance to the corresponding `caplets daemon ...` command and performs no daemon mutation. + - Every daemon subcommand is registered with `--json`; successful JSON payload assertions live with the units that implement those commands. + - Completion suggests `daemon` as a top-level command and daemon subcommands under `daemon`. +- **Verification:** The CLI exposes the new surface, the old surface is absent, and completion output matches registered commands. + +### U2. Establish daemon identity, paths, and persistent config merging + +- **Goal:** Replace `serve/default` daemon artifacts with `daemon/default` config, state, logs, and service artifact paths. +- **Requirements:** R4, R5, R6, R15; origin R7-R15, R22-R31, AE2, AE5, AE9. +- **Dependencies:** U1. +- **Files:** `packages/core/src/daemon/types.ts`, `packages/core/src/daemon/paths.ts`, `packages/core/src/daemon/config.ts`, `packages/core/src/serve/daemon/types.ts`, `packages/core/src/serve/daemon/paths.ts`, `packages/core/src/serve/daemon/config.ts`, `packages/core/test/daemon-config.test.ts`, `packages/core/test/serve-daemon.test.ts`. +- **Approach:** Create a daemon config model that stores instance, HTTP serve options, command plan, env overrides, inherit-env setting, resolved service paths, and timestamps. Move path resolution to `daemon/default` on every platform. Implement merge behavior for existing install config, `--reset`, `--env`, and `--unset-env`; apply unsets before env sets. +- **Patterns to follow:** Current JSON config/state helpers in `packages/core/src/serve/daemon/config.ts`; default config/state root helpers from `packages/core/src/config/paths`. +- **Test scenarios:** + - Linux and macOS paths resolve to config/state/log locations under `caplets/daemon/default`. + - Windows paths resolve to `Caplets/State/daemon/default` and `Caplets/daemon/default.json`. + - Existing `serve/default` files are ignored when daemon status or install runs. + - Generated install config records the user's home directory as the service working directory. + - Reinstall without flags preserves previous host, port, path, auth, env, and inherit-env settings. + - `--reset` clears previous persisted values before applying current flags. + - `--env NAME=value=with=equals` preserves everything after the first `=`. + - `--env EMPTY=` stores an empty value. + - Invalid env names and env values without `=` fail before config mutation. + - `--unset-env PATH --env PATH=/custom/bin` stores the explicit env value. +- **Verification:** Persisted daemon config is stable, platform paths use the new identity, and config merge semantics match the origin requirements. + +### U3. Add native service manager operations + +- **Goal:** Replace detached-process lifecycle with a native per-user manager abstraction for launchd, systemd user services, and Windows Scheduled Tasks. +- **Requirements:** R7, R8, R9, R13, R15; origin R6, R35-R44, AE7, AE9. +- **Dependencies:** U2. +- **Files:** `packages/core/src/daemon/manager.ts`, `packages/core/src/daemon/platform-darwin.ts`, `packages/core/src/daemon/platform-linux.ts`, `packages/core/src/daemon/platform-windows.ts`, `packages/core/src/daemon/process.ts`, `packages/core/src/daemon/index.ts`, `packages/core/test/daemon-manager.test.ts`, `packages/core/test/daemon-platform.test.ts`. +- **Approach:** Define a manager interface for normalized state, raw native details, install, uninstall, start, restart, stop, and descriptor rendering. Use injectable process execution so tests can assert launchd, systemd, and `schtasks` commands without mutating the host. Unsupported platforms and unavailable Linux user services should return Caplets errors rather than manual descriptors. Implement the command matrix above before wiring CLI behavior so install/start/status/uninstall semantics do not drift by platform. +- **Patterns to follow:** Existing platform descriptor builders in `packages/core/src/serve/daemon/platform-*.ts`; dependency injection style from current daemon tests. +- **Test scenarios:** + - macOS descriptors use `dev.caplets.daemon.default` and launchd UserAgent stdout/stderr paths. + - macOS descriptors include a working-directory setting for the user's home directory. + - Linux descriptors use `caplets-daemon-default.service`, a systemd user unit, log paths, and a working-directory setting. + - Windows descriptors use a Caplets daemon task name, current-user ONLOGON trigger, no elevated/system context, no execution time limit, restart-on-failure settings when supported, and wrapper-level cwd/log behavior. + - Install writes descriptor artifacts and invokes the native register command. + - Unavailable Linux user services fail with actionable install guidance. + - Unsupported platforms fail without detached process fallback. + - Runtime `start`, `restart`, and `stop` fail with install guidance when manager state is not installed. + - Runtime `start` succeeds when already running, `restart` starts when stopped, and `stop` succeeds when already stopped. + - Manager status maps failed systemd units, loaded-but-exited launchd jobs, stale descriptors, unavailable user services, and Scheduled Task Ready/Running/Last Result states into normalized state plus raw details. +- **Verification:** Every lifecycle operation routes through the native manager seam and can be tested without live OS service registration. + +### U4. Build service command, shell inheritance, and env override planning + +- **Goal:** Generate the managed HTTP serve command and optional shell wrapper from persisted install-time configuration. +- **Requirements:** R3, R6, R10; origin R11, R22-R34, AE2, AE5. +- **Dependencies:** U2, U3. +- **Files:** `packages/core/src/daemon/env.ts`, `packages/core/src/daemon/process.ts`, `packages/core/src/daemon/types.ts`, `packages/core/src/serve/options.ts`, `packages/core/test/daemon-env.test.ts`, `packages/core/test/serve-options.test.ts`. +- **Approach:** Add a daemon-only raw serve option shape that omits `transport`, then force HTTP internally when resolving serve options. Generate a command plan that records executable, args, working directory, env overrides, and shell-wrapper metadata. On macOS and Linux, resolve shell fallback from `SHELL`, account shell when discoverable, `/bin/sh`, then failure. On Windows, resolve `SHELL`, PowerShell, then `ComSpec` or `cmd.exe`. +- **Patterns to follow:** Existing `resolveDaemonServeOptions` default-to-HTTP behavior, but with no public daemon `transport` input; existing process command planner in `packages/core/src/serve/daemon/process.ts`. +- **Test scenarios:** + - Daemon install accepts every HTTP serve flag except `--transport` and persists the resolved HTTP serve config. + - Generated command invokes foreground HTTP serve internally. + - The command plan enforces the user's home directory as cwd on every supported platform, including platforms that need a wrapper to do it. + - The command plan redirects stdout/stderr to Caplets log files on every supported platform, including platforms without native descriptor fields. + - Explicit env overrides are present in the command plan and override inherited shell values. + - `--no-inherit-env` removes existing shell inheritance from persisted config. + - macOS/Linux shell fallback uses `SHELL`, then account shell, then `/bin/sh`. + - Windows shell fallback uses `SHELL`, then PowerShell, then `ComSpec` or `cmd.exe`. + - Missing shell fallback produces an actionable error when inherit-env is requested. +- **Verification:** Command plans are deterministic and platform-specific shell behavior is covered by unit tests. + +### U5. Implement install, update, validation, and restart prompting + +- **Goal:** Make `caplets daemon install` the only mutating configuration entrypoint and validate generated service commands before native registration. +- **Requirements:** R5, R6, R10, R15; origin R12-R21, R32-R34, AE2, AE4, AE6. +- **Dependencies:** U2, U3, U4. +- **Files:** `packages/core/src/daemon/index.ts`, `packages/core/src/daemon/validation.ts`, `packages/core/src/cli.ts`, `packages/core/src/serve/http.ts`, `packages/core/test/daemon-install.test.ts`, `packages/core/test/daemon-cli.test.ts`. +- **Approach:** Resolve install options, build an in-memory candidate config, generate the command, and run validation before registration unless skipped. Validation should start a temporary HTTP process with the same command/env/shell path and probe the configured health route from `servicePaths(resolved.path).health`; when the installed daemon already owns the target port, validate on a temporary loopback port while preserving the rest of the plan. Write descriptor artifacts, register through the native manager, then commit the daemon config only after native registration succeeds. After updating a running service, prompt interactively for restart unless `--start`, `--restart`, or `--no-restart` decides it. +- **Execution note:** Add characterization coverage around current daemon start/status behavior before replacing it with native-manager install semantics. +- **Patterns to follow:** Existing health endpoint in `packages/core/src/serve/http.ts`; existing CLI prompt helper pattern in setup commands; existing `fetch` timeout style in HTTP action code. +- **Test scenarios:** + - Covers AE2. `install --dry-run` returns descriptor, config, env, log paths, and planned actions without writing files, registering, or validating. + - `install --json` returns structured action, config, validation, and native registration details on successful paths. + - `install --no-validate` writes and registers without launching the validation process. + - Successful validation starts a temporary generated command, probes health, then stops it before registration. + - Validation failure stops the temporary process and leaves prior installed config untouched. + - Validation and status use the configured base path, e.g. `--path /caplets` probes `/caplets/v1/healthz`. + - Descriptor write failure, native register failure, and partial native registration rollback leave prior installed config untouched. + - Covers AE6. Validation uses a temporary loopback port when the existing running daemon occupies the configured port. + - `--start`, `--restart`, and `--no-restart` are mutually exclusive. + - Updating a running service interactively prompts for restart after writing config. + - Updating a running service in noninteractive mode fails unless a restart decision flag is supplied. + - `install --start` starts when stopped and restarts when running, then reports a distinct native-start health failure if the manager-started service fails its health probe. + - `install --restart` restarts after update and probes the manager-started service; `install --no-restart` leaves the running service unchanged. +- **Verification:** Install behavior is atomic around validation, restart decisions are explicit, and dry-run remains side-effect free. + +### U6. Implement runtime lifecycle, status, and doctor integration + +- **Goal:** Make runtime commands operate only against installed native services and report native state plus HTTP health. +- **Requirements:** R1, R7, R8, R9, R11; origin R35-R47, AE7. +- **Dependencies:** U3, U5. +- **Files:** `packages/core/src/daemon/index.ts`, `packages/core/src/daemon/manager.ts`, `packages/core/src/cli/doctor.ts`, `packages/core/src/cli.ts`, `packages/core/test/daemon-lifecycle.test.ts`, `packages/core/test/daemon-cli.test.ts`, `packages/core/test/doctor-cli.test.ts`. +- **Approach:** Implement `start`, `restart`, and `stop` as manager operations that first verify installed state. Status should succeed even when no service is installed, ask the manager for installed/running state when available, probe the configured health route only when running, include log paths, and render JSON without losing manager details. Update doctor diagnostics to read daemon status instead of returning static daemon data. +- **Patterns to follow:** Current doctor section formatting in `packages/core/src/cli/doctor.ts`; current daemon status redaction helper if retained for existing config output. +- **Test scenarios:** + - Covers AE7. Runtime lifecycle commands fail with install guidance when service is not installed. + - `status --json` before install succeeds with `installed: false`, `running: false`, no HTTP health probe, and expected log paths where available. + - Installed `start` invokes the native manager and reports success. + - Installed `restart` starts a stopped service and restarts a running service. + - Installed `stop` succeeds when stopped and stops when running. + - `start --json`, `restart --json`, and `stop --json` report the action taken and native manager state. + - `status --json` distinguishes installed, running, HTTP health, config, and log paths. + - Status does not probe HTTP health when the manager reports stopped. + - Doctor JSON includes daemon installed/running/health/log-path data. + - Plain doctor output shows daemon installed/running state without exposing static placeholder values. +- **Verification:** Runtime lifecycle no longer mutates install config and status reflects the native manager as source of truth. + +### U7. Implement logs, uninstall, and purge behavior + +- **Goal:** Add tail-like daemon log inspection and complete uninstall semantics. +- **Requirements:** R12, R13, R14, R15; origin R40-R44, R48-R53, AE8, AE9. +- **Dependencies:** U2, U3, U6. +- **Files:** `packages/core/src/daemon/logs.ts`, `packages/core/src/daemon/index.ts`, `packages/core/src/cli.ts`, `packages/core/test/daemon-logs.test.ts`, `packages/core/test/daemon-uninstall.test.ts`, `packages/core/test/daemon-cli.test.ts`. +- **Approach:** Implement file-backed log reading with stream selection, default last 10 lines, `--tail `, `--follow`, and `--tail 0 --follow`. Finite `logs --json` should return one JSON envelope with stream-labeled entries and paths; `logs --json --follow` should emit newline-delimited JSON events after an initial metadata event. Implement uninstall to stop running services before unregistering and deleting descriptor artifacts. Preserve logs/state by default, make log directories and files user-only, and make `--purge` remove config, state, and logs after unregistering. +- **Patterns to follow:** Caplets log-store tests for file-backed behavior; Node stream and filesystem helpers already used in daemon/process code. +- **Test scenarios:** + - Covers AE8. `logs` reads preserved stdout/stderr logs after uninstall without purge. + - Missing selected logs print an empty-state message with expected paths. + - `logs` defaults to the last 10 lines across selected streams. + - `logs --stream all` includes both streams with deterministic stream labels or headers and does not claim strict cross-file chronological ordering. + - `logs --tail 0 --follow` emits only appended lines. + - `logs --json --follow` emits NDJSON metadata and appended log events rather than waiting forever for a single JSON result. + - `--stream stdout`, `--stream stderr`, and `--stream all` select the expected files. + - `uninstall` stops a running service before unregistering. + - `logs --json` and `uninstall --json` return structured log entries or cleanup actions for finite, successful paths. + - `uninstall` preserves logs and historical state by default. + - Log directories and files are created with user-only permissions and documented as potentially sensitive because no log redaction is promised. + - `uninstall --purge` removes descriptor artifacts, config, state, and all stdout/stderr logs. + - `uninstall --dry-run --purge` lists every action and path without mutation. +- **Verification:** Log behavior matches tail-like semantics and uninstall cleanup preserves or purges state exactly as requested. + +### U8. Update docs, public references, and release metadata + +- **Goal:** Reflect the new daemon surface in durable docs and package release metadata. +- **Requirements:** R1, R2, R3, R8, R11, R12; origin success criteria. +- **Dependencies:** U1-U7. +- **Files:** `docs/architecture.md`, `docs/product/caplets-code-mode-prd.md`, `CONCEPTS.md`, `.changeset/*.md`, `packages/core/test/daemon-cli.test.ts`. +- **Approach:** Update architecture and product docs to describe `caplets daemon` as the service lifecycle surface and foreground `caplets serve` as stdio/HTTP serving only. Keep `CONCEPTS.md` glossary aligned with the Caplets Daemon definition. Add a changeset because this is user-facing CLI behavior in `@caplets/core`. +- **Patterns to follow:** Existing architecture doc source-authoritative posture; Changesets convention in the repo. +- **Test scenarios:** + - CLI help assertions prove docs examples refer to commands that exist. + - Generated docs checks pass after command surface updates. + - Changeset status recognizes the package-impacting CLI change. +- **Verification:** Public docs, generated references, and package release metadata agree with the implemented command surface. + +--- + +## Scope Boundaries + +### In Scope + +- Top-level `caplets daemon` command surface for the default per-user daemon. +- Native per-user launchd, systemd user service, and Windows Scheduled Task integration. +- Install-time HTTP serve config, explicit env overrides, shell inheritance, validation, status, logs, uninstall, and purge. +- Unit and integration tests using fake service managers and command runners. + +### Deferred to Follow-Up Work + +- Live OS smoke automation for actually registering launchd, systemd, or Scheduled Task services in CI. +- Migration or cleanup of historical `serve/default` daemon artifacts. +- Project-bound daemon instances. + +### Outside This Feature + +- Daemonizing stdio transport. +- Daemonizing `caplets attach` or arbitrary Caplets commands. +- System-wide or privileged service installation. +- Detached process fallback when native service management is unavailable. +- New secret storage, redaction, or auth mechanisms. + +--- + +## System-Wide Impact + +This change alters the exported CLI contract and shell completion surface. Scripts using `caplets serve start|stop|status|restart|enable|disable` will fail with command-specific migration guidance and should move to `caplets daemon ...`. + +Daemon config and state paths intentionally move from `serve/default` to `daemon/default`, so existing local daemon state will not be discovered. Doctor output should become more accurate because daemon status will be sourced from the daemon subsystem instead of static placeholder data. + +--- + +## Risks & Dependencies + +- **Native manager semantics differ by platform:** systemd enable/start are separate, launchd bootstrap/kickstart/bootout target GUI/user domains, and Scheduled Tasks split create/run/end/delete. The manager seam must normalize Caplets behavior without hiding platform-specific failures. +- **Shell wrapper quoting is easy to get wrong:** inherited env requires platform-specific command quoting and precedence tests, especially for Windows PowerShell and `cmd.exe`. +- **Windows needs wrapper-level cwd and log handling:** Scheduled Tasks do not provide the same descriptor fields as launchd and systemd, so the command planner must enforce cwd and stdout/stderr redirection itself. +- **Validation can race with existing services:** the temporary-port path must avoid mutating persisted serve config while still proving the generated command shape. +- **Native-start health can fail after preflight succeeds:** manager execution domains differ from temporary validation, so `install --start` and `install --restart` need a second health result tied to the native-started service. +- **Preserved logs can contain sensitive process output:** the feature does not add redaction, so file permissions, documentation, and purge coverage are the primary controls for retained stdout/stderr data. +- **Follow mode can hang tests:** log-follow tests need injectable file watching or stream control so unit tests do not depend on timing-heavy real tails. +- **Noninteractive prompting must be explicit:** install updates on a running service need deterministic behavior when stdin is not interactive. + +--- + +## Acceptance Examples + +- AE1. `caplets daemon --help` shows daemon lifecycle commands and `caplets serve --help` does not. +- AE2. `caplets daemon install --dry-run --host 127.0.0.1 --port 5388 --path /caplets --inherit-env --env FOO=bar` previews planned service state without side effects. +- AE3. `caplets daemon install --transport http` fails without creating or updating service artifacts. +- AE4. Updating a running installed daemon writes config first, then restarts only after a confirmation or explicit restart decision flag. +- AE5. `--inherit-env` validation uses the service shell wrapper, and explicit `--env PATH=/custom/bin` wins for the Caplets process. +- AE6. Install validation can use a temporary loopback port when the live daemon owns the configured port, and health probes honor configured base paths such as `/caplets/v1/healthz`. +- AE7. Runtime lifecycle commands fail before install and are idempotent once installed, while `status` succeeds before install with uninstalled state. +- AE8. `caplets daemon logs` can read preserved logs after uninstall without purge. +- AE9. `caplets daemon uninstall --dry-run --purge` lists all unregister and removal actions without mutating anything. + +--- + +## Documentation / Operational Notes + +The implementation should document that `install` is the configuration mutation point and that lifecycle commands do not accept serve flags. The docs should also explain that `--inherit-env` runs through a user shell wrapper, explicit `--env` values override the inherited environment for the Caplets process, and preserved daemon logs may contain sensitive raw process output until `uninstall --purge` removes them. + +Manual smoke after implementation should cover one platform at a time: install, status, logs, restart after config update, uninstall, and purge. CI should not register real user services. + +--- + +## Sources / Research + +- `docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md` defines the product requirements, flows, and acceptance examples. +- `STRATEGY.md` frames local runtime diagnosability as part of reliable Code Mode and native agent setup. +- `CONCEPTS.md` defines Caplets Daemon as the project vocabulary for this feature. +- `packages/core/src/cli.ts` currently wires daemon lifecycle under `serve`. +- `packages/core/src/cli/commands.ts` currently has no top-level `daemon` command and lists daemon subcommands under `serve`. +- `packages/core/src/serve/daemon/` contains the current config, path, descriptor, and detached process foundation to migrate or replace. +- `packages/core/test/serve-daemon.test.ts` captures current behavior that should be moved, rewritten, or intentionally deleted. +- [Apple Creating Launch Daemons and Agents](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html) documents per-user LaunchAgents and required plist keys. +- [launchctl manual](https://keith.github.io/xcode-man-pages/launchctl.1.html) documents launchd domains, bootstrap/bootout, enable/disable, and kickstart behavior. +- [systemctl manual](https://www.man7.org/linux/man-pages/man1/systemctl.1.html) documents enable/start separation and user service management semantics. +- [Microsoft schtasks commands](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks) documents Scheduled Task create, delete, end, query, and run operations. + +## Deferred / Open Questions + +### From 2026-06-19 review + +- **Doctor integration exceeds origin scope** - Implementation Units / U6 (P2, scope-guardian, confidence 75) + + U6 adds a second public diagnostic surface that the traced requirements do not require, so implementation can spend time changing and testing `doctor` instead of the daemon lifecycle and status behavior the origin asked for. Because this is an origin-backed plan, that extra surface should be explicitly justified or deferred rather than bundled into the status unit. diff --git a/docs/product/caplets-code-mode-prd.md b/docs/product/caplets-code-mode-prd.md index 2a873765..5472e101 100644 --- a/docs/product/caplets-code-mode-prd.md +++ b/docs/product/caplets-code-mode-prd.md @@ -46,7 +46,8 @@ Caplets should make backends usable as capability domains without hiding the exa The `caplets` package installs the CLI. Important commands include: - `caplets setup` for Codex, Claude Code, OpenCode, Pi, or generic MCP client setup. -- `caplets serve` for a local MCP server. +- `caplets serve` for foreground stdio or HTTP MCP serving. +- `caplets daemon` for installing, starting, stopping, inspecting, tailing logs for, and uninstalling the default local HTTP service through the native per-user service manager. - `caplets attach` for a remote-backed MCP server and Project Binding session. - `caplets install` for shared Caplet files. - `caplets add` for MCP, OpenAPI, GraphQL, HTTP, and CLI-backed Caplets. @@ -57,6 +58,8 @@ The `caplets` package installs the CLI. Important commands include: `caplets serve` registers a `code_mode` MCP tool when any configured backend resolves to Code Mode exposure. Progressive wrapper tools and direct tools are registered only for Caplets whose exposure policy enables those surfaces. +`caplets daemon install` is the configuration mutation point for the long-running local HTTP service. It accepts the HTTP serve flags from `caplets serve` except `--transport`, supports explicit `--env` overrides and optional shell inheritance, and writes stdout/stderr logs that remain readable until purged. + ### Native Integrations OpenCode and Pi use the native service from `@caplets/core/native`. They expose `caplets__code_mode` for Code Mode, `caplets__` tools for progressive exposure, and operation-level `caplets____` tools for direct exposure. Native integrations share the same local, remote, and Cloud selection rules as the CLI. diff --git a/docs/solutions/architecture-patterns/native-daemon-service-management.md b/docs/solutions/architecture-patterns/native-daemon-service-management.md new file mode 100644 index 00000000..51dcf1e2 --- /dev/null +++ b/docs/solutions/architecture-patterns/native-daemon-service-management.md @@ -0,0 +1,183 @@ +--- +title: Native Daemon Management Belongs Behind an Install-Time Service Contract +date: 2026-06-19 +category: architecture-patterns +module: Caplets daemon +problem_type: architecture_pattern +component: tooling +severity: medium +applies_when: + - "Moving a foreground CLI server into a native per-user service" + - "Service install flags persist configuration but runtime lifecycle commands should not mutate it" + - "Supporting launchd, systemd user services, and Windows Scheduled Tasks from one CLI surface" +tags: [daemon, native-services, launchd, systemd, scheduled-tasks, cli] +--- + +# Native Daemon Management Belongs Behind an Install-Time Service Contract + +## Context + +The Caplets daemon work moved lifecycle management out of `caplets serve` and into a top-level `caplets daemon` surface. Foreground `serve` remains the process entrypoint, while `daemon install` writes persistent service configuration and registers a per-user native service through launchd, systemd user services, or Windows Scheduled Tasks. + +The product requirement looked simple at first: create `install`, `start`, `restart`, `stop`, `uninstall`, `status`, and `logs`. The hard part was that each command has different ownership. `install` is allowed to mutate service configuration, descriptors, environment settings, validation behavior, and optional restart decisions. Runtime lifecycle commands should only operate on the already-installed service. Session history also surfaced review failures when this boundary was blurred: partial registration could leave stale artifacts, platform descriptors could misquote commands, and launchd could report an already-loaded job while still running the old descriptor. + +## Guidance + +Model the daemon as an install-time service contract, not as a detached variant of the foreground server. + +The public CLI should expose daemon lifecycle under the daemon noun: + +```text +caplets daemon install +caplets daemon uninstall +caplets daemon start +caplets daemon restart +caplets daemon stop +caplets daemon status +caplets daemon logs +``` + +Keep `caplets serve` focused on foreground serving. If old daemon subcommands existed under `serve`, leave migration guidance there, but do not keep a second daemon implementation alive. + +Separate configuration mutation from lifecycle mutation: + +```ts +// install owns config merge, validation, descriptor writing, and registration +await installDaemon({ + host, + port, + env, + inheritEnv, + start, + restart, + noRestart, +}); + +// lifecycle commands only read the installed service +await startDaemon(); +await restartDaemon(); +await stopDaemon(); +``` + +Build a native manager seam that normalizes the service lifecycle while preserving raw platform details for troubleshooting: + +```ts +type NativeDaemonStatus = { + state: "not_installed" | "installed_stopped" | "running" | "failed" | "unavailable" | "unknown"; + installed: boolean; + running: boolean; + pid?: number; + message?: string; + raw?: Record; +}; + +type DaemonManager = { + descriptor(config: DaemonConfig): DaemonDescriptor; + install(config: DaemonConfig): Promise; + uninstall(config: DaemonConfig | undefined, paths: DaemonPaths): Promise; + start(config: DaemonConfig): Promise; + restart(config: DaemonConfig): Promise; + stop(config: DaemonConfig): Promise; + status(config: DaemonConfig | undefined, paths: DaemonPaths): Promise; +}; +``` + +Treat native registration as transactional. Write descriptor artifacts before invoking the native manager, but back them up and restore them if registration fails. Only write installed Caplets config/state after the native install succeeds. This avoids claiming the daemon is installed when the OS service manager rejected it. + +Render service commands as argv for each platform, not as a reused display string. The managed process may still invoke `caplets serve --transport http` internally, but public `daemon install` should not accept `--transport` because the daemon is intentionally HTTP-only. Quote descriptors per platform: + +- systemd units need escaped `%`, quotes, backslashes, and newlines in `Environment=`, `WorkingDirectory=`, and `ExecStart=`. +- Windows Scheduled Tasks need a wrapper when argument handling, working directory, and file-backed logs cannot be represented safely in the task XML alone. +- launchd restart/update needs an explicit reload path, such as `bootout`, `bootstrap`, then `kickstart`, when a changed plist must become active. + +Make logs file-backed and manager-independent. Service descriptors or wrappers should redirect stdout and stderr into Caplets-managed files, and `caplets daemon logs` should read those files with tail-like options. This keeps logs readable after uninstall when logs are preserved. + +## Why This Matters + +Native service managers are not interchangeable process launchers. launchd has loaded-job semantics, systemd unit files have their own quoting rules, and Windows Scheduled Tasks are XML plus command-line interpretation. A daemon abstraction that hides all platform detail too early will either lose useful status information or silently produce descriptors that pass validation but fail after installation. + +The install/lifecycle split also protects user intent. Users expect `install --env FOO=bar --inherit-env --port 5388` to change persistent service configuration. They do not expect `start` or `restart` to change those values. Keeping that boundary strict makes failures easier to explain: if the service is missing, lifecycle commands fail with the install command; if the config changed while running, install updates the service and then requires an explicit restart decision. + +## When to Apply + +- Use this pattern when a CLI has both a foreground server mode and a persistent daemon/service mode. +- Use it when service configuration must survive terminal exits and login restarts. +- Use it when more than one native service manager must be supported behind one command surface. +- Use it when shell environment inheritance is product behavior, not an incidental launcher detail. +- Do not use this pattern for one-off detached processes that are intentionally not managed by the operating system. + +## Examples + +Fail closed on transport selection at the daemon boundary: + +```ts +export function resolveDaemonHttpServeOptions(raw: RawDaemonServeOptions): HttpServeOptions { + if ((raw as RawServeOptions).transport !== undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "caplets daemon install does not accept --transport.", + ); + } + + return resolveServeOptions({ ...raw, transport: "http" }) as HttpServeOptions; +} +``` + +Make lifecycle commands read the installed service and verify health after native start/restart: + +```ts +async function daemonLifecycle( + action: "start" | "restart" | "stop", + options: DaemonOperationOptions, +) { + const config = readDaemonConfig(paths); + if (!config || !existsSync(paths.descriptorFile)) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplets daemon is not installed. Run caplets daemon install${action === "stop" ? "" : " --start"} first.`, + ); + } + + const before = await manager.status(config, paths); + const effectiveAction = action === "start" && before.running ? "restart" : action; + const native = await runNativeLifecycle(effectiveAction, config); + const status = await daemonStatus({ ...options, manager }); + + if (effectiveAction !== "stop") { + assertDaemonHealth(status.health, "Native daemon health check"); + } + + return { action: effectiveAction, native, status }; +} +``` + +Regression coverage should include the platform behaviors that are easiest to miss: + +```ts +it("rolls back descriptor artifacts when native registration fails", async () => { + await expect(installDaemon({}, { manager: failingManager })).rejects.toThrow( + /registration failed/u, + ); + + expect(readDaemonConfig(paths)).toBeUndefined(); + expect(readFileSync(paths.descriptorFile, "utf8")).toBe(previousDescriptor); +}); + +it("reloads launchd descriptors before restart", async () => { + await restartDaemon(options); + + expect(commands).toContainEqual(["launchctl", "bootout", "gui/501", descriptorPath]); + expect(commands).toContainEqual(["launchctl", "bootstrap", "gui/501", descriptorPath]); + expect(commands).toContainEqual([ + "launchctl", + "kickstart", + "-k", + "gui/501/dev.caplets.daemon.default", + ]); +}); +``` + +## Related + +- [Caplets daemon service requirements](../../brainstorms/2026-06-19-caplets-daemon-service-requirements.md) +- [Caplets daemon implementation plan](../../plans/2026-06-19-001-feat-caplets-daemon-service-plan.md) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index c8c43c3d..f551b286 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -78,17 +78,21 @@ import { RemoteControlClient } from "./remote-control/client"; import type { RemoteCliCommand } from "./remote-control/types"; import { resolveCapletsRemote, resolveRemoteMode } from "./remote/options"; import { + daemonLogs, daemonStatus, - disableDaemon, - enableDaemon, - resolveServeOptions, + followDaemonLogs, + installDaemon, + redactDaemonInstallResult, + resolveDaemonPaths, restartDaemon, - serveResolvedCaplets, startDaemon, stopDaemon, - type ServeDaemonOperationOptions, - type ServeOptions, -} from "./serve"; + uninstallDaemon, + type DaemonInstallOptions, + type DaemonLogStream, + type DaemonOperationOptions, +} from "./daemon"; +import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -113,7 +117,7 @@ type CliIO = { setExitCode?: (code: number) => void; serve?: (options: ServeOptions) => Promise; attachServe?: (options: AttachServeOptions) => Promise; - daemon?: ServeDaemonOperationOptions; + daemon?: DaemonOperationOptions; runSetupCommand?: SetupCommandRunner; readStdin?: () => Promise; }; @@ -145,8 +149,7 @@ function normalizeCompletionWords(words: string[]): string[] { return words.map((word) => (word === trailingSpaceCompletionToken ? "" : word)); } -type ServeDaemonCommandOptions = { - transport?: string; +type DaemonInstallCommandOptions = { host?: string; port?: string; path?: string; @@ -155,18 +158,33 @@ type ServeDaemonCommandOptions = { allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; json?: boolean; + reset?: boolean; + env?: string[]; + unsetEnv?: string[]; + inheritEnv?: boolean; + dryRun?: boolean; + validate?: boolean; + start?: boolean; + restart?: boolean; + noRestart?: boolean; }; -function addServeDaemonCommand( - parent: Command, - name: string, - description: string, - action: (options: ServeDaemonCommandOptions) => Promise, -): void { - parent - .command(name) - .description(description) - .option("--transport ", "server transport: http") +type DaemonCommandOptions = { + json?: boolean; +}; + +type DaemonLogsCommandOptions = DaemonCommandOptions & { + follow?: boolean; + tail?: string; + stream?: DaemonLogStream; +}; + +function addJsonOption(command: Command): Command { + return command.option("--json", "print JSON output"); +} + +function addDaemonInstallOptions(command: Command): Command { + return addJsonOption(command) .option("--host ", "HTTP bind host") .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") @@ -177,12 +195,39 @@ function addServeDaemonCommand( "allow unauthenticated HTTP serving on non-loopback hosts", ) .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy") - .option("--json", "print JSON output") - .action(function (this: Command, options: ServeDaemonCommandOptions) { - return action({ ...this.parent?.opts(), ...options }); + .option("--reset", "rebuild daemon configuration from defaults") + .option("--env ", "set an environment variable for the service", collectValues, []) + .option( + "--unset-env ", + "remove an environment variable from the service", + collectValues, + [], + ) + .option("--inherit-env", "run the service through the user's shell environment") + .option("--no-inherit-env", "disable shell environment inheritance") + .option("--dry-run", "preview actions without writing files or registering a service") + .option("--no-validate", "skip temporary service command validation") + .option("--start", "start the service after install") + .option("--restart", "restart the service after install") + .option("--no-restart", "do not restart a running service after updating config"); +} + +function addServeMigrationCommand(parent: Command, name: string, replacement: string): void { + parent + .command(name, { hidden: true }) + .allowUnknownOption(true) + .action(() => { + throw new CapletsError( + "REQUEST_INVALID", + `caplets serve ${name} has moved. Use ${replacement}.`, + ); }); } +function collectValues(value: string, previous: string[]): string[] { + return [...previous, value]; +} + function cloudAuthStore( env: NodeJS.ProcessEnv | Record, ): CloudAuthStore { @@ -216,9 +261,8 @@ function isProjectBindingCliError(error: unknown): error is ProjectBindingError return error instanceof ProjectBindingError; } -function serveRawOptions(options: ServeDaemonCommandOptions) { +function daemonInstallOptions(options: DaemonInstallCommandOptions): DaemonInstallOptions { return { - ...(options.transport !== undefined ? { transport: options.transport } : {}), ...(options.host !== undefined ? { host: options.host } : {}), ...(options.port !== undefined ? { port: options.port } : {}), ...(options.path !== undefined ? { path: options.path } : {}), @@ -228,6 +272,16 @@ function serveRawOptions(options: ServeDaemonCommandOptions) { ? { allowUnauthenticatedHttp: options.allowUnauthenticatedHttp } : {}), ...(options.trustProxy !== undefined ? { trustProxy: options.trustProxy } : {}), + ...(options.reset !== undefined ? { reset: options.reset } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.unsetEnv !== undefined ? { unsetEnv: options.unsetEnv } : {}), + ...(options.inheritEnv !== undefined ? { inheritEnv: options.inheritEnv } : {}), + ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), + ...(options.validate !== undefined ? { validate: options.validate } : {}), + ...(options.start !== undefined ? { start: options.start } : {}), + ...(options.restart === true ? { restart: true } : {}), + ...(options.restart === false ? { noRestart: true } : {}), + ...(options.noRestart !== undefined ? { noRestart: options.noRestart } : {}), }; } @@ -510,92 +564,158 @@ export function createProgram(io: CliIO = {}): Command { }, ); - const daemonOptions = (): ServeDaemonOperationOptions => ({ + const daemonOptions = (): DaemonOperationOptions => ({ env, ...io.daemon, }); - addServeDaemonCommand( - serve, - "start", - "Start the default Caplets HTTP daemon.", - async (options) => { - const result = await startDaemon(serveRawOptions(options), daemonOptions()); - const serveConfig = result.status.config?.serve; - writeOut( - `Started Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`, - ); - if (options.json) writeOut(`${JSON.stringify(result.status, null, 2)}\n`); - }, - ); - addServeDaemonCommand(serve, "stop", "Stop the default Caplets HTTP daemon.", async (options) => { - const result = await stopDaemon(daemonOptions()); - if (options.json) { - writeOut(`${JSON.stringify(result.status, null, 2)}\n`); - return; - } - writeOut("Stopped Caplets HTTP daemon.\n"); - }); - addServeDaemonCommand( - serve, - "status", - "Show the default Caplets HTTP daemon status.", - async (options) => { - const status = await daemonStatus(daemonOptions()); + for (const [name, replacement] of Object.entries({ + start: "caplets daemon start", + stop: "caplets daemon stop", + status: "caplets daemon status", + restart: "caplets daemon restart", + enable: "caplets daemon install", + disable: "caplets daemon uninstall", + })) { + addServeMigrationCommand(serve, name, replacement); + } + + const daemon = program + .command(cliCommands.daemon) + .description("Install and manage the default Caplets daemon."); + + addDaemonInstallOptions( + daemon.command("install").description("Install or update the default Caplets daemon."), + ).action(async (options: DaemonInstallCommandOptions) => { + const prompt = options.json ? undefined : createSetupPromptHandle(io, writeOut); + try { + const result = await installDaemon(daemonInstallOptions(options), { + ...daemonOptions(), + ...(prompt + ? { readPrompt: prompt.readPrompt, isInteractive: true } + : { isInteractive: false }), + }); if (options.json) { - writeOut(`${JSON.stringify(status, null, 2)}\n`); + writeOut(`${JSON.stringify(redactDaemonInstallResult(result), null, 2)}\n`); return; } - writeOut( - status.running - ? `Caplets HTTP daemon is running${status.pid ? ` (pid ${status.pid})` : ""}.\n` - : "Caplets HTTP daemon is stopped.\n", - ); - }, - ); - addServeDaemonCommand( - serve, - "restart", - "Restart the default Caplets HTTP daemon.", - async (options) => { - const result = await restartDaemon(serveRawOptions(options), daemonOptions()); - if (options.json) { - writeOut(`${JSON.stringify(result.status, null, 2)}\n`); + if (result.dryRun) { + writeOut(`Would install Caplets daemon using ${result.descriptor.kind}.\n`); return; } - const serveConfig = result.status.config?.serve; - writeOut( - `Restarted Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`, - ); - }, - ); - addServeDaemonCommand( - serve, - "enable", - "Enable the default Caplets HTTP daemon at login.", - async (options) => { - const result = await enableDaemon(daemonOptions()); + writeOut(`Installed Caplets daemon using ${result.descriptor.kind}.\n`); + } finally { + prompt?.close(); + } + }); + + addJsonOption( + daemon + .command("uninstall") + .description("Uninstall the default Caplets daemon.") + .option("--purge", "remove daemon config, state, and logs") + .option("--dry-run", "preview uninstall actions without mutation"), + ).action(async (options: { json?: boolean; purge?: boolean; dryRun?: boolean }) => { + const result = await uninstallDaemon( + { purge: options.purge === true, dryRun: options.dryRun === true }, + daemonOptions(), + ); + if (options.json) { + writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + writeOut(result.dryRun ? "Would uninstall Caplets daemon.\n" : "Uninstalled Caplets daemon.\n"); + }); + + addJsonOption(daemon.command("start").description("Start the default Caplets daemon.")).action( + async (options: DaemonCommandOptions) => { + const result = await startDaemon(daemonOptions()); if (options.json) { writeOut(`${JSON.stringify(result, null, 2)}\n`); return; } - writeOut(`Enabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`); + writeOut( + result.action === "restart" ? "Restarted Caplets daemon.\n" : "Started Caplets daemon.\n", + ); }, ); - addServeDaemonCommand( - serve, - "disable", - "Disable the default Caplets HTTP daemon at login.", - async (options) => { - const result = await disableDaemon(daemonOptions()); + + addJsonOption( + daemon.command("restart").description("Restart the default Caplets daemon."), + ).action(async (options: DaemonCommandOptions) => { + const result = await restartDaemon(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + writeOut("Restarted Caplets daemon.\n"); + }); + + addJsonOption(daemon.command("stop").description("Stop the default Caplets daemon.")).action( + async (options: DaemonCommandOptions) => { + const result = await stopDaemon(daemonOptions()); if (options.json) { writeOut(`${JSON.stringify(result, null, 2)}\n`); return; } - writeOut(`Disabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`); + writeOut("Stopped Caplets daemon.\n"); }, ); + addJsonOption( + daemon.command("status").description("Show the default Caplets daemon status."), + ).action(async (options: DaemonCommandOptions) => { + const status = await daemonStatus(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + writeOut(formatDaemonStatus(status)); + }); + + addJsonOption( + daemon + .command("logs") + .description("Show Caplets daemon logs.") + .option("--follow", "follow appended log lines") + .option("--tail ", "show the last number of lines") + .option("--stream ", "log stream: stdout, stderr, or all"), + ).action(async (options: DaemonLogsCommandOptions) => { + const tail = options.tail === undefined ? 10 : parseNonNegativeInteger(options.tail, "--tail"); + const stream = parseLogStream(options.stream); + if (options.follow) { + const controller = new AbortController(); + await followDaemonLogs(resolveDaemonPaths(daemonOptions()), { + stream, + tail, + signal: io.signal ?? controller.signal, + write: (entry) => { + if (options.json) { + writeOut(`${JSON.stringify(entry)}\n`); + return; + } + if ("type" in entry) return; + writeOut(stream === "all" ? `[${entry.stream}] ${entry.line}\n` : `${entry.line}\n`); + }, + }); + return; + } + const result = daemonLogs({ ...daemonOptions(), stream, tail }); + if (options.json) { + writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (result.entries.length === 0) { + writeOut( + `No Caplets daemon logs found. Expected stdout at ${result.paths.stdoutLog} and stderr at ${result.paths.stderrLog}.\n`, + ); + return; + } + for (const entry of result.entries) { + writeOut(stream === "all" ? `[${entry.stream}] ${entry.line}\n` : `${entry.line}\n`); + } + }); + program .command(cliCommands.attach) .description("Start a remote-backed Caplets MCP server.") @@ -1012,10 +1132,16 @@ export function createProgram(io: CliIO = {}): Command { .option("--json", "print JSON output") .action(async (options: { json?: boolean }) => { if (options.json) { - writeOut(`${JSON.stringify(await doctorJsonReport({ env }), null, 2)}\n`); + writeOut( + `${JSON.stringify( + await doctorJsonReport({ env, ...(io.daemon ? { daemon: io.daemon } : {}) }), + null, + 2, + )}\n`, + ); return; } - writeOut(await formatDoctorReport({ env })); + writeOut(await formatDoctorReport({ env, ...(io.daemon ? { daemon: io.daemon } : {}) })); }); program @@ -1896,6 +2022,19 @@ export function createProgram(io: CliIO = {}): Command { return program; } +function formatDaemonStatus(status: Awaited>): string { + if (!status.installed) return "Caplets daemon is not installed.\n"; + const lines = [ + `Caplets daemon is ${status.running ? "running" : "stopped"} (${status.nativeState}).`, + ]; + if (status.health && !status.health.ok) { + lines.push( + `Health check failed for ${status.health.url}${status.health.status ? ` with HTTP ${status.health.status}` : ""}${status.health.error ? `: ${status.health.error}` : ""}.`, + ); + } + return `${lines.join("\n")}\n`; +} + function envConfigPath( env: NodeJS.ProcessEnv | Record, ): string | undefined { @@ -2142,6 +2281,21 @@ function parsePositiveInteger(value: string): number { return parsed; } +function parseNonNegativeInteger(value: string, label: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new CapletsError("REQUEST_INVALID", `${label} must be a non-negative integer`); + } + return parsed; +} + +function parseLogStream(value: string | undefined): DaemonLogStream { + if (value === undefined || value === "all" || value === "stdout" || value === "stderr") { + return value ?? "all"; + } + throw new CapletsError("REQUEST_INVALID", "--stream must be stdout, stderr, or all"); +} + type CliOutputFormat = "markdown" | "plain" | "json"; function parseOutputFormat(value: string): CliOutputFormat { diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 0b23b7da..db78ff3d 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -5,6 +5,7 @@ export const cliCommands = { completion: "completion", completeHidden: "__complete", codeMode: "code-mode", + daemon: "daemon", serve: "serve", attach: "attach", cloud: "cloud", @@ -34,6 +35,7 @@ export const cliCommands = { export const topLevelCommandNames = [ cliCommands.serve, + cliCommands.daemon, cliCommands.codeMode, cliCommands.attach, cliCommands.cloud, @@ -69,7 +71,7 @@ export const cliSubcommands = { [cliCommands.codeMode]: ["types"], [cliCommands.completion]: [...completionShells], [cliCommands.config]: ["path", "paths"], - [cliCommands.serve]: ["start", "stop", "status", "restart", "enable", "disable"], + [cliCommands.daemon]: ["install", "uninstall", "start", "restart", "stop", "status", "logs"], [cliCommands.setup]: ["codex", "claude-code", "opencode", "pi", "mcp-client"], } as const satisfies Record; diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index 5bf655d9..f4ef9400 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -29,6 +29,7 @@ import { import { FileObservedOutputShapeStore } from "../observed-output-shapes"; import { loadConfig, type CapletConfig } from "../config"; import { resolveExposure } from "../exposure/policy"; +import { daemonStatus, type DaemonOperationOptions } from "../daemon"; export type DoctorOptions = { env?: NodeJS.ProcessEnv | Record; @@ -36,6 +37,7 @@ export type DoctorOptions = { syncStatus?: MutagenProjectSyncDoctorData; cloudAuthStore?: CloudAuthStore; observedOutputShapeCacheDir?: string; + daemon?: DaemonOperationOptions; }; export type DoctorJsonReport = { @@ -86,10 +88,7 @@ export async function doctorJsonReport(options: DoctorOptions = {}): Promise, + options: DaemonOperationOptions | undefined, +) { + try { + const status = await daemonStatus({ ...options, env }); + return { + installed: status.installed, + running: status.running, + nativeState: status.nativeState, + health: status.health ? { ok: status.health.ok, url: status.health.url } : null, + logs: { + stdout: status.paths.stdoutLog, + stderr: status.paths.stderrLog, + }, + }; + } catch (error) { + return { + installed: false, + running: false, + nativeState: "unknown", + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } +} + async function resolveExposureSection(env: NodeJS.ProcessEnv | Record) { const configPath = env.CAPLETS_CONFIG?.trim() ? env.CAPLETS_CONFIG.trim() : resolveConfigPath(); const projectConfigPath = env.CAPLETS_PROJECT_CONFIG?.trim() diff --git a/packages/core/src/daemon/config.ts b/packages/core/src/daemon/config.ts new file mode 100644 index 00000000..3a9606dc --- /dev/null +++ b/packages/core/src/daemon/config.ts @@ -0,0 +1,81 @@ +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { CapletsError } from "../errors"; +import type { + DaemonConfig, + DaemonEnvConfig, + DaemonInstallOptions, + DaemonPaths, + DaemonState, +} from "./types"; + +export function readDaemonConfig(paths: DaemonPaths): DaemonConfig | undefined { + return readJson(paths.configFile); +} + +export function writeDaemonConfig(paths: DaemonPaths, config: DaemonConfig): DaemonConfig { + writeJson(paths.configFile, config); + return config; +} + +export function readDaemonState(paths: DaemonPaths): DaemonState | undefined { + return readJson(paths.stateFile); +} + +export function writeDaemonState(paths: DaemonPaths, state: DaemonState): DaemonState { + writeJson(paths.stateFile, state); + return state; +} + +export function removeDaemonConfig(paths: DaemonPaths): void { + rmSync(paths.configFile, { force: true }); +} + +export function removeDaemonState(paths: DaemonPaths): void { + rmSync(paths.stateFile, { force: true }); +} + +export function mergeDaemonEnv( + existing: DaemonEnvConfig | undefined, + install: Pick, +): DaemonEnvConfig { + const values = { ...existing?.values }; + for (const key of install.unsetEnv ?? []) { + validateEnvName(key); + delete values[key]; + } + for (const entry of install.env ?? []) { + const index = entry.indexOf("="); + if (index <= 0) { + throw new CapletsError("REQUEST_INVALID", "--env must use KEY=VALUE"); + } + const key = entry.slice(0, index); + validateEnvName(key); + values[key] = entry.slice(index + 1); + } + return { + inherit: install.inheritEnv ?? existing?.inherit ?? false, + values, + }; +} + +function validateEnvName(value: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) { + throw new CapletsError("REQUEST_INVALID", `Invalid environment variable name: ${value}`); + } +} + +function readJson(path: string): T | undefined { + try { + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); +} diff --git a/packages/core/src/daemon/env.ts b/packages/core/src/daemon/env.ts new file mode 100644 index 00000000..d924c596 --- /dev/null +++ b/packages/core/src/daemon/env.ts @@ -0,0 +1,65 @@ +import { existsSync } from "node:fs"; +import { userInfo } from "node:os"; +import { CapletsError } from "../errors"; +import type { DaemonOperationOptions, DaemonShellPlan } from "./types"; + +export function resolveDaemonShell( + options: Pick, +): DaemonShellPlan { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + + if (platform === "win32") { + const powerShell = firstExisting([ + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\pwsh.exe", + ]); + if (powerShell) return shellPlan(platform, powerShell, "fallback"); + const comSpec = nonEmpty(env.ComSpec) ?? nonEmpty(env.COMSPEC) ?? "cmd.exe"; + return shellPlan(platform, comSpec, "fallback"); + } + + const shell = nonEmpty(env.SHELL); + if (shell) return shellPlan(platform, shell, "SHELL"); + + const accountShell = nonEmpty(options.accountShell) ?? discoverAccountShell(); + if (accountShell) return shellPlan(platform, accountShell, "account"); + if (existsSync("/bin/sh")) return shellPlan(platform, "/bin/sh", "fallback"); + throw new CapletsError( + "SERVER_UNAVAILABLE", + "Could not resolve a user shell for --inherit-env. Set SHELL or use --no-inherit-env.", + ); +} + +function shellPlan( + platform: NodeJS.Platform, + executable: string, + source: DaemonShellPlan["source"], +): DaemonShellPlan { + if (platform === "win32") { + const lower = executable.toLocaleLowerCase(); + return lower.endsWith("powershell.exe") || lower.endsWith("pwsh.exe") + ? { executable, args: ["-Command"], source } + : { executable, args: ["/d", "/s", "/c"], source }; + } + if (executable.endsWith("/sh") || executable === "sh") + return { executable, args: ["-c"], source }; + return { executable, args: ["-lc"], source }; +} + +function firstExisting(paths: string[]): string | undefined { + return paths.find((path) => existsSync(path)); +} + +function discoverAccountShell(): string | undefined { + try { + return nonEmpty(userInfo().shell ?? undefined); + } catch { + return undefined; + } +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts new file mode 100644 index 00000000..bad74666 --- /dev/null +++ b/packages/core/src/daemon/index.ts @@ -0,0 +1,708 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { CapletsError } from "../errors"; +import { + mergeDaemonEnv, + readDaemonConfig, + removeDaemonConfig, + removeDaemonState, + writeDaemonConfig, + writeDaemonState, +} from "./config"; +import { ensureDaemonLogFiles, readDaemonLogs } from "./logs"; +import { createNativeDaemonManager } from "./manager"; +import { resolveDaemonPaths } from "./paths"; +import { buildDaemonCommandPlan, resolveDaemonHttpServeOptions } from "./process"; +import { + allocateLoopbackPort, + assertDaemonHealth, + probeDaemonHealth, + validateDaemonCommand, +} from "./validation"; +import type { + DaemonConfig, + DaemonHealthResult, + DaemonInstallOptions, + DaemonInstallResult, + DaemonLifecycleResult, + DaemonManager, + DaemonLogStream, + DaemonLogsResult, + DaemonOperationOptions, + DaemonStatus, + DaemonUninstallOptions, + DaemonUninstallResult, + RawDaemonServeOptions, +} from "./types"; + +export async function installDaemon( + install: DaemonInstallOptions = {}, + options: DaemonOperationOptions = {}, +): Promise { + assertRestartDecision(install); + const env = options.env ?? process.env; + const paths = resolveDaemonPaths(options); + const manager = options.manager ?? createNativeDaemonManager(options); + const persisted = readDaemonConfig(paths); + const existing = install.reset ? undefined : persisted; + const daemonEnv = mergeDaemonEnv(existing?.env, install); + const serveEnv = + existing?.serve.publicOrigin && env.CAPLETS_SERVER_URL === undefined + ? { ...env, CAPLETS_SERVER_URL: existing.serve.publicOrigin } + : env; + const serve = resolveDaemonHttpServeOptions(mergeServeOptions(existing, install), serveEnv); + const command = buildDaemonCommandPlan({ + serve, + paths, + operation: options, + explicitEnv: daemonEnv.values, + inheritEnv: daemonEnv.inherit, + }); + const config: DaemonConfig = { + instance: "default", + serve, + command, + env: daemonEnv, + paths, + updatedAt: (options.now ?? new Date()).toISOString(), + }; + const descriptor = manager.descriptor(config); + const plannedActions = ["write-config", "write-descriptor", "register-service"]; + + const existingNative = + persisted || existsSync(paths.descriptorFile) + ? await manager.status(persisted, paths) + : undefined; + const restartDecisionRequired = + existingNative?.running === true && !install.start && !install.restart && !install.noRestart; + if ( + restartDecisionRequired && + !install.dryRun && + (!options.isInteractive || !options.readPrompt) + ) { + throw new CapletsError( + "REQUEST_INVALID", + "Daemon is already running; rerun with --restart, --start, or --no-restart.", + ); + } + + if (install.dryRun) { + return { + action: "install", + status: await daemonStatusSnapshot(options), + config, + descriptor, + dryRun: true, + plannedActions, + }; + } + + let validation = undefined; + if (install.validate !== false) { + validation = await validateInstallCommand({ + config, + existing: persisted, + existingNativeRunning: existingNative?.running === true, + options, + }); + assertDaemonHealth(validation, "Daemon install validation"); + } + + mkdirSync(paths.logDir, { recursive: true, mode: 0o700 }); + ensureDaemonLogFiles(paths); + const persistenceBackups = backupPersistenceFiles([paths.configFile, paths.stateFile]); + const hadExistingDescriptor = existsSync(paths.descriptorFile); + let native: Awaited> | undefined; + try { + writeDaemonConfig(paths, config); + native = await manager.install(config); + writeDaemonState(paths, { + instance: "default", + installed: true, + running: native.native.running, + nativeState: native.native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(native.native.pid === undefined ? {} : { pid: native.native.pid }), + }); + } catch (error) { + if (native) await rollbackNativeInstall(manager, persisted, paths, hadExistingDescriptor); + restorePersistenceFiles(persistenceBackups); + throw error; + } + + if (install.start || install.restart) { + const action = + install.restart || (install.start && existingNative?.running) + ? await manager.restart(config) + : await manager.start(config); + const health = await waitForDaemonHealth(config, options); + assertDaemonHealth(health, "Native daemon health check"); + writeDaemonState(paths, { + instance: "default", + installed: true, + running: action.native.running, + nativeState: action.native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(action.native.pid === undefined ? {} : { pid: action.native.pid }), + }); + } else if (restartDecisionRequired && options.readPrompt) { + const answer = await options.readPrompt("Restart the running Caplets daemon now? [y/N] "); + if (/^y(?:es)?$/iu.test(answer.trim())) { + const action = await manager.restart(config); + const health = await waitForDaemonHealth(config, options); + assertDaemonHealth(health, "Native daemon health check"); + writeDaemonState(paths, { + instance: "default", + installed: true, + running: action.native.running, + nativeState: action.native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(action.native.pid === undefined ? {} : { pid: action.native.pid }), + }); + } + } + + return { + action: "install", + status: await daemonStatus({ ...options, manager }), + config, + descriptor, + ...(validation ? { validation } : {}), + native, + dryRun: false, + plannedActions, + }; +} + +async function temporaryValidationConfig( + config: DaemonConfig, + options: DaemonOperationOptions, +): Promise { + const serve = { + ...config.serve, + host: "127.0.0.1", + port: await allocateLoopbackPort(), + loopback: true, + warnUnauthenticatedNetwork: false, + }; + return { + ...config, + serve, + command: buildDaemonCommandPlan({ + serve, + paths: config.paths, + operation: options, + explicitEnv: config.env.values, + inheritEnv: config.env.inherit, + }), + }; +} + +async function validateInstallCommand(input: { + config: DaemonConfig; + existing: DaemonConfig | undefined; + existingNativeRunning: boolean; + options: DaemonOperationOptions; +}): Promise { + const useTemporaryPort = + input.existingNativeRunning && + (!input.existing || input.existing.serve.port === input.config.serve.port); + const attempts = useTemporaryPort ? 3 : 1; + let last: DaemonHealthResult | undefined; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const validationConfig = useTemporaryPort + ? await temporaryValidationConfig(input.config, input.options) + : input.config; + last = input.options.validateCommand + ? await input.options.validateCommand(validationConfig) + : await validateDaemonCommand( + validationConfig, + input.options.fetch ? { fetch: input.options.fetch } : {}, + ); + if (last.ok) return last; + } + return ( + last ?? { + ok: false, + url: "", + error: "daemon install validation did not run", + } + ); +} + +async function waitForDaemonHealth( + config: DaemonConfig, + options: DaemonOperationOptions, +): Promise { + const deadline = Date.now() + (options.healthTimeoutMs ?? 10_000); + const intervalMs = options.healthIntervalMs ?? 200; + let last: DaemonHealthResult | undefined; + while (Date.now() < deadline) { + last = await probeDaemonHealth(config, { + ...(options.fetch ? { fetch: options.fetch } : {}), + timeoutMs: 1_000, + }); + if (last.ok) return last; + await sleep(intervalMs); + } + return ( + last ?? { + ok: false, + url: "", + error: "native daemon health probe timed out", + } + ); +} + +export async function uninstallDaemon( + uninstall: DaemonUninstallOptions = {}, + options: DaemonOperationOptions = {}, +): Promise { + const paths = resolveDaemonPaths(options); + const manager = options.manager ?? createNativeDaemonManager(options); + const config = readDaemonConfig(paths); + const removesWrapper = (options.platform ?? process.platform) === "win32"; + const removed = uninstall.purge + ? [ + paths.descriptorFile, + ...(removesWrapper ? [paths.wrapperFile] : []), + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ] + : [paths.descriptorFile]; + if (uninstall.dryRun) { + return { + action: "uninstall", + status: await daemonStatusSnapshot({ ...options, manager }), + purge: uninstall.purge === true, + dryRun: true, + removed, + }; + } + const nativeStatus = await manager.status(config, paths); + if (nativeStatus.running) await manager.stop(config); + const native = await manager.uninstall(config, paths); + if (uninstall.purge) { + removeDaemonConfig(paths); + removeDaemonState(paths); + rmSync(paths.logDir, { recursive: true, force: true }); + rmSync(dirname(paths.configFile), { recursive: true, force: true }); + } + const status = uninstall.purge + ? { + instance: "default" as const, + installed: false, + running: false, + nativeState: "not_installed" as const, + paths, + native: native.native, + } + : await daemonStatus({ ...options, manager }); + return { + action: "uninstall", + status, + native, + purge: uninstall.purge === true, + dryRun: false, + removed, + }; +} + +export async function startDaemon( + options: DaemonOperationOptions = {}, +): Promise { + return daemonLifecycle("start", options); +} + +export async function restartDaemon( + options: DaemonOperationOptions = {}, +): Promise { + return daemonLifecycle("restart", options); +} + +export async function stopDaemon( + options: DaemonOperationOptions = {}, +): Promise { + return daemonLifecycle("stop", options); +} + +export async function daemonStatus(options: DaemonOperationOptions = {}): Promise { + return daemonStatusSnapshot(options, { writeState: true }); +} + +async function daemonStatusSnapshot( + options: DaemonOperationOptions = {}, + snapshotOptions: { writeState?: boolean } = {}, +): Promise { + const paths = resolveDaemonPaths(options); + const config = readDaemonConfig(paths); + const manager = options.manager ?? createNativeDaemonManager(options); + const native = await manager.status(config, paths); + if (snapshotOptions.writeState) { + writeDaemonState(paths, { + instance: "default", + installed: native.installed, + running: native.running, + nativeState: native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(native.pid === undefined ? {} : { pid: native.pid }), + }); + } + const status: DaemonStatus = { + instance: "default", + installed: native.installed, + running: native.running, + nativeState: native.state, + paths, + ...(config ? { config: redactDaemonConfig(config) } : {}), + native: redactNativeStatus(native, config), + }; + if (config && native.running) { + status.health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); + } + return status; +} + +export function redactDaemonInstallResult(result: DaemonInstallResult): DaemonInstallResult { + const secrets = collectDaemonSecrets(result.config); + return { + ...result, + config: redactDaemonConfig(result.config), + descriptor: redactDescriptor(result.descriptor, secrets), + ...(result.native + ? { + native: { + ...result.native, + native: redactNativeStatus(result.native.native, result.config), + ...(result.native.descriptor + ? { descriptor: redactDescriptor(result.native.descriptor, secrets) } + : {}), + }, + } + : {}), + }; +} + +function redactDaemonConfig(config: DaemonConfig): DaemonConfig { + const env = redactEnv(config.env.values); + return { + ...config, + env: { + ...config.env, + values: env, + }, + serve: { + ...config.serve, + auth: config.serve.auth.enabled + ? { ...config.serve.auth, password: "[redacted]" } + : config.serve.auth, + }, + command: { + ...config.command, + args: redactPasswordArgs(config.command.args), + env: redactEnv(config.command.env), + }, + }; +} + +function redactNativeStatus( + native: DaemonStatus["native"], + config: DaemonConfig | undefined, +): DaemonStatus["native"] { + return redactNativeValue( + native, + config ? collectDaemonSecrets(config) : [], + ) as DaemonStatus["native"]; +} + +function collectDaemonSecrets(config: DaemonConfig): string[] { + const secrets = Array.from( + new Set( + [ + config.serve.auth.enabled ? config.serve.auth.password : undefined, + ...Object.values(config.env.values), + ...Object.values(config.command.env), + ].filter((value): value is string => value !== undefined && value.length > 0), + ), + ); + return Array.from(new Set(secrets.flatMap(secretRedactionVariants))).sort( + (left, right) => right.length - left.length, + ); +} + +function redactNativeValue(value: unknown, secrets: string[]): unknown { + if (typeof value === "string") return redactSecrets(redactPasswordString(value), secrets); + if (Array.isArray(value)) return value.map((item) => redactNativeValue(item, secrets)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, redactNativeValue(item, secrets)]), + ); + } + return value; +} + +function redactDescriptor(descriptor: T, secrets: string[]): T { + return redactNativeValue(descriptor, secrets) as T; +} + +function redactEnv(env: Record): Record { + return Object.fromEntries(Object.keys(env).map((key) => [key, "[redacted]"])); +} + +function redactSecrets(value: string, secrets: string[]): string { + return secrets.reduce((redacted, secret) => redacted.replaceAll(secret, "[redacted]"), value); +} + +function secretRedactionVariants(secret: string): string[] { + const posixShellEscaped = posixShellEscapedSecret(secret); + const posixShellQuoted = posixShellQuotedSecret(secret); + return [ + secret, + jsonEscapedSecret(secret), + systemdEscapedSecret(secret), + systemdEscapedSecret(posixShellEscaped), + systemdEscapedSecret(posixShellQuoted), + xmlEscapedSecret(secret), + cmdEscapedSecret(secret), + powershellSingleQuotedSecret(secret), + posixShellEscaped, + posixShellQuoted, + ].filter((value) => value.length > 0); +} + +function jsonEscapedSecret(secret: string): string { + return JSON.stringify(secret).slice(1, -1); +} + +function systemdEscapedSecret(secret: string): string { + return secret + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("%", "%%") + .replaceAll("$", "$$$$") + .replaceAll("\n", "\\n"); +} + +function xmlEscapedSecret(secret: string): string { + return secret + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function cmdEscapedSecret(secret: string): string { + return secret.replaceAll("%", "%%"); +} + +function powershellSingleQuotedSecret(secret: string): string { + return secret.replaceAll("'", "''"); +} + +function posixShellEscapedSecret(secret: string): string { + return secret.replaceAll("'", "'\\''"); +} + +function posixShellQuotedSecret(secret: string): string { + return `'${posixShellEscapedSecret(secret)}'`; +} + +function redactPasswordString(value: string): string { + return value.replace(/(--password(?:=|\s+))("[^"]*"|'[^']*'|\S+)/giu, "$1[redacted]"); +} + +function redactPasswordArgs(args: string[]): string[] { + const redacted = [...args]; + for (let index = 0; index < redacted.length; index += 1) { + if (redacted[index] === "--password" && redacted[index + 1]) redacted[index + 1] = "[redacted]"; + } + return redacted; +} + +export function daemonLogs( + options: DaemonOperationOptions & { stream?: DaemonLogStream; tail?: number } = {}, +): DaemonLogsResult { + return readDaemonLogs(resolveDaemonPaths(options), options); +} + +async function daemonLifecycle( + action: "start" | "restart" | "stop", + options: DaemonOperationOptions, +): Promise { + const paths = resolveDaemonPaths(options); + const config = readDaemonConfig(paths); + if (!config) { + throw new CapletsError( + "REQUEST_INVALID", + `Caplets daemon is not installed. Run caplets daemon install${action === "start" || action === "restart" ? " --start" : ""} first.`, + ); + } + const manager = options.manager ?? createNativeDaemonManager(options); + const before = await manager.status(config, paths); + if (before.state === "not_installed") { + throw new CapletsError( + "REQUEST_INVALID", + `Caplets daemon is not installed. Run caplets daemon install${action === "start" || action === "restart" ? " --start" : ""} first.`, + ); + } + const effectiveAction = action === "start" && before.running ? "restart" : action; + const native = + effectiveAction === "start" + ? await manager.start(config) + : effectiveAction === "restart" + ? await manager.restart(config) + : await manager.stop(config); + writeDaemonState(paths, { + instance: "default", + installed: native.native.installed, + running: native.native.running, + nativeState: native.native.state, + updatedAt: (options.now ?? new Date()).toISOString(), + ...(native.native.pid === undefined ? {} : { pid: native.native.pid }), + }); + if (effectiveAction === "start" || effectiveAction === "restart") { + const health = await waitForDaemonHealth(config, options); + assertDaemonHealth(health, "Native daemon health check"); + const status = await daemonStatus({ ...options, manager }); + status.health = health; + return { action: effectiveAction, native, status }; + } + const status = await daemonStatus({ ...options, manager }); + return { action: effectiveAction, native, status }; +} + +function mergeServeOptions( + existing: DaemonConfig | undefined, + install: DaemonInstallOptions, +): RawDaemonServeOptions { + return { + ...(install.host !== undefined + ? { host: install.host } + : existing?.serve.host + ? { host: existing.serve.host } + : {}), + ...(install.port !== undefined + ? { port: install.port } + : existing?.serve.port + ? { port: existing.serve.port } + : {}), + ...(install.path !== undefined + ? { path: install.path } + : existing?.serve.path + ? { path: existing.serve.path } + : {}), + ...(install.user !== undefined + ? { user: install.user } + : existing?.serve.auth.enabled + ? { user: existing.serve.auth.user } + : {}), + ...(install.password !== undefined + ? { password: install.password } + : existing?.serve.auth.enabled + ? { password: existing.serve.auth.password } + : {}), + ...(install.allowUnauthenticatedHttp !== undefined + ? { allowUnauthenticatedHttp: install.allowUnauthenticatedHttp } + : existing + ? { allowUnauthenticatedHttp: existing.serve.allowUnauthenticatedHttp } + : {}), + ...(install.trustProxy !== undefined + ? { trustProxy: install.trustProxy } + : existing + ? { trustProxy: existing.serve.trustProxy } + : {}), + ...(existing && + !existing.serve.auth.enabled && + install.user === undefined && + install.password === undefined + ? { preserveUnauthenticatedAuth: true } + : {}), + }; +} + +type PersistenceBackup = { path: string; existed: boolean; contents?: Buffer; mode?: number }; + +function backupPersistenceFiles(paths: string[]): PersistenceBackup[] { + return paths.map((path) => ({ + path, + existed: existsSync(path), + ...(existsSync(path) ? { contents: readFileSync(path) } : {}), + ...(existsSync(path) ? { mode: statSync(path).mode & 0o777 } : {}), + })); +} + +function restorePersistenceFiles(backups: PersistenceBackup[]): void { + for (const backup of backups) { + if (backup.existed && backup.contents) { + mkdirSync(dirname(backup.path), { recursive: true, mode: 0o700 }); + writeFileSync(backup.path, backup.contents, { mode: backup.mode ?? 0o600 }); + chmodSync(backup.path, backup.mode ?? 0o600); + } else { + rmSync(backup.path, { recursive: true, force: true }); + } + } +} + +async function rollbackNativeInstall( + manager: DaemonManager, + persisted: DaemonConfig | undefined, + paths: DaemonConfig["paths"], + hadExistingDescriptor: boolean, +): Promise { + try { + if (persisted && hadExistingDescriptor) { + await manager.install(persisted); + } else { + await manager.uninstall(persisted, paths); + } + } catch { + // Preserve the local persistence failure as the actionable error. + } +} + +function assertRestartDecision(options: DaemonInstallOptions): void { + const selected = [options.start, options.restart, options.noRestart].filter(Boolean).length; + if (selected > 1) { + throw new CapletsError( + "REQUEST_INVALID", + "--start, --restart, and --no-restart are mutually exclusive.", + ); + } +} + +export { resolveDaemonPaths } from "./paths"; +export { resolveDaemonHttpServeOptions, daemonServeArgs } from "./process"; +export { readDaemonConfig, readDaemonState } from "./config"; +export { createNativeDaemonManager } from "./manager"; +export { followDaemonLogs } from "./logs"; +export type { + DaemonCommandPlan, + DaemonCommandRunner, + DaemonConfig, + DaemonDescriptor, + DaemonInstallOptions, + DaemonLogEntry, + DaemonLogStream, + DaemonLogsResult, + DaemonManager, + DaemonOperationOptions, + DaemonPaths, + DaemonStatus, + NativeDaemonStatus, + RawDaemonServeOptions, +} from "./types"; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/daemon/logs.ts b/packages/core/src/daemon/logs.ts new file mode 100644 index 00000000..cc7f9194 --- /dev/null +++ b/packages/core/src/daemon/logs.ts @@ -0,0 +1,166 @@ +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + statSync, + writeFileSync, + watch, +} from "node:fs"; +import { dirname } from "node:path"; +import type { DaemonLogEntry, DaemonLogStream, DaemonLogsResult, DaemonPaths } from "./types"; + +const TAIL_CHUNK_BYTES = 64 * 1024; + +export function readDaemonLogs( + paths: DaemonPaths, + options: { stream?: DaemonLogStream; tail?: number } = {}, +): DaemonLogsResult { + const stream = options.stream ?? "all"; + const tail = options.tail ?? 10; + const streamEntries = selectedStreams(stream).map((selected) => + tailLines(paths[selected === "stdout" ? "stdoutLog" : "stderrLog"], tail).map((line) => ({ + stream: selected, + line, + })), + ); + const entries = stream === "all" ? interleaveLogEntries(streamEntries) : streamEntries.flat(); + return { paths: { stdoutLog: paths.stdoutLog, stderrLog: paths.stderrLog }, entries }; +} + +export async function followDaemonLogs( + paths: DaemonPaths, + options: { + stream?: DaemonLogStream; + tail?: number; + write: ( + entry: + | DaemonLogEntry + | { type: "metadata"; paths: Pick }, + ) => void; + signal?: AbortSignal; + }, +): Promise { + options.write({ + type: "metadata", + paths: { stdoutLog: paths.stdoutLog, stderrLog: paths.stderrLog }, + }); + for (const entry of readDaemonLogs(paths, { + ...(options.stream ? { stream: options.stream } : {}), + ...(options.tail !== undefined ? { tail: options.tail } : {}), + }).entries) { + options.write(entry); + } + const watchers = selectedStreams(options.stream ?? "all").map((stream) => { + const file = paths[stream === "stdout" ? "stdoutLog" : "stderrLog"]; + ensureLogFile(file); + let offset = existsSync(file) ? statSync(file).size : 0; + return watch(file, { persistent: true }, () => { + const { content, nextOffset } = readFromOffset(file, offset); + offset = nextOffset; + for (const line of content.split(/\r?\n/u).filter(Boolean)) options.write({ stream, line }); + }); + }); + await new Promise((resolve) => { + if (options.signal?.aborted) resolve(); + options.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + for (const watcher of watchers) watcher.close(); +} + +export function ensureDaemonLogFiles(paths: DaemonPaths): void { + ensureLogFile(paths.stdoutLog); + ensureLogFile(paths.stderrLog); +} + +function selectedStreams(stream: DaemonLogStream): Array<"stdout" | "stderr"> { + return stream === "all" ? ["stdout", "stderr"] : [stream]; +} + +function tailLines(path: string, count: number): string[] { + if (count === 0 || !existsSync(path)) return []; + const lines = + count < 0 ? readFileSync(path, "utf8").split(/\r?\n/u) : readTailContent(path, count); + if (lines.at(-1) === "") lines.pop(); + return count < 0 ? lines : lines.slice(-count); +} + +function readTailContent(path: string, count: number): string[] { + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + const chunks: Buffer[] = []; + let position = size; + let newlineCount = 0; + while (position > 0 && newlineCount <= count) { + const readSize = Math.min(TAIL_CHUNK_BYTES, position); + position -= readSize; + const buffer = Buffer.allocUnsafe(readSize); + const bytesRead = readSync(fd, buffer, 0, readSize, position); + const chunk = buffer.subarray(0, bytesRead); + chunks.unshift(chunk); + for (const byte of chunk) { + if (byte === 10) newlineCount += 1; + } + } + return Buffer.concat(chunks).toString("utf8").split(/\r?\n/u); + } finally { + closeSync(fd); + } +} + +function readFromOffset(path: string, offset: number): { content: string; nextOffset: number } { + if (!existsSync(path)) return { content: "", nextOffset: 0 }; + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size <= offset) return { content: "", nextOffset: size }; + const length = size - offset; + const buffer = Buffer.allocUnsafe(length); + const bytesRead = readSync(fd, buffer, 0, length, offset); + return { content: buffer.subarray(0, bytesRead).toString("utf8"), nextOffset: size }; + } finally { + closeSync(fd); + } +} + +function interleaveLogEntries(streamEntries: DaemonLogEntry[][]): DaemonLogEntry[] { + const timestamps = streamEntries.flat().map((entry) => logTimestamp(entry.line)); + if (timestamps.length > 0 && timestamps.every((timestamp) => timestamp !== undefined)) { + return streamEntries + .flat() + .map((entry, index) => ({ entry, index, timestamp: timestamps[index]! })) + .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) + .map(({ entry }) => entry); + } + + const entries: DaemonLogEntry[] = []; + const maxLength = Math.max( + 0, + ...streamEntries.map((entriesForStream) => entriesForStream.length), + ); + for (let index = 0; index < maxLength; index += 1) { + for (const entriesForStream of streamEntries) { + const entry = entriesForStream[index]; + if (entry) entries.push(entry); + } + } + return entries; +} + +function logTimestamp(line: string): number | undefined { + const match = /^(?:\[(?[^\]]+)\]|(?\S+))/u.exec(line); + const value = match?.groups?.bracket ?? match?.groups?.plain; + if (!value) return undefined; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? undefined : timestamp; +} + +function ensureLogFile(path: string): void { + if (existsSync(path)) return; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, "", { mode: 0o600 }); +} diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts new file mode 100644 index 00000000..69f26503 --- /dev/null +++ b/packages/core/src/daemon/manager.ts @@ -0,0 +1,633 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { CapletsError } from "../errors"; +import { buildLaunchdDescriptor, LAUNCHD_LABEL } from "./platform-darwin"; +import { buildSystemdDescriptor, SYSTEMD_UNIT } from "./platform-linux"; +import { buildWindowsTaskDescriptor, WINDOWS_TASK_NAME } from "./platform-windows"; +import type { + DaemonCommandRunner, + DaemonDescriptor, + DaemonManager, + DaemonManagerAction, + DaemonOperationOptions, + NativeDaemonStatus, +} from "./types"; + +export function createNativeDaemonManager(options: DaemonOperationOptions = {}): DaemonManager { + const platform = options.platform ?? process.platform; + const runner = options.commandRunner ?? nodeCommandRunner(); + if (platform === "darwin") return launchdManager(runner, options.uid); + if (platform === "linux") return systemdManager(runner, options.serviceAvailable); + if (platform === "win32") return windowsTaskManager(runner); + return unsupportedManager(platform); +} + +function launchdManager( + runner: DaemonCommandRunner, + uid = typeof process.getuid === "function" ? process.getuid() : 0, +): DaemonManager { + const target = `gui/${uid}/${LAUNCHD_LABEL}`; + const domain = `gui/${uid}`; + return { + descriptor: buildLaunchdDescriptor, + status: async (config, paths) => { + if (!existsSync(paths.descriptorFile) && !config) return notInstalled(); + const result = await runner.exec("launchctl", ["print", target]); + if (result.code !== 0) + return existsSync(paths.descriptorFile) + ? stopped({ stderr: result.stderr }) + : notInstalled({ stderr: result.stderr }); + const pid = parseNumberMatch(result.stdout, /\bpid\s*=\s*(\d+)/u); + if (pid !== undefined) + return runningOrStopped(pid, { stdout: result.stdout, stderr: result.stderr }); + const failed = /\b(?:last exit code|LastExitStatus)\s*=\s*(?!0\b)(\d+)/iu.test(result.stdout); + return failed + ? failedStatus({ stdout: result.stdout, stderr: result.stderr, pid }) + : runningOrStopped(pid, { stdout: result.stdout, stderr: result.stderr }); + }, + install: async (config) => { + const descriptor = buildLaunchdDescriptor(config); + writeDescriptor(descriptor); + return { + action: "install", + native: stopped(), + commands: [], + descriptor, + }; + }, + uninstall: async (config, paths) => { + const hasDescriptor = existsSync(paths.descriptorFile); + if (!hasDescriptor && !config) { + return { + action: "uninstall", + native: notInstalled(), + commands: [], + }; + } + const commands = [ + hasDescriptor + ? ["launchctl", "bootout", domain, paths.descriptorFile] + : ["launchctl", "bootout", target], + ]; + const result = await runner.exec(commands[0]![0]!, commands[0]!.slice(1)); + if ( + result.code !== 0 && + !/No such process|not found|Could not find service|No such file or directory/iu.test( + result.stderr, + ) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd unregister failed: ${result.stderr || result.stdout || result.code}`, + ); + } + rmSync(paths.descriptorFile, { force: true }); + return { + action: "uninstall", + native: notInstalled({ stdout: result.stdout, stderr: result.stderr }), + commands, + }; + }, + start: async (config) => + launchdStartLifecycle(runner, domain, target, config.paths.descriptorFile), + restart: async (config) => + launchdRestartLifecycle(runner, domain, target, config.paths.descriptorFile), + stop: async (config) => { + const command = + config && existsSync(config.paths.descriptorFile) + ? ["launchctl", "bootout", domain, config.paths.descriptorFile] + : ["launchctl", "bootout", target]; + const result = await runner.exec(command[0]!, command.slice(1)); + if ( + result.code !== 0 && + !/No such process|not found|not running|Could not find service/iu.test(result.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd stop failed: ${result.stderr || result.stdout || result.code}`, + ); + } + return { action: "stop", native: stopped(), commands: [command] }; + }, + }; +} + +function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): DaemonManager { + return { + descriptor: buildSystemdDescriptor, + status: async (config, paths) => { + if (!serviceAvailable) return unavailable("systemd --user is not available."); + if (!existsSync(paths.descriptorFile) && !config) return notInstalled(); + const show = await runner.exec("systemctl", ["--user", "show", SYSTEMD_UNIT]); + if (show.code !== 0) { + const message = show.stderr || show.stdout || String(show.code); + if (isSystemdUnavailable(message)) + return unavailable(`systemd --user is not available: ${message}`); + return existsSync(paths.descriptorFile) + ? stopped({ stderr: show.stderr, stdout: show.stdout }) + : notInstalled({ stderr: show.stderr }); + } + const raw = parseSystemdShow(show.stdout); + if (!existsSync(paths.descriptorFile) && raw.LoadState === "not-found") + return notInstalled({ raw, stderr: show.stderr }); + const active = await runner.exec("systemctl", ["--user", "is-active", SYSTEMD_UNIT]); + if (active.code !== 0 && isSystemdUnavailable(active.stderr || active.stdout)) + return unavailable(`systemd --user is not available: ${active.stderr || active.stdout}`); + if (active.stdout.trim() === "active") + return { + state: "running", + installed: true, + running: true, + raw, + }; + if (active.stdout.trim() === "failed") + return failedStatus({ + active: active.stdout.trim(), + raw, + stderr: active.stderr, + }); + return stopped({ + active: active.stdout.trim(), + raw, + stderr: active.stderr, + }); + }, + install: async (config) => { + if (!serviceAvailable) + throw new CapletsError( + "SERVER_UNAVAILABLE", + "systemd --user is not available; install a user service manager before using caplets daemon.", + ); + const descriptor = buildSystemdDescriptor(config); + const commands = [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", SYSTEMD_UNIT], + ]; + await writeDescriptorForInstall( + descriptor, + async () => { + for (const command of commands) + await assertExec(runner, command, "systemd registration failed"); + }, + async () => { + await runner.exec("systemctl", ["--user", "daemon-reload"]); + }, + ); + return { action: "install", native: stopped(), commands, descriptor }; + }, + uninstall: async (_config, paths) => { + if (!serviceAvailable) + throw new CapletsError("SERVER_UNAVAILABLE", "systemd --user is not available."); + const commands = [ + ["systemctl", "--user", "disable", SYSTEMD_UNIT], + ["systemctl", "--user", "daemon-reload"], + ]; + await assertExecUnless( + runner, + commands[0]!, + "systemd unregister failed", + /not loaded|not found|No such file|does not exist/iu, + ); + const descriptorBackup = backupPath(paths.descriptorFile); + rmSync(paths.descriptorFile, { force: true }); + try { + await assertExec(runner, commands[1]!, "systemd unregister failed"); + } catch (error) { + restoreDescriptorFiles([descriptorBackup]); + await restoreSystemdEnabledState(runner); + throw error; + } + return { action: "uninstall", native: notInstalled(), commands }; + }, + start: async () => systemdLifecycle(runner, "start", "start"), + restart: async () => systemdLifecycle(runner, "restart", "restart"), + stop: async () => systemdLifecycle(runner, "stop", "stop", false), + }; +} + +function windowsTaskManager(runner: DaemonCommandRunner): DaemonManager { + return { + descriptor: buildWindowsTaskDescriptor, + status: async (config, paths) => { + if (!existsSync(paths.descriptorFile) && !config) return notInstalled(); + const result = await runner.exec("schtasks", [ + "/Query", + "/TN", + WINDOWS_TASK_NAME, + "/FO", + "LIST", + "/V", + ]); + if (result.code !== 0) return notInstalled({ stdout: result.stdout, stderr: result.stderr }); + const raw = parseWindowsList(result.stdout); + const status = String(raw.Status ?? ""); + const lastRun = String(raw["Last Run Result"] ?? ""); + if (/running/iu.test(status)) + return { state: "running", installed: true, running: true, raw }; + if (lastRun && !isSuccessfulWindowsLastRunResult(lastRun)) return failedStatus(raw); + return stopped(raw); + }, + install: async (config) => { + const descriptor = buildWindowsTaskDescriptor(config); + const command = [ + "schtasks", + "/Create", + "/TN", + WINDOWS_TASK_NAME, + "/XML", + descriptor.path, + "/F", + ]; + await writeDescriptorForInstall(descriptor, async () => { + await assertExec(runner, command, "Scheduled Task registration failed"); + }); + return { action: "install", native: stopped(), commands: [command], descriptor }; + }, + uninstall: async (_config, paths) => { + const command = ["schtasks", "/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]; + await assertExecUnless( + runner, + command, + "Scheduled Task unregister failed", + /cannot find|does not exist|not found/iu, + ); + rmSync(paths.descriptorFile, { force: true }); + rmSync(paths.wrapperFile, { force: true }); + return { action: "uninstall", native: notInstalled(), commands: [command] }; + }, + start: async () => windowsLifecycle(runner, "start", ["/Run", "/TN", WINDOWS_TASK_NAME]), + restart: async () => { + const end = ["schtasks", "/End", "/TN", WINDOWS_TASK_NAME]; + await assertExecUnless( + runner, + end, + "Scheduled Task restart failed", + /not currently running|cannot be stopped|not running/iu, + ); + const restart = await windowsLifecycle(runner, "restart", ["/Run", "/TN", WINDOWS_TASK_NAME]); + return { ...restart, commands: [end, ...(restart.commands ?? [])] }; + }, + stop: async () => { + const command = ["schtasks", "/End", "/TN", WINDOWS_TASK_NAME]; + const result = await runner.exec(command[0]!, command.slice(1)); + if ( + result.code !== 0 && + !/not currently running|cannot be stopped|not running/iu.test(result.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Scheduled Task stop failed: ${result.stderr || result.stdout || result.code}`, + ); + } + return { action: "stop", native: stopped(), commands: [command] }; + }, + }; +} + +function unsupportedManager(platform: NodeJS.Platform): DaemonManager { + const error = () => + new CapletsError( + "SERVER_UNAVAILABLE", + `caplets daemon requires launchd, systemd --user, or Windows Scheduled Tasks; ${platform} is unsupported.`, + ); + return { + descriptor: () => { + throw error(); + }, + status: async () => unavailable(error().message), + install: async () => { + throw error(); + }, + uninstall: async () => { + throw error(); + }, + start: async () => { + throw error(); + }, + restart: async () => { + throw error(); + }, + stop: async () => { + throw error(); + }, + }; +} + +function writeDescriptor(descriptor: DaemonDescriptor): void { + mkdirSync(dirname(descriptor.path), { recursive: true, mode: 0o700 }); + writeFileSync( + descriptor.path, + descriptor.kind === "windows-scheduled-task" ? descriptor.xml : descriptor.contents, + { mode: 0o600 }, + ); + chmodSync(descriptor.path, 0o600); + if (descriptor.kind === "windows-scheduled-task") { + mkdirSync(dirname(descriptor.wrapper.path), { recursive: true, mode: 0o700 }); + writeFileSync(descriptor.wrapper.path, descriptor.wrapper.contents, { mode: 0o700 }); + chmodSync(descriptor.wrapper.path, 0o700); + } +} + +async function writeDescriptorForInstall( + descriptor: DaemonDescriptor, + register: () => Promise, + afterRestore?: () => Promise, +): Promise { + const backups = backupDescriptorFiles(descriptor); + try { + writeDescriptor(descriptor); + return await register(); + } catch (error) { + restoreDescriptorFiles(backups); + if (afterRestore) { + try { + await afterRestore(); + } catch { + // Keep the original native registration failure as the actionable error. + } + } + throw error; + } +} + +type DescriptorBackup = { path: string; existed: boolean; contents?: Buffer; mode?: number }; + +function backupDescriptorFiles(descriptor: DaemonDescriptor): DescriptorBackup[] { + const paths = + descriptor.kind === "windows-scheduled-task" + ? [descriptor.path, descriptor.wrapper.path] + : [descriptor.path]; + return paths.map(backupPath); +} + +function backupPath(path: string): DescriptorBackup { + const existed = existsSync(path); + return { + path, + existed, + ...(existed ? { contents: readFileSync(path) } : {}), + ...(existed ? { mode: statSync(path).mode & 0o777 } : {}), + }; +} + +function restoreDescriptorFiles(backups: DescriptorBackup[]): void { + for (const backup of backups) { + if (backup.existed && backup.contents) { + mkdirSync(dirname(backup.path), { recursive: true, mode: 0o700 }); + writeFileSync(backup.path, backup.contents, { mode: backup.mode ?? 0o600 }); + // writeFileSync applies mode through umask; chmod restores the exact saved descriptor mode. + chmodSync(backup.path, backup.mode ?? 0o600); + } else { + rmSync(backup.path, { force: true }); + } + } +} + +async function assertExec( + runner: DaemonCommandRunner, + command: string[], + message: string, +): Promise { + const result = await runner.exec(command[0]!, command.slice(1)); + if (result.code !== 0) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `${message}: ${result.stderr || result.stdout || result.code}`, + ); + } +} + +async function assertExecUnless( + runner: DaemonCommandRunner, + command: string[], + message: string, + tolerated: RegExp, +): Promise { + const result = await runner.exec(command[0]!, command.slice(1)); + if (result.code !== 0 && !tolerated.test(`${result.stderr}\n${result.stdout}`)) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `${message}: ${result.stderr || result.stdout || result.code}`, + ); + } +} + +async function restoreSystemdEnabledState(runner: DaemonCommandRunner): Promise { + try { + await runner.exec("systemctl", ["--user", "enable", SYSTEMD_UNIT]); + } catch { + // Preserve the original uninstall error; this is a best-effort rollback of login autostart. + } +} + +async function launchdStartLifecycle( + runner: DaemonCommandRunner, + domain: string, + target: string, + descriptorPath: string, +): Promise { + const commands: string[][] = []; + const bootstrap = ["launchctl", "bootstrap", domain, descriptorPath]; + const bootstrapResult = await runner.exec(bootstrap[0]!, bootstrap.slice(1)); + commands.push(bootstrap); + if ( + bootstrapResult.code !== 0 && + !/already bootstrapped|service already loaded/iu.test(bootstrapResult.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd start failed: ${bootstrapResult.stderr || bootstrapResult.stdout || bootstrapResult.code}`, + ); + } + if (bootstrapResult.code !== 0) { + const bootout = ["launchctl", "bootout", domain, descriptorPath]; + const bootoutResult = await runner.exec(bootout[0]!, bootout.slice(1)); + commands.push(bootout); + if ( + bootoutResult.code !== 0 && + !/No such process|not found|Could not find service/iu.test(bootoutResult.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd start failed: ${bootoutResult.stderr || bootoutResult.stdout || bootoutResult.code}`, + ); + } + await assertExec(runner, bootstrap, "launchd start failed"); + commands.push(bootstrap); + } + const kickstart = ["launchctl", "kickstart", "-k", target]; + await assertExec(runner, kickstart, "launchd start failed"); + commands.push(kickstart); + return { + action: "start", + native: { state: "running", installed: true, running: true }, + commands, + }; +} + +async function launchdRestartLifecycle( + runner: DaemonCommandRunner, + domain: string, + target: string, + descriptorPath: string, +): Promise { + const bootout = ["launchctl", "bootout", domain, descriptorPath]; + const bootstrap = ["launchctl", "bootstrap", domain, descriptorPath]; + const kickstart = ["launchctl", "kickstart", "-k", target]; + const bootoutResult = await runner.exec(bootout[0]!, bootout.slice(1)); + if ( + bootoutResult.code !== 0 && + !/No such process|not found|Could not find service/iu.test(bootoutResult.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd restart failed: ${bootoutResult.stderr || bootoutResult.stdout || bootoutResult.code}`, + ); + } + await assertExec(runner, bootstrap, "launchd restart failed"); + await assertExec(runner, kickstart, "launchd restart failed"); + return { + action: "restart", + native: { state: "running", installed: true, running: true }, + commands: [bootout, bootstrap, kickstart], + }; +} + +async function systemdLifecycle( + runner: DaemonCommandRunner, + action: string, + systemdAction: string, + running = true, +): Promise { + const command = ["systemctl", "--user", systemdAction, SYSTEMD_UNIT]; + await assertExec(runner, command, `systemd ${action} failed`); + return { + action, + native: running ? { state: "running", installed: true, running: true } : stopped(), + commands: [command], + }; +} + +async function windowsLifecycle( + runner: DaemonCommandRunner, + action: string, + args: string[], + running = true, +): Promise { + const command = ["schtasks", ...args]; + await assertExec(runner, command, `Scheduled Task ${action} failed`); + return { + action, + native: running ? { state: "running", installed: true, running: true } : stopped(), + commands: [command], + }; +} + +function notInstalled(raw?: Record): NativeDaemonStatus { + return { state: "not_installed", installed: false, running: false, ...(raw ? { raw } : {}) }; +} + +function stopped(raw?: Record): NativeDaemonStatus { + return { state: "installed_stopped", installed: true, running: false, ...(raw ? { raw } : {}) }; +} + +function unavailable(message: string): NativeDaemonStatus { + return { state: "unavailable", installed: false, running: false, message }; +} + +function failedStatus(raw?: Record): NativeDaemonStatus { + return { state: "failed", installed: true, running: false, ...(raw ? { raw } : {}) }; +} + +function runningOrStopped( + pid: number | undefined, + raw: Record, +): NativeDaemonStatus { + return pid === undefined + ? stopped(raw) + : { state: "running", installed: true, running: true, pid, raw }; +} + +function parseNumberMatch(value: string, pattern: RegExp): number | undefined { + const match = pattern.exec(value); + if (!match?.[1]) return undefined; + const parsed = Number(match[1]); + return Number.isInteger(parsed) ? parsed : undefined; +} + +function parseSystemdShow(value: string): Record { + return Object.fromEntries( + value + .split(/\r?\n/u) + .filter((line) => line.includes("=")) + .map((line) => { + const index = line.indexOf("="); + return [line.slice(0, index), line.slice(index + 1)]; + }), + ); +} + +function isSystemdUnavailable(message: string): boolean { + return /failed to connect|no medium found|host is down|no such file or directory|bus|systemd.*not available/iu.test( + message, + ); +} + +function parseWindowsList(value: string): Record { + return Object.fromEntries( + value + .split(/\r?\n/u) + .map((line) => line.match(/^\s*([^:]+):\s*(.*)\s*$/u)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => [match[1]!.trim(), match[2]!.trim()]), + ); +} + +function isSuccessfulWindowsLastRunResult(value: string): boolean { + return /\b(?:0|0x0+|0x0*41303)\b/iu.test(value); +} + +function nodeCommandRunner(): DaemonCommandRunner { + return { + async exec(command, args) { + const { spawn } = await import("node:child_process"); + return await new Promise((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let settled = false; + let timeout: NodeJS.Timeout; + const finish = (result: { stdout: string; stderr: string; code: number }) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(result); + }; + timeout = setTimeout(() => { + child.kill("SIGTERM"); + finish({ stdout: "", stderr: "native service command timed out", code: 124 }); + }, 15_000); + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.on("error", (error) => { + finish({ stdout: "", stderr: error.message, code: 1 }); + }); + child.on("close", (code) => { + finish({ + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + code: code ?? 0, + }); + }); + }); + }, + }; +} diff --git a/packages/core/src/daemon/paths.ts b/packages/core/src/daemon/paths.ts new file mode 100644 index 00000000..524967bb --- /dev/null +++ b/packages/core/src/daemon/paths.ts @@ -0,0 +1,49 @@ +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { defaultConfigBaseDir, defaultStateBaseDir } from "../config/paths"; +import type { DaemonOperationOptions, DaemonPaths } from "./types"; + +export function resolveDaemonPaths( + options: Pick = {}, +): DaemonPaths { + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + const env = options.env ?? process.env; + const path = platform === "win32" ? win32 : posix; + const configBase = defaultConfigBaseDir(env as NodeJS.ProcessEnv, home, platform); + const stateBase = defaultStateBaseDir(env as NodeJS.ProcessEnv, home, platform); + + if (platform === "win32") { + const stateDir = path.join(stateBase, "Caplets", "State", "daemon", "default"); + const logDir = path.join(stateDir, "logs"); + return { + instance: "default", + stateDir, + logDir, + stateFile: path.join(stateDir, "state.json"), + stdoutLog: path.join(logDir, "stdout.log"), + stderrLog: path.join(logDir, "stderr.log"), + configFile: path.join(configBase, "Caplets", "daemon", "default.json"), + descriptorFile: path.join(configBase, "Caplets", "daemon", "default-task.xml"), + wrapperFile: path.join(stateDir, "caplets-daemon-default.cmd"), + }; + } + + const stateDir = path.join(stateBase, "caplets", "daemon", "default"); + const logDir = path.join(stateDir, "logs"); + const descriptorFile = + platform === "darwin" + ? path.join(home, "Library", "LaunchAgents", "dev.caplets.daemon.default.plist") + : path.join(configBase, "systemd", "user", "caplets-daemon-default.service"); + return { + instance: "default", + stateDir, + logDir, + stateFile: path.join(stateDir, "state.json"), + stdoutLog: path.join(logDir, "stdout.log"), + stderrLog: path.join(logDir, "stderr.log"), + configFile: path.join(configBase, "caplets", "daemon", "default.json"), + descriptorFile, + wrapperFile: path.join(stateDir, "caplets-daemon-default.sh"), + }; +} diff --git a/packages/core/src/daemon/platform-darwin.ts b/packages/core/src/daemon/platform-darwin.ts new file mode 100644 index 00000000..082cb7c3 --- /dev/null +++ b/packages/core/src/daemon/platform-darwin.ts @@ -0,0 +1,46 @@ +import { escapeXml } from "./xml"; +import { serviceCommand } from "./shell"; +import type { DaemonConfig, DaemonDescriptor } from "./types"; + +export const LAUNCHD_LABEL = "dev.caplets.daemon.default"; + +export function buildLaunchdDescriptor(config: DaemonConfig): DaemonDescriptor { + const planned = serviceCommand(config); + const command = [planned.executable, ...planned.args].map(escapeXml); + return { + kind: "launchd-user-agent", + label: LAUNCHD_LABEL, + path: config.paths.descriptorFile, + contents: ` + + + + Label + ${LAUNCHD_LABEL} + ProgramArguments + +${command.map((value) => ` ${value}`).join("\n")} + + RunAtLoad + + KeepAlive + + WorkingDirectory + ${escapeXml(config.command.workingDirectory)} + StandardOutPath + ${escapeXml(config.paths.stdoutLog)} + StandardErrorPath + ${escapeXml(config.paths.stderrLog)} + EnvironmentVariables + +${Object.entries(config.command.env) + .map( + ([key, value]) => ` ${escapeXml(key)}\n ${escapeXml(value)}`, + ) + .join("\n")} + + + +`, + }; +} diff --git a/packages/core/src/daemon/platform-linux.ts b/packages/core/src/daemon/platform-linux.ts new file mode 100644 index 00000000..b81efdb0 --- /dev/null +++ b/packages/core/src/daemon/platform-linux.ts @@ -0,0 +1,44 @@ +import { serviceCommand } from "./shell"; +import type { DaemonConfig, DaemonDescriptor } from "./types"; + +export const SYSTEMD_UNIT = "caplets-daemon-default.service"; + +export function buildSystemdDescriptor(config: DaemonConfig): DaemonDescriptor { + const command = serviceCommand(config); + const env = Object.entries(config.command.env) + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`, false)}`) + .join("\n"); + return { + kind: "systemd-user", + unitName: SYSTEMD_UNIT, + path: config.paths.descriptorFile, + contents: `[Unit] +Description=Caplets daemon + +[Service] +Type=simple +WorkingDirectory=${systemdQuote(config.command.workingDirectory)} +${env ? `${env}\n` : ""}ExecStart=${[command.executable, ...command.args].map((value) => systemdQuote(value)).join(" ")} +Restart=on-failure +StandardOutput=append:${systemdEscape(config.paths.stdoutLog, true)} +StandardError=append:${systemdEscape(config.paths.stderrLog, true)} + +[Install] +WantedBy=default.target +`, + }; +} + +function systemdQuote(value: string, escapeDollar = true): string { + return `"${systemdEscape(value, escapeDollar)}"`; +} + +function systemdEscape(value: string, escapeDollar: boolean): string { + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("%", "%%") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n"); + return escapeDollar ? escaped.replaceAll("$", "$$$$") : escaped; +} diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts new file mode 100644 index 00000000..fb2a0fda --- /dev/null +++ b/packages/core/src/daemon/platform-windows.ts @@ -0,0 +1,64 @@ +import { CapletsError } from "../errors"; +import type { DaemonConfig, DaemonDescriptor } from "./types"; +import { escapeXml } from "./xml"; +import { serviceCommand } from "./shell"; + +export const WINDOWS_TASK_NAME = "\\Caplets\\daemon-default"; + +export function buildWindowsTaskDescriptor(config: DaemonConfig): DaemonDescriptor { + const planned = serviceCommand(config); + const command = [planned.executable, ...planned.args].map(windowsArg).join(" "); + const wrapper = { + path: config.paths.wrapperFile, + contents: `@echo off\r +cd /d ${windowsArg(config.command.workingDirectory)}\r +${Object.entries(config.command.env) + .map(([key, value]) => `set "${key}=${windowsEnvValue(value)}"\r`) + .join( + "", + )}${command} >> ${windowsArg(config.paths.stdoutLog)} 2>> ${windowsArg(config.paths.stderrLog)}\r +`, + }; + const xml = ` + + true + InteractiveTokenLeastPrivilege + + PT0S + PT1M3 + + ${escapeXml(wrapper.path)}${escapeXml(config.command.workingDirectory)} + +`; + return { + kind: "windows-scheduled-task", + taskName: WINDOWS_TASK_NAME, + path: config.paths.descriptorFile, + command, + xml, + wrapper, + }; +} + +function windowsArg(value: string): string { + if (/[\r\n]/u.test(value)) { + throw new CapletsError( + "REQUEST_INVALID", + "Windows daemon wrapper arguments cannot contain CR or LF characters.", + ); + } + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +function windowsEnvValue(value: string): string { + if (value === "") { + throw new CapletsError("REQUEST_INVALID", "Windows daemon environment values cannot be empty."); + } + if (/["\r\n]/u.test(value)) { + throw new CapletsError( + "REQUEST_INVALID", + 'Windows daemon environment values cannot contain ", CR, or LF characters.', + ); + } + return value.replaceAll("%", "%%"); +} diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts new file mode 100644 index 00000000..de4d6d66 --- /dev/null +++ b/packages/core/src/daemon/process.ts @@ -0,0 +1,160 @@ +import { existsSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; +import { defaultConfigPath } from "../config/paths"; +import { CapletsError } from "../errors"; +import { resolveServeOptions, type HttpServeOptions, type RawServeOptions } from "../serve/options"; +import { resolveDaemonShell } from "./env"; +import type { + DaemonCommandPlan, + DaemonOperationOptions, + DaemonPaths, + RawDaemonServeOptions, +} from "./types"; + +export function resolveDaemonHttpServeOptions( + raw: RawDaemonServeOptions, + env: NodeJS.ProcessEnv | Record = process.env, +): HttpServeOptions { + if ((raw as RawServeOptions).transport !== undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "caplets daemon install does not accept --transport.", + ); + } + const { preserveUnauthenticatedAuth, ...serveRaw } = raw; + const serveEnv = preserveUnauthenticatedAuth + ? { ...env, CAPLETS_SERVER_USER: undefined, CAPLETS_SERVER_PASSWORD: undefined } + : env; + return resolveServeOptions({ ...serveRaw, transport: "http" }, serveEnv) as HttpServeOptions; +} + +export function daemonServeArgs(options: HttpServeOptions): string[] { + const args = [ + "serve", + "--transport", + "http", + "--host", + options.host, + "--port", + String(options.port), + "--path", + options.path, + ]; + if (options.auth.enabled) + args.push("--user", options.auth.user, "--password", options.auth.password); + if (options.allowUnauthenticatedHttp) args.push("--allow-unauthenticated-http"); + if (options.trustProxy) args.push("--trust-proxy"); + return args; +} + +export function buildDaemonCommandPlan(options: { + serve: HttpServeOptions; + paths: DaemonPaths; + operation: Pick; + explicitEnv?: Record; + inheritEnv?: boolean; +}): DaemonCommandPlan { + const platform = options.operation.platform ?? process.platform; + const executable = resolveDaemonExecutable(process.argv[1]); + const args = daemonServeArgs(options.serve); + const workingDirectory = options.operation.home ?? homedir(); + const explicitEnv = options.explicitEnv ?? {}; + const serviceEnv = daemonServiceEnv({ + explicitEnv, + operation: options.operation, + platform, + serve: options.serve, + workingDirectory, + }); + const base = { + executable, + args, + workingDirectory, + env: serviceEnv, + inheritEnv: options.inheritEnv ?? false, + stdoutLog: options.paths.stdoutLog, + stderrLog: options.paths.stderrLog, + }; + if (!options.inheritEnv) return base; + return { + ...base, + shell: resolveDaemonShell({ + ...(options.operation.env ? { env: options.operation.env } : {}), + platform, + ...(options.operation.accountShell ? { accountShell: options.operation.accountShell } : {}), + }), + }; +} + +function daemonServiceEnv(options: { + explicitEnv: Record; + operation: Pick; + platform: NodeJS.Platform; + serve: HttpServeOptions; + workingDirectory: string; +}): Record { + const env = options.operation.env ?? process.env; + const selectedConfigEnv = configSelectionEnv(env, options.workingDirectory, options.platform); + const serviceEnv = { ...selectedConfigEnv, ...options.explicitEnv }; + if (options.serve.publicOrigin && serviceEnv.CAPLETS_SERVER_URL === undefined) { + serviceEnv.CAPLETS_SERVER_URL = options.serve.publicOrigin; + } + return serviceEnv; +} + +function configSelectionEnv( + env: NodeJS.ProcessEnv | Record, + home: string, + platform: NodeJS.Platform, +): Record { + const selected: Record = {}; + const configPath = env.CAPLETS_CONFIG?.trim(); + if (configPath) { + selected.CAPLETS_CONFIG = configPath; + } else if (env.XDG_CONFIG_HOME?.trim() || (platform === "win32" && env.APPDATA?.trim())) { + selected.CAPLETS_CONFIG = defaultConfigPath(env, home, platform); + } + const projectConfigPath = env.CAPLETS_PROJECT_CONFIG?.trim(); + if (projectConfigPath) selected.CAPLETS_PROJECT_CONFIG = projectConfigPath; + for (const key of configSelectionEnvKeys(platform)) { + const value = env[key]?.trim(); + if (value) selected[key] = value; + } + return selected; +} + +function configSelectionEnvKeys(platform: NodeJS.Platform): string[] { + return platform === "win32" + ? ["APPDATA", "LOCALAPPDATA"] + : ["XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]; +} + +function resolveDaemonExecutable(scriptPath: string | undefined): string { + if (!scriptPath) { + throw new CapletsError( + "REQUEST_INVALID", + "Cannot install the daemon because the Caplets CLI path could not be resolved.", + ); + } + const executable = isAbsolute(scriptPath) ? scriptPath : resolve(scriptPath); + if (!existsSync(executable)) { + throw new CapletsError( + "REQUEST_INVALID", + `Cannot install the daemon because the Caplets CLI path does not exist: ${executable}`, + ); + } + const realExecutable = realpathSync(executable); + if (isTransientRunnerPath(executable) || isTransientRunnerPath(realExecutable)) { + throw new CapletsError( + "REQUEST_INVALID", + "Cannot install the daemon from a temporary runner such as npx or pnpm dlx. Install caplets first, then rerun caplets daemon install.", + ); + } + return executable; +} + +function isTransientRunnerPath(path: string): boolean { + const normalized = path.replaceAll("\\", "/"); + return /(?:^|\/)_npx(?:\/|$)/u.test(normalized) || /(?:^|\/)dlx-[^/]+(?:\/|$)/u.test(normalized); +} diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts new file mode 100644 index 00000000..af719b61 --- /dev/null +++ b/packages/core/src/daemon/shell.ts @@ -0,0 +1,85 @@ +import { CapletsError } from "../errors"; + +export function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function serviceCommand(config: { + command: { + executable: string; + args: string[]; + env?: Record; + shell?: { executable: string; args: string[] }; + }; +}): { executable: string; args: string[]; display: string } { + const direct = [process.execPath, config.command.executable, ...config.command.args]; + if (!config.command.shell) { + return { + executable: direct[0]!, + args: direct.slice(1), + display: direct.map(shellQuote).join(" "), + }; + } + const shellCommand = shellCommandLine(config.command.shell, direct, config.command.env ?? {}); + const shell = [config.command.shell.executable, ...config.command.shell.args, shellCommand]; + return { + executable: shell[0]!, + args: shell.slice(1), + display: shell.map(shellQuote).join(" "), + }; +} + +export function shellCommandLine( + shell: { executable: string; args: string[] }, + argv: string[], + env: Record = {}, +): string { + if (isPowerShell(shell)) { + const exports = Object.entries(env) + .map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`) + .join("; "); + return [exports, `& ${argv.map(powerShellQuote).join(" ")}`].filter(Boolean).join("; "); + } + if (isCmd(shell)) { + const exports = Object.entries(env) + .map(([key, value]) => `set "${key}=${cmdEnvValue(value)}"`) + .join("&& "); + return [exports, argv.map(cmdQuote).join(" ")].filter(Boolean).join("&& "); + } + const exports = Object.entries(env) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`) + .join("; "); + return [exports, `exec ${argv.map(shellQuote).join(" ")}`].filter(Boolean).join("; "); +} + +function isPowerShell(shell: { executable: string }): boolean { + const executable = shell.executable.replaceAll("\\", "/").toLocaleLowerCase(); + return executable.endsWith("/powershell.exe") || executable.endsWith("/pwsh.exe"); +} + +function isCmd(shell: { executable: string; args: string[] }): boolean { + const executable = shell.executable.replaceAll("\\", "/").toLocaleLowerCase(); + const executableName = executable.split("/").at(-1) ?? executable; + const isCmdExe = executableName === "cmd.exe" || executableName === "cmd"; + return isCmdExe && shell.args.some((arg) => arg.toLocaleLowerCase() === "/c"); +} + +function powerShellQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function cmdQuote(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) return value; + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; +} + +function cmdEnvValue(value: string): string { + if (/["\r\n]/u.test(value)) { + throw new CapletsError( + "REQUEST_INVALID", + 'Windows daemon environment values used with cmd.exe cannot contain ", CR, or LF characters.', + ); + } + return value.replaceAll("%", "%%"); +} diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts new file mode 100644 index 00000000..6618d37b --- /dev/null +++ b/packages/core/src/daemon/types.ts @@ -0,0 +1,205 @@ +import type { HttpServeOptions, RawServeOptions } from "../serve/options"; + +export type DaemonInstance = "default"; + +export type RawDaemonServeOptions = Omit & { + preserveUnauthenticatedAuth?: boolean; +}; + +export type DaemonPaths = { + instance: DaemonInstance; + stateDir: string; + logDir: string; + stateFile: string; + stdoutLog: string; + stderrLog: string; + configFile: string; + descriptorFile: string; + wrapperFile: string; +}; + +export type DaemonEnvConfig = { + inherit: boolean; + values: Record; +}; + +export type DaemonShellPlan = { + executable: string; + args: string[]; + source: "SHELL" | "account" | "fallback"; +}; + +export type DaemonCommandPlan = { + executable: string; + args: string[]; + workingDirectory: string; + env: Record; + inheritEnv: boolean; + stdoutLog: string; + stderrLog: string; + shell?: DaemonShellPlan; +}; + +export type DaemonConfig = { + instance: DaemonInstance; + serve: HttpServeOptions; + command: DaemonCommandPlan; + env: DaemonEnvConfig; + paths: DaemonPaths; + updatedAt: string; +}; + +export type DaemonState = { + instance: DaemonInstance; + installed: boolean; + running: boolean; + nativeState: NativeDaemonStateName; + updatedAt: string; + startedAt?: string; + pid?: number; +}; + +export type NativeDaemonStateName = + | "not_installed" + | "installed_stopped" + | "running" + | "failed" + | "unavailable" + | "unknown"; + +export type NativeDaemonStatus = { + state: NativeDaemonStateName; + installed: boolean; + running: boolean; + pid?: number; + raw?: Record; + message?: string; +}; + +export type DaemonStatus = { + instance: DaemonInstance; + installed: boolean; + running: boolean; + nativeState: NativeDaemonStateName; + paths: DaemonPaths; + config?: DaemonConfig; + native: NativeDaemonStatus; + health?: DaemonHealthResult; +}; + +export type DaemonHealthResult = { + ok: boolean; + url: string; + status?: number; + error?: string; +}; + +export type DaemonDescriptor = + | { + kind: "launchd-user-agent"; + label: string; + path: string; + contents: string; + } + | { + kind: "systemd-user"; + unitName: string; + path: string; + contents: string; + } + | { + kind: "windows-scheduled-task"; + taskName: string; + path: string; + command: string; + xml: string; + wrapper: { path: string; contents: string }; + }; + +export type DaemonManager = { + descriptor(config: DaemonConfig): DaemonDescriptor; + status(config: DaemonConfig | undefined, paths: DaemonPaths): Promise; + install(config: DaemonConfig): Promise; + uninstall(config: DaemonConfig | undefined, paths: DaemonPaths): Promise; + start(config: DaemonConfig): Promise; + restart(config: DaemonConfig): Promise; + stop(config?: DaemonConfig): Promise; +}; + +export type DaemonManagerAction = { + action: string; + native: NativeDaemonStatus; + commands?: string[][]; + descriptor?: DaemonDescriptor; +}; + +export type DaemonCommandRunner = { + exec(command: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }>; +}; + +export type DaemonOperationOptions = { + env?: NodeJS.ProcessEnv | Record; + home?: string; + platform?: NodeJS.Platform; + manager?: DaemonManager; + commandRunner?: DaemonCommandRunner; + fetch?: typeof fetch; + validateCommand?: (config: DaemonConfig) => Promise; + accountShell?: string; + serviceAvailable?: boolean; + uid?: number; + now?: Date; + readPrompt?: (prompt: string) => Promise; + isInteractive?: boolean; + healthTimeoutMs?: number; + healthIntervalMs?: number; +}; + +export type DaemonInstallOptions = RawDaemonServeOptions & { + reset?: boolean; + env?: string[]; + unsetEnv?: string[]; + inheritEnv?: boolean; + dryRun?: boolean; + validate?: boolean; + start?: boolean; + restart?: boolean; + noRestart?: boolean; +}; + +export type DaemonUninstallOptions = { + purge?: boolean; + dryRun?: boolean; +}; + +export type DaemonLifecycleResult = { + action: string; + status: DaemonStatus; + native?: DaemonManagerAction; +}; + +export type DaemonInstallResult = DaemonLifecycleResult & { + config: DaemonConfig; + descriptor: DaemonDescriptor; + validation?: DaemonHealthResult; + dryRun: boolean; + plannedActions: string[]; +}; + +export type DaemonUninstallResult = DaemonLifecycleResult & { + purge: boolean; + dryRun: boolean; + removed: string[]; +}; + +export type DaemonLogStream = "stdout" | "stderr" | "all"; + +export type DaemonLogEntry = { + stream: "stdout" | "stderr"; + line: string; +}; + +export type DaemonLogsResult = { + paths: Pick; + entries: DaemonLogEntry[]; +}; diff --git a/packages/core/src/daemon/validation.ts b/packages/core/src/daemon/validation.ts new file mode 100644 index 00000000..bb6cbcb3 --- /dev/null +++ b/packages/core/src/daemon/validation.ts @@ -0,0 +1,139 @@ +import { CapletsError } from "../errors"; +import { servicePaths } from "../serve/http"; +import type { DaemonConfig, DaemonHealthResult } from "./types"; +import { serviceCommand } from "./shell"; + +export async function validateDaemonCommand( + config: DaemonConfig, + options: { + fetch?: typeof fetch; + successSettleMs?: number; + timeoutMs?: number; + } = {}, +): Promise { + const { spawn } = await import("node:child_process"); + const command = validationSpawnCommand(config); + const child = spawn(command.command, command.args, { + cwd: config.command.workingDirectory, + env: config.command.env, + stdio: ["ignore", "ignore", "ignore"], + }); + let processDone = false; + let processError: string | undefined; + const processFailure = new Promise((resolve) => { + child.once("error", (error) => { + processDone = true; + processError = error.message; + resolve({ ok: false, url: healthUrl(config), error: processError }); + }); + child.once("exit", (code, signal) => { + processDone = true; + processError = `validation process exited${code === null ? "" : ` with code ${code}`}${signal ? ` due to ${signal}` : ""}`; + resolve({ ok: false, url: healthUrl(config), error: processError }); + }); + }); + try { + const deadline = Date.now() + (options.timeoutMs ?? 5_000); + let last: DaemonHealthResult | undefined; + while (Date.now() < deadline) { + if (processDone) break; + last = await Promise.race([ + probeDaemonHealth(config, { + ...(options.fetch ? { fetch: options.fetch } : {}), + timeoutMs: 750, + }), + processFailure, + ]); + if (last.ok) { + const settled = await Promise.race([ + processFailure, + sleep(options.successSettleMs ?? 1_000).then(() => undefined), + ]); + return settled ?? last; + } + if (processDone) break; + await sleep(150); + } + return ( + last ?? { + ok: false, + url: healthUrl(config), + error: + processError ?? (processDone ? "validation process exited" : "health probe timed out"), + } + ); + } finally { + if (!processDone) { + const closed = new Promise((resolve) => child.once("close", () => resolve())); + child.kill("SIGTERM"); + await Promise.race([closed, sleep(2_000)]); + } + } +} + +export async function probeDaemonHealth( + config: DaemonConfig, + options: { + fetch?: typeof fetch; + port?: number; + timeoutMs?: number; + } = {}, +): Promise { + const fetchImpl = options.fetch ?? fetch; + const url = healthUrl(config, options.port); + try { + const response = await fetchImpl(url, { + signal: AbortSignal.timeout(options.timeoutMs ?? 2_000), + }); + return { ok: response.ok, url, status: response.status }; + } catch (error) { + return { + ok: false, + url, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function assertDaemonHealth(result: DaemonHealthResult, label: string): void { + if (!result.ok) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `${label} failed for ${result.url}${result.status ? ` with HTTP ${result.status}` : ""}${result.error ? `: ${result.error}` : ""}`, + ); + } +} + +export async function allocateLoopbackPort(): Promise { + const { createServer } = await import("node:net"); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + if (!address || typeof address === "string") { + throw new CapletsError("SERVER_UNAVAILABLE", "Could not allocate a validation port."); + } + return address.port; +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function healthUrl(config: DaemonConfig, port = config.serve.port): string { + return `http://${formatHost(config.serve.host)}:${port}${servicePaths(config.serve.path).health}`; +} + +function validationSpawnCommand(config: DaemonConfig): { command: string; args: string[] } { + const planned = serviceCommand(config); + return { command: planned.executable, args: planned.args }; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/daemon/xml.ts b/packages/core/src/daemon/xml.ts new file mode 100644 index 00000000..65ac5c5b --- /dev/null +++ b/packages/core/src/daemon/xml.ts @@ -0,0 +1,8 @@ +export function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/packages/core/src/serve/daemon/config.ts b/packages/core/src/serve/daemon/config.ts deleted file mode 100644 index cba40c77..00000000 --- a/packages/core/src/serve/daemon/config.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import type { - DaemonCommandPlan, - ServeDaemonConfig, - ServeDaemonPaths, - ServeDaemonState, - ServeDaemonStatus, -} from "./types"; - -export function readDaemonConfig(paths: ServeDaemonPaths): ServeDaemonConfig | undefined { - return readJson(paths.configFile); -} - -export function writeDaemonConfig( - paths: ServeDaemonPaths, - serve: ServeDaemonConfig["serve"], - command: DaemonCommandPlan, - now = new Date(), -): ServeDaemonConfig { - const config: ServeDaemonConfig = { - instance: "default", - serve, - command, - paths, - updatedAt: now.toISOString(), - }; - writeJson(paths.configFile, config); - return config; -} - -export function readDaemonState(paths: ServeDaemonPaths): ServeDaemonState | undefined { - return readJson(paths.stateFile); -} - -export function writeDaemonState( - paths: ServeDaemonPaths, - state: Omit & { updatedAt?: string }, - now = new Date(), -): ServeDaemonState { - const next: ServeDaemonState = { - instance: "default", - ...state, - updatedAt: state.updatedAt ?? now.toISOString(), - }; - writeJson(paths.stateFile, next); - return next; -} - -export function redactDaemonStatus(status: ServeDaemonStatus): ServeDaemonStatus { - return redactDaemonValue(status) as ServeDaemonStatus; -} - -function redactDaemonValue(value: unknown): unknown { - if (Array.isArray(value)) return redactDaemonArray(value); - if (!value || typeof value !== "object") return value; - const redacted: Record = {}; - for (const [key, nested] of Object.entries(value)) { - redacted[key] = /password|token|secret|authorization|credential/iu.test(key) - ? "[REDACTED]" - : redactDaemonValue(nested); - } - return redacted; -} - -function redactDaemonArray(value: unknown[]): unknown[] { - const redacted: unknown[] = []; - let redactNext = false; - for (const item of value) { - if (redactNext) { - redacted.push("[REDACTED]"); - redactNext = false; - continue; - } - redacted.push(redactDaemonValue(item)); - if ( - typeof item === "string" && - /--(?:password|token|secret|authorization|credential)$/iu.test(item) - ) { - redactNext = true; - } - } - return redacted; -} - -function readJson(path: string): T | undefined { - if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as T; -} - -function writeJson(path: string, value: unknown): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); -} diff --git a/packages/core/src/serve/daemon/index.ts b/packages/core/src/serve/daemon/index.ts deleted file mode 100644 index 6dcc4599..00000000 --- a/packages/core/src/serve/daemon/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { CapletsError } from "../../errors"; -import { resolveDaemonServeOptions, type RawServeOptions } from "../options"; -import { - readDaemonConfig, - readDaemonState, - redactDaemonStatus, - writeDaemonConfig, - writeDaemonState, -} from "./config"; -import { buildDaemonPlatformDescriptor } from "./platform"; -import { createNodeDaemonProcessRunner, daemonServeCommand } from "./process"; -import { resolveServeDaemonPaths } from "./paths"; -import type { - DaemonPlatformDescriptor, - ServeDaemonOperationOptions, - ServeDaemonOperationResult, - ServeDaemonStatus, -} from "./types"; - -export type ServeDaemonServiceResult = { - enabled: boolean; - descriptor: DaemonPlatformDescriptor; - status: ServeDaemonStatus; -}; - -export async function startDaemon( - raw: RawServeOptions = {}, - options: ServeDaemonOperationOptions = {}, -): Promise { - const paths = resolveServeDaemonPaths(options); - const processRunner = options.process ?? createNodeDaemonProcessRunner(); - const existing = await daemonStatus({ ...options, process: processRunner }); - if (existing.running) { - throw new CapletsError("REQUEST_INVALID", "Caplets HTTP daemon is already running."); - } - - const serve = resolveDaemonServeOptions(raw, options.env ?? process.env); - const command = daemonServeCommand(serve); - mkdirSync(paths.logDir, { recursive: true }); - const config = writeDaemonConfig(paths, serve, command); - const pid = await processRunner.start({ - args: command.args, - stdoutLog: paths.stdoutLog, - stderrLog: paths.stderrLog, - configFile: paths.configFile, - }); - writeFileSync(paths.pidFile, `${pid}\n`); - const now = new Date().toISOString(); - const state = writeDaemonState(paths, { - running: true, - pid, - startedAt: now, - enabled: existing.enabled, - updatedAt: now, - }); - - return { - status: redactDaemonStatus({ ...state, paths, config }), - }; -} - -export async function stopDaemon( - options: ServeDaemonOperationOptions = {}, -): Promise { - const paths = resolveServeDaemonPaths(options); - const processRunner = options.process ?? createNodeDaemonProcessRunner(); - const existing = await daemonStatus({ ...options, process: processRunner }); - if (existing.running && existing.pid !== undefined) { - await processRunner.stop(existing.pid); - } - rmSync(paths.pidFile, { force: true }); - const state = writeDaemonState(paths, { - running: false, - enabled: existing.enabled, - }); - return { - status: redactDaemonStatus({ - ...state, - paths, - ...configProperty(readDaemonConfig(paths)), - }), - }; -} - -export async function restartDaemon( - raw: RawServeOptions = {}, - options: ServeDaemonOperationOptions = {}, -): Promise { - await stopDaemon(options); - return startDaemon(raw, options); -} - -export async function daemonStatus( - options: ServeDaemonOperationOptions = {}, -): Promise { - const paths = resolveServeDaemonPaths(options); - const processRunner = options.process ?? createNodeDaemonProcessRunner(); - const config = readDaemonConfig(paths); - const storedState = readDaemonState(paths); - const pid = readPid(paths.pidFile) ?? storedState?.pid; - const running = pid === undefined ? false : await processRunner.isRunning(pid); - if (!running) { - rmSync(paths.pidFile, { force: true }); - } - const state = writeDaemonState(paths, { - running, - ...(running && pid !== undefined ? { pid } : {}), - ...(running && storedState?.startedAt ? { startedAt: storedState.startedAt } : {}), - enabled: storedState?.enabled ?? false, - }); - return redactDaemonStatus({ ...state, paths, ...(config ? { config } : {}) }); -} - -export async function enableDaemon( - options: ServeDaemonOperationOptions = {}, -): Promise { - return setDaemonEnabled(true, options); -} - -export async function disableDaemon( - options: ServeDaemonOperationOptions = {}, -): Promise { - return setDaemonEnabled(false, options); -} - -async function setDaemonEnabled( - enabled: boolean, - options: ServeDaemonOperationOptions, -): Promise { - const paths = resolveServeDaemonPaths(options); - const config = readDaemonConfig(paths); - const command = config?.command ?? daemonServeCommand(resolveDaemonServeOptions({}, options.env)); - const descriptor = buildDaemonPlatformDescriptor({ - ...(options.platform !== undefined ? { platform: options.platform } : {}), - ...(options.serviceAvailable !== undefined - ? { serviceAvailable: options.serviceAvailable } - : {}), - paths, - command, - }); - const current = await daemonStatus(options); - const state = writeDaemonState(paths, { - running: current.running, - ...(current.running && current.pid !== undefined ? { pid: current.pid } : {}), - ...(current.running && current.startedAt ? { startedAt: current.startedAt } : {}), - enabled, - }); - return { - enabled, - descriptor, - status: redactDaemonStatus({ ...state, paths, ...configProperty(config) }), - }; -} - -function configProperty(config: ReturnType): { - config?: NonNullable; -} { - return config ? { config } : {}; -} - -function readPid(path: string): number | undefined { - try { - const value = Number(readFileSync(path, "utf8").trim()); - return Number.isInteger(value) && value > 0 ? value : undefined; - } catch { - return undefined; - } -} - -export { buildDaemonPlatformDescriptor } from "./platform"; -export { resolveServeDaemonPaths } from "./paths"; -export type { - DaemonPlatformDescriptor, - DaemonProcessRunner, - ServeDaemonConfig, - ServeDaemonOperationOptions, - ServeDaemonPaths, - ServeDaemonState, - ServeDaemonStatus, -} from "./types"; diff --git a/packages/core/src/serve/daemon/paths.ts b/packages/core/src/serve/daemon/paths.ts deleted file mode 100644 index a36cedaf..00000000 --- a/packages/core/src/serve/daemon/paths.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { homedir } from "node:os"; -import { dirname, posix, win32 } from "node:path"; -import { defaultConfigBaseDir, defaultStateBaseDir } from "../../config/paths"; -import type { ServeDaemonOperationOptions, ServeDaemonPaths } from "./types"; - -export function resolveServeDaemonPaths( - options: Pick = {}, -): ServeDaemonPaths { - const platform = options.platform ?? process.platform; - const home = options.home ?? homedir(); - const env = options.env ?? process.env; - const path = platform === "win32" ? win32 : posix; - const configBase = defaultConfigBaseDir(env as NodeJS.ProcessEnv, home, platform); - const stateBase = defaultStateBaseDir(env as NodeJS.ProcessEnv, home, platform); - - if (platform === "win32") { - const stateDir = path.join(stateBase, "Caplets", "State", "serve", "default"); - const logDir = path.join(stateDir, "logs"); - return { - instance: "default", - stateDir, - logDir, - stateFile: path.join(stateDir, "state.json"), - pidFile: path.join(stateDir, "server.pid"), - stdoutLog: path.join(logDir, "stdout.log"), - stderrLog: path.join(logDir, "stderr.log"), - configFile: path.join(configBase, "Caplets", "serve", "default.json"), - }; - } - - const stateDir = path.join(stateBase, "caplets", "serve", "default"); - const logDir = path.join(stateDir, "logs"); - return { - instance: "default", - stateDir, - logDir, - stateFile: path.join(stateDir, "state.json"), - pidFile: path.join(stateDir, "server.pid"), - stdoutLog: path.join(logDir, "stdout.log"), - stderrLog: path.join(logDir, "stderr.log"), - configFile: path.join(configBase, "caplets", "serve", "default.json"), - }; -} - -export function daemonServiceDescriptorPath( - paths: ServeDaemonPaths, - platform: NodeJS.Platform, -): string { - const path = platform === "win32" ? win32 : posix; - if (platform === "darwin") { - return path.join( - dirname(dirname(paths.configFile)), - "launchd", - "dev.caplets.serve.default.plist", - ); - } - if (platform === "linux") { - return path.join( - dirname(dirname(paths.configFile)), - "systemd", - "user", - "caplets-serve-default.service", - ); - } - return paths.configFile; -} diff --git a/packages/core/src/serve/daemon/platform-darwin.ts b/packages/core/src/serve/daemon/platform-darwin.ts deleted file mode 100644 index 1df79f3f..00000000 --- a/packages/core/src/serve/daemon/platform-darwin.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { escapeXml } from "./platform"; -import type { DaemonCommandPlan, LaunchdUserAgentDescriptor, ServeDaemonPaths } from "./types"; - -export function buildLaunchdUserAgentDescriptor( - paths: ServeDaemonPaths, - command: DaemonCommandPlan, -): LaunchdUserAgentDescriptor { - const label = "dev.caplets.serve.default"; - return { - kind: "launchd-user-agent", - label, - path: `${paths.configFile}.plist`, - plist: [ - '', - '', - '', - "", - " Label", - ` ${label}`, - " ProgramArguments", - " ", - ` ${escapeXml(command.executable)}`, - ...command.args.map((arg) => ` ${escapeXml(arg)}`), - " ", - " RunAtLoad", - " ", - " KeepAlive", - " ", - " StandardOutPath", - ` ${escapeXml(paths.stdoutLog)}`, - " StandardErrorPath", - ` ${escapeXml(paths.stderrLog)}`, - "", - "", - "", - ].join("\n"), - }; -} diff --git a/packages/core/src/serve/daemon/platform-linux.ts b/packages/core/src/serve/daemon/platform-linux.ts deleted file mode 100644 index 4ea6d133..00000000 --- a/packages/core/src/serve/daemon/platform-linux.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - DaemonCommandPlan, - ManualServiceDescriptor, - ServeDaemonPaths, - SystemdUserServiceDescriptor, -} from "./types"; - -export function buildLinuxServiceDescriptor( - paths: ServeDaemonPaths, - command: DaemonCommandPlan, - serviceAvailable = true, -): SystemdUserServiceDescriptor | ManualServiceDescriptor { - if (!serviceAvailable) { - return { - kind: "manual", - reason: "Linux systemd user service is not available; run the daemon command manually.", - command, - }; - } - - return { - kind: "systemd-user", - unitName: "caplets-serve-default.service", - path: `${paths.configFile}.service`, - unit: [ - "[Unit]", - "Description=Caplets HTTP daemon (default)", - "After=network.target", - "", - "[Service]", - "Type=simple", - `ExecStart=${shellJoin([command.executable, ...command.args])}`, - "Restart=on-failure", - `StandardOutput=append:${paths.stdoutLog}`, - `StandardError=append:${paths.stderrLog}`, - "", - "[Install]", - "WantedBy=default.target", - "", - ].join("\n"), - }; -} - -function shellJoin(args: string[]): string { - return args - .map((arg) => (/^[A-Za-z0-9_./:=@-]+$/u.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`)) - .join(" "); -} diff --git a/packages/core/src/serve/daemon/platform-windows.ts b/packages/core/src/serve/daemon/platform-windows.ts deleted file mode 100644 index 0cf5b22a..00000000 --- a/packages/core/src/serve/daemon/platform-windows.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { DaemonCommandPlan, WindowsScheduledTaskDescriptor } from "./types"; - -export function buildWindowsScheduledTaskDescriptor( - command: DaemonCommandPlan, -): WindowsScheduledTaskDescriptor { - const taskName = "Caplets Serve Default"; - const taskRun = commandLine([command.executable, ...command.args]); - return { - kind: "windows-scheduled-task", - taskName, - commands: { - register: `schtasks /Create /TN "${taskName}" /SC ONLOGON /TR "${taskRun}" /F`, - unregister: `schtasks /Delete /TN "${taskName}" /F`, - query: `schtasks /Query /TN "${taskName}"`, - }, - }; -} - -function commandLine(args: string[]): string { - return args.map((arg) => (arg.includes(" ") ? `\\"${arg}\\"` : arg)).join(" "); -} diff --git a/packages/core/src/serve/daemon/platform.ts b/packages/core/src/serve/daemon/platform.ts deleted file mode 100644 index 11434bf9..00000000 --- a/packages/core/src/serve/daemon/platform.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { buildLaunchdUserAgentDescriptor } from "./platform-darwin"; -import { buildLinuxServiceDescriptor } from "./platform-linux"; -import { buildWindowsScheduledTaskDescriptor } from "./platform-windows"; -import type { DaemonCommandPlan, DaemonPlatformDescriptor, ServeDaemonPaths } from "./types"; - -export type BuildDaemonPlatformDescriptorOptions = { - platform?: NodeJS.Platform; - serviceAvailable?: boolean; - paths: ServeDaemonPaths; - command: DaemonCommandPlan; -}; - -export function buildDaemonPlatformDescriptor( - options: BuildDaemonPlatformDescriptorOptions, -): DaemonPlatformDescriptor { - const platform = options.platform ?? process.platform; - if (platform === "darwin") { - return buildLaunchdUserAgentDescriptor(options.paths, options.command); - } - if (platform === "linux") { - return buildLinuxServiceDescriptor(options.paths, options.command, options.serviceAvailable); - } - if (platform === "win32") { - return buildWindowsScheduledTaskDescriptor(options.command); - } - return { - kind: "manual", - reason: `Automatic user service descriptors are not available on ${platform}.`, - command: options.command, - }; -} - -export function escapeXml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} diff --git a/packages/core/src/serve/daemon/process.ts b/packages/core/src/serve/daemon/process.ts deleted file mode 100644 index 74589332..00000000 --- a/packages/core/src/serve/daemon/process.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { spawn } from "node:child_process"; -import { closeSync, mkdirSync, openSync } from "node:fs"; -import { dirname } from "node:path"; -import type { HttpServeOptions } from "../options"; -import type { DaemonCommandPlan, DaemonProcessRunner, DaemonProcessStart } from "./types"; - -export function daemonServeCommand(options: HttpServeOptions): DaemonCommandPlan { - return { - executable: process.argv[1] ?? "caplets", - args: daemonServeArgs(options), - }; -} - -export function daemonServeArgs(options: HttpServeOptions): string[] { - const args = [ - "serve", - "--transport", - "http", - "--host", - options.host, - "--port", - String(options.port), - "--path", - options.path, - "--user", - options.auth.user, - ]; - if (options.auth.enabled) { - args.push("--password", options.auth.password); - } - if (options.warnUnauthenticatedNetwork) { - args.push("--allow-unauthenticated-http"); - } - if (options.trustProxy) { - args.push("--trust-proxy"); - } - return args; -} - -export function createNodeDaemonProcessRunner(): DaemonProcessRunner { - return { - async isRunning(pid: number): Promise { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - }, - async start(command: DaemonProcessStart): Promise { - mkdirSync(dirname(command.stdoutLog), { recursive: true }); - mkdirSync(dirname(command.stderrLog), { recursive: true }); - const stdout = openSync(command.stdoutLog, "a"); - const stderr = openSync(command.stderrLog, "a"); - try { - const child = spawn(process.execPath, [process.argv[1] ?? "caplets", ...command.args], { - detached: true, - stdio: ["ignore", stdout, stderr], - env: process.env, - }); - child.unref(); - return child.pid ?? 0; - } finally { - closeSync(stdout); - closeSync(stderr); - } - }, - async stop(pid: number): Promise { - try { - process.kill(pid, "SIGTERM"); - } catch { - return; - } - }, - }; -} diff --git a/packages/core/src/serve/daemon/types.ts b/packages/core/src/serve/daemon/types.ts deleted file mode 100644 index 0e34eb6a..00000000 --- a/packages/core/src/serve/daemon/types.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { HttpServeOptions, RawServeOptions } from "../options"; - -export type ServeDaemonInstance = "default"; - -export type ServeDaemonPaths = { - instance: ServeDaemonInstance; - stateDir: string; - logDir: string; - stateFile: string; - pidFile: string; - stdoutLog: string; - stderrLog: string; - configFile: string; -}; - -export type ServeDaemonConfig = { - instance: ServeDaemonInstance; - serve: HttpServeOptions; - command: DaemonCommandPlan; - paths: ServeDaemonPaths; - updatedAt: string; -}; - -export type ServeDaemonState = { - instance: ServeDaemonInstance; - running: boolean; - pid?: number; - startedAt?: string; - updatedAt: string; - enabled: boolean; -}; - -export type ServeDaemonStatus = ServeDaemonState & { - paths: ServeDaemonPaths; - config?: ServeDaemonConfig; -}; - -export type DaemonCommandPlan = { - executable: string; - args: string[]; -}; - -export type DaemonProcessStart = { - args: string[]; - stdoutLog: string; - stderrLog: string; - configFile: string; -}; - -export type DaemonProcessRunner = { - isRunning(pid: number): Promise; - start(command: DaemonProcessStart): Promise; - stop(pid: number): Promise; -}; - -export type ServeDaemonOperationOptions = { - env?: NodeJS.ProcessEnv | Record; - home?: string; - platform?: NodeJS.Platform; - process?: DaemonProcessRunner; - serviceAvailable?: boolean; -}; - -export type ServeDaemonStartOptions = ServeDaemonOperationOptions & { - raw?: RawServeOptions; -}; - -export type ServeDaemonOperationResult = { - status: ServeDaemonStatus; -}; - -export type LaunchdUserAgentDescriptor = { - kind: "launchd-user-agent"; - label: string; - path: string; - plist: string; -}; - -export type SystemdUserServiceDescriptor = { - kind: "systemd-user"; - unitName: string; - path: string; - unit: string; -}; - -export type ManualServiceDescriptor = { - kind: "manual"; - reason: string; - command: DaemonCommandPlan; -}; - -export type WindowsScheduledTaskDescriptor = { - kind: "windows-scheduled-task"; - taskName: string; - commands: { - register: string; - unregister: string; - query: string; - }; -}; - -export type DaemonPlatformDescriptor = - | LaunchdUserAgentDescriptor - | SystemdUserServiceDescriptor - | ManualServiceDescriptor - | WindowsScheduledTaskDescriptor; diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 790e492b..8483537c 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -4,30 +4,11 @@ import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./ import { serveStdio } from "./stdio"; export { serveHttp } from "./http"; -export { resolveDaemonServeOptions, resolveServeOptions } from "./options"; +export { resolveServeOptions } from "./options"; export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options"; export { NativeCapletsMcpSession } from "./native-session"; export type { NativeCapletsMcpSessionOptions, NativeToolServer } from "./native-session"; export { serveStdio } from "./stdio"; -export { - buildDaemonPlatformDescriptor, - daemonStatus, - disableDaemon, - enableDaemon, - resolveServeDaemonPaths, - restartDaemon, - startDaemon, - stopDaemon, -} from "./daemon"; -export type { - DaemonPlatformDescriptor, - DaemonProcessRunner, - ServeDaemonConfig, - ServeDaemonOperationOptions, - ServeDaemonPaths, - ServeDaemonState, - ServeDaemonStatus, -} from "./daemon"; export type ServeCapletsOptions = { raw: RawServeOptions; diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index e494fb7a..765490e8 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -114,16 +114,6 @@ export function resolveServeOptions( }; } -export function resolveDaemonServeOptions( - raw: RawServeOptions, - env: ServeEnv = process.env, -): HttpServeOptions { - if (raw.transport !== undefined && raw.transport !== "http") { - throw new CapletsError("REQUEST_INVALID", "Daemonized serve requires --transport http."); - } - return resolveServeOptions({ ...raw, transport: "http" }, env) as HttpServeOptions; -} - export function isLoopbackHost(host: string): boolean { const normalized = host.toLocaleLowerCase(); return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 2176a6a6..d84f6d80 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -76,6 +76,15 @@ describe("CLI completion resolver", () => { "list", "refresh", ]); + await expect(completeCliWords(["daemon", ""])).resolves.toEqual([ + "install", + "uninstall", + "start", + "restart", + "stop", + "status", + "logs", + ]); await expect(completeCliWords(["completion", ""])).resolves.toEqual([ "bash", "zsh", diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index de63c8f6..8332d220 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -1,119 +1,236 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join, posix, win32 } from "node:path"; +import { dirname, join, posix, win32 } from "node:path"; import { describe, expect, it } from "vitest"; import { runCli } from "../src/cli"; import type { CapletsError } from "../src/errors"; import { - buildDaemonPlatformDescriptor, + createNativeDaemonManager, + daemonServeArgs, + daemonLogs, daemonStatus, - disableDaemon, - enableDaemon, - resolveServeDaemonPaths, + installDaemon, + resolveDaemonHttpServeOptions, + resolveDaemonPaths, restartDaemon, startDaemon, stopDaemon, - type DaemonProcessRunner, -} from "../src/serve"; - -describe("caplets serve daemon CLI", () => { - it("shows daemon subcommand help", async () => { - const out: string[] = []; - - await runCli(["serve", "start", "--help"], { writeOut: (value) => out.push(value) }); - await runCli(["serve", "status", "--help"], { writeOut: (value) => out.push(value) }); - - const text = out.join(""); - expect(text).toContain("Start the default Caplets HTTP daemon."); - expect(text).toContain("Show the default Caplets HTTP daemon status."); - expect(text).toContain("--transport "); - }); - - it("rejects stdio daemon start", async () => { - await expect( - runCli(["serve", "start", "--transport", "stdio"], { writeErr: () => {} }), - ).rejects.toThrow( - expect.objectContaining({ - code: "REQUEST_INVALID", - message: "Daemonized serve requires --transport http.", - }) as CapletsError, - ); + uninstallDaemon, + type DaemonCommandRunner, + type DaemonManager, +} from "../src/daemon"; +import { serviceCommand } from "../src/daemon/shell"; +import { allocateLoopbackPort, validateDaemonCommand } from "../src/daemon/validation"; + +describe("caplets daemon CLI", () => { + it("shows daemon help and removes daemon lifecycle from serve help", async () => { + const daemonOut: string[] = []; + const serveOut: string[] = []; + + await runCli(["daemon", "--help"], { writeOut: (value) => daemonOut.push(value) }); + await runCli(["serve", "--help"], { writeOut: (value) => serveOut.push(value) }); + + const daemonHelp = daemonOut.join(""); + expect(daemonHelp).toContain("install"); + expect(daemonHelp).toContain("uninstall"); + expect(daemonHelp).toContain("start"); + expect(daemonHelp).toContain("restart"); + expect(daemonHelp).toContain("stop"); + expect(daemonHelp).toContain("status"); + expect(daemonHelp).toContain("logs"); + + const serveHelp = serveOut.join(""); + expect(serveHelp).not.toContain("enable"); + expect(serveHelp).not.toContain("disable"); + expect(serveHelp).not.toContain("Start the default Caplets HTTP daemon"); }); - it("defaults daemon start to HTTP", async () => { + it("rejects daemon install transport before writing artifacts", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-")); - const out: string[] = []; try { - await runCli(["serve", "start"], { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - writeOut: (value) => out.push(value), - daemon: { - process: fakeProcessRunner({ running: false, pid: 1200 }), - }, - }); + await expect( + runCli(["daemon", "install", "--transport", "http"], { + env: testEnv(dir), + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); - expect(out.join("")).toContain("Started Caplets HTTP daemon on 127.0.0.1:5387."); - const config = JSON.parse( - readFileSync(join(dir, "config", "caplets", "serve", "default.json"), "utf8"), - ) as { serve: { transport: string } }; - expect(config.serve.transport).toBe("http"); + expect(existsSync(join(dir, "config", "caplets", "daemon", "default.json"))).toBe(false); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("prints redacted JSON status", async () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-")); - const out: string[] = []; + it("moves removed serve daemon subcommands to daemon guidance", async () => { + await expect(runCli(["serve", "start"], { writeErr: () => {} })).rejects.toThrow( + /Use caplets daemon start/u, + ); + await expect(runCli(["serve", "enable"], { writeErr: () => {} })).rejects.toThrow( + /Use caplets daemon install/u, + ); + }); + + it("does not expose enable or disable aliases", async () => { + await expect(runCli(["daemon", "enable"], { writeErr: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + await expect(runCli(["daemon", "disable"], { writeErr: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); + + it("maps --no-restart through daemon install option validation", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-no-restart-")); + try { + await expect( + runCli(["daemon", "install", "--start", "--no-restart", "--dry-run"], { + env: testEnv(dir), + writeErr: () => {}, + }), + ).rejects.toThrow(/--start, --restart, and --no-restart are mutually exclusive/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps daemon install --json noninteractive", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-json-")); try { - await startDaemon( - { password: "super-secret-password" }, + const out: string[] = []; + const runner = fakeRunner({ active: true }); + await installDaemon( + { validate: false }, { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - process: fakeProcessRunner({ running: false, pid: 1300 }), + env: testEnv(dir), + platform: "linux", + commandRunner: runner, }, ); - await runCli(["serve", "status", "--json"], { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, + await expect( + runCli(["daemon", "install", "--json"], { + env: testEnv(dir), + writeOut: (value) => out.push(value), + writeErr: () => {}, + readStdin: async () => "y\n", + daemon: { platform: "linux", commandRunner: runner }, + }), + ).rejects.toThrow(/rerun with --restart, --start, or --no-restart/u); + + expect(out.join("")).toBe(""); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports daemon start as a restart when the service is already running", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-start-restart-")); + try { + const out: string[] = []; + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => new Response("ok"), + }; + await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await runCli(["daemon", "start"], { + env: testEnv(dir), writeOut: (value) => out.push(value), - daemon: { - process: fakeProcessRunner({ running: true, pid: 1300 }), - }, + daemon: options, }); - const status = JSON.parse(out.join("")) as { - config: { serve: { auth: { password: string } } }; + expect(out.join("")).toBe("Restarted Caplets daemon.\n"); + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("redacts daemon install --json config and descriptors", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-json-redaction-")); + try { + const out: string[] = []; + + await runCli( + [ + "daemon", + "install", + "--json", + "--dry-run", + "--user", + "alice", + "--password", + 'pa"$USER', + "--env", + 'TOKEN=a&b"c', + "--env", + "QUOTE=a'b", + "--inherit-env", + ], + { + env: { ...testEnv(dir), SHELL: "/bin/bash" }, + writeOut: (value) => out.push(value), + writeErr: () => {}, + daemon: { platform: "linux", commandRunner: fakeRunner() }, + }, + ); + + const serialized = out.join(""); + const parsed = JSON.parse(serialized) as { + descriptor?: { contents?: string }; }; - expect(status.config.serve.auth.password).toBe("[REDACTED]"); - expect(out.join("")).not.toContain("super-secret-password"); + expect(serialized).toContain("[redacted]"); + expect(serialized).not.toContain('pa"$USER'); + expect(serialized).not.toContain('a&b"c'); + expect(parsed.descriptor?.contents).not.toContain('pa\\"$$USER'); + expect(parsed.descriptor?.contents).not.toContain('a&b\\"c'); + expect(parsed.descriptor?.contents).not.toContain("a'\\\\''b"); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); -describe("serve daemon paths", () => { - it("uses XDG state and config roots for macOS and Linux", () => { - const paths = resolveServeDaemonPaths({ +describe("daemon paths and config", () => { + it("uses daemon/default paths on macOS and Linux", () => { + const paths = resolveDaemonPaths({ env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, home: "/home/alice", platform: "linux", }); - expect(paths.stateFile).toBe(posix.join("/state", "caplets", "serve", "default", "state.json")); - expect(paths.pidFile).toBe(posix.join("/state", "caplets", "serve", "default", "server.pid")); + expect(paths.stateFile).toBe( + posix.join("/state", "caplets", "daemon", "default", "state.json"), + ); expect(paths.stdoutLog).toBe( - posix.join("/state", "caplets", "serve", "default", "logs", "stdout.log"), + posix.join("/state", "caplets", "daemon", "default", "logs", "stdout.log"), ); - expect(paths.stderrLog).toBe( - posix.join("/state", "caplets", "serve", "default", "logs", "stderr.log"), + expect(paths.configFile).toBe(posix.join("/config", "caplets", "daemon", "default.json")); + expect(paths.descriptorFile).toBe( + posix.join("/config", "systemd", "user", "caplets-daemon-default.service"), ); - expect(paths.configFile).toBe(posix.join("/config", "caplets", "serve", "default.json")); }); - it("uses LOCALAPPDATA state and APPDATA config roots for Windows", () => { - const paths = resolveServeDaemonPaths({ + it("uses daemon/default paths on Windows", () => { + const paths = resolveDaemonPaths({ env: { APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local", @@ -127,249 +244,2139 @@ describe("serve daemon paths", () => { "C:\\Users\\Alice\\AppData\\Local", "Caplets", "State", - "serve", + "daemon", "default", "state.json", ), ); - expect(paths.pidFile).toBe( - win32.join( - "C:\\Users\\Alice\\AppData\\Local", - "Caplets", - "State", - "serve", - "default", - "server.pid", - ), - ); - expect(paths.stdoutLog).toBe( - win32.join( - "C:\\Users\\Alice\\AppData\\Local", - "Caplets", - "State", - "serve", - "default", - "logs", - "stdout.log", - ), - ); expect(paths.configFile).toBe( - win32.join("C:\\Users\\Alice\\AppData\\Roaming", "Caplets", "serve", "default.json"), + win32.join("C:\\Users\\Alice\\AppData\\Roaming", "Caplets", "daemon", "default.json"), ); }); -}); -describe("serve daemon lifecycle", () => { - it("starts, reports status, and stops the default instance", async () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); - const process = fakeProcessRunner({ running: false, pid: 1400 }); - try { - const options = { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - process, - }; - - const started = await startDaemon({ port: "5480", password: "secret-password" }, options); - expect(started.status.running).toBe(true); - expect(started.status.pid).toBe(1400); - expect(process.starts[0]?.args).toEqual([ - "serve", - "--transport", - "http", - "--host", - "127.0.0.1", - "--port", - "5480", - "--path", - "/", - "--user", - "caplets", - "--password", - "secret-password", + it("installs with HTTP serve config, env overrides, and home working directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-install-")); + try { + const runner = fakeRunner(); + const result = await installDaemon( + { + host: "127.0.0.1", + port: "5480", + path: "/caplets", + env: ["NAME=value=with=equals", "EMPTY="], + inheritEnv: true, + validate: false, + }, + { + env: testEnv(dir), + home: "/home/alice", + platform: "linux", + commandRunner: runner, + }, + ); + + expect(result.config.serve.transport).toBe("http"); + expect(result.config.serve.port).toBe(5480); + expect(result.config.command.workingDirectory).toBe("/home/alice"); + expect(result.config.env.values).toMatchObject({ NAME: "value=with=equals", EMPTY: "" }); + expect(result.config.env.inherit).toBe(true); + expect(result.descriptor.kind).toBe("systemd-user"); + expect(readFileSync(result.config.paths.configFile, "utf8")).toContain( + '"instance": "default"', + ); + expect(runner.commands.slice(0, 2)).toEqual([ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "caplets-daemon-default.service"], ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); - const status = await daemonStatus({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), - }); - expect(status.running).toBe(true); - expect(status.config?.serve.port).toBe(5480); + it("surfaces failed health checks in plain daemon status", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-status-health-")); + try { + const out: string[] = []; + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => new Response("nope", { status: 503 }), + }; + await installDaemon({ validate: false }, options); - const stopped = await stopDaemon({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), + await runCli(["daemon", "status"], { + env: testEnv(dir), + writeOut: (value) => out.push(value), + daemon: options, }); - expect(stopped.status.running).toBe(false); - expect(stopped.status.pid).toBeUndefined(); - const afterStop = await daemonStatus({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), - }); - expect(afterStop.running).toBe(false); + expect(out.join("")).toMatch( + /^Caplets daemon is running \(running\)\.\nHealth check failed for http:\/\/127\.0\.0\.1:\d+\/v1\/healthz with HTTP 503\.\n$/u, + ); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("fails start when already running and lets restart apply config changes", async () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); + it("preserves selected config paths in the daemon service environment", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-config-env-")); try { - const env = { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }; - await startDaemon( - { port: "5480" }, - { env, process: fakeProcessRunner({ running: false, pid: 1400 }) }, + const configPath = join(dir, "selected", "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + + const explicit = await installDaemon( + { validate: false, dryRun: true }, + { + env: { + ...testEnv(dir), + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + }, + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, ); + expect(explicit.config.command.env).toMatchObject({ + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + }); - await expect( - startDaemon( - { port: "5481" }, - { env, process: fakeProcessRunner({ running: true, pid: 1400 }) }, - ), - ).rejects.toThrow("Caplets HTTP daemon is already running."); + const xdg = await installDaemon( + { validate: false, dryRun: true }, + { + env: testEnv(join(dir, "xdg")), + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + expect(xdg.config.command.env.CAPLETS_CONFIG).toBe( + join(dir, "xdg", "config", "caplets", "config.json"), + ); + expect(xdg.config.command.env.XDG_CONFIG_HOME).toBe(join(dir, "xdg", "config")); + expect(xdg.config.command.env.XDG_STATE_HOME).toBe(join(dir, "xdg", "state")); - const process = fakeProcessRunner({ running: true, pid: 1400, nextPid: 1401 }); - const restarted = await restartDaemon({ port: "5481" }, { env, process }); + const windows = await installDaemon( + { validate: false, dryRun: true }, + { + env: { + APPDATA: win32.join(dir, "AppData", "Roaming"), + LOCALAPPDATA: win32.join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + expect(windows.config.command.env.CAPLETS_CONFIG).toBe( + win32.join(dir, "AppData", "Roaming", "caplets", "config.json"), + ); + expect(windows.config.command.env.APPDATA).toBe(win32.join(dir, "AppData", "Roaming")); + expect(windows.config.command.env.LOCALAPPDATA).toBe(win32.join(dir, "AppData", "Local")); - expect(restarted.status.running).toBe(true); - expect(restarted.status.pid).toBe(1401); - expect(process.stops).toEqual([1400]); - expect(process.starts[0]?.args).toContain("5481"); + const overridden = await installDaemon( + { validate: false, dryRun: true, env: ["CAPLETS_CONFIG=/explicit/service.json"] }, + { + env: { ...testEnv(dir), CAPLETS_CONFIG: configPath }, + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + expect(overridden.config.command.env.CAPLETS_CONFIG).toBe("/explicit/service.json"); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it("enables and disables the platform service without installing in tests", async () => { - const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); + it("does not emit auth flags for default unauthenticated loopback serve", () => { + const serve = resolveDaemonHttpServeOptions({}); + + expect(daemonServeArgs(serve)).not.toContain("--user"); + expect(daemonServeArgs(serve)).not.toContain("--password"); + }); + + it("validates updates to running daemons on a temporary loopback port", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-")); try { - const env = { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }; - await startDaemon( - { port: "5482" }, - { env, process: fakeProcessRunner({ running: false, pid: 1400 }) }, - ); + const options = { + env: testEnv(dir), + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), + }; + await installDaemon({ port: "5480", validate: false }, options); - const enabled = await enableDaemon({ env, platform: "linux", serviceAvailable: true }); - expect(enabled.enabled).toBe(true); - expect(enabled.descriptor.kind).toBe("systemd-user"); + const validatedPorts: number[] = []; + await installDaemon( + { + port: "5480", + noRestart: true, + }, + { + ...options, + validateCommand: async (config) => { + validatedPorts.push(config.serve.port); + expect(config.serve.host).toBe("127.0.0.1"); + return { ok: true, url: `http://127.0.0.1:${config.serve.port}/v1/healthz` }; + }, + }, + ); - const disabled = await disableDaemon({ env, platform: "linux", serviceAvailable: true }); - expect(disabled.enabled).toBe(false); - expect(disabled.descriptor.kind).toBe("systemd-user"); + expect(validatedPorts).toHaveLength(1); + expect(validatedPorts[0]).not.toBe(5480); } finally { rmSync(dir, { recursive: true, force: true }); } }); -}); -describe("serve daemon platform descriptors", () => { - it("describes a macOS launchd user agent", () => { - const descriptor = buildDaemonPlatformDescriptor({ - platform: "darwin", - paths: resolveServeDaemonPaths({ - env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, - home: "/Users/alice", - platform: "darwin", - }), - command: { executable: "/usr/local/bin/caplets", args: ["serve", "--transport", "http"] }, - }); + it("retries temporary validation ports when an update validation fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-retry-")); + try { + const options = { + env: testEnv(dir), + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), + }; + await installDaemon({ port: "5480", validate: false }, options); + + const validatedPorts: number[] = []; + await installDaemon( + { + port: "5480", + noRestart: true, + }, + { + ...options, + validateCommand: async (config) => { + validatedPorts.push(config.serve.port); + return validatedPorts.length === 1 + ? { ok: false, url: `http://127.0.0.1:${config.serve.port}/v1/healthz` } + : { ok: true, url: `http://127.0.0.1:${config.serve.port}/v1/healthz` }; + }, + }, + ); - expect(descriptor.kind).toBe("launchd-user-agent"); - if (descriptor.kind !== "launchd-user-agent") throw new Error("expected launchd descriptor"); - expect(descriptor.label).toBe("dev.caplets.serve.default"); - expect(descriptor.plist).toContain("Label"); - expect(descriptor.plist).toContain("dev.caplets.serve.default"); - expect(descriptor.plist).toContain("/usr/local/bin/caplets"); + expect(validatedPorts).toHaveLength(2); + expect(validatedPorts[0]).not.toBe(5480); + expect(validatedPorts[1]).not.toBe(5480); + expect(validatedPorts[1]).not.toBe(validatedPorts[0]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("describes a Linux systemd user service when available", () => { - const descriptor = buildDaemonPlatformDescriptor({ - platform: "linux", - serviceAvailable: true, - paths: resolveServeDaemonPaths({ - env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, + it("validates stale-config recovery installs on a temporary loopback port", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-stale-config-")); + try { + const options = { + env: testEnv(dir), home: "/home/alice", - platform: "linux", - }), - command: { executable: "/usr/bin/caplets", args: ["serve", "--transport", "http"] }, - }); + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), + }; + const installed = await installDaemon({ port: "5480", validate: false }, options); + rmSync(installed.config.paths.configFile, { force: true }); + + const validatedPorts: number[] = []; + await installDaemon( + { + port: "5480", + noRestart: true, + }, + { + ...options, + validateCommand: async (config) => { + validatedPorts.push(config.serve.port); + return { ok: true, url: `http://127.0.0.1:${config.serve.port}/v1/healthz` }; + }, + }, + ); - expect(descriptor.kind).toBe("systemd-user"); - if (descriptor.kind !== "systemd-user") throw new Error("expected systemd descriptor"); - expect(descriptor.unitName).toBe("caplets-serve-default.service"); - expect(descriptor.unit).toContain("[Service]"); - expect(descriptor.unit).toContain("ExecStart=/usr/bin/caplets serve --transport http"); + expect(validatedPorts).toHaveLength(1); + expect(validatedPorts[0]).not.toBe(5480); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("describes a Linux fallback when systemd user services are unavailable", () => { - const descriptor = buildDaemonPlatformDescriptor({ - platform: "linux", - serviceAvailable: false, - paths: resolveServeDaemonPaths({ - env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, - home: "/home/alice", - platform: "linux", - }), - command: { executable: "/usr/bin/caplets", args: ["serve", "--transport", "http"] }, - }); + it("renders native daemon identities for launchd, systemd, and Windows tasks", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-descriptor-")); + try { + const common = { + host: "127.0.0.1", + port: "5480", + validate: false, + dryRun: true, + }; + const launchd = await installDaemon(common, { + env: testEnv(dir), + home: "/Users/alice", + platform: "darwin", + commandRunner: fakeRunner(), + }); + expect(launchd.descriptor.kind).toBe("launchd-user-agent"); + expect(launchd.descriptor.path).toContain("dev.caplets.daemon.default.plist"); + if (launchd.descriptor.kind !== "launchd-user-agent") throw new Error("expected launchd"); + expect(launchd.descriptor.contents).toContain("dev.caplets.daemon.default"); + expect(launchd.descriptor.contents).toContain("RunAtLoad"); + expect(launchd.descriptor.contents).toContain("KeepAlive"); + expect(launchd.descriptor.contents).toContain("WorkingDirectory"); - expect(descriptor.kind).toBe("manual"); - if (descriptor.kind !== "manual") throw new Error("expected manual descriptor"); - expect(descriptor.reason).toContain("systemd user service is not available"); - }); + const systemd = await installDaemon( + { ...common, env: ["PATH=/custom/bin", "MULTI=line\nExecStartPre=/bin/false"] }, + { + env: testEnv(dir), + home: "/home/alice with space", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + expect(systemd.descriptor.kind).toBe("systemd-user"); + if (systemd.descriptor.kind !== "systemd-user") throw new Error("expected systemd"); + expect(systemd.descriptor.unitName).toBe("caplets-daemon-default.service"); + expect(systemd.descriptor.contents).toContain('WorkingDirectory="/home/alice with space"'); + expect(systemd.descriptor.contents).toContain('Environment="PATH=/custom/bin"'); + expect(systemd.descriptor.contents).toContain( + 'Environment="MULTI=line\\nExecStartPre=/bin/false"', + ); + expect(systemd.descriptor.contents).not.toContain("\nExecStartPre=/bin/false"); + const systemdWithCarriageReturn = await installDaemon( + { ...common, env: ["MULTI=line\rExecStartPre=/bin/false"] }, + { + env: testEnv(join(dir, "carriage-return")), + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + expect(systemdWithCarriageReturn.descriptor.kind).toBe("systemd-user"); + if (systemdWithCarriageReturn.descriptor.kind !== "systemd-user") + throw new Error("expected systemd"); + expect(systemdWithCarriageReturn.descriptor.contents).toContain( + 'Environment="MULTI=line\\rExecStartPre=/bin/false"', + ); + const systemdWithDollar = await installDaemon( + { + ...common, + password: "pa$USER", + allowUnauthenticatedHttp: false, + env: ["TOKEN=pa$USER"], + }, + { + env: testEnv(join(dir, "dollar")), + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + expect(systemdWithDollar.descriptor.kind).toBe("systemd-user"); + if (systemdWithDollar.descriptor.kind !== "systemd-user") throw new Error("expected systemd"); + expect(systemdWithDollar.descriptor.contents).toContain("pa$$USER"); + expect(systemdWithDollar.descriptor.contents).toContain('Environment="TOKEN=pa$USER"'); + expect(systemdWithDollar.descriptor.contents).not.toContain('Environment="TOKEN=pa$$USER"'); - it("describes a Windows per-user Scheduled Task command plan", () => { - const descriptor = buildDaemonPlatformDescriptor({ - platform: "win32", - paths: resolveServeDaemonPaths({ + const windows = await installDaemon(common, { env: { - APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local", + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), }, home: "C:\\Users\\Alice", platform: "win32", - }), + commandRunner: fakeRunner(), + }); + expect(windows.descriptor.kind).toBe("windows-scheduled-task"); + if (windows.descriptor.kind !== "windows-scheduled-task") throw new Error("expected task"); + expect(windows.descriptor.taskName).toBe("\\Caplets\\daemon-default"); + expect(windows.descriptor.xml).toContain(windows.descriptor.wrapper.path); + expect(windows.descriptor.xml).toContain(""); + expect(windows.descriptor.xml).toContain(''); + expect(windows.descriptor.xml).toContain("InteractiveToken"); + expect(windows.descriptor.xml).toContain("PT0S"); + expect(windows.descriptor.xml).toContain(""); + expect(windows.descriptor.wrapper.contents).toContain(">> "); + expect(windows.descriptor.wrapper.contents).toContain("2>> "); + + const escapedWindows = await installDaemon( + { ...common, password: "pa%USERNAME%ss", allowUnauthenticatedHttp: false }, + { + env: { + APPDATA: join(dir, "Escaped", "Roaming"), + LOCALAPPDATA: join(dir, "Escaped", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + expect(escapedWindows.descriptor.kind).toBe("windows-scheduled-task"); + if (escapedWindows.descriptor.kind !== "windows-scheduled-task") + throw new Error("expected task"); + expect(escapedWindows.descriptor.wrapper.contents).toContain("pa%%USERNAME%%ss"); + + await expect( + installDaemon( + { ...common, env: ["EMPTY="] }, + { + env: { + APPDATA: win32.join(dir, "AppData", "Roaming"), + LOCALAPPDATA: win32.join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ), + ).rejects.toThrow(/environment values cannot be empty/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects unsafe Windows wrapper environment values", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-env-")); + try { + await expect( + installDaemon( + { env: ["TOKEN=abc\r\nwhoami"], validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ), + ).rejects.toThrow(/cannot contain/u); + + await expect( + installDaemon( + { env: ['TOKEN=a"b'], validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ), + ).rejects.toThrow(/cannot contain/u); + + const descriptor = await installDaemon( + { env: ["TOKEN=%PATH%"], validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + expect(descriptor.descriptor.kind).toBe("windows-scheduled-task"); + if (descriptor.descriptor.kind !== "windows-scheduled-task") throw new Error("expected task"); + expect(descriptor.descriptor.wrapper.contents).toContain("TOKEN=%%PATH%%"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects unsafe Windows wrapper command arguments", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-args-")); + try { + await expect( + installDaemon( + { + password: "secret\r\nwhoami", + allowUnauthenticatedHttp: false, + validate: false, + dryRun: true, + }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ), + ).rejects.toThrow(/wrapper arguments cannot contain/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("quotes inherited Windows shell commands for cmd and PowerShell", () => { + const cmd = serviceCommand({ + command: { + executable: "C:\\Program Files\\Caplets\\cli.js", + args: ["serve", "--password", "pa ss"], + env: { PATH: "C:\\Tools" }, + shell: { executable: "cmd.exe", args: ["/d", "/s", "/c"] }, + }, + }); + expect(cmd.args.at(-1)).toContain('set "PATH=C:\\Tools"&& '); + expect(cmd.args.at(-1)).toContain('"C:\\Program Files\\Caplets\\cli.js"'); + expect(cmd.args.at(-1)).toContain('"pa ss"'); + expect(cmd.args.at(-1)).not.toContain("'C:\\Program Files"); + + const powerShell = serviceCommand({ + command: { + executable: "C:\\Program Files\\Caplets\\cli.js", + args: ["serve", "--password", "pa ss"], + env: { PATH: "C:\\Tools" }, + shell: { + executable: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: ["-NoProfile", "-Command"], + }, + }, + }); + expect(powerShell.args.at(-1)).toContain("$env:PATH = 'C:\\Tools'; & "); + expect(powerShell.args.at(-1)).toContain("& "); + expect(powerShell.args.at(-1)).toContain("'C:\\Program Files\\Caplets\\cli.js'"); + + const bash = serviceCommand({ + command: { + executable: "/usr/local/bin/caplets", + args: ["serve"], + env: { PATH: "/custom/bin" }, + shell: { executable: "/bin/bash", args: ["-lc"] }, + }, + }); + expect(bash.args.at(-1)).toContain("export PATH=/custom/bin; exec "); + expect(bash.args.at(-1)).toContain(" /usr/local/bin/caplets serve"); + + const shellWithSlashC = serviceCommand({ command: { - executable: "C:\\Program Files\\nodejs\\caplets.cmd", - args: ["serve", "--transport", "http"], + executable: "/usr/local/bin/caplets", + args: ["serve", "--password", "pa ss"], + env: { TOKEN: "value" }, + shell: { executable: "/usr/bin/custom-shell", args: ["/c"] }, }, }); + expect(shellWithSlashC.args.at(-1)).toContain("export TOKEN=value; exec "); + expect(shellWithSlashC.args.at(-1)).toContain("'pa ss'"); - expect(descriptor.kind).toBe("windows-scheduled-task"); - if (descriptor.kind !== "windows-scheduled-task") - throw new Error("expected scheduled task descriptor"); - expect(descriptor.taskName).toBe("Caplets Serve Default"); - expect(descriptor.commands.register).toContain("schtasks"); - expect(descriptor.commands.register).toContain("/SC ONLOGON"); - expect(descriptor.commands.register).toContain("caplets.cmd"); + expect(() => + serviceCommand({ + command: { + executable: "C:\\Program Files\\Caplets\\cli.js", + args: ["serve"], + env: { TOKEN: 'a"b' }, + shell: { executable: "cmd.exe", args: ["/d", "/s", "/c"] }, + }, + }), + ).toThrow(/cannot contain/u); }); -}); -function fakeProcessRunner(initial: { - running: boolean; - pid: number; - nextPid?: number; -}): DaemonProcessRunner & { - starts: Array<{ args: string[]; stdoutLog: string; stderrLog: string }>; - stops: number[]; -} { - let running = initial.running; - let pid = initial.pid; - const starts: Array<{ args: string[]; stdoutLog: string; stderrLog: string }> = []; - const stops: number[] = []; - return { - starts, - stops, - isRunning: async (candidate) => running && candidate === pid, - start: async (command) => { - pid = initial.nextPid ?? pid; - running = true; - starts.push(command); - return pid; - }, - stop: async (candidate) => { - stops.push(candidate); - running = false; + it("loads PowerShell profiles when using Windows inherited env fallback", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-powershell-")); + try { + const result = await installDaemon( + { inheritEnv: true, validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + + expect(result.config.command.shell).toMatchObject({ + executable: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: ["-Command"], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves env-derived public origins in the service environment", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-public-origin-")); + try { + const result = await installDaemon( + { validate: false, dryRun: true, allowUnauthenticatedHttp: true }, + { + env: { ...testEnv(dir), CAPLETS_SERVER_URL: "https://caplets.example.com/daemon" }, + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + + expect(result.config.serve.publicOrigin).toBe("https://caplets.example.com"); + expect(result.config.command.env.CAPLETS_SERVER_URL).toBe("https://caplets.example.com"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves public origins across daemon config updates", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-update-origin-")); + try { + const options = { + env: { ...testEnv(dir), CAPLETS_SERVER_URL: "https://caplets.example.com/daemon" }, + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + await installDaemon({ validate: false, allowUnauthenticatedHttp: true }, options); + + const updated = await installDaemon( + { validate: false, env: ["FOO=bar"], noRestart: true }, + { + ...options, + env: testEnv(dir), + }, + ); + + expect(updated.config.serve.publicOrigin).toBe("https://caplets.example.com"); + expect(updated.config.command.env.CAPLETS_SERVER_URL).toBe("https://caplets.example.com"); + expect(updated.config.env.values.FOO).toBe("bar"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves disabled auth across daemon config updates", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-update-no-auth-")); + try { + const options = { + env: testEnv(dir), + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + await installDaemon({ validate: false }, options); + + const updated = await installDaemon( + { validate: false, env: ["FOO=bar"], noRestart: true }, + { + ...options, + env: { + ...testEnv(dir), + CAPLETS_SERVER_USER: "alice", + CAPLETS_SERVER_PASSWORD: "secret", + }, + }, + ); + + expect(updated.config.serve.auth.enabled).toBe(false); + expect(updated.config.command.args).not.toContain("--password"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses non-login inherited shell mode for /bin/sh", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-sh-")); + try { + const result = await installDaemon( + { inheritEnv: true, validate: false, dryRun: true }, + { + env: { ...testEnv(dir), SHELL: "/bin/sh" }, + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + + expect(result.config.command.shell).toMatchObject({ executable: "/bin/sh", args: ["-c"] }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores POSIX SHELL values for Windows inheritance", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-shell-")); + try { + const result = await installDaemon( + { inheritEnv: true, validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + SHELL: "/usr/bin/bash", + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + + expect(result.config.command.shell).toMatchObject({ + executable: "cmd.exe", + args: ["/d", "/s", "/c"], + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("discovers the POSIX account shell before falling back to sh", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-account-shell-")); + try { + const result = await installDaemon( + { inheritEnv: true, validate: false, dryRun: true }, + { + env: testEnv(dir), + accountShell: "/bin/zsh", + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + + expect(result.config.command.shell).toMatchObject({ + executable: "/bin/zsh", + args: ["-lc"], + source: "account", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects temporary CLI runner paths for daemon installs", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-runner-")); + const originalArgv = process.argv[1]; + try { + const transientCli = join(dir, "dlx-12345", "node_modules", ".bin", "caplets"); + mkdirSync(join(dir, "dlx-12345", "node_modules", ".bin"), { recursive: true }); + writeFileSync(transientCli, "#!/usr/bin/env node\n"); + process.argv[1] = transientCli; + + await expect( + installDaemon( + { validate: false, dryRun: true }, + { + env: testEnv(dir), + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ), + ).rejects.toThrow(/temporary runner/u); + } finally { + if (originalArgv === undefined) process.argv.splice(1, 1); + else process.argv[1] = originalArgv; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rolls back descriptor files when native registration fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-rollback-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(join(dir, "config", "systemd", "user"), { recursive: true }); + writeFileSync(paths.descriptorFile, "old descriptor\n"); + chmodSync(paths.descriptorFile, 0o600); + const runner: DaemonCommandRunner = { + async exec() { + return { stdout: "", stderr: "boom", code: 1 }; + }, + }; + + await expect( + installDaemon( + { validate: false }, + { env: testEnv(dir), home: "/home/alice", platform: "linux", commandRunner: runner }, + ), + ).rejects.toThrow(/systemd registration failed/u); + + expect(readFileSync(paths.descriptorFile, "utf8")).toBe("old descriptor\n"); + expect(statSync(paths.descriptorFile).mode & 0o777).toBe(0o600); + expect(existsSync(paths.configFile)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rolls back descriptor files when descriptor writing fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-write-rollback-")); + const runner = fakeRunner(); + const manager = createNativeDaemonManager({ platform: "win32", commandRunner: runner }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + manager, + }; + const paths = resolveDaemonPaths(options); + try { + mkdirSync(dirname(paths.descriptorFile), { recursive: true }); + mkdirSync(paths.logDir, { recursive: true }); + writeFileSync(paths.descriptorFile, "old descriptor\n"); + writeFileSync(paths.stdoutLog, ""); + writeFileSync(paths.stderrLog, ""); + chmodSync(paths.stateDir, 0o500); + + await expect(installDaemon({ validate: false }, options)).rejects.toThrow(); + + expect(readFileSync(paths.descriptorFile, "utf8")).toBe("old descriptor\n"); + expect(existsSync(paths.configFile)).toBe(false); + expect(runner.commands).not.toContainEqual([ + "schtasks", + "/Create", + "/TN", + "\\Caplets\\daemon-default", + "/XML", + paths.descriptorFile, + "/F", + ]); + } finally { + chmodSync(paths.stateDir, 0o700); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("enforces private descriptor modes when rewriting existing descriptors", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-descriptor-mode-")); + try { + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + const installed = await installDaemon({ validate: false }, options); + chmodSync(installed.config.paths.descriptorFile, 0o644); + + await installDaemon({ validate: false, env: ["TOKEN=secret"] }, options); + + expect(statSync(installed.config.paths.descriptorFile).mode & 0o777).toBe(0o600); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reloads systemd after restoring a failed install", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-rollback-reload-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(dirname(paths.descriptorFile), { recursive: true }); + writeFileSync(paths.descriptorFile, "old descriptor\n"); + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (command === "systemctl" && args.includes("enable")) { + return { stdout: "", stderr: "boom", code: 1 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + + await expect( + installDaemon( + { validate: false }, + { env: testEnv(dir), home: "/home/alice", platform: "linux", commandRunner: runner }, + ), + ).rejects.toThrow(/systemd registration failed/u); + + expect(readFileSync(paths.descriptorFile, "utf8")).toBe("old descriptor\n"); + expect(runner.commands.filter((command) => command.includes("daemon-reload"))).toHaveLength( + 2, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rolls back native installs when daemon state persistence fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-state-rollback-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + let uninstalled = false; + const manager: DaemonManager = { + descriptor: (config) => ({ + kind: "systemd-user", + unitName: "caplets-daemon-default.service", + path: config.paths.descriptorFile, + contents: "new descriptor\n", + }), + status: async () => ({ state: "not_installed", installed: false, running: false }), + install: async (config) => { + mkdirSync(dirname(config.paths.descriptorFile), { recursive: true }); + writeFileSync(config.paths.descriptorFile, "new descriptor\n"); + mkdirSync(config.paths.stateFile, { recursive: true }); + return { + action: "install", + native: { state: "installed_stopped", installed: true, running: false }, + descriptor: manager.descriptor(config), + }; + }, + uninstall: async (_config, rollbackPaths) => { + uninstalled = true; + rmSync(rollbackPaths.descriptorFile, { force: true }); + return { + action: "uninstall", + native: { state: "not_installed", installed: false, running: false }, + }; + }, + start: async () => ({ + action: "start", + native: { state: "running", installed: true, running: true }, + }), + restart: async () => ({ + action: "restart", + native: { state: "running", installed: true, running: true }, + }), + stop: async () => ({ + action: "stop", + native: { state: "installed_stopped", installed: true, running: false }, + }), + }; + + await expect( + installDaemon({ validate: false }, { env: testEnv(dir), manager }), + ).rejects.toThrow(); + + expect(uninstalled).toBe(true); + expect(existsSync(paths.descriptorFile)).toBe(false); + expect(existsSync(paths.configFile)).toBe(false); + expect(existsSync(paths.stateFile)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores old serve/default artifacts", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-ignore-")); + try { + const oldConfig = join(dir, "config", "caplets", "serve", "default.json"); + mkdirSync(join(dir, "config", "caplets", "serve"), { recursive: true }); + writeFileSync(oldConfig, "{}\n"); + const status = await daemonStatus({ + env: testEnv(dir), + platform: "linux", + commandRunner: fakeRunner(), + }); + expect(status.installed).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("daemon lifecycle and logs", () => { + it("status succeeds before install", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-status-")); + try { + const status = await daemonStatus({ + env: testEnv(dir), + platform: "linux", + commandRunner: fakeRunner(), + }); + expect(status.installed).toBe(false); + expect(status.running).toBe(false); + expect(status.nativeState).toBe("not_installed"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports systemd command failures as unavailable", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-systemd-unavailable-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(dirname(paths.descriptorFile), { recursive: true }); + writeFileSync(paths.descriptorFile, "unit\n"); + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "systemctl" && args.includes("show")) { + return { stdout: "", stderr: "Failed to connect to bus", code: 1 }; + } + return { stdout: "", stderr: "", code: 1 }; + }, + }; + + const status = await daemonStatus({ + env: testEnv(dir), + platform: "linux", + commandRunner: runner, + }); + + expect(status.nativeState).toBe("unavailable"); + expect(status.native.message).toContain("systemd --user is not available"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports launchd jobs with a current pid as running despite stale exit status", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-running-after-crash-")); + try { + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "launchctl" && args[0] === "print") { + return { stdout: "pid = 4242\nLastExitStatus = 1\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + home: dir, + platform: "darwin" as const, + uid: 501, + commandRunner: runner, + }; + await installDaemon({ validate: false }, options); + + const status = await daemonStatus(options); + + expect(status.nativeState).toBe("running"); + expect(status.running).toBe(true); + expect(status.native.pid).toBe(4242); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("treats not-found systemd units as uninstalled when only config remains", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-systemd-not-found-")); + try { + const installed = await installDaemon( + { validate: false }, + { env: testEnv(dir), platform: "linux", commandRunner: fakeRunner() }, + ); + rmSync(installed.config.paths.descriptorFile, { force: true }); + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "systemctl" && args.includes("show")) { + return { stdout: "LoadState=not-found\n", stderr: "", code: 0 }; + } + if (command === "systemctl" && args.includes("is-active")) { + return { stdout: "inactive\n", stderr: "", code: 3 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + + const status = await daemonStatus({ + env: testEnv(dir), + platform: "linux", + commandRunner: runner, + }); + + expect(status.installed).toBe(false); + expect(status.nativeState).toBe("not_installed"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("runtime start fails before install with install guidance", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-start-")); + try { + await expect( + startDaemon({ env: testEnv(dir), platform: "linux", commandRunner: fakeRunner() }), + ).rejects.toThrow(/caplets daemon install --start/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("stops native services when the local descriptor is missing but config remains", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-stale-descriptor-stop-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + rmSync(installed.config.paths.descriptorFile, { force: true }); + runner.commands.length = 0; + + await stopDaemon(options); + + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "stop", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("start restarts when the installed daemon is already running", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-restart-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => new Response("ok"), + }; + await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + const result = await startDaemon(options); + + expect(result.action).toBe("restart"); + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("install --start restarts an already-running daemon after updating it", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-install-start-restart-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => new Response("ok"), + }; + await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await installDaemon({ start: true, validate: false, env: ["NAME=new"] }, options); + + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails noninteractive running daemon updates before writing service changes", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-restart-decision-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + const previousConfig = readFileSync(installed.config.paths.configFile, "utf8"); + const previousDescriptor = readFileSync(installed.config.paths.descriptorFile, "utf8"); + runner.commands.length = 0; + + await expect(installDaemon({ validate: false, env: ["NAME=new"] }, options)).rejects.toThrow( + /rerun with --restart, --start, or --no-restart/u, + ); + + expect(readFileSync(installed.config.paths.configFile, "utf8")).toBe(previousConfig); + expect(readFileSync(installed.config.paths.descriptorFile, "utf8")).toBe(previousDescriptor); + expect(runner.commands).not.toContainEqual([ + "systemctl", + "--user", + "enable", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails reset updates to running daemons before writing service changes", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-reset-restart-decision-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ port: "5480", validate: false }, options); + const previousConfig = readFileSync(installed.config.paths.configFile, "utf8"); + const previousDescriptor = readFileSync(installed.config.paths.descriptorFile, "utf8"); + runner.commands.length = 0; + + await expect( + installDaemon({ reset: true, validate: false, env: ["NAME=new"] }, options), + ).rejects.toThrow(/rerun with --restart, --start, or --no-restart/u); + + expect(readFileSync(installed.config.paths.configFile, "utf8")).toBe(previousConfig); + expect(readFileSync(installed.config.paths.descriptorFile, "utf8")).toBe(previousDescriptor); + expect(runner.commands).not.toContainEqual([ + "systemctl", + "--user", + "enable", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("checks health after prompted restarts", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-prompt-restart-health-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + isInteractive: true, + readPrompt: async () => "y", + fetch: async () => new Response("nope", { status: 503 }), + healthTimeoutMs: 10, + healthIntervalMs: 1, + }; + await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await expect(installDaemon({ validate: false, env: ["NAME=new"] }, options)).rejects.toThrow( + /Native daemon health check failed/u, + ); + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "restart", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails start when the native service does not pass HTTP health", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-health-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => new Response("nope", { status: 503 }), + healthTimeoutMs: 10, + healthIntervalMs: 1, + }; + await installDaemon({ validate: false }, options); + + await expect(startDaemon(options)).rejects.toThrow(/Native daemon health check failed/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("waits for native health to become ready after start", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-health-retry-")); + try { + const runner = fakeRunner({ active: true }); + let healthCalls = 0; + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + fetch: async () => { + healthCalls += 1; + return new Response(healthCalls < 3 ? "nope" : "ok", { + status: healthCalls < 3 ? 503 : 200, + }); + }, + healthTimeoutMs: 1_000, + healthIntervalMs: 1, + }; + await installDaemon({ validate: false }, options); + + await startDaemon(options); + + expect(healthCalls).toBeGreaterThanOrEqual(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not bootstrap launchd during plain install", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-install-")); + try { + const runner = fakeRunner(); + await installDaemon( + { validate: false }, + { + env: testEnv(dir), + home: dir, + platform: "darwin", + uid: 501, + commandRunner: runner, + }, + ); + + expect(runner.commands).not.toContainEqual([ + "launchctl", + "bootstrap", + "gui/501", + resolveDaemonPaths({ env: testEnv(dir), platform: "darwin" }).descriptorFile, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reloads already bootstrapped launchd descriptors before start", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-start-")); + try { + let bootstrapAttempts = 0; + let running = false; + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (command === "launchctl" && args[0] === "bootstrap") { + bootstrapAttempts += 1; + return bootstrapAttempts === 1 + ? { stdout: "", stderr: "service already loaded", code: 37 } + : { stdout: "", stderr: "", code: 0 }; + } + if (command === "launchctl" && args[0] === "print") { + return running + ? { stdout: "pid = 4242\n", stderr: "", code: 0 } + : { stdout: "", stderr: "not found", code: 113 }; + } + if (command === "launchctl" && args[0] === "kickstart") { + running = true; + return { stdout: "", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + home: dir, + platform: "darwin" as const, + uid: 501, + commandRunner: runner, + fetch: async () => new Response("ok"), + }; + const installed = await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await startDaemon(options); + + expect(runner.commands).toContainEqual([ + "launchctl", + "bootout", + "gui/501", + installed.config.paths.descriptorFile, + ]); + expect(runner.commands.filter((command) => command[1] === "bootstrap")).toHaveLength(2); + expect(runner.commands).toContainEqual([ + "launchctl", + "kickstart", + "-k", + "gui/501/dev.caplets.daemon.default", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reloads launchd descriptors before restart", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-restart-")); + try { + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (command === "launchctl" && args[0] === "print") { + return { stdout: "pid = 4242\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + home: dir, + platform: "darwin" as const, + uid: 501, + commandRunner: runner, + fetch: async () => new Response("ok"), + }; + const installed = await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await restartDaemon(options); + + expect(runner.commands).toContainEqual([ + "launchctl", + "bootout", + "gui/501", + installed.config.paths.descriptorFile, + ]); + expect(runner.commands).toContainEqual([ + "launchctl", + "bootstrap", + "gui/501", + installed.config.paths.descriptorFile, + ]); + expect(runner.commands).toContainEqual([ + "launchctl", + "kickstart", + "-k", + "gui/501/dev.caplets.daemon.default", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("boots out launchd jobs on stop so KeepAlive does not relaunch them", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-stop-")); + try { + const runner = fakeRunner(); + const options = { + env: testEnv(dir), + home: dir, + platform: "darwin" as const, + uid: 501, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + runner.commands.length = 0; + + await stopDaemon(options); + + expect(runner.commands).toContainEqual([ + "launchctl", + "bootout", + "gui/501", + installed.config.paths.descriptorFile, + ]); + expect(runner.commands).not.toContainEqual([ + "launchctl", + "kill", + "TERM", + "gui/501/dev.caplets.daemon.default", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("boots out launchd by label when the plist is missing but config remains", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-launchd-repeat-uninstall-")); + try { + const runner = fakeRunner(); + const options = { + env: testEnv(dir), + home: dir, + platform: "darwin" as const, + uid: 501, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + + await uninstallDaemon({}, options); + runner.commands.length = 0; + const repeated = await uninstallDaemon({}, options); + + expect(repeated.native?.native.state).toBe("not_installed"); + expect(runner.commands).toContainEqual([ + "launchctl", + "bootout", + "gui/501/dev.caplets.daemon.default", + ]); + expect(existsSync(installed.config.paths.configFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails Windows restart when stopping the running task fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-restart-end-")); + let paths: ReturnType | undefined; + try { + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (command === "schtasks" && args.includes("/Query")) { + return { stdout: "Status: Running\nLast Run Result: 0\n", stderr: "", code: 0 }; + } + if (command === "schtasks" && args.includes("/End")) { + return { stdout: "", stderr: "Access is denied.", code: 1 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32" as const, + commandRunner: runner, + }; + paths = resolveDaemonPaths(options); + await installDaemon({ validate: false }, options); + + await expect(restartDaemon(options)).rejects.toThrow(/Scheduled Task restart failed/u); + + expect(runner.commands).not.toContainEqual([ + "schtasks", + "/Run", + "/TN", + "\\Caplets\\daemon-default", + ]); + } finally { + if (paths) { + for (const path of [ + paths.descriptorFile, + paths.wrapperFile, + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ]) { + rmSync(path, { force: true }); + } + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reads file-backed logs with stream labels", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-logs-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(paths.logDir, { recursive: true }); + writeFileSync(paths.stdoutLog, "out1\nout2\n"); + writeFileSync(paths.stderrLog, "err1\nerr2\n"); + + expect(daemonLogs({ env: testEnv(dir), platform: "linux", tail: 10 }).entries).toEqual([ + { stream: "stdout", line: "out1" }, + { stream: "stderr", line: "err1" }, + { stream: "stdout", line: "out2" }, + { stream: "stderr", line: "err2" }, + ]); + expect(daemonLogs({ env: testEnv(dir), platform: "linux", tail: 1 }).entries).toEqual([ + { stream: "stdout", line: "out2" }, + { stream: "stderr", line: "err2" }, + ]); + expect( + daemonLogs({ env: testEnv(dir), platform: "linux", stream: "stdout", tail: 10 }).entries, + ).toEqual([ + { stream: "stdout", line: "out1" }, + { stream: "stdout", line: "out2" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("tails daemon logs from the end of large files", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-large-logs-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(paths.logDir, { recursive: true }); + writeFileSync( + paths.stdoutLog, + `${Array.from({ length: 7_000 }, (_value, index) => `out${index}`).join("\n")}\nout-last\n`, + ); + writeFileSync( + paths.stderrLog, + `${Array.from({ length: 7_000 }, (_value, index) => `err${index}`).join("\n")}\nerr-last\n`, + ); + + expect(daemonLogs({ env: testEnv(dir), platform: "linux", tail: 1 }).entries).toEqual([ + { stream: "stdout", line: "out-last" }, + { stream: "stderr", line: "err-last" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uninstall purge removes descriptor, config, state, and logs", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-purge-")); + try { + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + const installed = await installDaemon({ validate: false }, options); + writeFileSync(installed.config.paths.stdoutLog, "out\n"); + writeFileSync(installed.config.paths.stderrLog, "err\n"); + + const result = await uninstallDaemon({ purge: true }, options); + + expect(result.purge).toBe(true); + expect(result.removed).not.toContain(installed.config.paths.wrapperFile); + expect(existsSync(installed.config.paths.descriptorFile)).toBe(false); + expect(existsSync(installed.config.paths.configFile)).toBe(false); + expect(existsSync(installed.config.paths.stateFile)).toBe(false); + expect(existsSync(installed.config.paths.stdoutLog)).toBe(false); + expect(existsSync(installed.config.paths.stderrLog)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports Windows wrapper cleanup during purge dry-run", async () => { + const result = await uninstallDaemon( + { purge: true, dryRun: true }, + { + env: { APPDATA: "C:\\Users\\Alice\\AppData\\Roaming" }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), + }, + ); + const paths = resolveDaemonPaths({ + env: { APPDATA: "C:\\Users\\Alice\\AppData\\Roaming" }, + home: "C:\\Users\\Alice", + platform: "win32", + }); + + expect(result.removed).toContain(paths.wrapperFile); + }); + + it("stops running native services during uninstall when config is missing", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-uninstall-stale-config-")); + try { + const runner = fakeRunner({ active: true }); + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + rmSync(installed.config.paths.configFile, { force: true }); + runner.commands.length = 0; + + await uninstallDaemon({}, options); + + expect(runner.commands).toContainEqual([ + "systemctl", + "--user", + "stop", + "caplets-daemon-default.service", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("sorts timestamped daemon logs across streams", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-timestamp-logs-")); + try { + const paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(paths.logDir, { recursive: true }); + writeFileSync( + paths.stdoutLog, + "2026-06-19T10:00:00.000Z out1\n2026-06-19T10:00:02.000Z out2\n", + ); + writeFileSync(paths.stderrLog, "2026-06-19T10:00:01.000Z err1\n"); + + expect(daemonLogs({ env: testEnv(dir), platform: "linux", tail: 10 }).entries).toEqual([ + { stream: "stdout", line: "2026-06-19T10:00:00.000Z out1" }, + { stream: "stderr", line: "2026-06-19T10:00:01.000Z err1" }, + { stream: "stdout", line: "2026-06-19T10:00:02.000Z out2" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preserves daemon config on non-purge uninstall", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-uninstall-keep-config-")); + try { + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + const installed = await installDaemon({ validate: false }, options); + + await uninstallDaemon({}, options); + + expect(existsSync(installed.config.paths.descriptorFile)).toBe(false); + expect(existsSync(installed.config.paths.configFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails Linux uninstall when systemd unregister fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-linux-uninstall-fail-")); + try { + let failDisable = false; + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (failDisable && command === "systemctl" && args.includes("disable")) { + return { stdout: "", stderr: "access denied", code: 1 }; + } + if (args.includes("is-active")) return { stdout: "inactive\n", stderr: "", code: 3 }; + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + failDisable = true; + + await expect(uninstallDaemon({}, options)).rejects.toThrow(/systemd unregister failed/u); + + expect(existsSync(installed.config.paths.descriptorFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("restores the systemd descriptor when unregister reload fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-linux-uninstall-reload-fail-")); + try { + let failReload = false; + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (failReload && command === "systemctl" && args.includes("daemon-reload")) { + return { stdout: "", stderr: "reload failed", code: 1 }; + } + if (args.includes("is-active")) return { stdout: "inactive\n", stderr: "", code: 3 }; + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon({ validate: false }, options); + failReload = true; + runner.commands.length = 0; + + await expect(uninstallDaemon({}, options)).rejects.toThrow(/systemd unregister failed/u); + + expect(existsSync(installed.config.paths.descriptorFile)).toBe(true); + expect(runner.commands).toEqual([ + ["systemctl", "--user", "show", "caplets-daemon-default.service"], + ["systemctl", "--user", "is-active", "caplets-daemon-default.service"], + ["systemctl", "--user", "disable", "caplets-daemon-default.service"], + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "caplets-daemon-default.service"], + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("treats Windows tasks that have not run as installed and stopped", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-ready-")); + let paths: ReturnType | undefined; + try { + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "schtasks" && args.includes("/Query")) { + return { stdout: "Status: Ready\nLast Run Result: 0x41303\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32" as const, + commandRunner: runner, + }; + paths = resolveDaemonPaths(options); + await installDaemon({ validate: false }, options); + + const status = await daemonStatus(options); + + expect(status.nativeState).toBe("installed_stopped"); + expect(status.running).toBe(false); + } finally { + if (paths) { + for (const path of [ + paths.descriptorFile, + paths.wrapperFile, + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ]) { + rmSync(path, { force: true }); + } + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("treats stale Windows XML descriptors as uninstalled when schtasks cannot query the task", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-stale-xml-")); + let paths: ReturnType | undefined; + try { + const options = { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32" as const, + commandRunner: fakeRunner(), + }; + paths = resolveDaemonPaths(options); + await installDaemon({ validate: false }, options); + const status = await daemonStatus({ + ...options, + commandRunner: { + async exec(command, args) { + if (command === "schtasks" && args.includes("/Query")) { + return { + stdout: "", + stderr: "ERROR: The system cannot find the file specified.", + code: 1, + }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }, + }); + + expect(status.installed).toBe(false); + expect(status.nativeState).toBe("not_installed"); + } finally { + if (paths) { + for (const path of [ + paths.descriptorFile, + paths.wrapperFile, + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ]) { + rmSync(path, { force: true }); + } + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("removes Windows wrapper files and checks unregister failures", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-uninstall-")); + let paths: ReturnType | undefined; + try { + let failDelete = false; + const runner: DaemonCommandRunner & { commands: string[][] } = { + commands: [], + async exec(command, args) { + runner.commands.push([command, ...args]); + if (failDelete && command === "schtasks" && args.includes("/Delete")) { + return { stdout: "", stderr: "access denied", code: 1 }; + } + if (command === "schtasks" && args.includes("/Query")) { + return { stdout: "Status: Ready\nLast Run Result: 0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32" as const, + commandRunner: runner, + }; + paths = resolveDaemonPaths(options); + const installed = await installDaemon({ validate: false }, options); + expect(existsSync(installed.config.paths.wrapperFile)).toBe(true); + + failDelete = true; + await expect(uninstallDaemon({}, options)).rejects.toThrow( + /Scheduled Task unregister failed/u, + ); + expect(existsSync(installed.config.paths.wrapperFile)).toBe(true); + + failDelete = false; + await uninstallDaemon({}, options); + + expect(existsSync(installed.config.paths.descriptorFile)).toBe(false); + expect(existsSync(installed.config.paths.wrapperFile)).toBe(false); + } finally { + if (paths) { + for (const path of [ + paths.descriptorFile, + paths.wrapperFile, + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ]) { + rmSync(path, { force: true }); + } + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("redacts daemon auth secrets from status config", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-status-redaction-")); + try { + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "systemctl" && args.includes("show")) { + return { + stdout: + "ExecStart={ path=/node ; argv[]=/node /cli serve --password secret ; }\nEnvironment=TOKEN=abc123\n", + stderr: "", + code: 0, + }; + } + if (command === "systemctl" && args.includes("is-active")) { + return { stdout: "inactive\n", stderr: "", code: 3 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + await installDaemon( + { + user: "alice", + password: "secret", + allowUnauthenticatedHttp: false, + env: ["TOKEN=abc123"], + validate: false, + }, + options, + ); + + const status = await daemonStatus(options); + const serialized = JSON.stringify(status); + + expect(status.config?.serve.auth.enabled).toBe(true); + if (!status.config?.serve.auth.enabled) throw new Error("expected daemon auth"); + expect(status.config.serve.auth.password).toBe("[redacted]"); + expect(status.config.env.values.TOKEN).toBe("[redacted]"); + expect(status.config.command.env.TOKEN).toBe("[redacted]"); + expect(status.config?.command.args).toContain("--password"); + expect(status.config?.command.args).not.toContain("secret"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("abc123"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("redacts password arguments from native status when config is missing", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-status-stale-redaction-")); + try { + const runner: DaemonCommandRunner = { + async exec(command, args) { + if (command === "systemctl" && args.includes("show")) { + return { + stdout: "ExecStart={ path=/node ; argv[]=/node /cli serve --password secret ; }\n", + stderr: "", + code: 0, + }; + } + if (command === "systemctl" && args.includes("is-active")) { + return { stdout: "inactive\n", stderr: "", code: 3 }; + } + return { stdout: "", stderr: "", code: 0 }; + }, + }; + const options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: runner, + }; + const installed = await installDaemon( + { + user: "alice", + password: "secret", + allowUnauthenticatedHttp: false, + validate: false, + }, + options, + ); + rmSync(installed.config.paths.configFile, { force: true }); + + const status = await daemonStatus(options); + const serialized = JSON.stringify(status); + + expect(status.config).toBeUndefined(); + expect(serialized).toContain("[redacted]"); + expect(serialized).not.toContain("--password secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("daemon validation", () => { + it("returns a failed health result for spawn errors", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-spawn-")); + try { + const result = await installDaemon( + { validate: false, dryRun: true, inheritEnv: true }, + { + env: testEnv(dir), + accountShell: join(dir, "missing-shell"), + home: "/home/alice", + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + + await expect(validateDaemonCommand(result.config, { timeoutMs: 20 })).resolves.toMatchObject({ + ok: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not accept health checks from an existing server after the candidate exits", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-existing-server-")); + try { + const candidate = join(dir, "candidate-exits.mjs"); + writeFileSync(candidate, "process.exit(1);\n"); + const result = await installDaemon( + { validate: false, dryRun: true, host: "127.0.0.1", port: "5480" }, + { + env: testEnv(dir), + home: dir, + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + const config = { + ...result.config, + command: { + ...result.config.command, + executable: process.execPath, + args: [candidate], + }, + }; + + await expect( + validateDaemonCommand(config, { + timeoutMs: 1_000, + fetch: async () => new Response("ok"), + }), + ).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("validation process exited"), + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("validates with only the configured service environment", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-validation-env-")); + try { + const port = await allocateLoopbackPort(); + const envFile = join(dir, "child-env.json"); + const serverFile = join(dir, "server.mjs"); + writeFileSync( + serverFile, + `import { createServer } from "node:http"; +import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(envFile)}, JSON.stringify(process.env)); +createServer((_request, response) => response.end("ok")).listen(${port}, "127.0.0.1"); +`, + ); + const result = await installDaemon( + { + validate: false, + dryRun: true, + host: "127.0.0.1", + port: String(port), + env: ["SERVICE_ONLY=1"], + }, + { + env: { ...testEnv(dir), INSTALLER_ONLY: "1" }, + home: dir, + platform: "linux", + commandRunner: fakeRunner(), + }, + ); + const config = { + ...result.config, + command: { + ...result.config.command, + executable: serverFile, + args: [], + }, + }; + + await expect(validateDaemonCommand(config, { timeoutMs: 1_000 })).resolves.toMatchObject({ + ok: true, + }); + const childEnv = JSON.parse(readFileSync(envFile, "utf8")) as Record; + + expect(childEnv.SERVICE_ONLY).toBe("1"); + expect(childEnv.INSTALLER_ONLY).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +function testEnv(dir: string): NodeJS.ProcessEnv { + return { + XDG_CONFIG_HOME: join(dir, "config"), + XDG_STATE_HOME: join(dir, "state"), + }; +} + +function fakeRunner( + options: { active?: boolean } = {}, +): DaemonCommandRunner & { commands: string[][] } { + const commands: string[][] = []; + return { + commands, + async exec(command, args) { + commands.push([command, ...args]); + if (args.includes("is-active")) { + return options.active + ? { stdout: "active\n", stderr: "", code: 0 } + : { stdout: "inactive\n", stderr: "", code: 3 }; + } + return { stdout: "", stderr: "", code: 0 }; }, }; } diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index 13d3e020..71d79922 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveDaemonServeOptions, resolveServeOptions } from "../src/serve/options"; +import { resolveDaemonHttpServeOptions } from "../src/daemon"; +import { resolveServeOptions } from "../src/serve/options"; describe("resolveServeOptions", () => { it("defaults serve to stdio", () => { @@ -177,7 +178,7 @@ describe("resolveServeOptions", () => { }); it("defaults daemonized serve to HTTP", () => { - expect(resolveDaemonServeOptions({}, {})).toMatchObject({ + expect(resolveDaemonHttpServeOptions({}, {})).toMatchObject({ transport: "http", host: "127.0.0.1", port: 5387, @@ -186,8 +187,8 @@ describe("resolveServeOptions", () => { }); it("rejects daemonized stdio serve", () => { - expect(() => resolveDaemonServeOptions({ transport: "stdio" }, {})).toThrow( - "Daemonized serve requires --transport http.", + expect(() => resolveDaemonHttpServeOptions({ transport: "stdio" } as never, {})).toThrow( + "caplets daemon install does not accept --transport.", ); });