@smbcloud / cli / commits / 68f9ab9

Address feedbacks

keypair34 committed Aug 1, 2026 at 17:43 UTC 68f9ab9f36f460c4a5d03277bfe03bdf6ae08f0f
3 files changed +266 -25
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+53 -5
@@ -180,11 +180,59 @@ Prerequisites (one-time per app, done out-of-band before the first
180
181 1. Enable linger for the app-owner so its units run without an active login and
182 start at boot: `sudo loginctl enable-linger git`.
183 -2. Create the `~/.config/systemd/user/<pm2_app>.service` (+ a mode-600
184 - `<pm2_app>.env`) and cut over off PM2. Use the migration generator
185 - `pm2-to-systemd.sh <app> --apply`, which reads the live `pm2 jlist`,
186 - reconstructs `ExecStart`/env faithfully, enables the unit, and `pm2 delete`s
187 - the app.
183 +2. Create the unit and env file, then cut over off PM2 (all as the `git` user).
184 + Capture the app's current settings first — `pm2 describe <app>` shows the
185 + interpreter, script, cwd, and env PM2 is running with, which is what the unit
186 + has to reproduce:
187 +
188 + ```sh
189 + export XDG_RUNTIME_DIR="/run/user/$(id -u)"
190 + APP=my-app # == pm2_app == the unit name
191 + APP_PATH=~/apps/web/my-app # == `path` in .smb/config.toml
192 + mkdir -p ~/.config/systemd/user
193 +
194 + # Env file (mode 600 — it holds per-app secrets).
195 + # Port/hostname must match .smb/config.toml and the Nginx upstream.
196 + install -m 600 /dev/null ~/.config/systemd/user/$APP.env
197 + cat >> ~/.config/systemd/user/$APP.env <<'EOF'
198 + NODE_ENV=production
199 + PORT=3031
200 + HOSTNAME=127.0.0.1
201 + EOF
202 +
203 + cat > ~/.config/systemd/user/$APP.service <<EOF
204 + [Unit]
205 + Description=$APP (Next.js standalone)
206 + After=network-online.target
207 +
208 + [Service]
209 + Type=simple
210 + WorkingDirectory=$APP_PATH
211 + EnvironmentFile=%h/.config/systemd/user/$APP.env
212 + ExecStart=/usr/bin/env node server.js
213 + Restart=always
214 + RestartSec=2
215 +
216 + [Install]
217 + WantedBy=default.target
218 + EOF
219 + ```
220 +
221 + Then cut over, stopping PM2 first so both supervisors never hold the port at
222 + once:
223 +
224 + ```sh
225 + pm2 delete "$APP" || true
226 + pm2 save # forget it across pm2 resurrect
227 + systemctl --user daemon-reload
228 + systemctl --user enable --now "$APP.service"
229 + systemctl --user is-active "$APP.service" # expect: active
230 + curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3031
231 + ```
232 +
233 + If `ExecStart` needs to differ (a custom server entrypoint, a pinned Node
234 + version via `~/.nvm/versions/node/<v>/bin/node`), match whatever
235 + `pm2 describe` reported rather than the defaults above.
236 3. Set `process_manager = "systemd"` in the app's `.smb/config.toml`.
237
238 After that, every `smb deploy` restarts via `systemctl --user` and the deploy
crates/cli/src/cloud-deploy/process_deploy_nextjs_ssr.rs
+49 -20
@@ -88,30 +88,46 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
88 /// Build the shell snippet that (re)starts the app after the standalone bundle
89 /// is on the server.
90 ///
91 -/// Two supervisors are supported, selected by `process_manager`:
92 -/// - `None` / anything but `"systemd"` → **pm2** (default): delete + start
93 -/// fresh, preferring an operator-managed `ecosystem.config.cjs`/`.js`, else
94 -/// `pm2 start node -- server.js` with `PORT`/`HOSTNAME` inline.
91 +/// Two supervisors are supported, selected by `process_manager` (matched
92 +/// case-insensitively):
93 +/// - `None` / `"pm2"` → **pm2** (default): delete + start fresh, preferring an
94 +/// operator-managed `ecosystem.config.cjs`/`.js`, else `pm2 start node --
95 +/// server.js` with `PORT`/`HOSTNAME` inline.
96 /// - `"systemd"` → a **git-owned `systemctl --user` service** named
96 -/// `<pm2_app>.service` (created out-of-band by the pm2-to-systemd migration).
97 -/// Restarted with no sudo — the deploy SSHes as the git app-owner, and
98 -/// `systemctl --user` (with `XDG_RUNTIME_DIR` set) drives that user's own
97 +/// `<pm2_app>.service` (created out-of-band; see the smbcloud-deploy-nextjs
98 +/// skill). Restarted with no sudo — the deploy SSHes as the git app-owner,
99 +/// and `systemctl --user` (with `XDG_RUNTIME_DIR` set) drives that user's own
100 /// manager. Fails loudly if the unit does not exist.
101 ///
102 +/// Any other value is a hard error rather than a silent fall back to pm2: on an
103 +/// app already migrated to systemd, a typo like `system` would otherwise start
104 +/// a second pm2 process that collides with the systemd-owned port — the exact
105 +/// double-supervisor outage this option exists to prevent.
106 +///
107 /// The returned snippet references the shell var `$PM2_APP` (set earlier in the
102 -/// deploy script). `port` is only used by the pm2 fallback path.
103 -fn restart_stanza(process_manager: Option<&str>, port: u16) -> String {
104 - let use_systemd = process_manager
105 - .map(|m| m.eq_ignore_ascii_case("systemd"))
106 - .unwrap_or(false);
108 +/// deploy script). `port` is only used by the pm2 path.
109 +fn restart_stanza(process_manager: Option<&str>, port: u16) -> Result<String> {
110 + let use_systemd = match process_manager.map(str::trim) {
111 + None | Some("") => false,
112 + Some(m) if m.eq_ignore_ascii_case("pm2") => false,
113 + Some(m) if m.eq_ignore_ascii_case("systemd") => true,
114 + Some(other) => {
115 + return Err(anyhow!(fail_message(&format!(
116 + "Unknown process_manager '{other}' in .smb/config.toml. \
117 + Expected \"pm2\" (default) or \"systemd\"."
118 + ))))
119 + }
120 + };
121
108 - if use_systemd {
122 + Ok(if use_systemd {
123 r#" echo "Restarting $PM2_APP via systemd --user…"
124 export XDG_RUNTIME_DIR="/run/user/$(id -u)"
125 UNIT="$PM2_APP.service"
126 if ! systemctl --user cat "$UNIT" >/dev/null 2>&1; then
127 echo "Error: systemd --user unit $UNIT not found on the server."
114 - echo "Create it first with the pm2-to-systemd migration, then re-deploy."
128 + echo "Create ~/.config/systemd/user/$UNIT and enable it (see the"
129 + echo "smbcloud-deploy-nextjs skill, 'Process manager: systemd --user'),"
130 + echo "or unset process_manager to stay on pm2. Then re-deploy."
131 exit 1
132 fi
133 systemctl --user restart "$UNIT"
@@ -143,7 +159,7 @@ fn restart_stanza(process_manager: Option<&str>, port: u16) -> String {
159 echo "Done."
160 "#
161 )
146 - }
162 + })
163 }
164
165 /// Deploys a Next.js SSR app using standalone output mode.
@@ -593,7 +609,7 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
609 };
610
611 // Runtime supervisor: pm2 (default) or a git-owned systemd --user service.
596 - let restart_stanza = restart_stanza(config.project.process_manager.as_deref(), port);
612 + let restart_stanza = restart_stanza(config.project.process_manager.as_deref(), port)?;
613
614 let deploy_script = format!(
615 r#"set -e
@@ -972,17 +988,30 @@ mod tests {
988
989 #[test]
990 fn restart_stanza_defaults_to_pm2() {
975 - for pm in [None, Some("pm2"), Some("PM2"), Some("other")] {
976 - let s = restart_stanza(pm, 3025);
991 + for pm in [None, Some(""), Some("pm2"), Some("PM2"), Some(" pm2 ")] {
992 + let s = restart_stanza(pm, 3025).expect("pm2 selector is valid");
993 assert!(s.contains("pm2 start"), "pm={pm:?} should use pm2");
994 assert!(s.contains("PORT=3025"), "pm={pm:?} injects the port");
995 assert!(!s.contains("systemctl"), "pm={pm:?} must not touch systemd");
996 }
997 }
998
999 + /// An unrecognized supervisor must abort the deploy, not quietly run pm2
1000 + /// alongside a systemd-owned process and fight over the port.
1001 + #[test]
1002 + fn restart_stanza_rejects_unknown_process_manager() {
1003 + for pm in ["system", "systemctl", "supervisord", "PM3"] {
1004 + let err = restart_stanza(Some(pm), 3025)
1005 + .expect_err("unknown process_manager must be rejected");
1006 + let msg = err.to_string();
1007 + assert!(msg.contains(pm), "error names the offending value: {msg}");
1008 + assert!(msg.contains("systemd"), "error lists valid options: {msg}");
1009 + }
1010 + }
1011 +
1012 #[test]
1013 fn restart_stanza_systemd_uses_user_unit_no_sudo() {
985 - let s = restart_stanza(Some("systemd"), 3025);
1014 + let s = restart_stanza(Some("systemd"), 3025).expect("systemd selector is valid");
1015 assert!(s.contains("systemctl --user restart \"$UNIT\""));
1016 assert!(s.contains("UNIT=\"$PM2_APP.service\""));
1017 assert!(s.contains("XDG_RUNTIME_DIR=\"/run/user/$(id -u)\""));
@@ -990,6 +1019,6 @@ mod tests {
1019 assert!(!s.contains("sudo"), "must not require sudo");
1020 assert!(!s.contains("pm2 "), "systemd path must not call pm2");
1021 // Case-insensitive selector.
993 - assert_eq!(s, restart_stanza(Some("SystemD"), 3025));
1022 + assert_eq!(s, restart_stanza(Some("SystemD"), 3025).unwrap());
1023 }
1024 }
website/content/developer/cli/controlkit.mdx new
+164
@@ -0,0 +1,164 @@
1 +---
2 +title: "ControlKit runners"
3 +description: "Build and connect the native ControlKit runners for iOS, tvOS, visionOS, watchOS, and macOS through the smbCloud and xcrs MCP servers."
4 +---
5 +
6 +{/* AUTO-GENERATED — do not edit. Generated by scripts/generate-developer-docs.mjs from ../docs/controlkit.md. */}
7 +
8 +# ControlKit runners
9 +
10 +import { Callout } from 'nextra/components'
11 +
12 +<Callout type="info">
13 + Generated from [`docs/controlkit.md`](https://github.com/smbcloudXYZ/smbcloud-cli/blob/main/docs/controlkit.md) in
14 + [smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli). Edit it there.
15 +</Callout>
16 +
17 +ControlKit is the native XCTest runner used by the CLI's Apple-platform MCP
18 +tools. The public runner project is
19 +[`xcrs-controlkit`](https://github.com/ondeinference/xcrs-controlkit). It uses
20 +one Xcode project generated by XcodeGen, with platform-specific targets and
21 +schemes:
22 +
23 +| Platform | Scheme | App product |
24 +| --- | --- | --- |
25 +| iOS / iPadOS | `ControlKit-iOS` | `ControlKit.app` |
26 +| tvOS | `ControlKit-tvOS` | `ControlKit.app` |
27 +| visionOS | `ControlKit-visionOS` | `ControlKit.app` |
28 +| watchOS | `ControlKit-watchOS` | `ControlKit.app` |
29 +| macOS | `ControlKit-macOS` | `ControlKit.app` |
30 +
31 +The scheme and target names keep their platform suffixes so Xcode can select
32 +the correct SDK, while each built app is named `ControlKit`.
33 +
34 +## Build a runner
35 +
36 +Install XcodeGen once, clone the runner project, and generate the Xcode
37 +project:
38 +
39 +```sh
40 +brew install xcodegen
41 +cd <path-to-xcrs-controlkit>/Runners
42 +xcodegen generate
43 +```
44 +
45 +Build the simulator runner for the platform you need:
46 +
47 +```sh
48 +xcodebuild -project XCRSControlKitRunner.xcodeproj \
49 + -scheme ControlKit-<platform> \
50 + -destination 'generic/platform=<platform> Simulator' \
51 + build-for-testing
52 +```
53 +
54 +Replace `<platform>` with `iOS`, `tvOS`, `watchOS`, or `visionOS`. macOS is
55 +built with `-destination 'platform=macOS'` and runs on the local Mac rather
56 +than a simulator.
57 +
58 +## Start the runner
59 +
60 +Boot a compatible simulator and run the UI-test host:
61 +
62 +```sh
63 +xcodebuild -project XCRSControlKitRunner.xcodeproj \
64 + -scheme ControlKit-<platform> \
65 + -destination 'id=<simulator-udid>' \
66 + test-without-building
67 +```
68 +
69 +The shared XCTest runner starts a local ControlKit JSON-RPC server on
70 +`127.0.0.1:12004`. Keep that test process running while calling the MCP tools.
71 +The macOS UI-test target includes the network-server entitlement required to
72 +bind the local port.
73 +
74 +## Run on a physical device
75 +
76 +The same runner also works installed on a real iOS, tvOS, watchOS, or
77 +visionOS device, not just a simulator. Build and start it against the
78 +device's UDID (find it with `xcrun devicectl list devices`) instead of a
79 +simulator destination:
80 +
81 +```sh
82 +xcodebuild -project XCRSControlKitRunner.xcodeproj \
83 + -scheme ControlKit-<platform> \
84 + -destination 'id=<device-udid>' \
85 + build-for-testing
86 +
87 +TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0 xcodebuild \
88 + -project XCRSControlKitRunner.xcodeproj \
89 + -scheme ControlKit-<platform> \
90 + -destination 'id=<device-udid>' \
91 + test-without-building
92 +```
93 +
94 +The `CONTROLKIT_LISTEN_HOST` environment variable controls what the
95 +in-process RPC server binds to (`Shared/ControlKitRPCServer.swift`) — it
96 +defaults to `127.0.0.1`, which is loopback-only *on the device itself* and
97 +unreachable from your Mac. `xcodebuild` forwards any `TEST_RUNNER_`-prefixed
98 +environment variable into the test process with the prefix stripped, so
99 +setting `TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0` makes the server bind on
100 +every interface, including the one reachable over your LAN. Find the device's
101 +address with its Bonjour hostname (`<device-name>.local`, discoverable via
102 +`dns-sd -B _airplay._tcp local` for an Apple TV) or its CoreDevice tunnel
103 +address (`xcrun devicectl device info details --device <udid>`, under
104 +`Tunnel IP Address`).
105 +
106 +## Connect through `smb --mcp`
107 +
108 +Start the CLI MCP server:
109 +
110 +```sh
111 +smb --mcp
112 +```
113 +
114 +The runner tools accept either `simulator_name` or `simulator_udid` for a
115 +simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a
116 +physical device or any other remote runner. The standalone `xcrs --mcp`
117 +server exposes the same tools with the `xcrs_` prefix.
118 +
119 +| Tool | Runner | Purpose |
120 +| --- | --- | --- |
121 +| `smb_controlkit_capabilities` | All platforms | Read platform and capability information. |
122 +| `smb_macos_click` | macOS | Click at screen coordinates. |
123 +| `smb_visionos_spatial_tap` | visionOS | Perform a spatial tap. |
124 +| `smb_watchos_tap` | watchOS | Perform a touch tap. |
125 +| `smb_simulator_tap` | iOS / tvOS, simulator or physical | Perform a touch tap. |
126 +| `smb_simulator_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. |
127 +| `smb_simulator_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. |
128 +| `smb_simulator_press_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. |
129 +| `smb_simulator_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. |
130 +| `smb_simulator_screenshot` | Simulator only | Capture a PNG screenshot via `simctl`. |
131 +| `smb_device_screenshot` | Physical device only | Capture a PNG screenshot via `devicectl device capture screenshot` — works independently of the ControlKit runner, so it succeeds even if the RPC server isn't running. |
132 +| `smb_simulator_ui_dump` | All UI-test runners | Read the accessibility hierarchy. |
133 +
134 +For a local macOS runner, omit simulator fields and pass `host` and
135 +`controlkit_port` when needed:
136 +
137 +```json
138 +{
139 + "host": "127.0.0.1",
140 + "controlkit_port": 12004,
141 + "x": 400,
142 + "y": 300
143 +}
144 +```
145 +
146 +For a physical device, pass its Bonjour hostname or tunnel address as `host`
147 +instead:
148 +
149 +```json
150 +{
151 + "host": "<device-name>.local",
152 + "controlkit_port": 12004,
153 + "button": "select"
154 +}
155 +```
156 +
157 +Platform-specific operations intentionally reject unsupported input. For
158 +example, visionOS rejects raw touch, watchOS rejects pointer and spatial input,
159 +and macOS rejects touch and Home-button requests.
160 +
161 +## See also
162 +
163 +For the product-level overview of end-to-end testing with xcrs and
164 +ControlKit, see [smbcloud.xyz/xcrs-end-to-end-testing](https://smbcloud.xyz/xcrs-end-to-end-testing).