1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
use std::path::PathBuf;
use clap::{Parser, Subcommand};
/// Point local apps at any backend stand, keep servers and secrets at hand,
/// build and deploy - from any directory.
#[derive(Parser)]
#[command(name = "turnout", version, about, propagate_version = true)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Initialize the data directory and walk through first-run setup
Setup {
/// Skip confirmation prompts and accept defaults
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
/// Show what turnout knows: apps, servers, bindings, gateway state
Status,
/// Manage apps - the local projects turnout works with
App {
#[command(subcommand)]
command: AppCommand,
},
/// Manage servers - the stands turnout routes to and deploys on
Server {
#[command(subcommand)]
command: ServerCommand,
},
/// Manage credentials - who logs in, and with a password, a key or the SSH agent
#[command(alias = "cred")]
Credential {
#[command(subcommand)]
command: CredentialCommand,
},
/// Manage paths - named remote directories to deploy into
Path {
#[command(subcommand)]
command: PathCommand,
},
/// Manage targets - a named deploy route: app, server, credential and path
Target {
#[command(subcommand)]
command: TargetCommand,
},
/// Manage the secrets credentials use, stored in the OS keyring
Pass {
#[command(subcommand)]
command: PassCommand,
},
/// Set up SSH key access to a server, so the password is needed once
Key {
#[command(subcommand)]
command: KeyCommand,
},
/// Bind an app (or a whole group) to a server - the daily switch command
Use {
/// App or group name; picked interactively when omitted
app: Option<String>,
/// Target server; picked interactively when omitted
server: Option<String>,
/// Skip the stand reachability check
#[arg(short = 'n', long)]
no_check: bool,
},
/// Manage app groups - switch a whole contour with one `use`
Group {
#[command(subcommand)]
command: GroupCommand,
},
/// Run the local dev gateway that routes apps to their bound servers
Gateway {
#[command(subcommand)]
command: GatewayCommand,
},
/// Run the app's `dev` command (app resolved from the current directory if omitted)
Dev { app: Option<String> },
/// Open the app in the browser at http://NAME.localhost (through the gateway's front door)
Open { app: Option<String> },
/// Run the app's `build` command
Build { app: Option<String> },
/// Run the app's `test` command
Test { app: Option<String> },
/// Run the app's `lint` command
Lint { app: Option<String> },
/// Run any named command from the app config
Run { command: String, app: Option<String> },
/// Set up deployment of an app to a server in one wizard
#[command(name = "deploy-setup", alias = "setup-deploy")]
DeploySetup {
/// App name; picked interactively when omitted
app: Option<String>,
/// Target server; picked interactively when omitted
#[arg(short, long)]
server: Option<String>,
},
/// Deploy to a target over SSH/SFTP: build, upload, restart
Deploy {
/// Target name, or an app name to use its target on the bound server
target: Option<String>,
/// Target server (defaults to the app's current binding)
#[arg(short, long)]
server: Option<String>,
/// Credential to log in with (defaults to the server's)
#[arg(short = 'C', long)]
credential: Option<String>,
/// Named path to deploy into (defaults to the server's for this app)
#[arg(short = 'p', long)]
path: Option<String>,
/// Skip the build step
#[arg(short = 'n', long)]
no_build: bool,
/// Back up the remote directory before touching it
#[arg(short, long)]
backup: bool,
/// Clear the remote directory before uploading
#[arg(short, long)]
clear: bool,
/// Upload file by file instead of packing the artifacts into one archive
#[arg(short = 'A', long)]
no_archive: bool,
},
/// Back up a target's deploy directory on the server
Backup {
/// Target name, or an app name to use its target on the bound server
target: Option<String>,
/// Target server (defaults to the app's current binding)
#[arg(short, long)]
server: Option<String>,
/// Credential to log in with (defaults to the server's)
#[arg(short = 'C', long)]
credential: Option<String>,
/// Named path to back up (defaults to the server's for this app)
#[arg(short = 'p', long)]
path: Option<String>,
},
/// Restore a target's deploy directory from a backup
Restore {
/// Target name, or an app name to use its target on the bound server
target: Option<String>,
/// Target server (defaults to the app's current binding)
#[arg(short, long)]
server: Option<String>,
/// Credential to log in with (defaults to the server's)
#[arg(short = 'C', long)]
credential: Option<String>,
/// Named path to restore into (defaults to the server's for this app)
#[arg(short = 'p', long)]
path: Option<String>,
/// Backup archive name (defaults to the newest)
#[arg(short, long)]
from: Option<String>,
/// List available backups and exit
#[arg(short, long)]
list: bool,
},
/// Print a shell completion script (source it from your shell profile)
Completions {
/// Target shell
shell: clap_complete::Shell,
},
/// List entity names for shell completion (internal)
#[command(hide = true)]
Complete {
/// What to list
what: CompleteKind,
},
/// Write the catalogs to a file for another machine
Export {
/// Where to write; defaults to turnout-export.json in the current directory
#[arg(short, long)]
output: Option<std::path::PathBuf>,
/// Include stored secrets, encrypted with a passphrase you choose
#[arg(short = 'S', long)]
with_secrets: bool,
},
/// Read a file written by `turnout export`
Import {
/// The export file to read
file: std::path::PathBuf,
/// Overwrite entries that already exist instead of keeping them
#[arg(short, long)]
force: bool,
},
/// Update turnout itself to the latest release
SelfUpdate {
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
/// Replace the binary even when a package manager owns it
#[arg(short, long)]
force: bool,
},
/// Refresh the cached latest release (internal, run in the background)
#[command(hide = true)]
CheckUpdate,
}
/// Entity kinds the completion helper can list.
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum CompleteKind {
Apps,
Servers,
Credentials,
Paths,
/// Named deploy targets - what `deploy` accepts
Targets,
Groups,
/// Apps and groups together - what `use` accepts first
Bindable,
/// Command names defined across all apps - what `run` accepts
Commands,
}
#[derive(Subcommand)]
pub enum GroupCommand {
/// Create a group; apps are picked interactively when not passed
Add {
/// Group name (lowercase letters, digits, dashes)
name: Option<String>,
/// App to include (repeatable)
#[arg(short = 'a', long = "app", value_name = "APP")]
apps: Vec<String>,
},
/// List groups
List,
/// Show a group and where its apps point
Show {
/// Group name; picked interactively when omitted
name: Option<String>,
},
/// Add or remove apps in a group
Edit {
/// Group name; picked interactively when omitted
name: Option<String>,
/// Add an app (repeatable)
#[arg(short = 'a', long = "add-app", value_name = "APP")]
add_apps: Vec<String>,
/// Remove an app (repeatable)
#[arg(short = 'r', long = "rm-app", value_name = "APP")]
rm_apps: Vec<String>,
},
/// Remove a group (its apps are untouched)
Remove {
/// Group name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum GatewayCommand {
/// Start the gateway in the background
Start,
/// Run the gateway in the foreground (what `start` spawns)
Run {
/// Port for the front door; picked (80, then 7000) when omitted
#[arg(long, hide = true)]
front_port: Option<u16>,
},
/// Stop the background gateway
Stop,
}
#[derive(Subcommand)]
pub enum PassCommand {
/// Save or replace a credential's secret in the OS keyring
Set {
/// Credential name; picked interactively when omitted
credential: Option<String>,
},
/// Copy the secret (or the user) to the clipboard
Copy {
/// Credential name; picked interactively when omitted
credential: Option<String>,
/// Copy the user name instead of the secret
#[arg(short, long)]
user: bool,
/// Print to stdout instead of copying to the clipboard
#[arg(short, long)]
show: bool,
},
/// List which credentials have a secret stored (never prints secrets)
List,
/// Remove a credential's stored secret; the credential itself stays
Remove {
/// Credential name; picked interactively when omitted
credential: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum KeyCommand {
/// Generate or reuse a key, authorize it on the server, and switch the
/// credential to it
Setup {
/// Server name; picked interactively when omitted
server: Option<String>,
/// Credential to give key access (defaults to the server's)
#[arg(short, long)]
credential: Option<String>,
/// Existing private key file to authorize instead of generating one
#[arg(short = 'K', long, value_name = "PATH")]
key: Option<String>,
},
/// Check that a credential's key signs in to a server
Check {
/// Server name; picked interactively when omitted
server: Option<String>,
/// Credential to check (defaults to the server's)
#[arg(short, long)]
credential: Option<String>,
},
}
#[derive(Subcommand)]
pub enum CredentialCommand {
/// Add a credential; missing details are asked interactively
Add {
/// Credential name (lowercase letters, digits, dashes)
name: Option<String>,
/// Remote user this logs in as
#[arg(short, long)]
user: Option<String>,
/// How it authenticates: password, key or agent
#[arg(short, long)]
auth: Option<String>,
/// Private key file (implies --auth key)
#[arg(short = 'K', long, value_name = "PATH")]
key: Option<String>,
},
/// List credentials
List,
/// Show one credential in detail (never prints secrets)
Show {
/// Credential name; picked interactively when omitted
name: Option<String>,
},
/// Edit a credential; with no flags an interactive wizard walks the fields
Edit {
/// Credential name; picked interactively when omitted
name: Option<String>,
#[arg(short, long)]
user: Option<String>,
/// How it authenticates: password, key or agent
#[arg(short, long)]
auth: Option<String>,
/// Private key file (empty value removes it)
#[arg(short = 'K', long, value_name = "PATH")]
key: Option<String>,
},
/// Remove a credential and its stored secret
Remove {
/// Credential name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum PathCommand {
/// Add a path; missing details are asked interactively
Add {
/// Path name (lowercase letters, digits, dashes)
name: Option<String>,
/// Absolute directory on the server
#[arg(short, long)]
dir: Option<String>,
/// Command to run on the server after writing here
#[arg(short, long)]
restart: Option<String>,
},
/// List paths
List,
/// Show one path in detail
Show {
/// Path name; picked interactively when omitted
name: Option<String>,
},
/// Edit a path; with no flags an interactive wizard walks the fields
Edit {
/// Path name; picked interactively when omitted
name: Option<String>,
#[arg(short, long)]
dir: Option<String>,
/// Post-write command (empty value removes it)
#[arg(short, long)]
restart: Option<String>,
},
/// Remove a path; servers that used it are updated
Remove {
/// Path name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum TargetCommand {
/// Add a target; missing parts are picked interactively
Add {
/// Target name (defaults to APP-SERVER)
name: Option<String>,
/// The app whose artifacts travel
#[arg(short, long)]
app: Option<String>,
/// The server they land on
#[arg(short, long)]
server: Option<String>,
/// The credential that logs in (defaults to the server's)
#[arg(short = 'C', long)]
credential: Option<String>,
/// The named path they are written to
#[arg(short, long)]
path: Option<String>,
},
/// List targets
List,
/// Show one target in detail
Show {
/// Target name; picked interactively when omitted
name: Option<String>,
},
/// Edit a target; with no flags an interactive wizard walks the fields
Edit {
/// Target name; picked interactively when omitted
name: Option<String>,
#[arg(short, long)]
server: Option<String>,
#[arg(short = 'C', long)]
credential: Option<String>,
#[arg(short, long)]
path: Option<String>,
},
/// Rename a target
Rename {
/// Target name; picked interactively when omitted
name: Option<String>,
/// The new name
to: Option<String>,
},
/// Remove a target (the entities it names are untouched)
Remove {
/// Target name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum AppCommand {
/// Add an app; missing details are asked interactively
Add {
/// App name (lowercase letters, digits, dashes)
name: Option<String>,
/// Project directory
#[arg(short, long)]
path: Option<PathBuf>,
/// Local gateway port for this app
#[arg(short = 'P', long)]
port: Option<u16>,
/// Environment variable that carries the gateway URL to the app's commands (default: TURNOUT_GATEWAY_URL)
#[arg(short = 'e', long = "env-var", value_name = "NAME")]
env_var: Option<String>,
/// Dotenv file turnout keeps in step with the port, relative to the project (default: .env.development.local)
#[arg(long = "env-file", value_name = "FILE")]
env_file: Option<String>,
/// Port the dev server listens on (default: assigned from 5100-5199 on the first `dev`)
#[arg(long = "dev-port", value_name = "PORT")]
dev_port: Option<u16>,
/// Build artifact directory, relative to the project path
#[arg(short, long)]
dist: Option<String>,
/// Set a command as NAME=CMD (repeatable); overrides detected defaults
#[arg(short = 'c', long = "command", value_name = "NAME=CMD")]
commands: Vec<String>,
/// Allow a server for this app (repeatable)
#[arg(short = 's', long = "server", value_name = "SERVER")]
servers: Vec<String>,
},
/// List apps
List,
/// Show one app in detail
Show {
/// App name; picked interactively when omitted
name: Option<String>,
},
/// Edit an app; with no flags an interactive wizard walks the fields
Edit {
/// App name; picked interactively when omitted
name: Option<String>,
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(short = 'P', long)]
port: Option<u16>,
/// Environment variable that carries the gateway URL to the app's commands
#[arg(short = 'e', long = "env-var", value_name = "NAME")]
env_var: Option<String>,
/// Dotenv file turnout keeps in step with the port, relative to the project
#[arg(long = "env-file", value_name = "FILE")]
env_file: Option<String>,
/// Port the dev server listens on; 0 lets the next `dev` assign one
#[arg(long = "dev-port", value_name = "PORT")]
dev_port: Option<u16>,
#[arg(short, long)]
dist: Option<String>,
/// Set a command as NAME=CMD, or NAME= to remove it (repeatable)
#[arg(short = 'c', long = "command", value_name = "NAME=CMD")]
commands: Vec<String>,
/// Allow a server (repeatable)
#[arg(short = 'a', long = "add-server", value_name = "SERVER")]
add_servers: Vec<String>,
/// Disallow a server (repeatable)
#[arg(short = 'r', long = "rm-server", value_name = "SERVER")]
rm_servers: Vec<String>,
},
/// Remove an app from the catalog (the project on disk is not touched)
Remove {
/// App name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}
#[derive(Subcommand)]
pub enum ServerCommand {
/// Add a server; missing details are asked interactively
Add {
/// Server name (lowercase letters, digits, dashes)
name: Option<String>,
/// Base URL, e.g. https://staging.example.com
#[arg(short, long)]
url: Option<String>,
/// Human-friendly label
#[arg(short, long)]
label: Option<String>,
/// SSH host and port when they differ from the URL's host
#[arg(short = 'H', long, value_name = "HOST[:PORT]")]
host: Option<String>,
/// Credential used to log in here
#[arg(short, long)]
credential: Option<String>,
/// Accept self-signed or invalid TLS certificates for this server
#[arg(short, long)]
insecure: bool,
},
/// List servers
List,
/// Show one server in detail
Show {
/// Server name; picked interactively when omitted
name: Option<String>,
},
/// Edit a server; with no flags an interactive wizard walks the fields
Edit {
/// Server name; picked interactively when omitted
name: Option<String>,
#[arg(short, long)]
url: Option<String>,
#[arg(short, long)]
label: Option<String>,
/// SSH host and port (empty value falls back to the URL's host)
#[arg(short = 'H', long, value_name = "HOST[:PORT]")]
host: Option<String>,
/// Credential used to log in here (empty value unsets it)
#[arg(short, long)]
credential: Option<String>,
/// Accept invalid TLS certificates for this server
#[arg(short, long, conflicts_with = "secure")]
insecure: bool,
/// Require valid TLS certificates for this server
#[arg(short = 'S', long)]
secure: bool,
},
/// Remove a server from the catalog; apps that allowed it are updated
Remove {
/// Server name; picked interactively when omitted
name: Option<String>,
/// Skip the confirmation prompt
#[arg(short = 'y', long = "yes")]
assume_yes: bool,
},
}