Add Static runner and rsync deploy with SSH host key pinning
- Add `Static` variant to `Runner` and update CLI project flows to support it - Add `DeploymentMethod` enum to model Git vs Rsync deploys - Implement `rsync_deploy` with strict SSH host key pinning for static sites - Embed public SSH host keys in `known_hosts.rs` and use temp file for deploy - Update deploy logic to use rsync for Static runner, git for others - Add `tempfile` dependency for secure temp file handling - Improve runner detection and CLI output for static sites Signed-off-by: Seto <setoelkahfi@gmail.com>
Seto committed
Mar 23, 2026 at 22:18 UTC
d3a4d3619a8cb239e5b52ded67e80b9d169ea50c
15 files changed
+789
-22
.agents/skills/humanizer/SKILL.md
new
+440
@@ -0,0 +1,440 @@
1
+---
2
+name: humanizer
3
+description: Remove signs of AI-generated writing from text. Use after drafting to make copy sound more natural and human-written. Based on Wikipedia's "Signs of AI writing" guide.
4
+allowed-tools: Read, Write, Edit, Glob, Grep, AskUserQuestion
5
+user-invocable: true
6
+---
7
+
8
+# Humanizer: Remove AI Writing Patterns
9
+
10
+You are a writing editor that identifies and removes signs of AI-generated text. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup.
11
+
12
+Key insight: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."
13
+
14
+## Invocation
15
+
16
+```bash
17
+/humanizer # Review text for AI patterns
18
+/humanizer "paste text here" # Humanize specific text
19
+```
20
+
21
+## Your Task
22
+
23
+When given text to humanize:
24
+
25
+1. **Identify AI patterns** - Scan for the 24 patterns listed below
26
+2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives
27
+3. **Preserve meaning** - Keep the core message intact
28
+4. **Add soul** - Don't just remove bad patterns; inject actual personality
29
+5. **Final audit pass** - Ask "What makes this obviously AI generated?" then revise again
30
+
31
+---
32
+
33
+## PERSONALITY AND SOUL
34
+
35
+Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop.
36
+
37
+### Signs of soulless writing (even if technically "clean"):
38
+
39
+- Every sentence is the same length and structure
40
+- No opinions, just neutral reporting
41
+- No acknowledgment of uncertainty or mixed feelings
42
+- No first-person perspective when appropriate
43
+- No humor, no edge, no personality
44
+- Reads like a Wikipedia article or press release
45
+
46
+### How to add voice:
47
+
48
+**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
49
+
50
+**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
51
+
52
+**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
53
+
54
+**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
55
+
56
+**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
57
+
58
+**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
59
+
60
+### Before (clean but soulless):
61
+
62
+> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
63
+
64
+### After (has a pulse):
65
+
66
+> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night.
67
+
68
+---
69
+
70
+## THE 24 PATTERNS
71
+
72
+### Content Patterns
73
+
74
+#### 1. Significance Inflation
75
+
76
+**Watch for:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights importance, reflects broader, symbolizing ongoing/enduring/lasting, marking/shaping the, represents a shift, key turning point, evolving landscape
77
+
78
+**Before:**
79
+
80
+> The Statistical Institute was officially established in 1989, marking a pivotal moment in the evolution of regional statistics.
81
+
82
+**After:**
83
+
84
+> The Statistical Institute was established in 1989 to collect and publish regional statistics.
85
+
86
+#### 2. Notability Name-Dropping
87
+
88
+**Watch for:** cited in NYT, BBC, FT; independent coverage; active social media presence; written by a leading expert
89
+
90
+**Before:**
91
+
92
+> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu.
93
+
94
+**After:**
95
+
96
+> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
97
+
98
+#### 3. Superficial -ing Analyses
99
+
100
+**Watch for:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., showcasing...
101
+
102
+**Before:**
103
+
104
+> The temple's colors resonate with natural beauty, symbolizing bluebonnets, reflecting the community's deep connection to the land.
105
+
106
+**After:**
107
+
108
+> The temple uses blue and gold colors. The architect said these were chosen to reference local bluebonnets.
109
+
110
+#### 4. Promotional Language
111
+
112
+**Watch for:** boasts a, vibrant, rich (figurative), profound, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking, renowned, breathtaking, must-visit, stunning
113
+
114
+**Before:**
115
+
116
+> Nestled within the breathtaking region, Alamata stands as a vibrant town with rich cultural heritage and stunning natural beauty.
117
+
118
+**After:**
119
+
120
+> Alamata is a town in the Gonder region, known for its weekly market and 18th-century church.
121
+
122
+#### 5. Vague Attributions
123
+
124
+**Watch for:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications
125
+
126
+**Before:**
127
+
128
+> Experts believe it plays a crucial role in the regional ecosystem.
129
+
130
+**After:**
131
+
132
+> The river supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
133
+
134
+#### 6. Formulaic "Challenges" Sections
135
+
136
+**Watch for:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
137
+
138
+**Before:**
139
+
140
+> Despite challenges typical of urban areas, the city continues to thrive as an integral part of growth.
141
+
142
+**After:**
143
+
144
+> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a drainage project in 2022.
145
+
146
+---
147
+
148
+### Language Patterns
149
+
150
+#### 7. AI Vocabulary Words
151
+
152
+**High-frequency:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore (verb), valuable, vibrant
153
+
154
+**Before:**
155
+
156
+> Additionally, a distinctive feature showcases how these dishes have integrated into the traditional culinary landscape.
157
+
158
+**After:**
159
+
160
+> Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
161
+
162
+#### 8. Copula Avoidance
163
+
164
+**Watch for:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
165
+
166
+**Before:**
167
+
168
+> Gallery 825 serves as the exhibition space. The gallery features four spaces and boasts over 3,000 square feet.
169
+
170
+**After:**
171
+
172
+> Gallery 825 is the exhibition space. The gallery has four rooms totaling 3,000 square feet.
173
+
174
+#### 9. Negative Parallelisms
175
+
176
+**Watch for:** "Not only...but...", "It's not just about..., it's..."
177
+
178
+**Before:**
179
+
180
+> It's not just about the beat; it's part of the aggression. It's not merely a song, it's a statement.
181
+
182
+**After:**
183
+
184
+> The heavy beat adds to the aggressive tone.
185
+
186
+#### 10. Rule of Three Overuse
187
+
188
+**Before:**
189
+
190
+> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
191
+
192
+**After:**
193
+
194
+> The event includes talks and panels. There's also time for informal networking.
195
+
196
+#### 11. Synonym Cycling
197
+
198
+**Before:**
199
+
200
+> The protagonist faces challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
201
+
202
+**After:**
203
+
204
+> The protagonist faces many challenges but eventually triumphs and returns home.
205
+
206
+#### 12. False Ranges
207
+
208
+**Watch for:** "from X to Y" where X and Y aren't on a meaningful scale
209
+
210
+**Before:**
211
+
212
+> Our journey has taken us from the singularity of the Big Bang to the cosmic web, from the birth of stars to the dance of dark matter.
213
+
214
+**After:**
215
+
216
+> The book covers the Big Bang, star formation, and current theories about dark matter.
217
+
218
+---
219
+
220
+### Style Patterns
221
+
222
+#### 13. Em Dash Overuse
223
+
224
+**Before:**
225
+
226
+> The term is promoted by institutions—not the people themselves—yet this continues—even in documents.
227
+
228
+**After:**
229
+
230
+> The term is promoted by institutions, not the people themselves, yet this continues in official documents.
231
+
232
+#### 14. Boldface Overuse
233
+
234
+**Before:**
235
+
236
+> It blends **OKRs**, **KPIs**, and tools such as the **Business Model Canvas** and **Balanced Scorecard**.
237
+
238
+**After:**
239
+
240
+> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
241
+
242
+#### 15. Inline-Header Lists
243
+
244
+**Before:**
245
+
246
+> - **Performance:** Performance has been enhanced through optimized algorithms.
247
+> - **Security:** Security has been strengthened with encryption.
248
+
249
+**After:**
250
+
251
+> The update speeds up load times through optimized algorithms and adds end-to-end encryption.
252
+
253
+#### 16. Title Case Headings
254
+
255
+**Before:**
256
+
257
+> ## Strategic Negotiations And Global Partnerships
258
+
259
+**After:**
260
+
261
+> ## Strategic negotiations and global partnerships
262
+
263
+#### 17. Emojis in Professional Writing
264
+
265
+**Before:**
266
+
267
+> 🚀 **Launch Phase:** The product launches in Q3
268
+> 💡 **Key Insight:** Users prefer simplicity
269
+
270
+**After:**
271
+
272
+> The product launches in Q3. User research showed a preference for simplicity.
273
+
274
+#### 18. Curly Quotation Marks
275
+
276
+**Before:**
277
+
278
+> He said "the project is on track" but others disagreed.
279
+
280
+**After:**
281
+
282
+> He said "the project is on track" but others disagreed.
283
+
284
+---
285
+
286
+### Communication Patterns
287
+
288
+#### 19. Chatbot Artifacts
289
+
290
+**Watch for:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
291
+
292
+**Before:**
293
+
294
+> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
295
+
296
+**After:**
297
+
298
+> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
299
+
300
+#### 20. Knowledge-Cutoff Disclaimers
301
+
302
+**Watch for:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
303
+
304
+**Before:**
305
+
306
+> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
307
+
308
+**After:**
309
+
310
+> The company was founded in 1994, according to its registration documents.
311
+
312
+#### 21. Sycophantic Tone
313
+
314
+**Before:**
315
+
316
+> Great question! You're absolutely right that this is a complex topic. That's an excellent point!
317
+
318
+**After:**
319
+
320
+> The economic factors you mentioned are relevant here.
321
+
322
+---
323
+
324
+### Filler and Hedging
325
+
326
+#### 22. Filler Phrases
327
+
328
+| Before | After |
329
+| ------------------------------ | ----------------- |
330
+| "In order to achieve this" | "To achieve this" |
331
+| "Due to the fact that" | "Because" |
332
+| "At this point in time" | "Now" |
333
+| "It is important to note that" | (delete) |
334
+| "has the ability to" | "can" |
335
+
336
+#### 23. Excessive Hedging
337
+
338
+**Before:**
339
+
340
+> It could potentially possibly be argued that the policy might have some effect on outcomes.
341
+
342
+**After:**
343
+
344
+> The policy may affect outcomes.
345
+
346
+#### 24. Generic Positive Conclusions
347
+
348
+**Before:**
349
+
350
+> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence.
351
+
352
+**After:**
353
+
354
+> The company plans to open two more locations next year.
355
+
356
+---
357
+
358
+## Process
359
+
360
+1. Read the input text carefully
361
+2. Identify all instances of the 24 patterns
362
+3. Rewrite problematic sections
363
+4. Ensure the revised text:
364
+ - Sounds natural when read aloud
365
+ - Varies sentence structure
366
+ - Uses specific details over vague claims
367
+ - Uses simple constructions (is/are/has) where appropriate
368
+5. **Draft humanized version**
369
+6. **Ask:** "What makes this still obviously AI generated?"
370
+7. **List** remaining tells (if any)
371
+8. **Revise** based on the audit
372
+9. **Present final version**
373
+
374
+## Output Format
375
+
376
+```
377
+## Draft Rewrite
378
+
379
+[First pass at humanized text]
380
+
381
+## AI Audit
382
+
383
+What still reads as AI-generated:
384
+- [bullet points of remaining tells]
385
+
386
+## Final Version
387
+
388
+[Revised text after audit]
389
+
390
+## Changes Made
391
+
392
+- [Brief summary of transformations applied]
393
+```
394
+
395
+---
396
+
397
+## Full Example
398
+
399
+**Input (AI-heavy):**
400
+
401
+> Great question! Here is an essay on this topic. I hope this helps!
402
+>
403
+> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
404
+
405
+**Draft Rewrite:**
406
+
407
+> AI coding assistants speed up some tasks. In a 2024 study, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
408
+>
409
+> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong.
410
+
411
+**AI Audit:**
412
+
413
+What still reads as AI-generated:
414
+
415
+- Rhythm is still a bit too tidy (clean contrasts, evenly paced)
416
+- The closer leans slightly slogan-y
417
+
418
+**Final Version:**
419
+
420
+> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
421
+>
422
+> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
423
+>
424
+> If you don't have tests, you're basically guessing.
425
+
426
+**Changes Made:**
427
+
428
+- Removed chatbot artifacts ("Great question!", "I hope this helps!")
429
+- Removed significance inflation ("testament", "pivotal moment", "evolving landscape")
430
+- Removed promotional language ("groundbreaking", "nestled")
431
+- Removed em dashes
432
+- Removed copula avoidance ("serves as") → used direct statements
433
+- Added first-person voice and opinion
434
+- Varied sentence rhythm
435
+
436
+---
437
+
438
+## Reference
439
+
440
+Based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup.
Cargo.lock
+1
@@ -2118,6 +2118,7 @@ dependencies = [
2118
"smbcloud-utils",
2119
"spinners",
2120
"tabled",
2121
+ "tempfile",
2122
"thiserror 2.0.18",
2123
"tokio",
2124
"toml",
Cargo.toml
+2
@@ -11,6 +11,7 @@ clap_mangen = "0.2.10"
11
console = "0.16"
12
dialoguer = "0.11.0"
13
dirs = "6.0"
14
+
15
dotenv_codegen = "0.15"
16
git2 = "0.20.1"
17
home = "0.5.4"
@@ -33,6 +34,7 @@ spinners = "4.1.0"
34
strum = "0.27"
35
strum_macros = "0.27"
36
tabled = "0.20"
37
+tempfile = "3"
38
thiserror = "2.0"
39
tokio = "1.44"
40
tokio-postgres = "0.7"
crates/cli/Cargo.toml
+2
@@ -29,6 +29,7 @@ clap_mangen = { workspace = true }
29
console = { workspace = true }
30
dialoguer = { workspace = true }
31
dirs = { workspace = true }
32
+
33
dotenv_codegen = { workspace = true }
34
git2 = { workspace = true }
35
home = { workspace = true }
@@ -48,6 +49,7 @@ smbcloud-networking-project = { workspace = true }
49
smbcloud-utils = { workspace = true }
50
spinners = { workspace = true }
51
tabled = { workspace = true }
52
+tempfile = { workspace = true }
53
thiserror = { workspace = true }
54
tokio = { workspace = true, features = ["full"] }
55
toml = { workspace = true }
crates/cli/src/deploy/detect_runner.rs
+6
@@ -58,6 +58,12 @@ pub(crate) async fn detect_runner(config: &Config) -> Result<Runner> {
58
succeed_message("NodeJs 🟩 runner detected"),
59
);
60
}
61
+ Runner::Static => {
62
+ spinner.stop_and_persist(
63
+ &succeed_symbol(),
64
+ succeed_message("Static 🌐 site — no build step required"),
65
+ );
66
+ }
67
Runner::Ruby => {
68
if Path::new("Gemfile").exists() {
69
spinner.stop_and_persist(
crates/cli/src/deploy/known_hosts.rs
new
+81
@@ -0,0 +1,81 @@
1
+/// Pinned SSH host keys for smbCloud deployment servers.
2
+///
3
+/// These are **public** keys — safe to commit to a public repository.
4
+/// Host keys are designed to be known; only the private half (which never
5
+/// leaves the server) provides authentication.
6
+///
7
+/// Purpose: protect every `smb deploy` user from DNS/BGP hijacking.
8
+/// By pairing these constants with `StrictHostKeyChecking=yes` and a
9
+/// generated temp `UserKnownHostsFile`, the CLI refuses to connect to any
10
+/// server that doesn't present one of these exact keys — even if the
11
+/// hostname resolves correctly to an attacker's IP.
12
+///
13
+/// Both servers run OpenSSH 9.2p1 on Debian 12.
14
+///
15
+/// # Key rotation
16
+///
17
+/// If a server's host key is ever rotated (e.g. after a compromise or
18
+/// reprovisioning), update the relevant constant here and cut a new CLI
19
+/// release. Users on old releases will receive a hard SSH refusal:
20
+///
21
+/// ```text
22
+/// Host key verification failed.
23
+/// ```
24
+///
25
+/// That is the correct behaviour — they must upgrade before deploying.
26
+///
27
+/// To re-fetch the current keys:
28
+///
29
+/// ```sh
30
+/// ssh-keyscan -t ed25519 api.smbcloud.xyz api-1.smbcloud.xyz 2>/dev/null
31
+/// ```
32
+
33
+/// Pinned ed25519 host key for `api.smbcloud.xyz` (NodeJs / Static tier).
34
+pub const API_SMBCLOUD_XYZ: &str =
35
+ "api.smbcloud.xyz ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINGy4LHGyOilS7SXo770V3tQnXDRVQr7X7JsPLCfy4XB";
36
+
37
+/// Pinned ed25519 host key for `api-1.smbcloud.xyz` (Ruby / Swift tier).
38
+pub const API_1_SMBCLOUD_XYZ: &str =
39
+ "api-1.smbcloud.xyz ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII1G3JyS66+yIFGrN3Vgc/UlvBm/oS98qq5pS96UYxcz";
40
+
41
+/// Returns the pinned host key line for the given rsync hostname.
42
+///
43
+/// The returned string is in `known_hosts` format and can be written
44
+/// directly to a temp file passed to SSH via `-o UserKnownHostsFile=`.
45
+pub fn for_host(rsync_host: &str) -> &'static str {
46
+ if rsync_host.starts_with("api-1.") {
47
+ API_1_SMBCLOUD_XYZ
48
+ } else {
49
+ API_SMBCLOUD_XYZ
50
+ }
51
+}
52
+
53
+#[cfg(test)]
54
+mod tests {
55
+ use super::*;
56
+
57
+ #[test]
58
+ fn api_host_returns_correct_key() {
59
+ let key = for_host("api.smbcloud.xyz");
60
+ assert!(key.starts_with("api.smbcloud.xyz ssh-ed25519 "));
61
+ assert!(!key.contains("api-1"));
62
+ }
63
+
64
+ #[test]
65
+ fn api_1_host_returns_correct_key() {
66
+ let key = for_host("api-1.smbcloud.xyz");
67
+ assert!(key.starts_with("api-1.smbcloud.xyz ssh-ed25519 "));
68
+ }
69
+
70
+ #[test]
71
+ fn keys_are_distinct() {
72
+ assert_ne!(API_SMBCLOUD_XYZ, API_1_SMBCLOUD_XYZ);
73
+ }
74
+
75
+ #[test]
76
+ fn keys_contain_valid_ed25519_prefix() {
77
+ // ed25519 public keys always begin with this base64 prefix
78
+ assert!(API_SMBCLOUD_XYZ.contains("AAAA"));
79
+ assert!(API_1_SMBCLOUD_XYZ.contains("AAAA"));
80
+ }
81
+}
crates/cli/src/deploy/mod.rs
+2
@@ -1,8 +1,10 @@
1
pub mod config;
2
mod detect_runner;
3
mod git;
4
+mod known_hosts;
5
pub mod process_deploy;
6
mod remote_messages;
7
+pub(crate) mod rsync_deploy;
8
pub(crate) mod setup_create_new_project;
9
pub(crate) mod setup_project;
10
pub(crate) mod setup_select_project;
crates/cli/src/deploy/process_deploy.rs
+31
-11
@@ -8,13 +8,14 @@ use {
8
detect_runner::detect_runner,
9
git::remote_deployment_setup,
10
remote_messages::{build_next_app, start_server},
11
+ rsync_deploy::rsync_deploy,
12
},
13
token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in},
14
ui::{fail_message, succeed_message, succeed_symbol},
15
},
16
anyhow::{anyhow, Result},
17
git2::{PushOptions, RemoteCallbacks, Repository},
17
- smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
18
+ smbcloud_model::project::{DeploymentMethod, DeploymentPayload, DeploymentStatus},
19
smbcloud_network::environment::Environment,
20
smbcloud_networking_account::me::me,
21
smbcloud_networking_project::{
@@ -41,12 +42,29 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
42
// Check config.
43
let config = get_config(env, Some(&access_token)).await?;
44
44
- // Check runner.
45
- let runner = detect_runner(&config).await?;
46
-
45
// Validate config with project.
46
check_project(env, &access_token, config.project.id).await?;
47
48
+ match config.project.deployment_method {
49
+ DeploymentMethod::Rsync => {
50
+ // For rsync deployments the runner is known from config — no framework
51
+ // detection needed, the source tree may have no package.json/Gemfile/etc.
52
+ let runner = config.project.runner;
53
+ let user = me(env, client(), &access_token).await?;
54
+ rsync_deploy(&config, &runner, user.id, ".")
55
+ }
56
+ DeploymentMethod::Git => git_deploy(env, &access_token, config).await,
57
+ }
58
+}
59
+
60
+async fn git_deploy(
61
+ env: Environment,
62
+ access_token: &str,
63
+ config: smbcloud_utils::config::Config,
64
+) -> Result<CommandResult> {
65
+ // Runner detection requires framework files (package.json, Gemfile, etc.) —
66
+ // only needed for the git push path where the server builds the project.
67
+ let runner = detect_runner(&config).await?;
68
// Check remote repository setup.
69
let repo = match Repository::open(".") {
70
Ok(repo) => repo,
@@ -78,9 +96,10 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
96
};
97
98
if branch_name != "main" && branch_name != "master" {
81
- return Err(anyhow!(fail_message(
82
- &format!("Not on main branch. Current branch: '{}'. Switch to main branch with `git checkout main` command.", branch_name)
83
- )));
99
+ return Err(anyhow!(fail_message(&format!(
100
+ "Not on main branch. Current branch: '{}'. Switch to main branch with `git checkout main` command.",
101
+ branch_name
102
+ ))));
103
}
104
105
let main_branch = head;
@@ -99,14 +118,15 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
118
},
119
Err(_) => return Err(anyhow!("Cannot resolve main branch.")),
120
};
121
+
122
let payload = DeploymentPayload {
123
commit_hash: commit_hash.to_string(),
124
status: DeploymentStatus::Started,
125
};
126
127
let created_deployment =
108
- create_deployment(env, client(), &access_token, config.project.id, payload).await?;
109
- let user = me(env, client(), &access_token).await?;
128
+ create_deployment(env, client(), access_token, config.project.id, payload).await?;
129
+ let user = me(env, client(), access_token).await?;
130
131
let mut push_opts = PushOptions::new();
132
let mut callbacks = RemoteCallbacks::new();
@@ -114,7 +134,7 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
134
// For updating status to failed
135
let deployment_failed_flag = Arc::new(AtomicBool::new(false));
136
let update_env = env; // Env is Copy
117
- let update_access_token = access_token.clone();
137
+ let update_access_token = access_token.to_owned();
138
let update_project_id = config.project.id;
139
let update_deployment_id = created_deployment.id;
140
@@ -196,7 +216,7 @@ pub async fn process_deploy(env: Environment) -> Result<CommandResult> {
216
let result = update(
217
env,
218
client(),
199
- access_token.clone(),
219
+ access_token.to_owned(),
220
config.project.id,
221
created_deployment.id,
222
update_payload,
crates/cli/src/deploy/rsync_deploy.rs
new
+158
@@ -0,0 +1,158 @@
1
+use {
2
+ crate::{
3
+ cli::CommandResult,
4
+ deploy::known_hosts,
5
+ ui::{fail_message, succeed_message, succeed_symbol},
6
+ },
7
+ anyhow::{anyhow, Result},
8
+ smbcloud_model::runner::Runner,
9
+ smbcloud_utils::config::Config,
10
+ spinners::{Spinner, Spinners},
11
+ std::{io::Write, process::Command},
12
+ tempfile::NamedTempFile,
13
+};
14
+
15
+/// Runs an rsync transfer replicating:
16
+/// rsync -a --exclude='.git' --exclude='.smb' \
17
+/// -e "ssh -i ~/.ssh/id_<user_id>@smbcloud
18
+/// -o StrictHostKeyChecking=yes
19
+/// -o UserKnownHostsFile=<temp_pinned_known_hosts>
20
+/// -o IdentitiesOnly=yes
21
+/// -o PasswordAuthentication=no
22
+/// -o BatchMode=yes" \
23
+/// <source>/ git@<api-host>.smbcloud.xyz:<remote_path>/
24
+///
25
+/// # Why std::process::Command instead of embedding::run_client
26
+///
27
+/// `embedding::run_client` drives the oc-rsync client, which sends extension
28
+/// message codes (e.g. code 90, wire tag 97) that standard rsync 3.2.7 on the
29
+/// server does not recognise, causing it to abort with:
30
+///
31
+/// unexpected tag 97 [Receiver]
32
+/// rsync error: error in rsync protocol data stream (code 12)
33
+///
34
+/// oc-rsync is designed for oc-rsync ↔ oc-rsync transfers. Using it against a
35
+/// stock rsync server is outside its design envelope without patching the
36
+/// protocol layer. System rsync speaks exactly what the server expects.
37
+///
38
+/// # Host key pinning
39
+///
40
+/// The server's ed25519 public key is embedded in `known_hosts.rs` and written
41
+/// to a temp file at deploy time. SSH is told to use that file exclusively
42
+/// (`UserKnownHostsFile`) and to refuse any key that doesn't match
43
+/// (`StrictHostKeyChecking=yes`). This protects every user — not just the
44
+/// developer — against DNS/BGP hijacking, even on untrusted networks. The temp
45
+/// file is deleted automatically when this function returns.
46
+pub fn rsync_deploy(
47
+ config: &Config,
48
+ runner: &Runner,
49
+ user_id: i32,
50
+ source: &str,
51
+) -> Result<CommandResult> {
52
+ // Identity file: ~/.ssh/id_<user_id>@smbcloud
53
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
54
+ let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user_id));
55
+ let identity_file_str = identity_file.to_string_lossy().into_owned();
56
+
57
+ // Write the pinned host key to a temp known_hosts file.
58
+ // NamedTempFile is deleted automatically when it drops at end of this fn.
59
+ let rsync_host = runner.rsync_host();
60
+ let mut known_hosts_file = NamedTempFile::new()
61
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
62
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
63
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
64
+
65
+ // Fully hardened SSH command:
66
+ // StrictHostKeyChecking=yes — refuse any host not in our pinned known_hosts
67
+ // UserKnownHostsFile=<temp> — only trust our pinned key, not ~/.ssh/known_hosts
68
+ // IdentitiesOnly=yes — use only the specified key, ignore ssh-agent
69
+ // PasswordAuthentication=no — no fallback to password auth
70
+ // BatchMode=yes — never prompt; fail fast
71
+ let ssh_command = format!(
72
+ "ssh -i {identity} \
73
+ -o StrictHostKeyChecking=yes \
74
+ -o UserKnownHostsFile={known_hosts} \
75
+ -o IdentitiesOnly=yes \
76
+ -o PasswordAuthentication=no \
77
+ -o BatchMode=yes",
78
+ identity = identity_file_str,
79
+ known_hosts = known_hosts_file.path().display(),
80
+ );
81
+
82
+ // Remote destination path: use project.path if set, otherwise derive from name.
83
+ let remote_path = match &config.project.path {
84
+ Some(path) => path.clone(),
85
+ None => format!("apps/web/{}", config.project.name),
86
+ };
87
+
88
+ // rsync -a src/ syncs contents, not the directory itself —
89
+ // both source and destination must have a trailing slash.
90
+ let source_with_slash = if source.ends_with('/') {
91
+ source.to_owned()
92
+ } else {
93
+ format!("{}/", source)
94
+ };
95
+
96
+ let remote_with_slash = if remote_path.ends_with('/') {
97
+ remote_path
98
+ } else {
99
+ format!("{}/", remote_path)
100
+ };
101
+
102
+ let destination = format!("git@{}:{}", rsync_host, remote_with_slash);
103
+
104
+ let spinner = Spinner::new(
105
+ Spinners::Hamburger,
106
+ succeed_message(&format!("Syncing {} -> {}", source_with_slash, destination)),
107
+ );
108
+
109
+ // known_hosts_file must remain alive until rsync exits so SSH can read it.
110
+ let output = Command::new("rsync")
111
+ .args([
112
+ "-a",
113
+ "--exclude=.git",
114
+ "--exclude=.smb",
115
+ "-e",
116
+ &ssh_command,
117
+ &source_with_slash,
118
+ &destination,
119
+ ])
120
+ .output();
121
+
122
+ // Drop the temp known_hosts file now that the process has finished.
123
+ drop(known_hosts_file);
124
+
125
+ match output {
126
+ Ok(result) if result.status.success() => {
127
+ let stderr = String::from_utf8_lossy(&result.stderr);
128
+ if !stderr.is_empty() {
129
+ for line in stderr.lines() {
130
+ println!("{}", line);
131
+ }
132
+ }
133
+ Ok(CommandResult {
134
+ spinner,
135
+ symbol: succeed_symbol(),
136
+ msg: succeed_message("Deployment complete via rsync."),
137
+ })
138
+ }
139
+ Ok(result) => {
140
+ drop(spinner);
141
+ let stderr = String::from_utf8_lossy(&result.stderr);
142
+ if !stderr.is_empty() {
143
+ eprintln!("{}", stderr);
144
+ }
145
+ Err(anyhow!(fail_message(&format!(
146
+ "rsync exited with status {}",
147
+ result.status.code().unwrap_or(-1)
148
+ ))))
149
+ }
150
+ Err(e) => {
151
+ drop(spinner);
152
+ Err(anyhow!(fail_message(&format!(
153
+ "Failed to launch rsync: {}. Is rsync installed?",
154
+ e
155
+ ))))
156
+ }
157
+ }
158
+}
crates/cli/src/deploy/setup.rs
+3
@@ -6,6 +6,7 @@ use {
6
smbcloud_model::{
7
error_codes::{ErrorCode, ErrorResponse},
8
project::{Project, ProjectCreate},
9
+ runner::Runner,
10
},
11
smbcloud_network::environment::Environment,
12
smbcloud_networking_project::{
@@ -223,6 +224,8 @@ async fn create_new_project(env: Environment, path: &str) -> Result<Project, Err
224
name,
225
repository,
226
description,
227
+ runner: Runner::NodeJs,
228
+ deployment_method: Default::default(),
229
},
230
)
231
.await
crates/cli/src/deploy/setup_create_new_project.rs
+2
-1
@@ -74,7 +74,7 @@ pub(crate) async fn create_new_project(
74
}
75
};
76
77
- let runners = vec![Runner::NodeJs, Runner::Swift, Runner::Ruby];
77
+ let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
78
let runner = Select::with_theme(&ColorfulTheme::default())
79
.items(&runners)
80
.default(0)
@@ -101,6 +101,7 @@ pub(crate) async fn create_new_project(
101
runner,
102
repository,
103
description,
104
+ deployment_method: Default::default(),
105
},
106
)
107
.await
crates/cli/src/project/crud_create.rs
+4
-1
@@ -36,7 +36,7 @@ pub async fn process_project_init(
36
}
37
};
38
39
- let runners = vec![Runner::NodeJs, Runner::Swift, Runner::Ruby];
39
+ let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
40
let runner = Select::with_theme(&ColorfulTheme::default())
41
.items(&runners)
42
.default(0)
@@ -82,6 +82,7 @@ pub async fn process_project_init(
82
runner,
83
repository,
84
description: description.clone(),
85
+ deployment_method: Default::default(),
86
},
87
)
88
.await
@@ -113,6 +114,8 @@ description = "{description}"
114
[project]
115
id = 1
116
name = "{repository_name}"
117
+runner = 0
118
+deployment_method = 0
119
repository = "{repository_name}"
120
description = "{description}"
121
created_at = "{now}"
crates/cli/src/project/crud_update.rs
+1
-1
@@ -41,7 +41,7 @@ pub async fn process_project_update(env: Environment, project_id: String) -> Res
41
Err(_) => return Err(anyhow!("Invalid description.")),
42
};
43
44
- let runners = vec![Runner::NodeJs, Runner::Swift, Runner::Ruby];
44
+ let runners = vec![Runner::NodeJs, Runner::Static, Runner::Ruby, Runner::Swift];
45
let runner = Select::with_theme(&ColorfulTheme::default())
46
.items(&runners)
47
.default(0)
crates/smbcloud-model/src/project.rs
+33
-2
@@ -7,6 +7,30 @@ use {
7
tsync::tsync,
8
};
9
10
+/// How the project's files are delivered to the server.
11
+///
12
+/// `Git` — the classic smbCloud flow: push to a remote git repo, the server
13
+/// builds and restarts the process.
14
+/// `Rsync` — files are transferred directly with rsync over SSH; no build step
15
+/// runs on the server. Ideal for pre-built static sites or assets.
16
+#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
17
+#[repr(u8)]
18
+#[tsync]
19
+pub enum DeploymentMethod {
20
+ #[default]
21
+ Git = 0,
22
+ Rsync = 1,
23
+}
24
+
25
+impl Display for DeploymentMethod {
26
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27
+ match self {
28
+ DeploymentMethod::Git => write!(f, "Git"),
29
+ DeploymentMethod::Rsync => write!(f, "Rsync"),
30
+ }
31
+ }
32
+}
33
+
34
#[derive(Deserialize, Debug, Serialize)]
35
pub struct Config {
36
pub current_project: Option<Project>,
@@ -19,6 +43,9 @@ pub struct Project {
43
pub id: i32,
44
pub name: String,
45
pub runner: Runner,
46
+ /// Defaults to `Git` when absent (older API responses won't include the field).
47
+ #[serde(default)]
48
+ pub deployment_method: DeploymentMethod,
49
pub path: Option<String>,
50
pub repository: Option<String>,
51
pub description: Option<String>,
@@ -38,6 +65,8 @@ pub struct ProjectCreate {
65
pub repository: String,
66
pub description: String,
67
pub runner: Runner,
68
+ #[serde(default)]
69
+ pub deployment_method: DeploymentMethod,
70
}
71
72
#[derive(Deserialize, Serialize, Debug)]
@@ -89,12 +118,14 @@ mod tests {
118
repository: "test".to_owned(),
119
description: "test".to_owned(),
120
runner: Runner::NodeJs,
121
+ deployment_method: DeploymentMethod::Git,
122
};
123
let json = json!({
124
"name": "test",
95
- "repository": "test", // Corrected: repository should be included as per struct
125
+ "repository": "test",
126
"description": "test",
97
- "runner": 0
127
+ "runner": 0,
128
+ "deployment_method": 0
129
});
130
assert_eq!(serde_json::to_value(project_create).unwrap(), json);
131
}
crates/smbcloud-model/src/runner.rs
+23
-6
@@ -9,19 +9,29 @@ use {
9
},
10
};
11
12
-#[derive(Debug, Deserialize_repr, Serialize_repr, Clone, Copy, PartialEq)]
12
+#[derive(Debug, Deserialize_repr, Serialize_repr, Clone, Copy, PartialEq, Default)]
13
#[repr(u8)]
14
#[tsync::tsync]
15
pub enum Runner {
16
- NodeJs,
17
- Ruby,
18
- Swift,
16
+ #[default]
17
+ NodeJs = 0,
18
+ /// A pure static site: no app process on the server, nginx serves files
19
+ /// directly. Always deployed via rsync — git push has no build step to run.
20
+ Static = 1,
21
+ Ruby = 2,
22
+ Swift = 3,
23
Monorepo = 255,
24
}
25
26
impl Display for Runner {
27
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
24
- write!(f, "{:?}", self)
28
+ match self {
29
+ Runner::NodeJs => write!(f, "NodeJs"),
30
+ Runner::Static => write!(f, "Static"),
31
+ Runner::Ruby => write!(f, "Ruby"),
32
+ Runner::Swift => write!(f, "Swift"),
33
+ Runner::Monorepo => write!(f, "Monorepo"),
34
+ }
35
}
36
}
37
@@ -67,10 +77,17 @@ impl Runner {
77
format!("git@{}.smbcloud.xyz", self.api())
78
}
79
80
+ /// Returns the explicit hostname used for rsync SSH connections.
81
+ /// e.g. `api.smbcloud.xyz` or `api-1.smbcloud.xyz`
82
+ pub fn rsync_host(&self) -> String {
83
+ format!("{}.smbcloud.xyz", self.api())
84
+ }
85
+
86
fn api(&self) -> &str {
87
match self {
88
Runner::Monorepo => "monorepo",
73
- Runner::NodeJs => "api",
89
+ // Static sites and NodeJs projects share the same lightweight tier
90
+ Runner::NodeJs | Runner::Static => "api",
91
Runner::Ruby | Runner::Swift => "api-1",
92
}
93
}