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
//! `--account` on the session-spawning verbs (#8914).
//!
//! Why: `--account` is a clap `global = true` flag, so `tm sessions new
//! --account X <path>` parsed it and then dropped it; the session ran as the
//! machine's active gh account. A spawn verb must either run as X or refuse.
//! What: [`pin_account_for_dir`] proves X for the checkout's origin
//! ([`trusty_mpm::core::gh_session_account::prepare_session_account`]), then
//! pins X and its per-account gh dir on every registry record for that origin.
//! Every spawn, resume and relaunch reads that pin, and the daemon builds the
//! session env from it. [`session_as_account`] applies it to `tm sessions
//! new`/`start` and refuses `--account` on a verb that spawns nothing.
//! Test: `session_account_tests.rs`.
use std::path::{Path, PathBuf};
use trusty_mpm::project::Project;
use crate::cli::SessionAction;
use crate::commands::projects::registry::{RegisterInput, current_default_branch, register};
/// The directory a `tm sessions` verb spawns a session in, or `None` for a
/// verb that spawns nothing.
/// Test: `spawn_dir_names_the_new_and_start_checkouts`,
/// `spawn_dir_is_none_for_a_verb_that_spawns_nothing`.
pub(crate) fn spawn_dir(action: &SessionAction) -> anyhow::Result<Option<PathBuf>> {
Ok(match action {
SessionAction::New { repo, .. } => Some(PathBuf::from(repo)),
SessionAction::Start { dir } => Some(crate::commands::project::resolve_dir(dir.clone())?),
_ => None,
})
}
/// `tm sessions <verb>` with the global `--account` applied (#8914).
///
/// What: no account → the plain [`super::session::session`] dispatch. With an
/// account, a spawn verb pins it first ([`pin_account_for_dir`]) and any other
/// verb is refused, so the flag is never silently dropped.
/// Test: `spawn_dir_is_none_for_a_verb_that_spawns_nothing` covers the
/// decision; the pin is [`pin_account_for_dir`]'s.
pub(crate) async fn session_as_account(
daemon: &trusty_mpm::client::DaemonClient,
action: SessionAction,
account: Option<&str>,
account_token: Option<&str>,
) -> anyhow::Result<()> {
if let Some(login) = account {
let Some(dir) = spawn_dir(&action)? else {
anyhow::bail!(
"--account {login} applies to `tm sessions new` and `tm sessions start`; this \
verb spawns no session. A resumed session keeps the account its project pins."
);
};
pin_account_for_dir(daemon, &dir, login, account_token).await?;
}
super::session::session(daemon, action).await
}
/// Prove `login` for the checkout at `dir`, then pin it on the project (#8914).
///
/// Why: the pin is what every later spawn, resume and relaunch of a session in
/// this checkout reads, and it must name a proven account before any of them
/// runs. A failure at any step refuses the command.
/// What: `dir` must be an existing directory with an `origin` remote. Then
/// `prepare_session_account` (blocking, off the executor; `account_token` is
/// the `--account-token-stdin` token), then every registry record whose
/// `repo_url` matches the origin — or one new record named from it — is
/// upserted with `gh_account = login` and `github.config_dir` = the
/// per-account dir. A record left pinning another account fails every session
/// for the origin closed, so all are updated. Each change is printed as
/// [`pin_notice`], then [`transport_notice`].
/// Test: the pure steps are `pin_targets_updates_every_matching_record`,
/// `pin_targets_names_a_new_record_from_the_origin`,
/// `pin_notice_names_the_account_it_replaces`,
/// `transport_notice_says_when_git_uses_ssh`; the rest is HTTP and `gh`.
pub(crate) async fn pin_account_for_dir(
daemon: &trusty_mpm::client::DaemonClient,
dir: &Path,
login: &str,
account_token: Option<&str>,
) -> anyhow::Result<()> {
if !dir.is_dir() {
anyhow::bail!(
"--account {login}: '{}' is not an existing local directory",
dir.display()
);
}
let origin = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(dir)
.map_err(|e| anyhow::anyhow!("--account {login}: {e}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"--account {login}: '{}' has no git origin remote to pin the account on",
dir.display()
)
})?;
let (who, from) = (login.to_string(), origin.clone());
let token = account_token.map(str::to_string);
let config_dir = tokio::task::spawn_blocking(move || {
trusty_mpm::core::gh_session_account::prepare_session_account(&who, &from, token.as_deref())
})
.await?
.map_err(|e| anyhow::anyhow!("{e}"))?;
let projects = daemon.registry_list_projects(None).await?;
for (name, repo_url) in pin_targets(&projects, &origin)? {
let previous = projects
.iter()
.find(|p| p.name == name)
.and_then(|p| p.gh_account.as_deref());
let notice = pin_notice(&name, previous, login);
let default_branch = current_default_branch(daemon, &name).await;
register(
daemon,
RegisterInput {
default_branch,
gh_account: Some(login.to_string()),
gh_config_dir: Some(config_dir.clone()),
..RegisterInput::new(name, repo_url)
},
)
.await?;
eprintln!("{notice}");
}
eprintln!("{}", transport_notice(&origin, login));
Ok(())
}
/// The line naming a project-wide pin change (#8914 MEDIUM).
///
/// Why: `--account` re-pins the whole project, so every later session of it
/// runs as `login`, not only this one.
/// What: `tm: every session of project '<name>' now runs as gh account
/// '<login>' (was '<previous>')`, or `(was unpinned)`.
/// Test: `pin_notice_names_the_account_it_replaces`.
pub(crate) fn pin_notice(name: &str, previous: Option<&str>, login: &str) -> String {
let was = previous
.map(str::trim)
.filter(|p| !p.is_empty())
.map_or_else(|| "unpinned".to_string(), |p| format!("'{p}'"));
format!("tm: every session of project '{name}' now runs as gh account '{login}' (was {was})")
}
/// What `--account` does and does not pin for `origin`'s git transport
/// (#8914 MEDIUM).
///
/// Why: the pin covers gh and HTTPS git. An SSH origin authenticates with the
/// operator's SSH key, which tm does not select.
/// What: an `ssh://` or scp-style (`user@host:path`) origin gets a line naming
/// the SSH key as unpinned; any other origin gets the HTTPS line. The origin
/// is shown redacted (#9124).
/// Test: `transport_notice_says_when_git_uses_ssh`,
/// `account_notices_never_quote_the_origin_token`.
pub(crate) fn transport_notice(origin: &str, login: &str) -> String {
let scp_style = !origin.contains("://") && origin.contains('@') && origin.contains(':');
let ssh = origin.starts_with("ssh://") || scp_style;
let origin = trusty_mpm::core::remote_url_redact::redact_stored_url(origin); // #9124
if ssh {
format!(
"tm: gh runs as '{login}'. git uses SSH for {origin}, so fetch and push authenticate \
with your SSH key, which tm does not pin to '{login}'."
)
} else {
format!("tm: this session runs gh and HTTPS git as '{login}'")
}
}
/// The token `--account-token-stdin` supplies, read from `reader` (#8914).
///
/// What: at most 64 KiB, trimmed; refuses an empty token and one with
/// whitespace inside it (two tokens, or a pasted sentence).
/// Test: `read_stdin_token_trims_one_token`,
/// `read_stdin_token_refuses_empty_or_spaced_input`.
pub(crate) fn read_stdin_token(reader: impl std::io::Read) -> anyhow::Result<String> {
use std::io::Read as _;
let mut text = String::new();
reader
.take(64 * 1024)
.read_to_string(&mut text)
.map_err(|e| anyhow::anyhow!("--account-token-stdin: cannot read stdin ({e})"))?;
let token = text.trim();
if token.is_empty() || token.contains(char::is_whitespace) {
anyhow::bail!("--account-token-stdin: stdin must hold exactly one token");
}
Ok(token.to_string())
}
/// The `(name, repo_url)` records to pin for `origin`: every registered record
/// whose `repo_url` matches it, else one new record named from it. A refusal
/// shows the origin redacted (#9124).
/// Test: `pin_targets_updates_every_matching_record`,
/// `pin_targets_names_a_new_record_from_the_origin`,
/// `account_notices_never_quote_the_origin_token`.
pub(crate) fn pin_targets(
projects: &[Project],
origin: &str,
) -> anyhow::Result<Vec<(String, String)>> {
let matching: Vec<(String, String)> = projects
.iter()
.filter(|p| trusty_mpm::project::record::repo_url_matches(&p.repo_url, origin))
.map(|p| (p.name.clone(), p.repo_url.clone()))
.collect();
if !matching.is_empty() {
return Ok(matching);
}
let name = trusty_mpm::project::derive_name_from_url(origin).ok_or_else(|| {
let shown = trusty_mpm::core::remote_url_redact::redact_stored_url(origin); // #9124
anyhow::anyhow!("cannot name a project for origin '{shown}'")
})?;
Ok(vec![(name, origin.to_string())])
}
#[cfg(test)]
#[path = "session_account_tests.rs"]
mod tests;