From e4ba2db763ae700effabb329518fab9d439fc534 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 09:26:55 -0400 Subject: [PATCH 01/14] feat(daemon): manage caplets with native services --- .changeset/sour-pandas-serve.md | 5 + CONCEPTS.md | 22 +- docs/architecture.md | 6 + ...-19-caplets-daemon-service-requirements.md | 222 ++++++ ...19-001-feat-caplets-daemon-service-plan.md | 417 +++++++++++ docs/product/caplets-code-mode-prd.md | 5 +- .../native-daemon-service-management.md | 183 +++++ packages/core/src/cli.ts | 324 ++++++--- packages/core/src/cli/commands.ts | 4 +- packages/core/src/cli/doctor.ts | 37 +- packages/core/src/daemon/config.ts | 77 ++ packages/core/src/daemon/env.ts | 53 ++ packages/core/src/daemon/index.ts | 392 ++++++++++ packages/core/src/daemon/logs.ts | 81 +++ packages/core/src/daemon/manager.ts | 497 +++++++++++++ packages/core/src/daemon/paths.ts | 49 ++ packages/core/src/daemon/platform-darwin.ts | 43 ++ packages/core/src/daemon/platform-linux.ts | 42 ++ packages/core/src/daemon/platform-windows.ts | 43 ++ packages/core/src/daemon/process.ts | 73 ++ packages/core/src/daemon/shell.ts | 28 + packages/core/src/daemon/types.ts | 201 ++++++ packages/core/src/daemon/validation.ts | 116 +++ packages/core/src/daemon/xml.ts | 8 + packages/core/src/serve/daemon/config.ts | 94 --- packages/core/src/serve/daemon/index.ts | 181 ----- packages/core/src/serve/daemon/paths.ts | 66 -- .../core/src/serve/daemon/platform-darwin.ts | 38 - .../core/src/serve/daemon/platform-linux.ts | 48 -- .../core/src/serve/daemon/platform-windows.ts | 21 - packages/core/src/serve/daemon/platform.ts | 40 -- packages/core/src/serve/daemon/process.ts | 76 -- packages/core/src/serve/daemon/types.ts | 106 --- packages/core/src/serve/index.ts | 21 +- packages/core/src/serve/options.ts | 10 - packages/core/test/cli-completion.test.ts | 9 + packages/core/test/serve-daemon.test.ts | 673 +++++++++++------- packages/core/test/serve-options.test.ts | 9 +- 38 files changed, 3246 insertions(+), 1074 deletions(-) create mode 100644 .changeset/sour-pandas-serve.md create mode 100644 docs/brainstorms/2026-06-19-caplets-daemon-service-requirements.md create mode 100644 docs/plans/2026-06-19-001-feat-caplets-daemon-service-plan.md create mode 100644 docs/solutions/architecture-patterns/native-daemon-service-management.md create mode 100644 packages/core/src/daemon/config.ts create mode 100644 packages/core/src/daemon/env.ts create mode 100644 packages/core/src/daemon/index.ts create mode 100644 packages/core/src/daemon/logs.ts create mode 100644 packages/core/src/daemon/manager.ts create mode 100644 packages/core/src/daemon/paths.ts create mode 100644 packages/core/src/daemon/platform-darwin.ts create mode 100644 packages/core/src/daemon/platform-linux.ts create mode 100644 packages/core/src/daemon/platform-windows.ts create mode 100644 packages/core/src/daemon/process.ts create mode 100644 packages/core/src/daemon/shell.ts create mode 100644 packages/core/src/daemon/types.ts create mode 100644 packages/core/src/daemon/validation.ts create mode 100644 packages/core/src/daemon/xml.ts delete mode 100644 packages/core/src/serve/daemon/config.ts delete mode 100644 packages/core/src/serve/daemon/index.ts delete mode 100644 packages/core/src/serve/daemon/paths.ts delete mode 100644 packages/core/src/serve/daemon/platform-darwin.ts delete mode 100644 packages/core/src/serve/daemon/platform-linux.ts delete mode 100644 packages/core/src/serve/daemon/platform-windows.ts delete mode 100644 packages/core/src/serve/daemon/platform.ts delete mode 100644 packages/core/src/serve/daemon/process.ts delete mode 100644 packages/core/src/serve/daemon/types.ts 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..811a25de 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -78,17 +78,20 @@ 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, + 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 +116,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 +148,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 +157,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 +194,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 +260,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 +271,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 +563,160 @@ 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 = 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(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("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( + status.installed + ? `Caplets daemon is ${status.running ? "running" : "stopped"} (${status.nativeState}).\n` + : "Caplets daemon is not installed.\n", + ); + }); + + 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 +1133,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 @@ -2142,6 +2269,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..f7cbe2a1 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({ env, ...options }); + 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..4c8d1a07 --- /dev/null +++ b/packages/core/src/daemon/config.ts @@ -0,0 +1,77 @@ +import { chmodSync, existsSync, 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 { + 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, 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..a7ab3de4 --- /dev/null +++ b/packages/core/src/daemon/env.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +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; + const shell = nonEmpty(env.SHELL); + if (shell) return shellPlan(platform, shell, "SHELL"); + + 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 accountShell = nonEmpty(options.accountShell); + 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: ["-NoProfile", "-Command"], source } + : { executable, args: ["/d", "/s", "/c"], source }; + } + return { executable, args: ["-lc"], source }; +} + +function firstExisting(paths: string[]): string | undefined { + return paths.find((path) => existsSync(path)); +} + +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..a5f46c06 --- /dev/null +++ b/packages/core/src/daemon/index.ts @@ -0,0 +1,392 @@ +import { existsSync, mkdirSync, rmSync } 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, + DaemonInstallOptions, + DaemonInstallResult, + DaemonLifecycleResult, + 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 existing = install.reset ? undefined : readDaemonConfig(paths); + const daemonEnv = mergeDaemonEnv(existing?.env, install); + const serve = resolveDaemonHttpServeOptions(mergeServeOptions(existing, install), env); + 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"]; + + if (install.dryRun) { + return { + action: "install", + status: await daemonStatusSnapshot(options), + config, + descriptor, + dryRun: true, + plannedActions, + }; + } + + const existingNative = existing ? await manager.status(existing, paths) : undefined; + let validation = undefined; + if (install.validate !== false) { + const validationConfig = + existing && existingNative?.running && existing.serve.port === config.serve.port + ? await temporaryValidationConfig(config, options) + : config; + validation = options.validateCommand + ? await options.validateCommand(validationConfig) + : await validateDaemonCommand( + validationConfig, + options.fetch ? { fetch: options.fetch } : {}, + ); + assertDaemonHealth(validation, "Daemon install validation"); + } + + mkdirSync(paths.logDir, { recursive: true, mode: 0o700 }); + ensureDaemonLogFiles(paths); + const native = await manager.install(config); + writeDaemonConfig(paths, 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 }), + }); + + if (install.start || install.restart) { + const action = install.restart ? await manager.restart(config) : await manager.start(config); + const health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); + 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 (existing) { + const current = existingNative ?? (await manager.status(existing, paths)); + if (current.running && !install.noRestart) { + if (!options.isInteractive || !options.readPrompt) { + throw new CapletsError( + "REQUEST_INVALID", + "Daemon is already running; rerun with --restart, --start, or --no-restart.", + ); + } + const answer = await options.readPrompt("Restart the running Caplets daemon now? [y/N] "); + if (/^y(?:es)?$/iu.test(answer.trim())) await manager.restart(config); + } + } + + 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, + }), + }; +} + +export async function uninstallDaemon( + uninstall: DaemonUninstallOptions = {}, + options: DaemonOperationOptions = {}, +): Promise { + const paths = resolveDaemonPaths(options); + const manager = options.manager ?? createNativeDaemonManager(options); + const config = readDaemonConfig(paths); + const removed = uninstall.purge + ? [paths.descriptorFile, 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 && config) await manager.stop(config); + const native = await manager.uninstall(config, paths); + removeDaemonConfig(paths); + if (uninstall.purge) { + 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 } : {}), + native, + }; + if (config && native.running) { + status.health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); + } + return status; +} + +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 || !existsSync(paths.descriptorFile)) { + 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); + 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 }), + }); + const status = await daemonStatus({ ...options, manager }); + if (effectiveAction === "start" || effectiveAction === "restart") { + assertDaemonHealth( + status.health ?? { + ok: false, + url: "", + error: "native daemon did not report a running service", + }, + "Native daemon health check", + ); + } + 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 } + : {}), + }; +} + +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"; diff --git a/packages/core/src/daemon/logs.ts b/packages/core/src/daemon/logs.ts new file mode 100644 index 00000000..f4d38030 --- /dev/null +++ b/packages/core/src/daemon/logs.ts @@ -0,0 +1,81 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, watch } from "node:fs"; +import { dirname } from "node:path"; +import type { DaemonLogEntry, DaemonLogStream, DaemonLogsResult, DaemonPaths } from "./types"; + +export function readDaemonLogs( + paths: DaemonPaths, + options: { stream?: DaemonLogStream; tail?: number } = {}, +): DaemonLogsResult { + const stream = options.stream ?? "all"; + const tail = options.tail ?? 10; + const entries = selectedStreams(stream).flatMap((selected) => + tailLines(paths[selected === "stdout" ? "stdoutLog" : "stderrLog"], tail).map((line) => ({ + stream: selected, + line, + })), + ); + 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) ? readFileSync(file, "utf8").length : 0; + return watch(file, { persistent: true }, () => { + const content = existsSync(file) ? readFileSync(file, "utf8") : ""; + const appended = content.slice(offset); + offset = content.length; + for (const line of appended.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 = readFileSync(path, "utf8").split(/\r?\n/u); + if (lines.at(-1) === "") lines.pop(); + return count < 0 ? lines : lines.slice(-count); +} + +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..4a6bfa68 --- /dev/null +++ b/packages/core/src/daemon/manager.ts @@ -0,0 +1,497 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, 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); + 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); + const commands = [["launchctl", "bootstrap", domain, descriptor.path]]; + const result = await writeDescriptorForInstall(descriptor, async () => { + const bootstrap = await runner.exec(commands[0]![0]!, commands[0]!.slice(1)); + if ( + bootstrap.code !== 0 && + !/already bootstrapped|service already loaded/iu.test(bootstrap.stderr) + ) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `launchd registration failed: ${bootstrap.stderr || bootstrap.stdout || bootstrap.code}`, + ); + } + return bootstrap; + }); + return { + action: "install", + native: stopped({ stdout: result.stdout, stderr: result.stderr }), + commands, + descriptor, + }; + }, + uninstall: async (_config, paths) => { + const commands = [["launchctl", "bootout", domain, paths.descriptorFile]]; + 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/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 () => launchdLifecycle(runner, "start", ["launchctl", "kickstart", "-k", target]), + restart: async (config) => + launchdRestartLifecycle(runner, domain, target, config.paths.descriptorFile), + stop: async () => { + const command = ["launchctl", "kill", "TERM", target]; + const result = await runner.exec(command[0]!, command.slice(1)); + if ( + result.code !== 0 && + !/No such process|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]); + const active = await runner.exec("systemctl", ["--user", "is-active", SYSTEMD_UNIT]); + if (show.code !== 0 && !existsSync(paths.descriptorFile)) + return notInstalled({ stderr: show.stderr }); + if (active.stdout.trim() === "active") + return { + state: "running", + installed: true, + running: true, + raw: parseSystemdShow(show.stdout), + }; + if (active.stdout.trim() === "failed") + return failedStatus({ + active: active.stdout.trim(), + raw: parseSystemdShow(show.stdout), + stderr: active.stderr, + }); + return stopped({ + active: active.stdout.trim(), + raw: parseSystemdShow(show.stdout), + 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"); + }); + 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 runner.exec("systemctl", ["--user", "disable", SYSTEMD_UNIT]); + rmSync(paths.descriptorFile, { force: true }); + await runner.exec("systemctl", ["--user", "daemon-reload"]); + 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 existsSync(paths.descriptorFile) + ? stopped({ stderr: result.stderr }) + : notInstalled({ 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 && !/0x0|\b0\b/iu.test(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 runner.exec(command[0]!, command.slice(1)); + rmSync(paths.descriptorFile, { force: true }); + return { action: "uninstall", native: notInstalled(), commands: [command] }; + }, + start: async () => windowsLifecycle(runner, "start", ["/Run", "/TN", WINDOWS_TASK_NAME]), + restart: async () => { + await runner.exec("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME]); + return windowsLifecycle(runner, "restart", ["/Run", "/TN", WINDOWS_TASK_NAME]); + }, + 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 }, + ); + if (descriptor.kind === "windows-scheduled-task") { + mkdirSync(dirname(descriptor.wrapper.path), { recursive: true, mode: 0o700 }); + writeFileSync(descriptor.wrapper.path, descriptor.wrapper.contents, { mode: 0o700 }); + } +} + +async function writeDescriptorForInstall( + descriptor: DaemonDescriptor, + register: () => Promise, +): Promise { + const backups = backupDescriptorFiles(descriptor); + writeDescriptor(descriptor); + try { + return await register(); + } catch (error) { + restoreDescriptorFiles(backups); + throw error; + } +} + +type DescriptorBackup = { path: string; existed: boolean; contents?: Buffer }; + +function backupDescriptorFiles(descriptor: DaemonDescriptor): DescriptorBackup[] { + const paths = + descriptor.kind === "windows-scheduled-task" + ? [descriptor.path, descriptor.wrapper.path] + : [descriptor.path]; + return paths.map((path) => ({ + path, + existed: existsSync(path), + ...(existsSync(path) ? { contents: readFileSync(path) } : {}), + })); +} + +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); + } 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 launchdLifecycle( + runner: DaemonCommandRunner, + action: string, + command: string[], + running = true, +): Promise { + await assertExec(runner, command, `launchd ${action} failed`); + return { + action, + native: running ? { state: "running", installed: true, running: true } : stopped(), + commands: [command], + }; +} + +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 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 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..6069db03 --- /dev/null +++ b/packages/core/src/daemon/platform-darwin.ts @@ -0,0 +1,43 @@ +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 command = [serviceCommand(config).executable, ...serviceCommand(config).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")} + + 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..c36862b8 --- /dev/null +++ b/packages/core/src/daemon/platform-linux.ts @@ -0,0 +1,42 @@ +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}`)}`) + .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(systemdQuote).join(" ")} +Restart=on-failure +StandardOutput=append:${systemdEscape(config.paths.stdoutLog)} +StandardError=append:${systemdEscape(config.paths.stderrLog)} + +[Install] +WantedBy=default.target +`, + }; +} + +function systemdQuote(value: string): string { + return `"${systemdEscape(value)}"`; +} + +function systemdEscape(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("%", "%%") + .replaceAll("\n", "\\n"); +} diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts new file mode 100644 index 00000000..1de8418f --- /dev/null +++ b/packages/core/src/daemon/platform-windows.ts @@ -0,0 +1,43 @@ +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}=${value.replaceAll('"', '""')}"\r`) + .join( + "", + )}${command} >> ${windowsArg(config.paths.stdoutLog)} 2>> ${windowsArg(config.paths.stderrLog)}\r +`, + }; + const xml = ` + + true + + 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 { + 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..8675595f --- /dev/null +++ b/packages/core/src/daemon/process.ts @@ -0,0 +1,73 @@ +import { homedir } from "node:os"; +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.", + ); + } + return resolveServeOptions({ ...raw, transport: "http" }, env) 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 = process.argv[1] ?? "caplets"; + const args = daemonServeArgs(options.serve); + const workingDirectory = options.operation.home ?? homedir(); + const base = { + executable, + args, + workingDirectory, + env: options.explicitEnv ?? {}, + 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 } : {}), + }), + }; +} diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts new file mode 100644 index 00000000..ef81e38c --- /dev/null +++ b/packages/core/src/daemon/shell.ts @@ -0,0 +1,28 @@ +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[]; + 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 = direct.map(shellQuote).join(" "); + const shell = [config.command.shell.executable, ...config.command.shell.args, shellCommand]; + return { + executable: shell[0]!, + args: shell.slice(1), + display: shell.map(shellQuote).join(" "), + }; +} diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts new file mode 100644 index 00000000..10e713d2 --- /dev/null +++ b/packages/core/src/daemon/types.ts @@ -0,0 +1,201 @@ +import type { HttpServeOptions, RawServeOptions } from "../serve/options"; + +export type DaemonInstance = "default"; + +export type RawDaemonServeOptions = Omit; + +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; +}; + +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..665822f1 --- /dev/null +++ b/packages/core/src/daemon/validation.ts @@ -0,0 +1,116 @@ +import { CapletsError } from "../errors"; +import { servicePaths } from "../serve/http"; +import type { DaemonConfig, DaemonHealthResult } from "./types"; +import { shellQuote } from "./shell"; + +export async function validateDaemonCommand( + config: DaemonConfig, + options: { + fetch?: typeof fetch; + 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: { ...process.env, ...config.command.env }, + stdio: ["ignore", "ignore", "ignore"], + }); + try { + const deadline = Date.now() + (options.timeoutMs ?? 5_000); + let last: DaemonHealthResult | undefined; + while (Date.now() < deadline) { + if (child.exitCode !== null) break; + last = await probeDaemonHealth(config, { + ...(options.fetch ? { fetch: options.fetch } : {}), + timeoutMs: 750, + }); + if (last.ok) return last; + await sleep(150); + } + return ( + last ?? { + ok: false, + url: healthUrl(config), + error: child.exitCode === null ? "health probe timed out" : "validation process exited", + } + ); + } finally { + if (child.exitCode === null) child.kill("SIGTERM"); + } +} + +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 argv = [config.command.executable, ...config.command.args]; + if (!config.command.shell) return { command: process.execPath, args: argv }; + return { + command: config.command.shell.executable, + args: [ + ...config.command.shell.args, + `${shellQuote(process.execPath)} ${argv.map(shellQuote).join(" ")}`, + ], + }; +} + +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..39ed4a81 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -1,119 +1,102 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { 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, + 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, +} from "../src/daemon"; + +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[] = []; - try { - await startDaemon( - { password: "super-secret-password" }, - { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - process: fakeProcessRunner({ running: false, pid: 1300 }), - }, - ); - - await runCli(["serve", "status", "--json"], { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - writeOut: (value) => out.push(value), - daemon: { - process: fakeProcessRunner({ running: true, pid: 1300 }), - }, - }); + 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, + ); + }); - const status = JSON.parse(out.join("")) as { - config: { serve: { auth: { password: string } } }; - }; - expect(status.config.serve.auth.password).toBe("[REDACTED]"); - expect(out.join("")).not.toContain("super-secret-password"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + 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, + ); }); }); -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 +110,395 @@ 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 }); + 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 }); + } + }); + + 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 options = { - env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, - process, + env: testEnv(dir), + home: "/home/alice", + platform: "linux" as const, + commandRunner: fakeRunner({ active: true }), }; + await installDaemon({ port: "5480", validate: false }, options); - 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", - ]); + 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 status = await daemonStatus({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), - }); - expect(status.running).toBe(true); - expect(status.config?.serve.port).toBe(5480); + expect(validatedPorts).toHaveLength(1); + expect(validatedPorts[0]).not.toBe(5480); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); - const stopped = await stopDaemon({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), + 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(stopped.status.running).toBe(false); - expect(stopped.status.pid).toBeUndefined(); + 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("WorkingDirectory"); + + 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 afterStop = await daemonStatus({ - ...options, - process: fakeProcessRunner({ running: true, pid: 1400 }), + const windows = await installDaemon(common, { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), + }, + home: "C:\\Users\\Alice", + platform: "win32", + commandRunner: fakeRunner(), }); - expect(afterStop.running).toBe(false); + 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("PT0S"); + expect(windows.descriptor.xml).toContain(""); + expect(windows.descriptor.wrapper.contents).toContain(">> "); + expect(windows.descriptor.wrapper.contents).toContain("2>> "); } 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("rolls back descriptor files when native registration fails", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-rollback-")); 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 paths = resolveDaemonPaths({ env: testEnv(dir), platform: "linux" }); + mkdirSync(join(dir, "config", "systemd", "user"), { recursive: true }); + writeFileSync(paths.descriptorFile, "old descriptor\n"); + const runner: DaemonCommandRunner = { + async exec() { + return { stdout: "", stderr: "boom", code: 1 }; + }, + }; await expect( - startDaemon( - { port: "5481" }, - { env, process: fakeProcessRunner({ running: true, pid: 1400 }) }, + installDaemon( + { validate: false }, + { env: testEnv(dir), home: "/home/alice", platform: "linux", commandRunner: runner }, ), - ).rejects.toThrow("Caplets HTTP daemon is already running."); + ).rejects.toThrow(/systemd registration failed/u); - const process = fakeProcessRunner({ running: true, pid: 1400, nextPid: 1401 }); - const restarted = await restartDaemon({ port: "5481" }, { env, process }); - - expect(restarted.status.running).toBe(true); - expect(restarted.status.pid).toBe(1401); - expect(process.stops).toEqual([1400]); - expect(process.starts[0]?.args).toContain("5481"); + expect(readFileSync(paths.descriptorFile, "utf8")).toBe("old descriptor\n"); + expect(existsSync(paths.configFile)).toBe(false); } 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("ignores old serve/default artifacts", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-ignore-")); 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 enabled = await enableDaemon({ env, platform: "linux", serviceAvailable: true }); - expect(enabled.enabled).toBe(true); - expect(enabled.descriptor.kind).toBe("systemd-user"); - - const disabled = await disableDaemon({ env, platform: "linux", serviceAvailable: true }); - expect(disabled.enabled).toBe(false); - expect(disabled.descriptor.kind).toBe("systemd-user"); + 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("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"] }, - }); +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 }); + } + }); - 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"); + 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("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" }, - home: "/home/alice", - platform: "linux", - }), - command: { executable: "/usr/bin/caplets", args: ["serve", "--transport", "http"] }, - }); + 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; - 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"); + 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("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("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 }), + }; + await installDaemon({ validate: false }, options); - 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"); + await expect(startDaemon(options)).rejects.toThrow(/Native daemon health check failed/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); - it("describes a Windows per-user Scheduled Task command plan", () => { - const descriptor = buildDaemonPlatformDescriptor({ - platform: "win32", - paths: resolveServeDaemonPaths({ - env: { - APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local", + 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 }; }, - home: "C:\\Users\\Alice", - platform: "win32", - }), - command: { - executable: "C:\\Program Files\\nodejs\\caplets.cmd", - args: ["serve", "--transport", "http"], - }, - }); + }; + 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(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(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("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: 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("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(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 }); + } }); }); -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[] = []; +function testEnv(dir: string): NodeJS.ProcessEnv { 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; + 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.", ); }); From 45a806a794282a5bd1e16172fc6ff701d0b1ac10 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 09:44:48 -0400 Subject: [PATCH 02/14] fix(daemon): address native service review feedback --- packages/core/src/cli.ts | 1 - packages/core/src/daemon/index.ts | 50 ++++++-- packages/core/src/daemon/logs.ts | 35 ++++- packages/core/src/daemon/platform-darwin.ts | 5 +- packages/core/src/daemon/platform-windows.ts | 13 +- packages/core/src/daemon/process.ts | 33 ++++- packages/core/test/serve-daemon.test.ts | 128 +++++++++++++++++++ 7 files changed, 248 insertions(+), 17 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 811a25de..e92cb25b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -279,7 +279,6 @@ function daemonInstallOptions(options: DaemonInstallCommandOptions): DaemonInsta ...(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 } : {}), }; } diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index a5f46c06..2b5a232b 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -21,6 +21,7 @@ import { } from "./validation"; import type { DaemonConfig, + DaemonHealthResult, DaemonInstallOptions, DaemonInstallResult, DaemonLifecycleResult, @@ -76,16 +77,12 @@ export async function installDaemon( const existingNative = existing ? await manager.status(existing, paths) : undefined; let validation = undefined; if (install.validate !== false) { - const validationConfig = - existing && existingNative?.running && existing.serve.port === config.serve.port - ? await temporaryValidationConfig(config, options) - : config; - validation = options.validateCommand - ? await options.validateCommand(validationConfig) - : await validateDaemonCommand( - validationConfig, - options.fetch ? { fetch: options.fetch } : {}, - ); + validation = await validateInstallCommand({ + config, + existing, + existingNativeRunning: existingNative?.running === true, + options, + }); assertDaemonHealth(validation, "Daemon install validation"); } @@ -164,6 +161,39 @@ async function temporaryValidationConfig( }; } +async function validateInstallCommand(input: { + config: DaemonConfig; + existing: DaemonConfig | undefined; + existingNativeRunning: boolean; + options: DaemonOperationOptions; +}): Promise { + const useTemporaryPort = + input.existing && + input.existingNativeRunning && + 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", + } + ); +} + export async function uninstallDaemon( uninstall: DaemonUninstallOptions = {}, options: DaemonOperationOptions = {}, diff --git a/packages/core/src/daemon/logs.ts b/packages/core/src/daemon/logs.ts index f4d38030..6b5483e1 100644 --- a/packages/core/src/daemon/logs.ts +++ b/packages/core/src/daemon/logs.ts @@ -8,12 +8,13 @@ export function readDaemonLogs( ): DaemonLogsResult { const stream = options.stream ?? "all"; const tail = options.tail ?? 10; - const entries = selectedStreams(stream).flatMap((selected) => + 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 }; } @@ -74,6 +75,38 @@ function tailLines(path: string, count: number): string[] { return count < 0 ? lines : lines.slice(-count); } +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 }); diff --git a/packages/core/src/daemon/platform-darwin.ts b/packages/core/src/daemon/platform-darwin.ts index 6069db03..1a4b5558 100644 --- a/packages/core/src/daemon/platform-darwin.ts +++ b/packages/core/src/daemon/platform-darwin.ts @@ -5,9 +5,8 @@ import type { DaemonConfig, DaemonDescriptor } from "./types"; export const LAUNCHD_LABEL = "dev.caplets.daemon.default"; export function buildLaunchdDescriptor(config: DaemonConfig): DaemonDescriptor { - const command = [serviceCommand(config).executable, ...serviceCommand(config).args].map( - escapeXml, - ); + const planned = serviceCommand(config); + const command = [planned.executable, ...planned.args].map(escapeXml); return { kind: "launchd-user-agent", label: LAUNCHD_LABEL, diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts index 1de8418f..34ba317b 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -1,3 +1,4 @@ +import { CapletsError } from "../errors"; import type { DaemonConfig, DaemonDescriptor } from "./types"; import { escapeXml } from "./xml"; import { serviceCommand } from "./shell"; @@ -12,7 +13,7 @@ export function buildWindowsTaskDescriptor(config: DaemonConfig): DaemonDescript contents: `@echo off\r cd /d ${windowsArg(config.command.workingDirectory)}\r ${Object.entries(config.command.env) - .map(([key, value]) => `set "${key}=${value.replaceAll('"', '""')}"\r`) + .map(([key, value]) => `set "${key}=${windowsEnvValue(value)}"\r`) .join( "", )}${command} >> ${windowsArg(config.paths.stdoutLog)} 2>> ${windowsArg(config.paths.stderrLog)}\r @@ -41,3 +42,13 @@ ${Object.entries(config.command.env) function windowsArg(value: string): string { return `"${value.replaceAll('"', '""')}"`; } + +function windowsEnvValue(value: string): string { + if (/[\r\n%]/u.test(value)) { + throw new CapletsError( + "REQUEST_INVALID", + "Windows daemon environment values cannot contain CR, LF, or % characters.", + ); + } + return value.replaceAll('"', '""'); +} diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts index 8675595f..7bea38cb 100644 --- a/packages/core/src/daemon/process.ts +++ b/packages/core/src/daemon/process.ts @@ -1,4 +1,6 @@ +import { existsSync, realpathSync } from "node:fs"; import { homedir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; import { CapletsError } from "../errors"; import { resolveServeOptions, type HttpServeOptions, type RawServeOptions } from "../serve/options"; import { resolveDaemonShell } from "./env"; @@ -49,7 +51,7 @@ export function buildDaemonCommandPlan(options: { inheritEnv?: boolean; }): DaemonCommandPlan { const platform = options.operation.platform ?? process.platform; - const executable = process.argv[1] ?? "caplets"; + const executable = resolveDaemonExecutable(process.argv[1]); const args = daemonServeArgs(options.serve); const workingDirectory = options.operation.home ?? homedir(); const base = { @@ -71,3 +73,32 @@ export function buildDaemonCommandPlan(options: { }), }; } + +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/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 39ed4a81..44ba24a3 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -200,6 +200,43 @@ describe("daemon paths and config", () => { } }); + 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(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("renders native daemon identities for launchd, systemd, and Windows tasks", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-descriptor-")); try { @@ -263,6 +300,70 @@ describe("daemon paths and config", () => { } }); + 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 CR, LF, or %/u); + + await expect( + 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(), + }, + ), + ).rejects.toThrow(/cannot contain CR, LF, or %/u); + } 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 { @@ -437,6 +538,12 @@ describe("daemon lifecycle and logs", () => { 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" }, @@ -476,6 +583,27 @@ describe("daemon lifecycle and logs", () => { 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 }); + } + }); }); function testEnv(dir: string): NodeJS.ProcessEnv { From 3aa6161d8e0b2b84aec63ee9e84cdd3690e3e7bb Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 09:53:32 -0400 Subject: [PATCH 03/14] fix(daemon): harden native service lifecycle --- packages/core/src/cli/doctor.ts | 2 +- packages/core/src/daemon/config.ts | 10 +- packages/core/src/daemon/index.ts | 63 +++-- packages/core/src/daemon/manager.ts | 32 ++- packages/core/src/daemon/platform-darwin.ts | 2 + packages/core/src/daemon/platform-windows.ts | 6 +- packages/core/src/daemon/shell.ts | 27 +- packages/core/test/serve-daemon.test.ts | 248 +++++++++++++++++-- 8 files changed, 349 insertions(+), 41 deletions(-) diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index f7cbe2a1..f4ef9400 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -175,7 +175,7 @@ async function resolveDaemonSection( options: DaemonOperationOptions | undefined, ) { try { - const status = await daemonStatus({ env, ...options }); + const status = await daemonStatus({ ...options, env }); return { installed: status.installed, running: status.running, diff --git a/packages/core/src/daemon/config.ts b/packages/core/src/daemon/config.ts index 4c8d1a07..3a9606dc 100644 --- a/packages/core/src/daemon/config.ts +++ b/packages/core/src/daemon/config.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { CapletsError } from "../errors"; import type { @@ -66,8 +66,12 @@ function validateEnvName(value: string): void { } function readJson(path: string): T | undefined { - if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as T; + 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 { diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index 2b5a232b..678419df 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -63,6 +63,20 @@ export async function installDaemon( const descriptor = manager.descriptor(config); const plannedActions = ["write-config", "write-descriptor", "register-service"]; + const existingNative = existing ? await manager.status(existing, 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", @@ -74,7 +88,6 @@ export async function installDaemon( }; } - const existingNative = existing ? await manager.status(existing, paths) : undefined; let validation = undefined; if (install.validate !== false) { validation = await validateInstallCommand({ @@ -100,7 +113,10 @@ export async function installDaemon( }); if (install.start || install.restart) { - const action = install.restart ? await manager.restart(config) : await manager.start(config); + const action = + install.restart || (install.start && existingNative?.running) + ? await manager.restart(config) + : await manager.start(config); const health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); assertDaemonHealth(health, "Native daemon health check"); writeDaemonState(paths, { @@ -111,18 +127,9 @@ export async function installDaemon( updatedAt: (options.now ?? new Date()).toISOString(), ...(action.native.pid === undefined ? {} : { pid: action.native.pid }), }); - } else if (existing) { - const current = existingNative ?? (await manager.status(existing, paths)); - if (current.running && !install.noRestart) { - if (!options.isInteractive || !options.readPrompt) { - throw new CapletsError( - "REQUEST_INVALID", - "Daemon is already running; rerun with --restart, --start, or --no-restart.", - ); - } - const answer = await options.readPrompt("Restart the running Caplets daemon now? [y/N] "); - if (/^y(?:es)?$/iu.test(answer.trim())) await manager.restart(config); - } + } 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())) await manager.restart(config); } return { @@ -216,8 +223,8 @@ export async function uninstallDaemon( const nativeStatus = await manager.status(config, paths); if (nativeStatus.running && config) await manager.stop(config); const native = await manager.uninstall(config, paths); - removeDaemonConfig(paths); if (uninstall.purge) { + removeDaemonConfig(paths); removeDaemonState(paths); rmSync(paths.logDir, { recursive: true, force: true }); rmSync(dirname(paths.configFile), { recursive: true, force: true }); @@ -288,7 +295,7 @@ async function daemonStatusSnapshot( running: native.running, nativeState: native.state, paths, - ...(config ? { config } : {}), + ...(config ? { config: redactDaemonConfig(config) } : {}), native, }; if (config && native.running) { @@ -297,6 +304,30 @@ async function daemonStatusSnapshot( return status; } +function redactDaemonConfig(config: DaemonConfig): DaemonConfig { + return { + ...config, + serve: { + ...config.serve, + auth: config.serve.auth.enabled + ? { ...config.serve.auth, password: "[redacted]" } + : config.serve.auth, + }, + command: { + ...config.command, + args: redactPasswordArgs(config.command.args), + }, + }; +} + +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 { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 4a6bfa68..f7112efc 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -158,9 +158,14 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D ["systemctl", "--user", "disable", SYSTEMD_UNIT], ["systemctl", "--user", "daemon-reload"], ]; - await runner.exec("systemctl", ["--user", "disable", SYSTEMD_UNIT]); + await assertExecUnless( + runner, + commands[0]!, + "systemd unregister failed", + /not loaded|not found|No such file|does not exist/iu, + ); rmSync(paths.descriptorFile, { force: true }); - await runner.exec("systemctl", ["--user", "daemon-reload"]); + await assertExec(runner, commands[1]!, "systemd unregister failed"); return { action: "uninstall", native: notInstalled(), commands }; }, start: async () => systemdLifecycle(runner, "start", "start"), @@ -212,8 +217,14 @@ function windowsTaskManager(runner: DaemonCommandRunner): DaemonManager { }, uninstall: async (_config, paths) => { const command = ["schtasks", "/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]; - await runner.exec(command[0]!, command.slice(1)); + 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]), @@ -333,6 +344,21 @@ async function assertExec( } } +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 launchdLifecycle( runner: DaemonCommandRunner, action: string, diff --git a/packages/core/src/daemon/platform-darwin.ts b/packages/core/src/daemon/platform-darwin.ts index 1a4b5558..33e54975 100644 --- a/packages/core/src/daemon/platform-darwin.ts +++ b/packages/core/src/daemon/platform-darwin.ts @@ -21,6 +21,8 @@ export function buildLaunchdDescriptor(config: DaemonConfig): DaemonDescriptor { ${command.map((value) => ` ${value}`).join("\n")} + RunAtLoad + WorkingDirectory ${escapeXml(config.command.workingDirectory)} StandardOutPath diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts index 34ba317b..cbf83006 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -44,11 +44,11 @@ function windowsArg(value: string): string { } function windowsEnvValue(value: string): string { - if (/[\r\n%]/u.test(value)) { + if (/[\r\n]/u.test(value)) { throw new CapletsError( "REQUEST_INVALID", - "Windows daemon environment values cannot contain CR, LF, or % characters.", + "Windows daemon environment values cannot contain CR or LF characters.", ); } - return value.replaceAll('"', '""'); + return value.replaceAll("%", "%%").replaceAll('"', '""'); } diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts index ef81e38c..2606a315 100644 --- a/packages/core/src/daemon/shell.ts +++ b/packages/core/src/daemon/shell.ts @@ -18,7 +18,7 @@ export function serviceCommand(config: { display: direct.map(shellQuote).join(" "), }; } - const shellCommand = direct.map(shellQuote).join(" "); + const shellCommand = shellCommandLine(config.command.shell, direct); const shell = [config.command.shell.executable, ...config.command.shell.args, shellCommand]; return { executable: shell[0]!, @@ -26,3 +26,28 @@ export function serviceCommand(config: { display: shell.map(shellQuote).join(" "), }; } + +function shellCommandLine(shell: { executable: string; args: string[] }, argv: string[]): string { + if (isPowerShell(shell)) return `& ${argv.map(powerShellQuote).join(" ")}`; + if (isCmd(shell)) return argv.map(cmdQuote).join(" "); + return argv.map(shellQuote).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(); + return executable.endsWith("/cmd.exe") || executable === "cmd.exe" || shell.args.includes("/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('"', '""')}"`; +} diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 44ba24a3..c3dbbb00 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -16,6 +16,7 @@ import { uninstallDaemon, type DaemonCommandRunner, } from "../src/daemon"; +import { serviceCommand } from "../src/daemon/shell"; describe("caplets daemon CLI", () => { it("shows daemon help and removes daemon lifecycle from serve help", async () => { @@ -256,6 +257,7 @@ describe("daemon paths and config", () => { 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("WorkingDirectory"); const systemd = await installDaemon( @@ -316,27 +318,54 @@ describe("daemon paths and config", () => { commandRunner: fakeRunner(), }, ), - ).rejects.toThrow(/cannot contain CR, LF, or %/u); + ).rejects.toThrow(/cannot contain CR or LF/u); - await expect( - 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(), + const descriptor = await installDaemon( + { env: ["TOKEN=%PATH%"], validate: false, dryRun: true }, + { + env: { + APPDATA: join(dir, "AppData", "Roaming"), + LOCALAPPDATA: join(dir, "AppData", "Local"), }, - ), - ).rejects.toThrow(/cannot contain CR, LF, or %/u); + 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("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"], + shell: { executable: "cmd.exe", args: ["/d", "/s", "/c"] }, + }, + }); + 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"], + shell: { + executable: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + args: ["-NoProfile", "-Command"], + }, + }, + }); + expect(powerShell.args.at(-1)).toContain("& "); + expect(powerShell.args.at(-1)).toContain("'C:\\Program Files\\Caplets\\cli.js'"); + }); + it("rejects temporary CLI runner paths for daemon installs", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-runner-")); const originalArgv = process.argv[1]; @@ -463,6 +492,63 @@ describe("daemon lifecycle and logs", () => { } }); + 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 start when the native service does not pass HTTP health", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-health-")); try { @@ -604,6 +690,140 @@ describe("daemon lifecycle and logs", () => { 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("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 options = { + env: testEnv(dir), + platform: "linux" as const, + commandRunner: fakeRunner(), + }; + await installDaemon( + { user: "alice", password: "secret", allowUnauthenticatedHttp: false, validate: false }, + options, + ); + + const status = await daemonStatus(options); + + 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?.command.args).toContain("--password"); + expect(status.config?.command.args).not.toContain("secret"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); function testEnv(dir: string): NodeJS.ProcessEnv { From d2fd11c56f188374a2b512af64ae303e197474de Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 09:56:06 -0400 Subject: [PATCH 04/14] fix(daemon): preserve descriptor modes on rollback --- packages/core/src/daemon/manager.ts | 16 +++++++++++++--- packages/core/test/serve-daemon.test.ts | 13 ++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index f7112efc..bcbf8067 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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"; @@ -305,7 +313,7 @@ async function writeDescriptorForInstall( } } -type DescriptorBackup = { path: string; existed: boolean; contents?: Buffer }; +type DescriptorBackup = { path: string; existed: boolean; contents?: Buffer; mode?: number }; function backupDescriptorFiles(descriptor: DaemonDescriptor): DescriptorBackup[] { const paths = @@ -316,6 +324,7 @@ function backupDescriptorFiles(descriptor: DaemonDescriptor): DescriptorBackup[] path, existed: existsSync(path), ...(existsSync(path) ? { contents: readFileSync(path) } : {}), + ...(existsSync(path) ? { mode: statSync(path).mode & 0o777 } : {}), })); } @@ -323,7 +332,8 @@ 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); + writeFileSync(backup.path, backup.contents, { mode: backup.mode ?? 0o600 }); + chmodSync(backup.path, backup.mode ?? 0o600); } else { rmSync(backup.path, { force: true }); } diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index c3dbbb00..81875074 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -1,4 +1,13 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { describe, expect, it } from "vitest"; @@ -399,6 +408,7 @@ describe("daemon paths and config", () => { 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 }; @@ -413,6 +423,7 @@ describe("daemon paths and config", () => { ).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 }); From 5d53b46fde27ea363c34b3e6021987b164c8210a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 10:22:30 -0400 Subject: [PATCH 05/14] fix(daemon): address native lifecycle review feedback --- packages/core/src/cli.ts | 1 + packages/core/src/daemon/env.ts | 2 + packages/core/src/daemon/index.ts | 55 ++++-- packages/core/src/daemon/manager.ts | 69 ++++--- packages/core/src/daemon/platform-windows.ts | 1 + packages/core/src/daemon/shell.ts | 32 ++- packages/core/src/daemon/types.ts | 2 + packages/core/src/daemon/validation.ts | 19 +- packages/core/test/serve-daemon.test.ts | 195 +++++++++++++++++++ 9 files changed, 322 insertions(+), 54 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e92cb25b..811a25de 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -279,6 +279,7 @@ function daemonInstallOptions(options: DaemonInstallCommandOptions): DaemonInsta ...(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 } : {}), }; } diff --git a/packages/core/src/daemon/env.ts b/packages/core/src/daemon/env.ts index a7ab3de4..8a41ea97 100644 --- a/packages/core/src/daemon/env.ts +++ b/packages/core/src/daemon/env.ts @@ -40,6 +40,8 @@ function shellPlan( ? { executable, args: ["-NoProfile", "-Command"], source } : { executable, args: ["/d", "/s", "/c"], source }; } + if (executable.endsWith("/sh") || executable === "sh") + return { executable, args: ["-c"], source }; return { executable, args: ["-lc"], source }; } diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index 678419df..637a554f 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -42,7 +42,8 @@ export async function installDaemon( const env = options.env ?? process.env; const paths = resolveDaemonPaths(options); const manager = options.manager ?? createNativeDaemonManager(options); - const existing = install.reset ? undefined : readDaemonConfig(paths); + const persisted = readDaemonConfig(paths); + const existing = install.reset ? undefined : persisted; const daemonEnv = mergeDaemonEnv(existing?.env, install); const serve = resolveDaemonHttpServeOptions(mergeServeOptions(existing, install), env); const command = buildDaemonCommandPlan({ @@ -63,7 +64,10 @@ export async function installDaemon( const descriptor = manager.descriptor(config); const plannedActions = ["write-config", "write-descriptor", "register-service"]; - const existingNative = existing ? await manager.status(existing, paths) : undefined; + const existingNative = + persisted || existsSync(paths.descriptorFile) + ? await manager.status(persisted, paths) + : undefined; const restartDecisionRequired = existingNative?.running === true && !install.start && !install.restart && !install.noRestart; if ( @@ -92,7 +96,7 @@ export async function installDaemon( if (install.validate !== false) { validation = await validateInstallCommand({ config, - existing, + existing: persisted, existingNativeRunning: existingNative?.running === true, options, }); @@ -117,7 +121,7 @@ export async function installDaemon( install.restart || (install.start && existingNative?.running) ? await manager.restart(config) : await manager.start(config); - const health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); + const health = await waitForDaemonHealth(config, options); assertDaemonHealth(health, "Native daemon health check"); writeDaemonState(paths, { instance: "default", @@ -201,6 +205,30 @@ async function validateInstallCommand(input: { ); } +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 = {}, @@ -363,17 +391,14 @@ async function daemonLifecycle( updatedAt: (options.now ?? new Date()).toISOString(), ...(native.native.pid === undefined ? {} : { pid: native.native.pid }), }); - const status = await daemonStatus({ ...options, manager }); if (effectiveAction === "start" || effectiveAction === "restart") { - assertDaemonHealth( - status.health ?? { - ok: false, - url: "", - error: "native daemon did not report a running service", - }, - "Native daemon health check", - ); + 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 }; } @@ -451,3 +476,7 @@ export type { NativeDaemonStatus, RawDaemonServeOptions, } from "./types"; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index bcbf8067..60e882b8 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -53,24 +53,11 @@ function launchdManager( }, install: async (config) => { const descriptor = buildLaunchdDescriptor(config); - const commands = [["launchctl", "bootstrap", domain, descriptor.path]]; - const result = await writeDescriptorForInstall(descriptor, async () => { - const bootstrap = await runner.exec(commands[0]![0]!, commands[0]!.slice(1)); - if ( - bootstrap.code !== 0 && - !/already bootstrapped|service already loaded/iu.test(bootstrap.stderr) - ) { - throw new CapletsError( - "SERVER_UNAVAILABLE", - `launchd registration failed: ${bootstrap.stderr || bootstrap.stdout || bootstrap.code}`, - ); - } - return bootstrap; - }); + writeDescriptor(descriptor); return { action: "install", - native: stopped({ stdout: result.stdout, stderr: result.stderr }), - commands, + native: stopped(), + commands: [], descriptor, }; }, @@ -93,7 +80,8 @@ function launchdManager( commands, }; }, - start: async () => launchdLifecycle(runner, "start", ["launchctl", "kickstart", "-k", target]), + start: async (config) => + launchdStartLifecycle(runner, domain, target, config.paths.descriptorFile), restart: async (config) => launchdRestartLifecycle(runner, domain, target, config.paths.descriptorFile), stop: async () => { @@ -369,17 +357,48 @@ async function assertExecUnless( } } -async function launchdLifecycle( +async function launchdStartLifecycle( runner: DaemonCommandRunner, - action: string, - command: string[], - running = true, + domain: string, + target: string, + descriptorPath: string, ): Promise { - await assertExec(runner, command, `launchd ${action} failed`); + 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, - native: running ? { state: "running", installed: true, running: true } : stopped(), - commands: [command], + action: "start", + native: { state: "running", installed: true, running: true }, + commands, }; } diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts index cbf83006..1e11f236 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -22,6 +22,7 @@ ${Object.entries(config.command.env) const xml = ` true + InteractiveTokenLeastPrivilege PT0S PT1M3 diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts index 2606a315..42caaaed 100644 --- a/packages/core/src/daemon/shell.ts +++ b/packages/core/src/daemon/shell.ts @@ -7,6 +7,7 @@ export function serviceCommand(config: { command: { executable: string; args: string[]; + env?: Record; shell?: { executable: string; args: string[] }; }; }): { executable: string; args: string[]; display: string } { @@ -18,7 +19,7 @@ export function serviceCommand(config: { display: direct.map(shellQuote).join(" "), }; } - const shellCommand = shellCommandLine(config.command.shell, direct); + 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]!, @@ -27,10 +28,27 @@ export function serviceCommand(config: { }; } -function shellCommandLine(shell: { executable: string; args: string[] }, argv: string[]): string { - if (isPowerShell(shell)) return `& ${argv.map(powerShellQuote).join(" ")}`; - if (isCmd(shell)) return argv.map(cmdQuote).join(" "); - return argv.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 { @@ -51,3 +69,7 @@ function cmdQuote(value: string): string { if (/^[A-Za-z0-9_./:=@+-]+$/u.test(value)) return value; return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; } + +function cmdEnvValue(value: string): string { + return value.replaceAll(/\r?\n/gu, " ").replaceAll("%", "%%").replaceAll('"', '""'); +} diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts index 10e713d2..bc2a696e 100644 --- a/packages/core/src/daemon/types.ts +++ b/packages/core/src/daemon/types.ts @@ -149,6 +149,8 @@ export type DaemonOperationOptions = { now?: Date; readPrompt?: (prompt: string) => Promise; isInteractive?: boolean; + healthTimeoutMs?: number; + healthIntervalMs?: number; }; export type DaemonInstallOptions = RawDaemonServeOptions & { diff --git a/packages/core/src/daemon/validation.ts b/packages/core/src/daemon/validation.ts index 665822f1..c604413d 100644 --- a/packages/core/src/daemon/validation.ts +++ b/packages/core/src/daemon/validation.ts @@ -1,7 +1,7 @@ import { CapletsError } from "../errors"; import { servicePaths } from "../serve/http"; import type { DaemonConfig, DaemonHealthResult } from "./types"; -import { shellQuote } from "./shell"; +import { serviceCommand } from "./shell"; export async function validateDaemonCommand( config: DaemonConfig, @@ -37,7 +37,11 @@ export async function validateDaemonCommand( } ); } finally { - if (child.exitCode === null) child.kill("SIGTERM"); + if (child.exitCode === null) { + const closed = new Promise((resolve) => child.once("close", () => resolve())); + child.kill("SIGTERM"); + await Promise.race([closed, sleep(2_000)]); + } } } @@ -100,15 +104,8 @@ function healthUrl(config: DaemonConfig, port = config.serve.port): string { } function validationSpawnCommand(config: DaemonConfig): { command: string; args: string[] } { - const argv = [config.command.executable, ...config.command.args]; - if (!config.command.shell) return { command: process.execPath, args: argv }; - return { - command: config.command.shell.executable, - args: [ - ...config.command.shell.args, - `${shellQuote(process.execPath)} ${argv.map(shellQuote).join(" ")}`, - ], - }; + const planned = serviceCommand(config); + return { command: planned.executable, args: planned.args }; } async function sleep(ms: number): Promise { diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 81875074..a27ce2aa 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -83,6 +83,20 @@ describe("caplets daemon CLI", () => { 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 }); + } + }); }); describe("daemon paths and config", () => { @@ -302,6 +316,8 @@ describe("daemon paths and config", () => { 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(">> "); @@ -354,9 +370,11 @@ describe("daemon paths and config", () => { 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"); @@ -365,14 +383,46 @@ describe("daemon paths and config", () => { 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"); + }); + + 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("rejects temporary CLI runner paths for daemon installs", async () => { @@ -560,6 +610,37 @@ describe("daemon lifecycle and logs", () => { } }); + 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("fails start when the native service does not pass HTTP health", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-health-")); try { @@ -569,6 +650,8 @@ describe("daemon lifecycle and logs", () => { platform: "linux" as const, commandRunner: runner, fetch: async () => new Response("nope", { status: 503 }), + healthTimeoutMs: 10, + healthIntervalMs: 1, }; await installDaemon({ validate: false }, options); @@ -578,6 +661,118 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { From 3b852a714d0a21b36a20aacd58e3727a789dbd8c Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 14:36:28 -0400 Subject: [PATCH 06/14] fix(daemon): harden review edge cases --- packages/core/src/cli.ts | 2 +- packages/core/src/daemon/env.ts | 16 +- packages/core/src/daemon/index.ts | 53 ++++- packages/core/src/daemon/manager.ts | 40 +++- packages/core/src/daemon/types.ts | 2 +- packages/core/src/daemon/validation.ts | 29 ++- packages/core/test/serve-daemon.test.ts | 275 +++++++++++++++++++++++- 7 files changed, 393 insertions(+), 24 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 811a25de..503a0c7c 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -586,7 +586,7 @@ export function createProgram(io: CliIO = {}): Command { addDaemonInstallOptions( daemon.command("install").description("Install or update the default Caplets daemon."), ).action(async (options: DaemonInstallCommandOptions) => { - const prompt = createSetupPromptHandle(io, writeOut); + const prompt = options.json ? undefined : createSetupPromptHandle(io, writeOut); try { const result = await installDaemon(daemonInstallOptions(options), { ...daemonOptions(), diff --git a/packages/core/src/daemon/env.ts b/packages/core/src/daemon/env.ts index 8a41ea97..383c17cf 100644 --- a/packages/core/src/daemon/env.ts +++ b/packages/core/src/daemon/env.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { userInfo } from "node:os"; import { CapletsError } from "../errors"; import type { DaemonOperationOptions, DaemonShellPlan } from "./types"; @@ -7,8 +8,6 @@ export function resolveDaemonShell( ): DaemonShellPlan { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; - const shell = nonEmpty(env.SHELL); - if (shell) return shellPlan(platform, shell, "SHELL"); if (platform === "win32") { const powerShell = firstExisting([ @@ -20,7 +19,10 @@ export function resolveDaemonShell( return shellPlan(platform, comSpec, "fallback"); } - const accountShell = nonEmpty(options.accountShell); + 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( @@ -49,6 +51,14 @@ 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 index 637a554f..b84dddfa 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -249,7 +249,7 @@ export async function uninstallDaemon( }; } const nativeStatus = await manager.status(config, paths); - if (nativeStatus.running && config) await manager.stop(config); + if (nativeStatus.running) await manager.stop(config); const native = await manager.uninstall(config, paths); if (uninstall.purge) { removeDaemonConfig(paths); @@ -324,7 +324,7 @@ async function daemonStatusSnapshot( nativeState: native.state, paths, ...(config ? { config: redactDaemonConfig(config) } : {}), - native, + native: redactNativeStatus(native, config), }; if (config && native.running) { status.health = await probeDaemonHealth(config, options.fetch ? { fetch: options.fetch } : {}); @@ -333,8 +333,13 @@ async function daemonStatusSnapshot( } 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 @@ -344,10 +349,54 @@ function redactDaemonConfig(config: DaemonConfig): DaemonConfig { command: { ...config.command, args: redactPasswordArgs(config.command.args), + env: redactEnv(config.command.env), }, }; } +function redactNativeStatus( + native: DaemonStatus["native"], + config: DaemonConfig | undefined, +): DaemonStatus["native"] { + if (!config) return native; + return redactNativeValue(native, collectDaemonSecrets(config)) as DaemonStatus["native"]; +} + +function collectDaemonSecrets(config: DaemonConfig): string[] { + return 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), + ), + ); +} + +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 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 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) { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 60e882b8..b1ad508f 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -108,9 +108,17 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D 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 active = await runner.exec("systemctl", ["--user", "is-active", SYSTEMD_UNIT]); - if (show.code !== 0 && !existsSync(paths.descriptorFile)) - return notInstalled({ stderr: show.stderr }); + 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", @@ -141,10 +149,16 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D ["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"); - }); + 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) => { @@ -290,6 +304,7 @@ function writeDescriptor(descriptor: DaemonDescriptor): void { async function writeDescriptorForInstall( descriptor: DaemonDescriptor, register: () => Promise, + afterRestore?: () => Promise, ): Promise { const backups = backupDescriptorFiles(descriptor); writeDescriptor(descriptor); @@ -297,6 +312,13 @@ async function writeDescriptorForInstall( 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; } } @@ -504,6 +526,12 @@ function parseSystemdShow(value: string): Record { ); } +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 diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts index bc2a696e..cd441b79 100644 --- a/packages/core/src/daemon/types.ts +++ b/packages/core/src/daemon/types.ts @@ -121,7 +121,7 @@ export type DaemonManager = { uninstall(config: DaemonConfig | undefined, paths: DaemonPaths): Promise; start(config: DaemonConfig): Promise; restart(config: DaemonConfig): Promise; - stop(config: DaemonConfig): Promise; + stop(config?: DaemonConfig): Promise; }; export type DaemonManagerAction = { diff --git a/packages/core/src/daemon/validation.ts b/packages/core/src/daemon/validation.ts index c604413d..041aa4a5 100644 --- a/packages/core/src/daemon/validation.ts +++ b/packages/core/src/daemon/validation.ts @@ -14,30 +14,43 @@ export async function validateDaemonCommand( const command = validationSpawnCommand(config); const child = spawn(command.command, command.args, { cwd: config.command.workingDirectory, - env: { ...process.env, ...config.command.env }, + env: config.command.env, stdio: ["ignore", "ignore", "ignore"], }); + let spawnError: Error | undefined; + const spawnFailure = new Promise((resolve) => { + child.once("error", (error) => { + spawnError = error; + resolve({ ok: false, url: healthUrl(config), error: error.message }); + }); + }); try { const deadline = Date.now() + (options.timeoutMs ?? 5_000); let last: DaemonHealthResult | undefined; while (Date.now() < deadline) { - if (child.exitCode !== null) break; - last = await probeDaemonHealth(config, { - ...(options.fetch ? { fetch: options.fetch } : {}), - timeoutMs: 750, - }); + if (child.exitCode !== null || spawnError) break; + last = await Promise.race([ + probeDaemonHealth(config, { + ...(options.fetch ? { fetch: options.fetch } : {}), + timeoutMs: 750, + }), + spawnFailure, + ]); if (last.ok) return last; + if (spawnError) break; await sleep(150); } return ( last ?? { ok: false, url: healthUrl(config), - error: child.exitCode === null ? "health probe timed out" : "validation process exited", + error: + spawnError?.message ?? + (child.exitCode === null ? "health probe timed out" : "validation process exited"), } ); } finally { - if (child.exitCode === null) { + if (child.exitCode === null && !spawnError) { const closed = new Promise((resolve) => child.once("close", () => resolve())); child.kill("SIGTERM"); await Promise.race([closed, sleep(2_000)]); diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index a27ce2aa..295b80a5 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -9,7 +9,7 @@ import { 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"; @@ -26,6 +26,7 @@ import { type DaemonCommandRunner, } 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 () => { @@ -97,6 +98,36 @@ describe("caplets daemon CLI", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("keeps daemon install --json noninteractive", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-json-")); + try { + const out: string[] = []; + const runner = fakeRunner({ active: true }); + await installDaemon( + { validate: false }, + { + env: testEnv(dir), + platform: "linux", + commandRunner: runner, + }, + ); + + 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 }); + } + }); }); describe("daemon paths and config", () => { @@ -425,6 +456,56 @@ describe("daemon paths and config", () => { } }); + 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]; @@ -480,6 +561,39 @@ describe("daemon paths and config", () => { } }); + 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("ignores old serve/default artifacts", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-ignore-")); try { @@ -515,6 +629,34 @@ describe("daemon lifecycle and logs", () => { } }); + 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("runtime start fails before install with install guidance", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-start-")); try { @@ -876,6 +1018,32 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { @@ -1009,23 +1177,124 @@ describe("daemon lifecycle and logs", () => { 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: fakeRunner(), + commandRunner: runner, }; await installDaemon( - { user: "alice", password: "secret", allowUnauthenticatedHttp: false, validate: false }, + { + 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 }); + } + }); +}); + +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("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 }); } From 3407fa4b1f908f0c24f1401183b30e6d2ca5604e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 16:25:45 -0400 Subject: [PATCH 07/14] fix(daemon): close remaining service review gaps --- packages/core/src/cli.ts | 3 +- packages/core/src/daemon/index.ts | 38 ++++- packages/core/src/daemon/manager.ts | 9 +- packages/core/src/daemon/platform-windows.ts | 8 +- packages/core/src/daemon/process.ts | 7 +- packages/core/src/daemon/shell.ts | 10 +- packages/core/test/serve-daemon.test.ts | 164 ++++++++++++++++++- 7 files changed, 227 insertions(+), 12 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 503a0c7c..412c8572 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -82,6 +82,7 @@ import { daemonStatus, followDaemonLogs, installDaemon, + redactDaemonInstallResult, resolveDaemonPaths, restartDaemon, startDaemon, @@ -595,7 +596,7 @@ export function createProgram(io: CliIO = {}): Command { : { isInteractive: false }), }); if (options.json) { - writeOut(`${JSON.stringify(result, null, 2)}\n`); + writeOut(`${JSON.stringify(redactDaemonInstallResult(result), null, 2)}\n`); return; } if (result.dryRun) { diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index b84dddfa..a07851c3 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -133,7 +133,19 @@ export async function installDaemon( }); } 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())) await manager.restart(config); + 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 { @@ -332,6 +344,26 @@ async function daemonStatusSnapshot( 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 { @@ -385,6 +417,10 @@ function redactNativeValue(value: unknown, secrets: string[]): unknown { 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]"])); } diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index b1ad508f..1e766a05 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -116,6 +116,9 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D ? 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}`); @@ -124,17 +127,17 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D state: "running", installed: true, running: true, - raw: parseSystemdShow(show.stdout), + raw, }; if (active.stdout.trim() === "failed") return failedStatus({ active: active.stdout.trim(), - raw: parseSystemdShow(show.stdout), + raw, stderr: active.stderr, }); return stopped({ active: active.stdout.trim(), - raw: parseSystemdShow(show.stdout), + raw, stderr: active.stderr, }); }, diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts index 1e11f236..874e831a 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -41,15 +41,15 @@ ${Object.entries(config.command.env) } function windowsArg(value: string): string { - return `"${value.replaceAll('"', '""')}"`; + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; } function windowsEnvValue(value: string): string { - if (/[\r\n]/u.test(value)) { + if (/["\r\n]/u.test(value)) { throw new CapletsError( "REQUEST_INVALID", - "Windows daemon environment values cannot contain CR or LF characters.", + 'Windows daemon environment values cannot contain ", CR, or LF characters.', ); } - return value.replaceAll("%", "%%").replaceAll('"', '""'); + return value.replaceAll("%", "%%"); } diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts index 7bea38cb..726c28dc 100644 --- a/packages/core/src/daemon/process.ts +++ b/packages/core/src/daemon/process.ts @@ -54,11 +54,16 @@ export function buildDaemonCommandPlan(options: { const executable = resolveDaemonExecutable(process.argv[1]); const args = daemonServeArgs(options.serve); const workingDirectory = options.operation.home ?? homedir(); + const explicitEnv = options.explicitEnv ?? {}; + const serviceEnv = + options.serve.publicOrigin && explicitEnv.CAPLETS_SERVER_URL === undefined + ? { ...explicitEnv, CAPLETS_SERVER_URL: options.serve.publicOrigin } + : explicitEnv; const base = { executable, args, workingDirectory, - env: options.explicitEnv ?? {}, + env: serviceEnv, inheritEnv: options.inheritEnv ?? false, stdoutLog: options.paths.stdoutLog, stderrLog: options.paths.stderrLog, diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts index 42caaaed..d2049fd4 100644 --- a/packages/core/src/daemon/shell.ts +++ b/packages/core/src/daemon/shell.ts @@ -1,3 +1,5 @@ +import { CapletsError } from "../errors"; + export function shellQuote(value: string): string { if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(value)) return value; return `'${value.replaceAll("'", "'\\''")}'`; @@ -71,5 +73,11 @@ function cmdQuote(value: string): string { } function cmdEnvValue(value: string): string { - return value.replaceAll(/\r?\n/gu, " ").replaceAll("%", "%%").replaceAll('"', '""'); + 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/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 295b80a5..c667a726 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -128,6 +128,41 @@ describe("caplets daemon CLI", () => { 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", + "secret", + "--env", + "TOKEN=abc123", + ], + { + env: testEnv(dir), + writeOut: (value) => out.push(value), + writeErr: () => {}, + daemon: { platform: "linux", commandRunner: fakeRunner() }, + }, + ); + + const serialized = out.join(""); + expect(serialized).toContain("[redacted]"); + expect(serialized).not.toContain("secret"); + expect(serialized).not.toContain("abc123"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("daemon paths and config", () => { @@ -353,6 +388,23 @@ describe("daemon paths and config", () => { 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"); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -374,7 +426,22 @@ describe("daemon paths and config", () => { commandRunner: fakeRunner(), }, ), - ).rejects.toThrow(/cannot contain CR or LF/u); + ).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 }, @@ -435,6 +502,37 @@ describe("daemon paths and config", () => { }); expect(bash.args.at(-1)).toContain("export PATH=/custom/bin; exec "); expect(bash.args.at(-1)).toContain(" /usr/local/bin/caplets serve"); + + 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); + }); + + 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("uses non-login inherited shell mode for /bin/sh", async () => { @@ -657,6 +755,39 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { @@ -783,6 +914,37 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { From 9af063596bbbd212738852985c283b650bd8c9f8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 17:56:02 -0400 Subject: [PATCH 08/14] fix(daemon): address service review edge cases --- packages/core/src/daemon/index.ts | 143 +++++++++++-- packages/core/src/daemon/manager.ts | 22 +- packages/core/src/daemon/platform-linux.ts | 1 + packages/core/src/daemon/process.ts | 6 +- packages/core/src/daemon/types.ts | 4 +- packages/core/test/serve-daemon.test.ts | 225 ++++++++++++++++++++- 6 files changed, 379 insertions(+), 22 deletions(-) diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index a07851c3..3fab1be3 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { dirname } from "node:path"; import { CapletsError } from "../errors"; import { @@ -25,6 +33,7 @@ import type { DaemonInstallOptions, DaemonInstallResult, DaemonLifecycleResult, + DaemonManager, DaemonLogStream, DaemonLogsResult, DaemonOperationOptions, @@ -45,7 +54,11 @@ export async function installDaemon( const persisted = readDaemonConfig(paths); const existing = install.reset ? undefined : persisted; const daemonEnv = mergeDaemonEnv(existing?.env, install); - const serve = resolveDaemonHttpServeOptions(mergeServeOptions(existing, install), env); + 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, @@ -105,16 +118,25 @@ export async function installDaemon( mkdirSync(paths.logDir, { recursive: true, mode: 0o700 }); ensureDaemonLogFiles(paths); - const native = await manager.install(config); - writeDaemonConfig(paths, 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 }), - }); + 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 = @@ -395,7 +417,7 @@ function redactNativeStatus( } function collectDaemonSecrets(config: DaemonConfig): string[] { - return Array.from( + const secrets = Array.from( new Set( [ config.serve.auth.enabled ? config.serve.auth.password : undefined, @@ -404,6 +426,9 @@ function collectDaemonSecrets(config: DaemonConfig): string[] { ].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 { @@ -429,6 +454,52 @@ function redactSecrets(value: string, secrets: string[]): string { return secrets.reduce((redacted, secret) => redacted.replaceAll(secret, "[redacted]"), value); } +function secretRedactionVariants(secret: string): string[] { + return [ + secret, + jsonEscapedSecret(secret), + systemdEscapedSecret(secret), + xmlEscapedSecret(secret), + cmdEscapedSecret(secret), + powershellSingleQuotedSecret(secret), + posixSingleQuotedSecret(secret), + ].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 posixSingleQuotedSecret(secret: string): string { + return secret.replaceAll("'", "'\"'\"'"); +} + function redactPasswordString(value: string): string { return value.replace(/(--password(?:=|\s+))("[^"]*"|'[^']*'|\S+)/giu, "$1[redacted]"); } @@ -527,9 +598,55 @@ function mergeServeOptions( : 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) { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 1e766a05..048a46f7 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -62,11 +62,20 @@ function launchdManager( }; }, uninstall: async (_config, paths) => { + if (!existsSync(paths.descriptorFile)) { + return { + action: "uninstall", + native: notInstalled(), + commands: [], + }; + } const commands = [["launchctl", "bootout", domain, paths.descriptorFile]]; 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/iu.test(result.stderr) + !/No such process|not found|Could not find service|No such file or directory/iu.test( + result.stderr, + ) ) { throw new CapletsError( "SERVER_UNAVAILABLE", @@ -242,8 +251,15 @@ function windowsTaskManager(runner: DaemonCommandRunner): DaemonManager { }, start: async () => windowsLifecycle(runner, "start", ["/Run", "/TN", WINDOWS_TASK_NAME]), restart: async () => { - await runner.exec("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME]); - return windowsLifecycle(runner, "restart", ["/Run", "/TN", WINDOWS_TASK_NAME]); + 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]; diff --git a/packages/core/src/daemon/platform-linux.ts b/packages/core/src/daemon/platform-linux.ts index c36862b8..64dc569b 100644 --- a/packages/core/src/daemon/platform-linux.ts +++ b/packages/core/src/daemon/platform-linux.ts @@ -38,5 +38,6 @@ function systemdEscape(value: string): string { .replaceAll("\\", "\\\\") .replaceAll('"', '\\"') .replaceAll("%", "%%") + .replaceAll("$", "$$$$") .replaceAll("\n", "\\n"); } diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts index 726c28dc..3232224e 100644 --- a/packages/core/src/daemon/process.ts +++ b/packages/core/src/daemon/process.ts @@ -21,7 +21,11 @@ export function resolveDaemonHttpServeOptions( "caplets daemon install does not accept --transport.", ); } - return resolveServeOptions({ ...raw, transport: "http" }, env) as HttpServeOptions; + 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[] { diff --git a/packages/core/src/daemon/types.ts b/packages/core/src/daemon/types.ts index cd441b79..6618d37b 100644 --- a/packages/core/src/daemon/types.ts +++ b/packages/core/src/daemon/types.ts @@ -2,7 +2,9 @@ import type { HttpServeOptions, RawServeOptions } from "../serve/options"; export type DaemonInstance = "default"; -export type RawDaemonServeOptions = Omit; +export type RawDaemonServeOptions = Omit & { + preserveUnauthenticatedAuth?: boolean; +}; export type DaemonPaths = { instance: DaemonInstance; diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index c667a726..729bd793 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -24,6 +24,7 @@ import { startDaemon, uninstallDaemon, type DaemonCommandRunner, + type DaemonManager, } from "../src/daemon"; import { serviceCommand } from "../src/daemon/shell"; import { allocateLoopbackPort, validateDaemonCommand } from "../src/daemon/validation"; @@ -143,9 +144,9 @@ describe("caplets daemon CLI", () => { "--user", "alice", "--password", - "secret", + 'pa"$USER', "--env", - "TOKEN=abc123", + 'TOKEN=a&b"c', ], { env: testEnv(dir), @@ -156,9 +157,14 @@ describe("caplets daemon CLI", () => { ); const serialized = out.join(""); + const parsed = JSON.parse(serialized) as { + descriptor?: { contents?: string }; + }; expect(serialized).toContain("[redacted]"); - expect(serialized).not.toContain("secret"); - expect(serialized).not.toContain("abc123"); + 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'); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -367,6 +373,18 @@ describe("daemon paths and config", () => { 'Environment="MULTI=line\\nExecStartPre=/bin/false"', ); expect(systemd.descriptor.contents).not.toContain("\nExecStartPre=/bin/false"); + const systemdWithDollar = await installDaemon( + { ...common, password: "pa$USER", allowUnauthenticatedHttp: false }, + { + 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"); const windows = await installDaemon(common, { env: { @@ -535,6 +553,63 @@ describe("daemon paths and config", () => { } }); + 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 { @@ -692,6 +767,64 @@ describe("daemon paths and config", () => { } }); + 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 { @@ -1126,6 +1259,90 @@ describe("daemon lifecycle and logs", () => { } }); + it("treats repeated launchd uninstall with preserved config as not installed", 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).not.toContainEqual([ + "launchctl", + "bootout", + "gui/501", + installed.config.paths.descriptorFile, + ]); + 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 { From ce130a2c01d45c64efb6a9a8ce6e32804b3a41dd Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 21:48:56 -0400 Subject: [PATCH 09/14] fix(daemon): address review edge cases --- packages/core/src/cli.ts | 4 +- packages/core/src/daemon/index.ts | 10 ++- packages/core/src/daemon/manager.ts | 1 + packages/core/src/daemon/platform-linux.ts | 1 + packages/core/src/daemon/shell.ts | 4 +- packages/core/test/serve-daemon.test.ts | 78 ++++++++++++++++++++++ 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 412c8572..c8b6463c 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -634,7 +634,9 @@ export function createProgram(io: CliIO = {}): Command { writeOut(`${JSON.stringify(result, null, 2)}\n`); return; } - writeOut("Started Caplets daemon.\n"); + writeOut( + result.action === "restart" ? "Restarted Caplets daemon.\n" : "Started Caplets daemon.\n", + ); }, ); diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index 3fab1be3..1a4202c9 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -270,8 +270,16 @@ export async function uninstallDaemon( 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, paths.configFile, paths.stateFile, paths.stdoutLog, paths.stderrLog] + ? [ + paths.descriptorFile, + ...(removesWrapper ? [paths.wrapperFile] : []), + paths.configFile, + paths.stateFile, + paths.stdoutLog, + paths.stderrLog, + ] : [paths.descriptorFile]; if (uninstall.dryRun) { return { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 048a46f7..f74be434 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -362,6 +362,7 @@ function restoreDescriptorFiles(backups: DescriptorBackup[]): void { 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 }); diff --git a/packages/core/src/daemon/platform-linux.ts b/packages/core/src/daemon/platform-linux.ts index 64dc569b..2db844cb 100644 --- a/packages/core/src/daemon/platform-linux.ts +++ b/packages/core/src/daemon/platform-linux.ts @@ -39,5 +39,6 @@ function systemdEscape(value: string): string { .replaceAll('"', '\\"') .replaceAll("%", "%%") .replaceAll("$", "$$$$") + .replaceAll("\r", "\\r") .replaceAll("\n", "\\n"); } diff --git a/packages/core/src/daemon/shell.ts b/packages/core/src/daemon/shell.ts index d2049fd4..af719b61 100644 --- a/packages/core/src/daemon/shell.ts +++ b/packages/core/src/daemon/shell.ts @@ -60,7 +60,9 @@ function isPowerShell(shell: { executable: string }): boolean { function isCmd(shell: { executable: string; args: string[] }): boolean { const executable = shell.executable.replaceAll("\\", "/").toLocaleLowerCase(); - return executable.endsWith("/cmd.exe") || executable === "cmd.exe" || shell.args.includes("/c"); + 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 { diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 729bd793..47ad4cee 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -130,6 +130,38 @@ describe("caplets daemon CLI", () => { } }); + 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: options, + }); + + 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 { @@ -373,6 +405,21 @@ describe("daemon paths and config", () => { '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 }, { @@ -521,6 +568,17 @@ describe("daemon paths and config", () => { 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: "/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(() => serviceCommand({ command: { @@ -1387,6 +1445,7 @@ describe("daemon lifecycle and logs", () => { 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); @@ -1397,6 +1456,25 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { From ca46393287f9776674deac16f0ad121f8780bafb Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 20 Jun 2026 05:12:52 -0400 Subject: [PATCH 10/14] fix(daemon): address native service review gaps --- packages/core/src/daemon/env.ts | 2 +- packages/core/src/daemon/index.ts | 24 +- packages/core/src/daemon/manager.ts | 40 +++- packages/core/src/daemon/platform-darwin.ts | 2 + packages/core/src/daemon/platform-windows.ts | 6 + packages/core/test/serve-daemon.test.ts | 222 ++++++++++++++++++- 6 files changed, 273 insertions(+), 23 deletions(-) diff --git a/packages/core/src/daemon/env.ts b/packages/core/src/daemon/env.ts index 383c17cf..d924c596 100644 --- a/packages/core/src/daemon/env.ts +++ b/packages/core/src/daemon/env.ts @@ -39,7 +39,7 @@ function shellPlan( if (platform === "win32") { const lower = executable.toLocaleLowerCase(); return lower.endsWith("powershell.exe") || lower.endsWith("pwsh.exe") - ? { executable, args: ["-NoProfile", "-Command"], source } + ? { executable, args: ["-Command"], source } : { executable, args: ["/d", "/s", "/c"], source }; } if (executable.endsWith("/sh") || executable === "sh") diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index 1a4202c9..f3f9aa95 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -213,9 +213,8 @@ async function validateInstallCommand(input: { options: DaemonOperationOptions; }): Promise { const useTemporaryPort = - input.existing && input.existingNativeRunning && - input.existing.serve.port === input.config.serve.port; + (!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) { @@ -420,8 +419,10 @@ function redactNativeStatus( native: DaemonStatus["native"], config: DaemonConfig | undefined, ): DaemonStatus["native"] { - if (!config) return native; - return redactNativeValue(native, collectDaemonSecrets(config)) as DaemonStatus["native"]; + return redactNativeValue( + native, + config ? collectDaemonSecrets(config) : [], + ) as DaemonStatus["native"]; } function collectDaemonSecrets(config: DaemonConfig): string[] { @@ -463,14 +464,19 @@ function redactSecrets(value: string, secrets: string[]): string { } 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), - posixSingleQuotedSecret(secret), + posixShellEscaped, + posixShellQuoted, ].filter((value) => value.length > 0); } @@ -504,8 +510,12 @@ function powershellSingleQuotedSecret(secret: string): string { return secret.replaceAll("'", "''"); } -function posixSingleQuotedSecret(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 { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index f74be434..d9ad68dc 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -61,15 +61,20 @@ function launchdManager( descriptor, }; }, - uninstall: async (_config, paths) => { - if (!existsSync(paths.descriptorFile)) { + uninstall: async (config, paths) => { + const hasDescriptor = existsSync(paths.descriptorFile); + if (!hasDescriptor && !config) { return { action: "uninstall", native: notInstalled(), commands: [], }; } - const commands = [["launchctl", "bootout", domain, paths.descriptorFile]]; + 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 && @@ -186,8 +191,14 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D "systemd unregister failed", /not loaded|not found|No such file|does not exist/iu, ); + const descriptorBackup = backupPath(paths.descriptorFile); rmSync(paths.descriptorFile, { force: true }); - await assertExec(runner, commands[1]!, "systemd unregister failed"); + try { + await assertExec(runner, commands[1]!, "systemd unregister failed"); + } catch (error) { + restoreDescriptorFiles([descriptorBackup]); + throw error; + } return { action: "uninstall", native: notInstalled(), commands }; }, start: async () => systemdLifecycle(runner, "start", "start"), @@ -218,7 +229,7 @@ function windowsTaskManager(runner: DaemonCommandRunner): DaemonManager { const lastRun = String(raw["Last Run Result"] ?? ""); if (/running/iu.test(status)) return { state: "running", installed: true, running: true, raw }; - if (lastRun && !/0x0|\b0\b/iu.test(lastRun)) return failedStatus(raw); + if (lastRun && !isSuccessfulWindowsLastRunResult(lastRun)) return failedStatus(raw); return stopped(raw); }, install: async (config) => { @@ -349,12 +360,17 @@ function backupDescriptorFiles(descriptor: DaemonDescriptor): DescriptorBackup[] descriptor.kind === "windows-scheduled-task" ? [descriptor.path, descriptor.wrapper.path] : [descriptor.path]; - return paths.map((path) => ({ + return paths.map(backupPath); +} + +function backupPath(path: string): DescriptorBackup { + const existed = existsSync(path); + return { path, - existed: existsSync(path), - ...(existsSync(path) ? { contents: readFileSync(path) } : {}), - ...(existsSync(path) ? { mode: statSync(path).mode & 0o777 } : {}), - })); + existed, + ...(existed ? { contents: readFileSync(path) } : {}), + ...(existed ? { mode: statSync(path).mode & 0o777 } : {}), + }; } function restoreDescriptorFiles(backups: DescriptorBackup[]): void { @@ -562,6 +578,10 @@ function parseWindowsList(value: string): Record { ); } +function isSuccessfulWindowsLastRunResult(value: string): boolean { + return /\b(?:0|0x0+|0x0*41303)\b/iu.test(value); +} + function nodeCommandRunner(): DaemonCommandRunner { return { async exec(command, args) { diff --git a/packages/core/src/daemon/platform-darwin.ts b/packages/core/src/daemon/platform-darwin.ts index 33e54975..082cb7c3 100644 --- a/packages/core/src/daemon/platform-darwin.ts +++ b/packages/core/src/daemon/platform-darwin.ts @@ -23,6 +23,8 @@ ${command.map((value) => ` ${value}`).join("\n")} RunAtLoad + KeepAlive + WorkingDirectory ${escapeXml(config.command.workingDirectory)} StandardOutPath diff --git a/packages/core/src/daemon/platform-windows.ts b/packages/core/src/daemon/platform-windows.ts index 874e831a..9dea92f6 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -41,6 +41,12 @@ ${Object.entries(config.command.env) } 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('"', '""')}"`; } diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 47ad4cee..60fbb515 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -179,9 +179,12 @@ describe("caplets daemon CLI", () => { 'pa"$USER', "--env", 'TOKEN=a&b"c', + "--env", + "QUOTE=a'b", + "--inherit-env", ], { - env: testEnv(dir), + env: { ...testEnv(dir), SHELL: "/bin/bash" }, writeOut: (value) => out.push(value), writeErr: () => {}, daemon: { platform: "linux", commandRunner: fakeRunner() }, @@ -197,6 +200,7 @@ describe("caplets daemon CLI", () => { 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 }); } @@ -365,6 +369,40 @@ describe("daemon paths and config", () => { } }); + 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" 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(validatedPorts).toHaveLength(1); + expect(validatedPorts[0]).not.toBe(5480); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("renders native daemon identities for launchd, systemd, and Windows tasks", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-descriptor-")); try { @@ -385,6 +423,7 @@ describe("daemon paths and config", () => { 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"); const systemd = await installDaemon( @@ -528,6 +567,33 @@ describe("daemon paths and config", () => { } }); + 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: { @@ -591,6 +657,32 @@ describe("daemon paths and config", () => { ).toThrow(/cannot contain/u); }); + 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 { @@ -1317,7 +1409,7 @@ describe("daemon lifecycle and logs", () => { } }); - it("treats repeated launchd uninstall with preserved config as not installed", async () => { + 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(); @@ -1335,11 +1427,10 @@ describe("daemon lifecycle and logs", () => { const repeated = await uninstallDaemon({}, options); expect(repeated.native?.native.state).toBe("not_installed"); - expect(runner.commands).not.toContainEqual([ + expect(runner.commands).toContainEqual([ "launchctl", "bootout", - "gui/501", - installed.config.paths.descriptorFile, + "gui/501/dev.caplets.daemon.default", ]); expect(existsSync(installed.config.paths.configFile)).toBe(true); } finally { @@ -1572,6 +1663,82 @@ describe("daemon lifecycle and logs", () => { } }); + 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; + + 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("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("removes Windows wrapper files and checks unregister failures", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-windows-uninstall-")); let paths: ReturnType | undefined; @@ -1682,6 +1849,51 @@ describe("daemon lifecycle and logs", () => { 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", () => { From b130198722d5cf7fbd511cee0a3600684fa3d782 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 20 Jun 2026 05:48:04 -0400 Subject: [PATCH 11/14] fix(daemon): honor native service state on lifecycle --- packages/core/src/daemon/index.ts | 8 +- packages/core/src/daemon/manager.ts | 11 ++- packages/core/test/serve-daemon.test.ts | 98 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/core/src/daemon/index.ts b/packages/core/src/daemon/index.ts index f3f9aa95..bad74666 100644 --- a/packages/core/src/daemon/index.ts +++ b/packages/core/src/daemon/index.ts @@ -542,7 +542,7 @@ async function daemonLifecycle( ): Promise { const paths = resolveDaemonPaths(options); const config = readDaemonConfig(paths); - if (!config || !existsSync(paths.descriptorFile)) { + if (!config) { throw new CapletsError( "REQUEST_INVALID", `Caplets daemon is not installed. Run caplets daemon install${action === "start" || action === "restart" ? " --start" : ""} first.`, @@ -550,6 +550,12 @@ async function daemonLifecycle( } 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" diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index d9ad68dc..1a4064b6 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -98,12 +98,15 @@ function launchdManager( launchdStartLifecycle(runner, domain, target, config.paths.descriptorFile), restart: async (config) => launchdRestartLifecycle(runner, domain, target, config.paths.descriptorFile), - stop: async () => { - const command = ["launchctl", "kill", "TERM", target]; + 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 running|Could not find service/iu.test(result.stderr) + !/No such process|not found|not running|Could not find service/iu.test(result.stderr) ) { throw new CapletsError( "SERVER_UNAVAILABLE", @@ -337,8 +340,8 @@ async function writeDescriptorForInstall( afterRestore?: () => Promise, ): Promise { const backups = backupDescriptorFiles(descriptor); - writeDescriptor(descriptor); try { + writeDescriptor(descriptor); return await register(); } catch (error) { restoreDescriptorFiles(backups); diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 60fbb515..54d502db 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from "vitest"; import { runCli } from "../src/cli"; import type { CapletsError } from "../src/errors"; import { + createNativeDaemonManager, daemonServeArgs, daemonLogs, daemonStatus, @@ -22,6 +23,7 @@ import { resolveDaemonPaths, restartDaemon, startDaemon, + stopDaemon, uninstallDaemon, type DaemonCommandRunner, type DaemonManager, @@ -884,6 +886,43 @@ describe("daemon paths and config", () => { } }); + 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("reloads systemd after restoring a failed install", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-rollback-reload-")); try { @@ -1082,6 +1121,32 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { @@ -1409,6 +1474,39 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { From 4f1fe36bdb7f7c433b363885d0958c4d7595e69a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 20 Jun 2026 06:44:22 -0400 Subject: [PATCH 12/14] fix(daemon): address native service edge cases --- packages/core/src/daemon/manager.ts | 4 + packages/core/src/daemon/platform-linux.ts | 18 +-- packages/core/src/daemon/platform-windows.ts | 3 + packages/core/src/daemon/validation.ts | 31 ++++-- packages/core/test/serve-daemon.test.ts | 110 ++++++++++++++++++- 5 files changed, 145 insertions(+), 21 deletions(-) diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 1a4064b6..13123026 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -46,6 +46,8 @@ function launchdManager( ? 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 }) @@ -328,9 +330,11 @@ function writeDescriptor(descriptor: DaemonDescriptor): void { 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); } } diff --git a/packages/core/src/daemon/platform-linux.ts b/packages/core/src/daemon/platform-linux.ts index 2db844cb..b81efdb0 100644 --- a/packages/core/src/daemon/platform-linux.ts +++ b/packages/core/src/daemon/platform-linux.ts @@ -6,7 +6,7 @@ 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}`)}`) + .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`, false)}`) .join("\n"); return { kind: "systemd-user", @@ -18,10 +18,10 @@ Description=Caplets daemon [Service] Type=simple WorkingDirectory=${systemdQuote(config.command.workingDirectory)} -${env ? `${env}\n` : ""}ExecStart=${[command.executable, ...command.args].map(systemdQuote).join(" ")} +${env ? `${env}\n` : ""}ExecStart=${[command.executable, ...command.args].map((value) => systemdQuote(value)).join(" ")} Restart=on-failure -StandardOutput=append:${systemdEscape(config.paths.stdoutLog)} -StandardError=append:${systemdEscape(config.paths.stderrLog)} +StandardOutput=append:${systemdEscape(config.paths.stdoutLog, true)} +StandardError=append:${systemdEscape(config.paths.stderrLog, true)} [Install] WantedBy=default.target @@ -29,16 +29,16 @@ WantedBy=default.target }; } -function systemdQuote(value: string): string { - return `"${systemdEscape(value)}"`; +function systemdQuote(value: string, escapeDollar = true): string { + return `"${systemdEscape(value, escapeDollar)}"`; } -function systemdEscape(value: string): string { - return value +function systemdEscape(value: string, escapeDollar: boolean): string { + const escaped = value .replaceAll("\\", "\\\\") .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 index 9dea92f6..fb2a0fda 100644 --- a/packages/core/src/daemon/platform-windows.ts +++ b/packages/core/src/daemon/platform-windows.ts @@ -51,6 +51,9 @@ function windowsArg(value: string): string { } 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", diff --git a/packages/core/src/daemon/validation.ts b/packages/core/src/daemon/validation.ts index 041aa4a5..f662449a 100644 --- a/packages/core/src/daemon/validation.ts +++ b/packages/core/src/daemon/validation.ts @@ -17,27 +17,37 @@ export async function validateDaemonCommand( env: config.command.env, stdio: ["ignore", "ignore", "ignore"], }); - let spawnError: Error | undefined; - const spawnFailure = new Promise((resolve) => { + let processDone = false; + let processError: string | undefined; + const processFailure = new Promise((resolve) => { child.once("error", (error) => { - spawnError = error; - resolve({ ok: false, url: healthUrl(config), error: error.message }); + 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 (child.exitCode !== null || spawnError) break; + if (processDone) break; last = await Promise.race([ probeDaemonHealth(config, { ...(options.fetch ? { fetch: options.fetch } : {}), timeoutMs: 750, }), - spawnFailure, + processFailure, ]); - if (last.ok) return last; - if (spawnError) break; + if (last.ok) { + const settled = await Promise.race([processFailure, sleep(250).then(() => undefined)]); + return settled ?? last; + } + if (processDone) break; await sleep(150); } return ( @@ -45,12 +55,11 @@ export async function validateDaemonCommand( ok: false, url: healthUrl(config), error: - spawnError?.message ?? - (child.exitCode === null ? "health probe timed out" : "validation process exited"), + processError ?? (processDone ? "validation process exited" : "health probe timed out"), } ); } finally { - if (child.exitCode === null && !spawnError) { + if (!processDone) { const closed = new Promise((resolve) => child.once("close", () => resolve())); child.kill("SIGTERM"); await Promise.race([closed, sleep(2_000)]); diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 54d502db..4e3a2936 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -462,7 +462,12 @@ describe("daemon paths and config", () => { 'Environment="MULTI=line\\rExecStartPre=/bin/false"', ); const systemdWithDollar = await installDaemon( - { ...common, password: "pa$USER", allowUnauthenticatedHttp: false }, + { + ...common, + password: "pa$USER", + allowUnauthenticatedHttp: false, + env: ["TOKEN=pa$USER"], + }, { env: testEnv(join(dir, "dollar")), home: "/home/alice", @@ -473,6 +478,8 @@ describe("daemon paths and config", () => { 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"'); const windows = await installDaemon(common, { env: { @@ -511,6 +518,21 @@ describe("daemon paths and config", () => { 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 }); } @@ -923,6 +945,25 @@ describe("daemon paths and config", () => { } }); + 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 { @@ -1077,6 +1118,36 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { @@ -2017,6 +2088,43 @@ describe("daemon validation", () => { } }); + 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 { From 8a0f4b2a0063bfd5feb096ec6273d097eb8767a8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 20 Jun 2026 07:04:22 -0400 Subject: [PATCH 13/14] fix(daemon): wait longer after validation health --- packages/core/src/daemon/validation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/daemon/validation.ts b/packages/core/src/daemon/validation.ts index f662449a..bb6cbcb3 100644 --- a/packages/core/src/daemon/validation.ts +++ b/packages/core/src/daemon/validation.ts @@ -7,6 +7,7 @@ export async function validateDaemonCommand( config: DaemonConfig, options: { fetch?: typeof fetch; + successSettleMs?: number; timeoutMs?: number; } = {}, ): Promise { @@ -44,7 +45,10 @@ export async function validateDaemonCommand( processFailure, ]); if (last.ok) { - const settled = await Promise.race([processFailure, sleep(250).then(() => undefined)]); + const settled = await Promise.race([ + processFailure, + sleep(options.successSettleMs ?? 1_000).then(() => undefined), + ]); return settled ?? last; } if (processDone) break; From 31624b6a778f5443f3cfd12be341901b6a50b2f6 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 20 Jun 2026 07:44:19 -0400 Subject: [PATCH 14/14] fix(daemon): address native service review feedback --- packages/core/src/cli.ts | 19 ++- packages/core/src/daemon/logs.ts | 66 ++++++++- packages/core/src/daemon/manager.ts | 14 +- packages/core/src/daemon/process.ts | 55 +++++++- packages/core/test/serve-daemon.test.ts | 180 ++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 20 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index c8b6463c..f551b286 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -670,11 +670,7 @@ export function createProgram(io: CliIO = {}): Command { writeOut(`${JSON.stringify(status, null, 2)}\n`); return; } - writeOut( - status.installed - ? `Caplets daemon is ${status.running ? "running" : "stopped"} (${status.nativeState}).\n` - : "Caplets daemon is not installed.\n", - ); + writeOut(formatDaemonStatus(status)); }); addJsonOption( @@ -2026,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 { diff --git a/packages/core/src/daemon/logs.ts b/packages/core/src/daemon/logs.ts index 6b5483e1..cc7f9194 100644 --- a/packages/core/src/daemon/logs.ts +++ b/packages/core/src/daemon/logs.ts @@ -1,7 +1,20 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, watch } from "node:fs"; +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 } = {}, @@ -44,12 +57,11 @@ export async function followDaemonLogs( const watchers = selectedStreams(options.stream ?? "all").map((stream) => { const file = paths[stream === "stdout" ? "stdoutLog" : "stderrLog"]; ensureLogFile(file); - let offset = existsSync(file) ? readFileSync(file, "utf8").length : 0; + let offset = existsSync(file) ? statSync(file).size : 0; return watch(file, { persistent: true }, () => { - const content = existsSync(file) ? readFileSync(file, "utf8") : ""; - const appended = content.slice(offset); - offset = content.length; - for (const line of appended.split(/\r?\n/u).filter(Boolean)) options.write({ stream, line }); + 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) => { @@ -70,11 +82,51 @@ function selectedStreams(stream: DaemonLogStream): Array<"stdout" | "stderr"> { function tailLines(path: string, count: number): string[] { if (count === 0 || !existsSync(path)) return []; - const lines = readFileSync(path, "utf8").split(/\r?\n/u); + 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)) { diff --git a/packages/core/src/daemon/manager.ts b/packages/core/src/daemon/manager.ts index 13123026..69f26503 100644 --- a/packages/core/src/daemon/manager.ts +++ b/packages/core/src/daemon/manager.ts @@ -202,6 +202,7 @@ function systemdManager(runner: DaemonCommandRunner, serviceAvailable = true): D await assertExec(runner, commands[1]!, "systemd unregister failed"); } catch (error) { restoreDescriptorFiles([descriptorBackup]); + await restoreSystemdEnabledState(runner); throw error; } return { action: "uninstall", native: notInstalled(), commands }; @@ -225,10 +226,7 @@ function windowsTaskManager(runner: DaemonCommandRunner): DaemonManager { "LIST", "/V", ]); - if (result.code !== 0) - return existsSync(paths.descriptorFile) - ? stopped({ stderr: result.stderr }) - : notInstalled({ stderr: result.stderr }); + 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"] ?? ""); @@ -422,6 +420,14 @@ async function assertExecUnless( } } +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, diff --git a/packages/core/src/daemon/process.ts b/packages/core/src/daemon/process.ts index 3232224e..de4d6d66 100644 --- a/packages/core/src/daemon/process.ts +++ b/packages/core/src/daemon/process.ts @@ -1,6 +1,7 @@ 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"; @@ -59,10 +60,13 @@ export function buildDaemonCommandPlan(options: { const args = daemonServeArgs(options.serve); const workingDirectory = options.operation.home ?? homedir(); const explicitEnv = options.explicitEnv ?? {}; - const serviceEnv = - options.serve.publicOrigin && explicitEnv.CAPLETS_SERVER_URL === undefined - ? { ...explicitEnv, CAPLETS_SERVER_URL: options.serve.publicOrigin } - : explicitEnv; + const serviceEnv = daemonServiceEnv({ + explicitEnv, + operation: options.operation, + platform, + serve: options.serve, + workingDirectory, + }); const base = { executable, args, @@ -83,6 +87,49 @@ export function buildDaemonCommandPlan(options: { }; } +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( diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts index 4e3a2936..8332d220 100644 --- a/packages/core/test/serve-daemon.test.ts +++ b/packages/core/test/serve-daemon.test.ts @@ -293,6 +293,105 @@ describe("daemon paths and config", () => { } }); + 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); + + await runCli(["daemon", "status"], { + env: testEnv(dir), + writeOut: (value) => out.push(value), + daemon: options, + }); + + 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("preserves selected config paths in the daemon service environment", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-config-env-")); + try { + 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, + }); + + 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 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")); + + 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("does not emit auth flags for default unauthenticated loopback serve", () => { const serve = resolveDaemonHttpServeOptions({}); @@ -1690,6 +1789,29 @@ describe("daemon lifecycle and logs", () => { } }); + 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 { @@ -1854,10 +1976,18 @@ describe("daemon lifecycle and logs", () => { }; 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 }); } @@ -1908,6 +2038,56 @@ describe("daemon lifecycle and logs", () => { } }); + 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;