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).