Repository navigation
cmux-tui: start-chat plan answers the folder's repo; start-chat ensure gets the workspace machine #30414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
cmux-tui: start-chat plan answers the folder's repo; start-chat ensure gets the workspace machine #30414
Changes from all commits
0c3d3b9
87cad70
e9c2cfa
addc257
0a91169
06bfb6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -22,6 +22,14 @@ | |||||
| //! turn, unless this send's copy already finished there: a second push | ||||||
| //! would overwrite what the chat changed since. Answers the chat's folder | ||||||
| //! there. | ||||||
| //! - `ensure --workspace W --key K --repo OWNER/NAME [--ref REF] [--environment env_...] | ||||||
| //! [--platform URL]`, the cmux credential on stdin: the workspace's own | ||||||
| //! machine from the cmux platform (`POST /v1/workspaces/{W}/machine`), made | ||||||
| //! under the plan's `create_key` so a retry gets the same one. With the | ||||||
| //! folder's repository (the plan's `repo`) it boots from the repository's | ||||||
| //! set-up snapshot once that is ready (zero-setup hot start), keyed by the | ||||||
| //! chosen cloud environment (`cloud.startChat`'s `environment`; absent: the | ||||||
| //! personal Default). | ||||||
| //! - `import --entries JSON`: takes in the sends the App kept in its user | ||||||
| //! defaults before this did ([`Ledger::import`]), once, on upgrade. | ||||||
| //! | ||||||
|
|
@@ -46,6 +54,7 @@ use pick::{Candidate, Destination}; | |||||
|
|
||||||
| const USAGE: &str = "usage: cmux link start-chat plan --request JSON\n \ | ||||||
| cmux link start-chat pin --key KEY --account ACCOUNT --machine ID [--workspace ID]\n \ | ||||||
| cmux link start-chat ensure --workspace ID --key KEY --repo OWNER/NAME [--ref REF] [--environment env_ID] [--platform URL] (the cmux credential on stdin)\n \ | ||||||
| cmux link start-chat import --entries JSON\n \ | ||||||
| cmux link start-chat copy --key KEY --account ACCOUNT --machine ID --socket PATH --local FOLDER [--subpath PATH] --work-root PATH..."; | ||||||
|
|
||||||
|
|
@@ -83,6 +92,7 @@ pub(super) fn run(args: &[String]) -> i32 { | |||||
| }, | ||||||
| "copy" => StartCopy::of(&flags).map(|copy| copy.run(&ledger, &push)), | ||||||
| "import" => flags.one("--entries").map(|entries| import(&ledger, entries)), | ||||||
| "ensure" => Ensure::of(&flags).map(|ensure| ensure.run(&mut std::io::stdin().lock())), | ||||||
| _ => None, | ||||||
| }; | ||||||
| let Some(answer) = answer else { | ||||||
|
|
@@ -171,6 +181,12 @@ struct PlanRequest { | |||||
| /// since): a send pinned to one keeps it, but none is picked. | ||||||
| #[serde(default)] | ||||||
| stored: Vec<String>, | ||||||
| /// The chat's folder on this Mac: its GitHub `origin` is the plan's `repo`. | ||||||
| #[serde(default)] | ||||||
| local: Option<String>, | ||||||
| /// The cloud environment the send chose (`cloud.startChat`'s `environment`), echoed for `ensure`. | ||||||
| #[serde(default)] | ||||||
| environment: Option<String>, | ||||||
| } | ||||||
|
|
||||||
| /// `plan`: where the send goes, pinned when it is a machine that exists. | ||||||
|
|
@@ -192,6 +208,9 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value { | |||||
| "the workspace id is empty, too long or has control characters", | ||||||
| ); | ||||||
| } | ||||||
| if request.environment.as_deref().is_some_and(|id| !valid_environment(id)) { | ||||||
| return refused("bad_request", "the environment is not a cloud environment id (env_...)"); | ||||||
| } | ||||||
| let keys = Keys::of(&request.key); | ||||||
| let send = Send::new(&request.key, &request.account); | ||||||
| let planned = Ledger::update(ledger, |pins| { | ||||||
|
|
@@ -227,10 +246,227 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value { | |||||
| }; | ||||||
| if let (Value::Object(line), Value::Object(fields)) = (&mut line, fields) { | ||||||
| line.extend(fields); | ||||||
| line.insert("repo".into(), json!(request.local.as_deref().and_then(folder_repo))); | ||||||
| line.insert("environment".into(), json!(request.environment)); | ||||||
| } | ||||||
| line | ||||||
| } | ||||||
|
|
||||||
| /// A cloud environment's id, as the cmux platform makes them: `env_` and 24 hex digits. | ||||||
| pub(super) fn valid_environment(id: &str) -> bool { | ||||||
| id.len() == 28 | ||||||
| && id.starts_with("env_") | ||||||
| && id[4..].bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) | ||||||
| } | ||||||
|
|
||||||
| /// A repository as the cmux platform names one: `owner/name`, GitHub's characters, at most 140 bytes. | ||||||
| pub(super) fn valid_repo(repo: &str) -> bool { | ||||||
| let part = |part: &str| { | ||||||
| !part.is_empty() | ||||||
| && part != "." | ||||||
| && part != ".." | ||||||
| && part | ||||||
| .bytes() | ||||||
| .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) | ||||||
| }; | ||||||
| repo.len() <= 140 | ||||||
| && matches!(repo.split_once('/'), Some((owner, name)) if part(owner) && part(name)) | ||||||
| } | ||||||
|
|
||||||
| /// The GitHub repository of the folder `local` (absolute): its repository's `origin` remote, when that is | ||||||
| /// GitHub's. Read from the repository's config file, never by running git: no hooks, no inherited `GIT_*` | ||||||
| /// variables, no Command Line Tools prompt, nothing that can wait on a credential. | ||||||
| fn folder_repo(local: &str) -> Option<String> { | ||||||
| let local = Path::new(local); | ||||||
| if !local.is_absolute() { | ||||||
| return None; | ||||||
| } | ||||||
| let config = std::fs::read_to_string(git_common_dir(local)?.join("config")).ok()?; | ||||||
| github_repo(&origin_url(&config)?) | ||||||
| } | ||||||
|
|
||||||
| /// The git directory that holds the config of the repository `folder` is in: the nearest `.git` at or above | ||||||
| /// it, followed through a worktree's `gitdir:` file and its `commondir`. | ||||||
| fn git_common_dir(folder: &Path) -> Option<PathBuf> { | ||||||
| let dot_git = | ||||||
| folder.ancestors().map(|dir| dir.join(".git")).find(|dot_git| dot_git.exists())?; | ||||||
| if dot_git.is_dir() { | ||||||
| return Some(dot_git); | ||||||
| } | ||||||
| let link = std::fs::read_to_string(&dot_git).ok()?; | ||||||
| let gitdir = link.lines().find_map(|line| line.strip_prefix("gitdir:"))?.trim(); | ||||||
| let gitdir = dot_git.parent()?.join(gitdir); | ||||||
| match std::fs::read_to_string(gitdir.join("commondir")) { | ||||||
| Ok(common) => Some(gitdir.join(common.trim())), | ||||||
| Err(_) => Some(gitdir), | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// The `url` of `[remote "origin"]` in a git config file's text (the last one wins, as in git). | ||||||
| pub(super) fn origin_url(config: &str) -> Option<String> { | ||||||
| let mut in_origin = false; | ||||||
| let mut url = None; | ||||||
| for line in config.lines().map(str::trim) { | ||||||
| if let Some(section) = line.strip_prefix('[') { | ||||||
| let section = section.split(']').next().unwrap_or(""); | ||||||
| let (name, sub) = section.split_once(char::is_whitespace).unwrap_or((section, "")); | ||||||
| in_origin = name.eq_ignore_ascii_case("remote") && sub.trim() == "\"origin\""; | ||||||
| } else if in_origin | ||||||
| && let Some((key, value)) = line.split_once('=') | ||||||
| && key.trim().eq_ignore_ascii_case("url") | ||||||
| { | ||||||
| url = Some(value.trim().trim_matches('"').to_string()); | ||||||
| } | ||||||
| } | ||||||
| url | ||||||
| } | ||||||
|
|
||||||
| /// `owner/name` of a GitHub remote URL (https, ssh or scp-like, any case, with or without a user), None for | ||||||
| /// any other host. | ||||||
| pub(super) fn github_repo(url: &str) -> Option<String> { | ||||||
| let rest = ["https://", "http://", "ssh://", "git+ssh://"].iter().find_map(|scheme| { | ||||||
| url.get(..scheme.len()) | ||||||
| .filter(|s| s.eq_ignore_ascii_case(scheme)) | ||||||
| .map(|_| &url[scheme.len()..]) | ||||||
| }); | ||||||
| let path = match rest { | ||||||
| Some(rest) => { | ||||||
| let (host, path) = rest.split_once('/')?; | ||||||
| let host = host.rsplit('@').next()?; | ||||||
| host.eq_ignore_ascii_case("github.com").then_some(path)? | ||||||
| } | ||||||
| None => { | ||||||
| // scp-like: [user@]github.com:owner/name | ||||||
| let (host, path) = url.split_once(':')?; | ||||||
| let host = host.rsplit('@').next()?; | ||||||
| host.eq_ignore_ascii_case("github.com").then_some(path)? | ||||||
| } | ||||||
| }; | ||||||
| let path = path.trim_end_matches('/'); | ||||||
| let path = path.strip_suffix(".git").unwrap_or(path); | ||||||
| valid_repo(path).then(|| path.to_string()) | ||||||
| } | ||||||
|
|
||||||
| /// `ensure`: the Mac workspace's own machine from the cmux platform. | ||||||
| pub(super) struct Ensure { | ||||||
| workspace: String, | ||||||
| create_key: String, | ||||||
| repo: String, | ||||||
| git_ref: Option<String>, | ||||||
| environment: Option<String>, | ||||||
| platform: Option<String>, | ||||||
| } | ||||||
|
|
||||||
| impl Ensure { | ||||||
| fn of(flags: &Flags) -> Option<Self> { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||
| let optional = |name: &str| match flags.all(name).as_slice() { | ||||||
| [] => Some(None), | ||||||
| [value] => Some(Some(value.to_string())), | ||||||
| _ => None, | ||||||
| }; | ||||||
| let environment = optional("--environment")?; | ||||||
| let git_ref = optional("--ref")?; | ||||||
| if environment.as_deref().is_some_and(|id| !valid_environment(id)) | ||||||
| || git_ref | ||||||
| .as_deref() | ||||||
| .is_some_and(|r| r.is_empty() || r.len() > 200 || r.chars().any(char::is_control)) | ||||||
| { | ||||||
| return None; | ||||||
| } | ||||||
| Some(Self { | ||||||
| workspace: flags.workspace()??.to_string(), | ||||||
| create_key: Keys::of(&flags.key()?).create, | ||||||
| repo: flags.one("--repo").filter(|repo| valid_repo(repo))?.to_string(), | ||||||
| git_ref, | ||||||
| environment, | ||||||
| platform: optional("--platform")?, | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| /// The request body: the plan's `create_key` as the client key, so a retry gets the same machine. | ||||||
| pub(super) fn body(&self) -> Value { | ||||||
| let mut body = json!({"clientKey": self.create_key, "repo": self.repo}); | ||||||
| if let Some(git_ref) = &self.git_ref { | ||||||
| body["ref"] = json!(git_ref); | ||||||
| } | ||||||
| if let Some(environment) = &self.environment { | ||||||
| body["environment"] = json!(environment); | ||||||
| } | ||||||
| body | ||||||
| } | ||||||
|
|
||||||
| fn run(&self, stdin: &mut dyn std::io::BufRead) -> Value { | ||||||
| let mut credential = String::new(); | ||||||
| if stdin.read_line(&mut credential).is_err() || credential.trim().is_empty() { | ||||||
| return refused("unauthorized", "no cmux credential on stdin"); | ||||||
| } | ||||||
| let env = |name: &str| std::env::var(name).ok(); | ||||||
| let origin = match crate::cli::platform_origin(self.platform.as_deref(), &env) { | ||||||
| Ok(origin) => origin, | ||||||
| Err(error) => return refused("bad_request", error), | ||||||
| }; | ||||||
| // A new machine's placement may take a while; the platform answers once it is admitted. | ||||||
| let platform = match crate::cli::Platform::new( | ||||||
| origin, | ||||||
| credential.trim().to_string(), | ||||||
| std::time::Duration::from_secs(120), | ||||||
| ) { | ||||||
| Ok(platform) => platform, | ||||||
| Err(failure) => return refused(failure.code, failure.message), | ||||||
| }; | ||||||
| let path = format!("/v1/workspaces/{}/machine", percent_segment(&self.workspace)); | ||||||
| ensure_answer(platform.post(&path, self.body())) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// One URL path segment: unreserved bytes kept, every other byte percent-encoded. | ||||||
| pub(super) fn percent_segment(segment: &str) -> String { | ||||||
| segment | ||||||
| .bytes() | ||||||
| .map(|byte| match byte { | ||||||
| b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||
| (byte as char).to_string() | ||||||
| } | ||||||
| _ => format!("%{byte:02X}"), | ||||||
| }) | ||||||
| .collect() | ||||||
| } | ||||||
|
|
||||||
| /// `ensure`'s line from the platform's answer: the machine's id and what it booted from. | ||||||
| pub(super) fn ensure_answer(answer: Result<Value, crate::cli::PlatformCallError>) -> Value { | ||||||
| use crate::cli::PlatformCallError as E; | ||||||
| match answer { | ||||||
| Ok(body) => match body.pointer("/machine/id").and_then(Value::as_str) { | ||||||
| Some(id) => json!({ | ||||||
| "ok": true, | ||||||
| "machine": id, | ||||||
| "team": body.get("team"), | ||||||
| "created": body.get("created").and_then(Value::as_bool).unwrap_or(false), | ||||||
| "snapshot": body.get("snapshot"), | ||||||
| "environment": body.get("environment"), | ||||||
| }), | ||||||
| None => refused("bad_answer", "the cmux platform answered without the machine"), | ||||||
| }, | ||||||
| Err(E::Status { status: 404, code: "" | "not_found" }) => { | ||||||
| refused("unavailable", "this cmux platform has no workspace machines (it answered 404)") | ||||||
| } | ||||||
| Err(E::Status { status, code }) => refused( | ||||||
| if code.is_empty() { "refused" } else { code }, | ||||||
| format!( | ||||||
| "the cmux platform refused the workspace's machine: {}", | ||||||
| format!("{status} {code}").trim_end() | ||||||
| ), | ||||||
| ), | ||||||
| Err(E::Unreachable(reason)) => { | ||||||
| refused("unreachable", format!("the cmux platform did not answer: {reason}")) | ||||||
| } | ||||||
| Err(E::Unreadable(status)) => refused( | ||||||
| "bad_answer", | ||||||
| format!("the cmux platform answered {status} with no readable body"), | ||||||
| ), | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// `pin`: the send `key` of `account` runs on `machine` (one the App just made | ||||||
| /// for it), which is the Mac workspace `workspace`'s own from now on when the | ||||||
| /// workspace has none yet and no other workspace has it ([`pick::claim`]). | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
github_reporejects valid GitHub remotes with explicit transport ports, such ashttps://github.com:443/owner/repoandssh://git@github.com:22/owner/repo. Parse the authority and port separately, normalizing HTTPS443and ignoring SSH transport ports, soplancan identify the repository for zero-setup boot.(Based on your team's feedback about GitHub remote port normalization.)
View Feedback
Prompt for AI agents