Self-hosting: the clone pack cache on S3 (MinIO, expiring), the API and MCP on their own port with PUBLIC_URL-derived addresses across the site, API and mail, scheduler --once, and smoke.sh covering pull requests from branches and forks and the merge queue
42 files+1244−2650/42 viewed
| 178 | 178 | ``` | |
| 179 | 179 | ||
| 180 | 180 | Then open http://localhost:8787 and sign up. The confirmation mail is in | |
| 181 | − | Mailpit at http://localhost:8025. Repositories, push and clone, issues and | |
| 182 | − | code browsing work; agents, deployments and search are off in this version. | |
| 181 | + | Mailpit at http://localhost:8025. Repositories, push and clone, issues, | |
| 182 | + | pull requests and code browsing work, and the API and MCP server answer at | |
| 183 | + | http://localhost:8789; agents, deployments and context search are off in | |
| 184 | + | this version. | |
| 183 | 185 | [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md) says what works and what is next. | |
| 184 | 186 | ||
| 185 | 187 | ### On Cloudflare |
| 1 | + | //! Where this installation is reached: the site, the REST API and the MCP | |
| 2 | + | //! server. Hosted g1t sets none of the variables and gets g1t.sh's | |
| 3 | + | //! addresses; a self-hosted one sets them from its PUBLIC_URL | |
| 4 | + | //! (deploy/self-host/configs.mjs). | |
| 5 | + | //! | |
| 6 | + | //! The MCP server is on its own host hosted (`mcp.g1t.sh`). Self-hosted it | |
| 7 | + | //! can be a path on the API's host instead (`http://localhost:8789/mcp`): | |
| 8 | + | //! a request is for MCP when its host starts with `mcp.`, or when MCP_URL | |
| 9 | + | //! has a path and the request is under it. | |
| 10 | + | ||
| 11 | + | use worker::{Env, Url}; | |
| 12 | + | ||
| 13 | + | pub const HOSTED_SITE: &str = "https://g1t.sh"; | |
| 14 | + | pub const HOSTED_API: &str = "https://api.g1t.sh"; | |
| 15 | + | pub const HOSTED_MCP: &str = "https://mcp.g1t.sh"; | |
| 16 | + | ||
| 17 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 18 | + | pub struct Addresses { | |
| 19 | + | /// SITE_URL: where people sign in and approve, and where git remotes are. | |
| 20 | + | pub site: String, | |
| 21 | + | /// API_URL: the REST API, and the OAuth issuer. | |
| 22 | + | pub api: String, | |
| 23 | + | /// MCP_URL: the MCP server, and the OAuth protected resource. | |
| 24 | + | pub mcp: String, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | impl Default for Addresses { | |
| 28 | + | fn default() -> Self { | |
| 29 | + | Addresses { | |
| 30 | + | site: HOSTED_SITE.to_owned(), | |
| 31 | + | api: HOSTED_API.to_owned(), | |
| 32 | + | mcp: HOSTED_MCP.to_owned(), | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | fn setting(value: Option<String>, default: &str) -> String { | |
| 38 | + | let value = value.unwrap_or_default(); | |
| 39 | + | let value = value.trim().trim_end_matches('/'); | |
| 40 | + | if value.is_empty() { default.to_owned() } else { value.to_owned() } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | impl Addresses { | |
| 44 | + | pub fn from_settings(site: Option<String>, api: Option<String>, mcp: Option<String>) -> Addresses { | |
| 45 | + | Addresses { | |
| 46 | + | site: setting(site, HOSTED_SITE), | |
| 47 | + | api: setting(api, HOSTED_API), | |
| 48 | + | mcp: setting(mcp, HOSTED_MCP), | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn from_env(env: &Env) -> Addresses { | |
| 53 | + | let var = |name: &str| env.var(name).ok().map(|value| value.to_string()); | |
| 54 | + | Addresses::from_settings(var("SITE_URL"), var("API_URL"), var("MCP_URL")) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// What a client is told when it must sign in first (RFC 9728). | |
| 58 | + | pub fn mcp_challenge(&self) -> String { | |
| 59 | + | format!("Bearer resource_metadata=\"{}\"", self.protected_resource()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// The protected resource metadata, at the MCP server's origin with | |
| 63 | + | /// its path appended, as RFC 9728 places it. | |
| 64 | + | pub fn protected_resource(&self) -> String { | |
| 65 | + | match Url::parse(&self.mcp) { | |
| 66 | + | Ok(url) if url.path() != "/" => { | |
| 67 | + | let origin = url.origin().ascii_serialization(); | |
| 68 | + | format!("{origin}/.well-known/oauth-protected-resource{}", url.path().trim_end_matches('/')) | |
| 69 | + | } | |
| 70 | + | _ => format!("{}/.well-known/oauth-protected-resource", self.mcp), | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// A repository's git remote. | |
| 75 | + | pub fn git_remote(&self, owner: &str, name: &str) -> String { | |
| 76 | + | format!("{}/{owner}/{name}.git", self.site) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Whether a request to `url` is for the MCP server, and the path it | |
| 80 | + | /// asks for there. | |
| 81 | + | pub fn mcp_path(&self, url: &Url) -> Option<String> { | |
| 82 | + | if url.host_str().is_some_and(|host| host.starts_with("mcp.")) { | |
| 83 | + | return Some(url.path().to_owned()); | |
| 84 | + | } | |
| 85 | + | // By path alone: behind a proxy the host a request names need not | |
| 86 | + | // be the one people use, and no REST route starts with it. | |
| 87 | + | let mcp = Url::parse(&self.mcp).ok()?; | |
| 88 | + | let base = mcp.path().trim_end_matches('/'); | |
| 89 | + | if base.is_empty() { | |
| 90 | + | return None; | |
| 91 | + | } | |
| 92 | + | let rest = url.path().strip_prefix(base)?; | |
| 93 | + | (rest.is_empty() || rest.starts_with('/')).then(|| if rest.is_empty() { "/".to_owned() } else { rest.to_owned() }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | #[cfg(test)] | |
| 98 | + | mod tests { | |
| 99 | + | use super::*; | |
| 100 | + | ||
| 101 | + | #[test] | |
| 102 | + | fn hosted_is_the_default() { | |
| 103 | + | let hosted = Addresses::from_settings(None, Some(String::new()), Some(" ".into())); | |
| 104 | + | assert_eq!(hosted, Addresses::default()); | |
| 105 | + | assert_eq!( | |
| 106 | + | hosted.mcp_challenge(), | |
| 107 | + | "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\"" | |
| 108 | + | ); | |
| 109 | + | assert_eq!(hosted.git_remote("acme", "rocket"), "https://g1t.sh/acme/rocket.git"); | |
| 110 | + | let on_mcp = Url::parse("https://mcp.g1t.sh/").unwrap(); | |
| 111 | + | assert_eq!(hosted.mcp_path(&on_mcp).as_deref(), Some("/")); | |
| 112 | + | let on_api = Url::parse("https://api.g1t.sh/user").unwrap(); | |
| 113 | + | assert_eq!(hosted.mcp_path(&on_api), None); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn self_hosted_mcp_is_a_path_on_the_api() { | |
| 118 | + | let own = Addresses::from_settings( | |
| 119 | + | Some("http://localhost:8787/".into()), | |
| 120 | + | Some("http://localhost:8789".into()), | |
| 121 | + | Some("http://localhost:8789/mcp".into()), | |
| 122 | + | ); | |
| 123 | + | assert_eq!(own.site, "http://localhost:8787"); | |
| 124 | + | assert_eq!( | |
| 125 | + | own.protected_resource(), | |
| 126 | + | "http://localhost:8789/.well-known/oauth-protected-resource/mcp" | |
| 127 | + | ); | |
| 128 | + | let url = |text: &str| Url::parse(text).unwrap(); | |
| 129 | + | assert_eq!(own.mcp_path(&url("http://localhost:8789/mcp")).as_deref(), Some("/")); | |
| 130 | + | assert_eq!(own.mcp_path(&url("http://localhost:8789/mcp/")).as_deref(), Some("/")); | |
| 131 | + | assert_eq!(own.mcp_path(&url("http://localhost:8789/mcpx")), None); | |
| 132 | + | assert_eq!(own.mcp_path(&url("http://localhost:8789/user")), None); | |
| 133 | + | assert_eq!(own.mcp_path(&url("http://127.0.0.1:8789/mcp")).as_deref(), Some("/"), "by path, whatever the host"); | |
| 134 | + | assert_eq!(own.git_remote("acme", "rocket"), "http://localhost:8787/acme/rocket.git"); | |
| 135 | + | } | |
| 136 | + | } |
| 4 | 4 | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | + | mod addresses; | |
| 7 | 8 | mod alerts; | |
| 8 | 9 | mod audit; | |
| 9 | 10 | mod blobs; | |
| 33 | 34 | use worker::{Context, Env, Method, Request, Response, Result, event}; | |
| 34 | 35 | ||
| 35 | 36 | use operations::Services; | |
| 36 | − | ||
| 37 | − | const API: &str = "https://api.g1t.sh"; | |
| 38 | 37 | ||
| 39 | 38 | fn method_name(method: Method) -> &'static str { | |
| 40 | 39 | match method { | |
| 110 | 109 | // Tells an MCP client where to sign in again. | |
| 111 | 110 | response.headers_mut().set( | |
| 112 | 111 | "www-authenticate", | |
| 113 | − | &format!("{}, error=\"invalid_token\"", oauth::MCP_CHALLENGE), | |
| 112 | + | &format!("{}, error=\"invalid_token\"", services.addresses.mcp_challenge()), | |
| 114 | 113 | )?; | |
| 115 | 114 | Ok(Err(response)) | |
| 116 | 115 | } | |
| 117 | 116 | ||
| 118 | 117 | /// Where everything is, for someone or something exploring the API. | |
| 119 | − | fn index() -> Value { | |
| 120 | − | let repo = format!("{API}/repos/{{owner}}/{{name}}"); | |
| 118 | + | fn index(addresses: &addresses::Addresses) -> Value { | |
| 119 | + | let api = &addresses.api; | |
| 120 | + | let repo = format!("{api}/repos/{{owner}}/{{name}}"); | |
| 121 | 121 | json!({ | |
| 122 | 122 | "documentation_url": "https://docs.g1t.sh/reference/api/", | |
| 123 | − | "openapi_url": format!("{API}/openapi.json"), | |
| 124 | − | "mcp_url": "https://mcp.g1t.sh", | |
| 125 | − | "current_user_url": format!("{API}/user"), | |
| 126 | − | "workspaces_url": format!("{API}/workspaces"), | |
| 127 | − | "repositories_url": format!("{API}/repos{{?q}}"), | |
| 128 | − | "search_url": format!("{API}/search{{?q,type,page,per_page}}"), | |
| 123 | + | "openapi_url": format!("{api}/openapi.json"), | |
| 124 | + | "mcp_url": addresses.mcp, | |
| 125 | + | "current_user_url": format!("{api}/user"), | |
| 126 | + | "workspaces_url": format!("{api}/workspaces"), | |
| 127 | + | "repositories_url": format!("{api}/repos{{?q}}"), | |
| 128 | + | "search_url": format!("{api}/search{{?q,type,page,per_page}}"), | |
| 129 | 129 | "repository_url": repo, | |
| 130 | 130 | "repository_events_url": format!("{repo}/events{{?before}}"), | |
| 131 | 131 | "labels_url": format!("{repo}/labels"), | |
| 137 | 137 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| 138 | 138 | "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"), | |
| 139 | 139 | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), | |
| 140 | − | "device_code_url": format!("{API}/device/code"), | |
| 141 | − | "device_token_url": format!("{API}/device/token"), | |
| 142 | − | "oauth_metadata_url": format!("{API}/.well-known/oauth-authorization-server"), | |
| 143 | − | "git_url": "https://g1t.sh/{owner}/{name}.git", | |
| 144 | − | "integrations_url": format!("{API}/workspaces/{{workspace}}/integrations"), | |
| 140 | + | "device_code_url": format!("{api}/device/code"), | |
| 141 | + | "device_token_url": format!("{api}/device/token"), | |
| 142 | + | "oauth_metadata_url": format!("{api}/.well-known/oauth-authorization-server"), | |
| 143 | + | "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site), | |
| 144 | + | "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"), | |
| 145 | 145 | "context_url": format!("{repo}/context{{?reference}}"), | |
| 146 | 146 | "import_issue_url": format!("{repo}/issues/import"), | |
| 147 | − | "hooks_url": format!("{API}/hooks/{{integration}}"), | |
| 147 | + | "hooks_url": format!("{api}/hooks/{{integration}}"), | |
| 148 | 148 | }) | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 212 | 212 | reply(&json!({ | |
| 213 | 213 | "device_code": started.device_code, | |
| 214 | 214 | "user_code": started.user_code, | |
| 215 | − | "verification_uri": "https://g1t.sh/device", | |
| 216 | − | "verification_uri_complete": format!("https://g1t.sh/device?code={}", started.user_code), | |
| 215 | + | "verification_uri": format!("{}/device", services.addresses.site), | |
| 216 | + | "verification_uri_complete": format!("{}/device?code={}", services.addresses.site, started.user_code), | |
| 217 | 217 | "expires_in": started.expires_in, | |
| 218 | 218 | "interval": started.interval, | |
| 219 | 219 | })) | |
| 474 | 474 | Some(rest) if rest.is_empty() || rest.starts_with('/') => rest.to_owned(), | |
| 475 | 475 | _ => url.path().to_owned(), | |
| 476 | 476 | }; | |
| 477 | − | let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp.")); | |
| 478 | 477 | let mut services = Services::new(env)?; | |
| 478 | + | // MCP is a host of its own hosted, and may be a path on this one | |
| 479 | + | // self-hosted (addresses.rs). | |
| 480 | + | let on_mcp = services.addresses.mcp_path(&url).is_some(); | |
| 479 | 481 | ||
| 480 | 482 | // Stripe reporting to billing. Signed with the secret of the endpoint | |
| 481 | 483 | // billing registered; the body goes through exactly as received, since | |
| 581 | 583 | } | |
| 582 | 584 | ||
| 583 | 585 | match (method, path.trim_end_matches('/')) { | |
| 584 | − | ("GET", "") => return reply(&index()), | |
| 586 | + | ("GET", "") => return reply(&index(&services.addresses)), | |
| 585 | 587 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 586 | 588 | // A run's artifacts: listed, or one downloaded. | |
| 587 | 589 | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => { |
| 5 | 5 | use serde_json::{Value, json}; | |
| 6 | 6 | use worker::{Method, Request, Response, Result}; | |
| 7 | 7 | ||
| 8 | − | use crate::oauth::MCP_CHALLENGE; | |
| 9 | 8 | use crate::operations::{Op, Services}; | |
| 10 | 9 | use crate::tools::{Gate, TOOLS, Tool}; | |
| 11 | 10 | ||
| 114 | 113 | ||
| 115 | 114 | /// What someone sees when they open the server's address in a browser: | |
| 116 | 115 | /// what this is, how to connect, and what it offers. | |
| 117 | − | fn card() -> Value { | |
| 116 | + | fn card(addresses: &crate::addresses::Addresses) -> Value { | |
| 118 | 117 | let tools: Vec<Value> = TOOLS | |
| 119 | 118 | .iter() | |
| 120 | 119 | .map(|tool| { | |
| 136 | 135 | json!({ | |
| 137 | 136 | "name": "g1t", | |
| 138 | 137 | "description": "The g1t MCP server: issues, pull requests and sessions for agents.", | |
| 139 | − | "endpoint": "https://mcp.g1t.sh", | |
| 138 | + | "endpoint": addresses.mcp, | |
| 140 | 139 | "transport": "streamable-http", | |
| 141 | 140 | "protocol_versions": SUPPORTED_VERSIONS, | |
| 142 | − | "connect": "claude mcp add --transport http g1t https://mcp.g1t.sh", | |
| 141 | + | "connect": format!("claude mcp add --transport http g1t {}", addresses.mcp), | |
| 143 | 142 | "authorization": { | |
| 144 | 143 | "required": true, | |
| 145 | − | "oauth_protected_resource": "https://mcp.g1t.sh/.well-known/oauth-protected-resource", | |
| 144 | + | "oauth_protected_resource": addresses.protected_resource(), | |
| 146 | 145 | "alternative": "Authorization: Bearer <g1t access token>", | |
| 147 | 146 | }, | |
| 148 | 147 | "documentation_url": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 164 | 163 | .get("accept")? | |
| 165 | 164 | .is_some_and(|accept| accept.contains("text/event-stream")); | |
| 166 | 165 | if request.method() == Method::Get && !wants_stream { | |
| 167 | − | return Response::from_json(&card()); | |
| 166 | + | return Response::from_json(&card(&services.addresses)); | |
| 168 | 167 | } | |
| 169 | 168 | let mut response = Response::empty()?.with_status(405); | |
| 170 | 169 | response.headers_mut().set("allow", "GET, POST")?; | |
| 181 | 180 | .with_status(401); | |
| 182 | 181 | response | |
| 183 | 182 | .headers_mut() | |
| 184 | − | .set("www-authenticate", MCP_CHALLENGE)?; | |
| 183 | + | .set("www-authenticate", &services.addresses.mcp_challenge())?; | |
| 185 | 184 | return Ok(response); | |
| 186 | 185 | } | |
| 187 | 186 | let Ok(body) = request.json::<Value>().await else { |
| 14 | 14 | ||
| 15 | 15 | use crate::operations::Services; | |
| 16 | 16 | ||
| 17 | − | const ISSUER: &str = "https://api.g1t.sh"; | |
| 18 | − | const MCP_RESOURCE: &str = "https://mcp.g1t.sh"; | |
| 19 | − | /// What an MCP client is told when it must sign in first (RFC 9728). | |
| 20 | − | pub const MCP_CHALLENGE: &str = | |
| 21 | − | "Bearer resource_metadata=\"https://mcp.g1t.sh/.well-known/oauth-protected-resource\""; | |
| 17 | + | use crate::addresses::Addresses; | |
| 22 | 18 | ||
| 23 | 19 | const CLIENT_PREFIX: &str = "g1c_"; | |
| 24 | 20 | const MAX_NAME_CHARS: usize = 80; | |
| 109 | 105 | .collect() | |
| 110 | 106 | } | |
| 111 | 107 | ||
| 112 | − | fn server_metadata() -> Value { | |
| 108 | + | /// The issuer is the API (API_URL); people approve on the site (SITE_URL). | |
| 109 | + | fn server_metadata(addresses: &Addresses) -> Value { | |
| 110 | + | let issuer = &addresses.api; | |
| 113 | 111 | json!({ | |
| 114 | − | "issuer": ISSUER, | |
| 115 | − | "authorization_endpoint": "https://g1t.sh/oauth/authorize", | |
| 116 | − | "token_endpoint": format!("{ISSUER}/oauth/token"), | |
| 117 | − | "registration_endpoint": format!("{ISSUER}/oauth/register"), | |
| 112 | + | "issuer": issuer, | |
| 113 | + | "authorization_endpoint": format!("{}/oauth/authorize", addresses.site), | |
| 114 | + | "token_endpoint": format!("{issuer}/oauth/token"), | |
| 115 | + | "registration_endpoint": format!("{issuer}/oauth/register"), | |
| 118 | 116 | "response_types_supported": ["code"], | |
| 119 | 117 | "grant_types_supported": ["authorization_code", "refresh_token"], | |
| 120 | 118 | "code_challenge_methods_supported": ["S256"], | |
| 238 | 236 | ) -> Result<Option<Response>> { | |
| 239 | 237 | let response = match (method, path) { | |
| 240 | 238 | ("GET", "/.well-known/oauth-authorization-server") => { | |
| 241 | − | crate::reply(&server_metadata())? | |
| 239 | + | crate::reply(&server_metadata(&services.addresses))? | |
| 242 | 240 | } | |
| 243 | 241 | // Asked for with or without the MCP server's path appended. | |
| 244 | 242 | ("GET", path) if path.starts_with("/.well-known/oauth-protected-resource") => { | |
| 245 | 243 | crate::reply(&json!({ | |
| 246 | − | "resource": MCP_RESOURCE, | |
| 247 | − | "authorization_servers": [ISSUER], | |
| 244 | + | "resource": services.addresses.mcp, | |
| 245 | + | "authorization_servers": [services.addresses.api], | |
| 248 | 246 | "bearer_methods_supported": ["header"], | |
| 249 | 247 | "resource_documentation": "https://docs.g1t.sh/guides/bring-your-own-agent/", | |
| 250 | 248 | "scopes_supported": g1t_contracts::scopes::Scope::ALL.map(|scope| scope.as_str()), |
| 47 | 47 | pub audit: crate::audit::AuditContext, | |
| 48 | 48 | /// Set for a request made with an agent's token: all it may do. | |
| 49 | 49 | pub scope: Option<AgentScope>, | |
| 50 | + | /// Where this installation is reached (addresses.rs). | |
| 51 | + | pub addresses: crate::addresses::Addresses, | |
| 50 | 52 | } | |
| 51 | 53 | ||
| 52 | 54 | impl Services { | |
| 66 | 68 | security: env.service("SECURITY")?, | |
| 67 | 69 | scope: None, | |
| 68 | 70 | audit: crate::audit::AuditContext::default(), | |
| 71 | + | addresses: crate::addresses::Addresses::from_env(env), | |
| 69 | 72 | }) | |
| 70 | 73 | } | |
| 71 | 74 | } | |
| 2834 | 2837 | // Where to push. A pull request from a branch has no fork: | |
| 2835 | 2838 | // push to that branch of the repository. | |
| 2836 | 2839 | let source = pull.fork.as_ref().unwrap_or(&repo); | |
| 2837 | − | let remote = format!("https://g1t.sh/{}/{}.git", source.namespace, source.name); | |
| 2840 | + | let remote = services.addresses.git_remote(&source.namespace, &source.name); | |
| 2838 | 2841 | ok(&json!({ | |
| 2839 | 2842 | "pull": pull, | |
| 2840 | 2843 | "git": { |
| 5 | 5 | ||
| 6 | 6 | g1t is MIT licensed. You can run the core forge on your own machine: | |
| 7 | 7 | accounts, workspaces, repositories, git over HTTP, issues and pull | |
| 8 | − | requests, and the site to browse them. Your repositories are plain bare | |
| 9 | − | git repositories on a Docker volume. | |
| 8 | + | requests, the site to browse them, and the REST API and MCP server. Your | |
| 9 | + | repositories are plain bare git repositories on a Docker volume. | |
| 10 | 10 | ||
| 11 | 11 | This is an early version. It is for trying g1t out and for small teams on | |
| 12 | 12 | a private network, not yet for an installation on the open internet. | |
| 17 | 17 | | --- | --- | | |
| 18 | 18 | | Sign up, sign in, email confirmation | Works. Mail goes to the bundled Mailpit inbox. | | |
| 19 | 19 | | Workspaces, members, access tokens | Works | | |
| 20 | − | | Repositories: create, push and clone over HTTP, browse code, commits | Works | | |
| 20 | + | | Repositories: create, push and clone over HTTP, browse code, commits | Works. A fresh clone's pack is kept in the bundled MinIO, so the next clone of the same commit is served from there. | | |
| 21 | 21 | | Issues, comments, labels | Works | | |
| 22 | + | | Pull requests from a branch or from a fork, merged onto the default branch | Works, when the pull request is up to date with the default branch. Bringing one up to date first needs g1t's agent, which is off. | | |
| 23 | + | | The merge queue | Takes pull requests and shows them waiting. Testing and landing them needs g1t's agent, which is off: take a pull request out of the queue, or turn the queue off, to merge it. | | |
| 24 | + | | [The REST API](/reference/api/), OAuth and [MCP](/reference/mcp/) | Work, on a port of their own: `http://localhost:8789`, with the MCP server at `http://localhost:8789/mcp` | | |
| 22 | 25 | | [Container images](/guides/containers/): `docker login`, push and pull at your `PUBLIC_URL` | Works, kept in the bundled MinIO, with no limit on a layer's size or on pulls | | |
| 23 | 26 | | Site search | Works | | |
| 24 | 27 | | A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) | | |
| 25 | 28 | | Webhooks, integrations | Work, retries included | | |
| 26 | − | | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. | | |
| 29 | + | | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync on GitHub's webhook once GitHub can reach your API, and with **Sync now** either way. | | |
| 27 | 30 | | g1t's agent: changes, plans and reviews | Off | | |
| 28 | 31 | | Context hub search | Off | | |
| 29 | 32 | | Deployments on `g1t.page` | Off | | |
| 30 | 33 | | Billing | Off. Nothing is charged, and no usage limit stops work. | | |
| 31 | − | | Git over SSH, the REST API, MCP and the `g1t` CLI | Not available yet | | |
| 32 | − | | Scheduled jobs | Run once a minute inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. | | |
| 34 | + | | Git over SSH and the `g1t` CLI | Not available yet | | |
| 35 | + | | Scheduled jobs | Run on their schedules inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. | | |
| 33 | 36 | ||
| 34 | 37 | What hosted g1t cannot do yet either is on | |
| 35 | 38 | [What g1t can't do yet](/about/limitations/). | |
| 38 | 41 | ||
| 39 | 42 | - Docker with Compose v2 (`docker compose version`). | |
| 40 | 43 | - About 4 GB of free disk space for the images. | |
| 41 | − | - Ports 8787, 8788 and 8025 free on your machine. | |
| 44 | + | - Ports 8787, 8788, 8789 and 8025 free on your machine. | |
| 42 | 45 | ||
| 43 | 46 | ## Start g1t | |
| 44 | 47 | ||
| 79 | 82 | git clone http://localhost:8787/<workspace>/<repo>.git | |
| 80 | 83 | ``` | |
| 81 | 84 | ||
| 85 | + | ## Use the API and MCP | |
| 86 | + | ||
| 87 | + | The API answers at `http://localhost:8789`, the same routes as | |
| 88 | + | `https://api.g1t.sh` (see the [API reference](/reference/api/)). Make an | |
| 89 | + | access token under **Settings → Access tokens**, then: | |
| 90 | + | ||
| 91 | + | ```sh | |
| 92 | + | curl -H "Authorization: Bearer $G1T_TOKEN" http://localhost:8789/user | |
| 93 | + | ``` | |
| 94 | + | ||
| 95 | + | The MCP server is at `http://localhost:8789/mcp`. Connect an agent to it | |
| 96 | + | as [Bring your own agent](/guides/bring-your-own-agent/) shows, with this | |
| 97 | + | address in place of `https://mcp.g1t.sh`: | |
| 98 | + | ||
| 99 | + | ```sh | |
| 100 | + | claude mcp add --transport http g1t http://localhost:8789/mcp | |
| 101 | + | ``` | |
| 102 | + | ||
| 103 | + | Applications that sign people in with OAuth find everything at | |
| 104 | + | `http://localhost:8789/.well-known/oauth-authorization-server`: the issuer | |
| 105 | + | is the API's address, and people approve on your site, at | |
| 106 | + | `PUBLIC_URL/oauth/authorize`. The site's clone box, agent setup and | |
| 107 | + | access token examples show your own addresses. | |
| 108 | + | ||
| 82 | 109 | ## Check an installation | |
| 83 | 110 | ||
| 84 | − | `deploy/self-host/smoke.sh` signs up a new account, confirms it through | |
| 85 | − | Mailpit, makes a workspace and a repository, pushes, clones, opens an issue | |
| 86 | − | and reads the code back through the site: | |
| 111 | + | `deploy/self-host/smoke.sh` checks an installation from end to end: | |
| 87 | 112 | ||
| 113 | + | 1. It signs up a new account, confirms it through Mailpit, makes a | |
| 114 | + | workspace and a repository, pushes and clones (twice, the second from | |
| 115 | + | the clone pack cache), opens an issue and reads the code back through | |
| 116 | + | the site. | |
| 117 | + | 2. It makes an access token and calls the API, the OAuth metadata and the | |
| 118 | + | MCP server with it. | |
| 119 | + | 3. It opens a pull request from a branch and one from a fork, through the | |
| 120 | + | API, and merges both onto `main`. | |
| 121 | + | 4. It turns the merge queue on, merges a pull request into it, takes it | |
| 122 | + | out again, and merges it with the queue off. | |
| 123 | + | ||
| 88 | 124 | ```sh | |
| 89 | 125 | bash deploy/self-host/smoke.sh | |
| 90 | 126 | ``` | |
| 91 | 127 | ||
| 92 | − | It prints `All checks passed` when every step worked. | |
| 128 | + | To also run every scheduled job once, give it the command that does so | |
| 129 | + | inside the container: | |
| 93 | 130 | ||
| 131 | + | ```sh | |
| 132 | + | SCHEDULER_ONCE="docker compose -f deploy/self-host/docker-compose.yml exec -T g1t \ | |
| 133 | + | node deploy/self-host/scheduler.mjs --once /data/generated/schedules.json" \ | |
| 134 | + | bash deploy/self-host/smoke.sh | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | It prints `All checks passed` when every step worked. It needs `curl`, | |
| 138 | + | `git` and `node`. | |
| 139 | + | ||
| 94 | 140 | ## Settings | |
| 95 | 141 | ||
| 96 | 142 | Set these in the environment, or in a `.env` file next to | |
| 98 | 144 | ||
| 99 | 145 | | Variable | Default | What it does | | |
| 100 | 146 | | --- | --- | --- | | |
| 101 | − | | `PUBLIC_URL` | `http://localhost:8787` | The address people use. Links in email point here. | | |
| 147 | + | | `PUBLIC_URL` | `http://localhost:8787` | The address people use. Links in email, clone addresses and the site's link previews point here. | | |
| 102 | 148 | | `G1T_PORT` | `8787` | The port the site is published on | | |
| 149 | + | | `API_PORT` | `8789` | The port the API and the MCP server are published on | | |
| 150 | + | | `API_URL` | `PUBLIC_URL`'s host on `API_PORT` | The address of the API, as people and applications reach it. It is also the OAuth issuer. Set it when the API is behind a proxy, for example `https://api.git.example.com`. | | |
| 151 | + | | `MCP_URL` | `API_URL/mcp` | The address of the MCP server. | | |
| 103 | 152 | | `MAILPIT_PORT` | `8025` | The port of the Mailpit inbox | | |
| 104 | 153 | | `MAIL_FROM` | `g1t <noreply@localhost>` | The sender of g1t's email | | |
| 105 | 154 | | `MAIL_URL` | `http://mailpit:8025` | The Mailpit server g1t sends mail through | | |
| 108 | 157 | | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. | | |
| 109 | 158 | | `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | the bundled MinIO, bucket `g1t-packages` | Where packages' files are kept: any S3-compatible store. Change the two keys before first start; MinIO is made with them. | | |
| 110 | 159 | | `S3_PUBLIC_ENDPOINT` | (none) | The store's address as clients reach it. When set, large layers are downloaded from it directly with a signed URL. | | |
| 160 | + | | `PACK_S3_BUCKET` | `g1t-git-packs` | The bucket on the same store that packs for fresh clones are kept in, so the next clone of the same commit is not built again. The bundled MinIO deletes packs after 7 days; on another store, give the bucket a rule that expires objects under `packs/` and unfinished multipart uploads. | | |
| 161 | + | | `MINIO_IMAGE` | `pgsty/minio:latest` | The MinIO server image the bundled store runs. MinIO no longer publishes its own images; this is a community build of the same server. | | |
| 111 | 162 | | `BACKUP_S3_BUCKET` | `g1t-backups` | The bucket on the same store that nightly repository backups (a `git bundle` of each repository whose branches or tags changed) are kept in. The bundles are cut by g1t's runner, which this installation does not run yet, so the bucket stays empty for now: copy the volumes, as below. | | |
| 112 | 163 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 113 | 164 | | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. | | |
| 123 | 174 | Every minute it loads the site's sign-in page from inside Compose, and, | |
| 124 | 175 | with `STATUS_PROBE_REPO` set, lists that repository's branches. It keeps | |
| 125 | 176 | 90 days of history on its own volume, `g1t-status`. Parts an installation | |
| 126 | − | of your own does not run (the API, MCP, docs, deployments, the model | |
| 177 | + | of your own does not check (the API, MCP, docs, deployments, the model | |
| 127 | 178 | proxy and billing) are left off its page. | |
| 128 | 179 | ||
| 129 | 180 | The links to **Status** in the site's footer and account menu still point | |
| 156 | 207 | | Enable Device Flow | Off | | |
| 157 | 208 | | Setup URL | `PUBLIC_URL/integrations/github/setup` | | |
| 158 | 209 | | Redirect on update | On | | |
| 159 | − | | Webhook | Off for now: a self-hosted g1t does not serve the API, where GitHub's deliveries arrive. Mirrors sync with **Sync now**. | | |
| 210 | + | | Webhook | On, with the URL `API_URL/hooks/github` and a secret you choose, once GitHub can reach your API. Otherwise off: mirrors then sync with **Sync now**. | | |
| 160 | 211 | | Repository permissions | Contents: Read and write; Metadata: Read; Issues: Read | | |
| 161 | 212 | | Account permissions | Email addresses: Read | | |
| 162 | 213 | ||
| 173 | 224 | | `GITHUB_APP_CLIENT_ID` | The Client ID | | |
| 174 | 225 | | `GITHUB_APP_CLIENT_SECRET` | The client secret | | |
| 175 | 226 | | `GITHUB_APP_PRIVATE_KEY` | The `.pem` file's contents, as downloaded. Line breaks may be written as `\n`. | | |
| 176 | − | | `GITHUB_APP_WEBHOOK_SECRET` | Only once the API is served: the webhook secret | | |
| 227 | + | | `GITHUB_APP_WEBHOOK_SECRET` | The webhook secret, if the webhook is on | | |
| 177 | 228 | ||
| 178 | 229 | 5. Restart g1t: `docker compose -f deploy/self-host/docker-compose.yml up -d`. | |
| 179 | 230 | ||
| 187 | 238 | | --- | --- | | |
| 188 | 239 | | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) | | |
| 189 | 240 | | `g1t_g1t-git` | Your repositories, one bare git repository each | | |
| 190 | − | | `g1t_g1t-packages` | Container images' layers and other package files, and the `g1t-backups` bucket (MinIO) | | |
| 241 | + | | `g1t_g1t-packages` | Container images' layers and other package files, the `g1t-backups` bucket and the clone packs in `g1t-git-packs` (MinIO) | | |
| 191 | 242 | | `g1t_g1t-secrets` | The key the site and the git store share | | |
| 192 | 243 | ||
| 193 | 244 | To back up, stop g1t and copy the volumes: |
| 1 | 1 | import { Check, Copy, ExternalLink } from "lucide-react"; | |
| 2 | − | import { type KeyboardEvent, useId, useState, useSyncExternalStore } from "react"; | |
| 2 | + | import { type KeyboardEvent, useId, useMemo, useState, useSyncExternalStore } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | − | AGENTS, | |
| 6 | 5 | AGENT_IDS, | |
| 7 | 6 | type AgentId, | |
| 8 | 7 | DEFAULT_AGENT, | |
| 8 | + | agentsFor, | |
| 9 | 9 | cursorInstallLink, | |
| 10 | 10 | getAgentChoice, | |
| 11 | 11 | setAgentChoice, | |
| 12 | 12 | subscribeAgentChoice, | |
| 13 | 13 | } from "../lib/agent-setup"; | |
| 14 | + | import { useAddresses } from "../lib/addresses"; | |
| 14 | 15 | import { cn } from "../lib/cn"; | |
| 15 | 16 | ||
| 16 | 17 | /** The agent this browser chose last; every toggle on the page shares it. */ | |
| 28 | 29 | const [chosen, choose] = useAgentChoice(); | |
| 29 | 30 | const [copied, setCopied] = useState(false); | |
| 30 | 31 | const base = useId(); | |
| 31 | − | const agent = AGENTS[chosen]; | |
| 32 | + | const { mcp } = useAddresses(); | |
| 33 | + | const agents = useMemo(() => agentsFor(mcp), [mcp]); | |
| 34 | + | const agent = agents[chosen]; | |
| 32 | 35 | ||
| 33 | 36 | const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { | |
| 34 | 37 | const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; | |
| 75 | 78 | on ? "bg-raised text-fg" : "text-muted hover:text-fg", | |
| 76 | 79 | )} | |
| 77 | 80 | > | |
| 78 | − | {AGENTS[id].label} | |
| 81 | + | {agents[id].label} | |
| 79 | 82 | </button> | |
| 80 | 83 | ); | |
| 81 | 84 | })} | |
| 120 | 123 | {chosen === "cursor" && ( | |
| 121 | 124 | <> | |
| 122 | 125 | {" "} | |
| 123 | − | <a href={cursorInstallLink()} className="inline-flex items-center gap-1 text-fg underline underline-offset-4"> | |
| 126 | + | <a href={cursorInstallLink(mcp)} className="inline-flex items-center gap-1 text-fg underline underline-offset-4"> | |
| 124 | 127 | Add to Cursor <ExternalLink size={11} /> | |
| 125 | 128 | </a> | |
| 126 | 129 | </> |
| 1 | 1 | import { Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | + | import { cloneUrl, useAddresses } from "../lib/addresses"; | |
| 3 | 4 | import { AgentSetup } from "./agent-setup"; | |
| 4 | 5 | import { CopyLine } from "./ui"; | |
| 5 | 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 6 | 7 | ||
| 7 | 8 | /** The ways to get a repository onto a machine or in front of an agent. */ | |
| 8 | 9 | export function CloneBox({ path }: { path: string }) { | |
| 10 | + | const addresses = useAddresses(); | |
| 9 | 11 | return ( | |
| 10 | 12 | <Tabs defaultValue="https"> | |
| 11 | 13 | <TabsList> | |
| 14 | 16 | <TabsTrigger value="agent">Agent</TabsTrigger> | |
| 15 | 17 | </TabsList> | |
| 16 | 18 | <TabsContent value="https"> | |
| 17 | − | <CopyLine text={`https://g1t.sh/${path}.git`} /> | |
| 19 | + | <CopyLine text={cloneUrl(addresses, path)} /> | |
| 18 | 20 | <p className="mt-2 text-xs text-muted"> | |
| 19 | 21 | To push, use your username and an{" "} | |
| 20 | 22 | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> |
| 24 | 24 | ||
| 25 | 25 | import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, PullBranchUpdate, Pull, RequiredCheck } from "@g1t/contracts"; | |
| 26 | 26 | ||
| 27 | + | import { useAddresses } from "../lib/addresses"; | |
| 27 | 28 | import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up"; | |
| 28 | 29 | import { duration } from "./actions"; | |
| 29 | 30 | import { Elapsed, useRuns } from "./agents"; | |
| 564 | 565 | // --- Mergeability --------------------------------------------------------- | |
| 565 | 566 | ||
| 566 | 567 | /** How to resolve the conflicts by hand, step by step, for a branch or a fork. */ | |
| 567 | − | export function commandLineSteps(pull: Pull, owner: string, repo: string, defaultBranch: string, files: string[]): string[] { | |
| 568 | − | const upstream = `https://g1t.sh/${owner}/${repo}.git`; | |
| 568 | + | export function commandLineSteps( | |
| 569 | + | pull: Pull, | |
| 570 | + | owner: string, | |
| 571 | + | repo: string, | |
| 572 | + | defaultBranch: string, | |
| 573 | + | files: string[], | |
| 574 | + | site = "https://g1t.sh", | |
| 575 | + | ): string[] { | |
| 576 | + | const upstream = `${site}/${owner}/${repo}.git`; | |
| 569 | 577 | const add = files.length > 0 && files.length <= 6 ? `git add ${files.join(" ")}` : "git add -A"; | |
| 570 | 578 | if (pull.branch) { | |
| 571 | 579 | return [ | |
| 578 | 586 | } | |
| 579 | 587 | const fork = pull.fork!; | |
| 580 | 588 | return [ | |
| 581 | − | `git clone https://g1t.sh/${fork.namespace}/${fork.name}.git && cd ${fork.name}`, | |
| 589 | + | `git clone ${site}/${fork.namespace}/${fork.name}.git && cd ${fork.name}`, | |
| 582 | 590 | `git pull --no-rebase ${upstream} ${defaultBranch}`, | |
| 583 | 591 | `${add} && git commit --no-edit`, | |
| 584 | 592 | `git push`, | |
| 613 | 621 | resolving: boolean; | |
| 614 | 622 | error?: string | null; | |
| 615 | 623 | }) { | |
| 616 | − | const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts); | |
| 624 | + | const { site } = useAddresses(); | |
| 625 | + | const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts, site); | |
| 617 | 626 | return ( | |
| 618 | 627 | <div className="bg-danger/5"> | |
| 619 | 628 | <div className="flex gap-3 px-4 py-3"> |
| 8 | 8 | import { CodeLines } from "./code-lines"; | |
| 9 | 9 | ||
| 10 | 10 | import { AgentSetup } from "./agent-setup"; | |
| 11 | + | import { useAddresses } from "../lib/addresses"; | |
| 11 | 12 | import { CloneBox } from "./clone-box"; | |
| 12 | 13 | import { Markdown } from "./markdown"; | |
| 13 | 14 | import { Topics } from "./topics"; | |
| 287 | 288 | const { repo, ref, path, head, entries, readme } = tree; | |
| 288 | 289 | const base = `/${repo.namespace}/${repo.name}`; | |
| 289 | 290 | const prefix = path ? `${encodePath(path)}/` : ""; | |
| 290 | − | const cloneUrl = `https://g1t.sh${base}.git`; | |
| 291 | + | const cloneUrl = `${useAddresses().site}${base}.git`; | |
| 291 | 292 | ||
| 292 | 293 | if (!head) { | |
| 293 | 294 | return ( |
| 1 | + | import { env } from "cloudflare:workers"; | |
| 2 | + | ||
| 3 | + | import { type Addresses, addressesFor } from "./addresses"; | |
| 4 | + | ||
| 5 | + | /** This g1t's addresses, from the Worker's settings (SITE_URL, API_URL, MCP_URL, OG_URL). */ | |
| 6 | + | export function addresses(): Addresses { | |
| 7 | + | return addressesFor(env); | |
| 8 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { HOSTED_ADDRESSES, addressesFor, addressesFrom, cloneUrl } from "./addresses.ts"; | |
| 5 | + | import { MCP_URL } from "./agent-setup.ts"; | |
| 6 | + | import { OG, SITE } from "./meta.ts"; | |
| 7 | + | ||
| 8 | + | test("with no settings, the addresses are g1t.sh's", () => { | |
| 9 | + | assert.deepEqual(addressesFor({}), HOSTED_ADDRESSES); | |
| 10 | + | assert.deepEqual(addressesFor({ SITE_URL: "", API_URL: "", MCP_URL: "" }), HOSTED_ADDRESSES); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("settings replace the addresses, without trailing slashes", () => { | |
| 14 | + | assert.deepEqual( | |
| 15 | + | addressesFor({ | |
| 16 | + | SITE_URL: "http://localhost:8787/", | |
| 17 | + | API_URL: "http://localhost:8788", | |
| 18 | + | MCP_URL: "http://localhost:8790/mcp/", | |
| 19 | + | OG_URL: "", | |
| 20 | + | }), | |
| 21 | + | { site: "http://localhost:8787", api: "http://localhost:8788", mcp: "http://localhost:8790/mcp", og: null }, | |
| 22 | + | ); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | test("pages without root data use g1t.sh's addresses", () => { | |
| 26 | + | assert.deepEqual(addressesFrom(undefined), HOSTED_ADDRESSES); | |
| 27 | + | const own = { site: "http://localhost:8787", api: "a", mcp: "m", og: null }; | |
| 28 | + | assert.deepEqual(addressesFrom({ addresses: own }), own); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("the defaults kept in meta and agent setup are g1t.sh's", () => { | |
| 32 | + | assert.equal(SITE, HOSTED_ADDRESSES.site); | |
| 33 | + | assert.equal(OG, HOSTED_ADDRESSES.og); | |
| 34 | + | assert.equal(MCP_URL, HOSTED_ADDRESSES.mcp); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("a clone address is under the site", () => { | |
| 38 | + | assert.equal(cloneUrl(HOSTED_ADDRESSES, "acme/web"), "https://g1t.sh/acme/web.git"); | |
| 39 | + | }); |
| 1 | + | import { useRouteLoaderData } from "react-router"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * Where this g1t lives: the site, the REST API, the MCP server and the | |
| 5 | + | * social-card service. g1t.sh uses the defaults below; a self-hosted g1t | |
| 6 | + | * sets its own (app/lib/addresses.server.ts reads them), and the root | |
| 7 | + | * loader hands them to every page. | |
| 8 | + | */ | |
| 9 | + | export type Addresses = { | |
| 10 | + | /** The site's origin, such as `https://g1t.sh`. Git remotes are under it. */ | |
| 11 | + | site: string; | |
| 12 | + | /** The REST API's origin, which is also the OAuth issuer. */ | |
| 13 | + | api: string; | |
| 14 | + | /** The MCP server's URL, as agents are told to add it. */ | |
| 15 | + | mcp: string; | |
| 16 | + | /** The social-card image service's origin, or null when there is none. */ | |
| 17 | + | og: string | null; | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | export const HOSTED_ADDRESSES: Addresses = { | |
| 21 | + | site: "https://g1t.sh", | |
| 22 | + | api: "https://api.g1t.sh", | |
| 23 | + | mcp: "https://mcp.g1t.sh", | |
| 24 | + | og: "https://og.g1t.sh", | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | /** The Worker settings the addresses come from; every one optional. */ | |
| 28 | + | export type AddressSettings = { SITE_URL?: string; API_URL?: string; MCP_URL?: string; OG_URL?: string }; | |
| 29 | + | ||
| 30 | + | const trim = (value: string) => value.trim().replace(/\/+$/, ""); | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * The addresses from the Worker's settings. An unset or empty setting is | |
| 34 | + | * g1t.sh's address, except `OG_URL`: set to an empty string, it means there | |
| 35 | + | * is no card service, and pages carry no image tags. | |
| 36 | + | */ | |
| 37 | + | export function addressesFor(settings: AddressSettings): Addresses { | |
| 38 | + | const pick = (value: string | undefined, fallback: string) => (value ? trim(value) : "") || fallback; | |
| 39 | + | return { | |
| 40 | + | site: pick(settings.SITE_URL, HOSTED_ADDRESSES.site), | |
| 41 | + | api: pick(settings.API_URL, HOSTED_ADDRESSES.api), | |
| 42 | + | mcp: pick(settings.MCP_URL, HOSTED_ADDRESSES.mcp), | |
| 43 | + | og: settings.OG_URL === undefined ? HOSTED_ADDRESSES.og : trim(settings.OG_URL) || null, | |
| 44 | + | }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /** The addresses in the root loader's data, or g1t.sh's when it has none. */ | |
| 48 | + | export function addressesFrom(rootData: unknown): Addresses { | |
| 49 | + | return (rootData as { addresses?: Addresses } | null | undefined)?.addresses ?? HOSTED_ADDRESSES; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** This g1t's addresses, from the root loader. */ | |
| 53 | + | export function useAddresses(): Addresses { | |
| 54 | + | return addressesFrom(useRouteLoaderData("root")); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** The HTTPS clone address of a repository, from its `owner/name`. */ | |
| 58 | + | export function cloneUrl(addresses: Pick<Addresses, "site">, path: string): string { | |
| 59 | + | return `${addresses.site}/${path.replace(/^\/+/, "")}.git`; | |
| 60 | + | } |
| 7 | 7 | AGENT_STORAGE_KEY, | |
| 8 | 8 | DEFAULT_AGENT, | |
| 9 | 9 | MCP_URL, | |
| 10 | + | agentsFor, | |
| 10 | 11 | cursorInstallLink, | |
| 11 | 12 | getAgentChoice, | |
| 12 | 13 | parseAgent, | |
| 88 | 89 | const config = new URL(cursorInstallLink()).searchParams.get("config"); | |
| 89 | 90 | assert.deepEqual(JSON.parse(atob(config ?? "")), { url: MCP_URL }); | |
| 90 | 91 | }); | |
| 92 | + | ||
| 93 | + | test("the snippets can point at another MCP server", () => { | |
| 94 | + | const mcp = "http://localhost:8790/mcp"; | |
| 95 | + | const agents = agentsFor(mcp); | |
| 96 | + | for (const id of AGENT_IDS) { | |
| 97 | + | assert.ok(agents[id].code.includes(mcp), id); | |
| 98 | + | assert.ok(!agents[id].code.includes(MCP_URL), id); | |
| 99 | + | } | |
| 100 | + | const config = new URL(cursorInstallLink(mcp)).searchParams.get("config"); | |
| 101 | + | assert.deepEqual(JSON.parse(atob(config ?? "")), { url: mcp }); | |
| 102 | + | }); |
| 6 | 6 | * The commands follow each agent's own documentation for adding a remote | |
| 7 | 7 | * MCP server; keep them in step with `apps/docs/src/data/agents.ts`, the | |
| 8 | 8 | * docs' copy. | |
| 9 | + | * | |
| 10 | + | * Each snippet is built for an MCP server URL: g1t.sh's by default, or a | |
| 11 | + | * self-hosted g1t's own (`useAddresses().mcp`). | |
| 9 | 12 | */ | |
| 10 | 13 | ||
| 14 | + | /** g1t.sh's MCP server, the same as HOSTED_ADDRESSES.mcp in ./addresses. */ | |
| 11 | 15 | export const MCP_URL = "https://mcp.g1t.sh"; | |
| 12 | 16 | ||
| 13 | 17 | export const AGENT_IDS = ["claude-code", "codex", "opencode", "cursor"] as const; | |
| 31 | 35 | ||
| 32 | 36 | const json = (value: unknown) => JSON.stringify(value, null, 2); | |
| 33 | 37 | ||
| 34 | − | export const AGENTS: Record<AgentId, AgentSetup> = { | |
| 35 | − | "claude-code": { | |
| 36 | − | id: "claude-code", | |
| 37 | − | label: "Claude Code", | |
| 38 | − | file: null, | |
| 39 | − | lang: "sh", | |
| 40 | − | code: `claude mcp add --transport http g1t ${MCP_URL}`, | |
| 41 | − | then: "Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.", | |
| 42 | − | instructions: "CLAUDE.md", | |
| 43 | − | }, | |
| 44 | − | codex: { | |
| 45 | − | id: "codex", | |
| 46 | − | label: "Codex", | |
| 47 | − | file: null, | |
| 48 | − | lang: "sh", | |
| 49 | − | code: `codex mcp add g1t --url ${MCP_URL}\ncodex mcp login g1t`, | |
| 50 | − | then: "The login opens your browser to sign in to g1t.", | |
| 51 | − | instructions: "AGENTS.md", | |
| 52 | − | }, | |
| 53 | − | opencode: { | |
| 54 | − | id: "opencode", | |
| 55 | − | label: "OpenCode", | |
| 56 | − | file: "opencode.json", | |
| 57 | − | lang: "json", | |
| 58 | − | code: json({ | |
| 59 | − | $schema: "https://opencode.ai/config.json", | |
| 60 | − | mcp: { g1t: { type: "remote", url: MCP_URL, enabled: true } }, | |
| 61 | − | }), | |
| 62 | − | then: "OpenCode signs you in through your browser when it first connects; `opencode mcp auth g1t` starts it by hand.", | |
| 63 | − | instructions: "AGENTS.md", | |
| 64 | − | }, | |
| 65 | − | cursor: { | |
| 66 | − | id: "cursor", | |
| 67 | − | label: "Cursor", | |
| 68 | − | file: ".cursor/mcp.json", | |
| 69 | − | lang: "json", | |
| 70 | − | code: json({ mcpServers: { g1t: { url: MCP_URL } } }), | |
| 71 | − | then: "Use `~/.cursor/mcp.json` for every project. Cursor signs you in through your browser when it first connects.", | |
| 72 | − | instructions: "AGENTS.md", | |
| 73 | − | }, | |
| 74 | − | }; | |
| 38 | + | /** How each agent adds the MCP server at `mcp`. */ | |
| 39 | + | export function agentsFor(mcp: string = MCP_URL): Record<AgentId, AgentSetup> { | |
| 40 | + | return { | |
| 41 | + | "claude-code": { | |
| 42 | + | id: "claude-code", | |
| 43 | + | label: "Claude Code", | |
| 44 | + | file: null, | |
| 45 | + | lang: "sh", | |
| 46 | + | code: `claude mcp add --transport http g1t ${mcp}`, | |
| 47 | + | then: "Then run `/mcp` in Claude Code and choose g1t to sign in through your browser.", | |
| 48 | + | instructions: "CLAUDE.md", | |
| 49 | + | }, | |
| 50 | + | codex: { | |
| 51 | + | id: "codex", | |
| 52 | + | label: "Codex", | |
| 53 | + | file: null, | |
| 54 | + | lang: "sh", | |
| 55 | + | code: `codex mcp add g1t --url ${mcp}\ncodex mcp login g1t`, | |
| 56 | + | then: "The login opens your browser to sign in to g1t.", | |
| 57 | + | instructions: "AGENTS.md", | |
| 58 | + | }, | |
| 59 | + | opencode: { | |
| 60 | + | id: "opencode", | |
| 61 | + | label: "OpenCode", | |
| 62 | + | file: "opencode.json", | |
| 63 | + | lang: "json", | |
| 64 | + | code: json({ | |
| 65 | + | $schema: "https://opencode.ai/config.json", | |
| 66 | + | mcp: { g1t: { type: "remote", url: mcp, enabled: true } }, | |
| 67 | + | }), | |
| 68 | + | then: "OpenCode signs you in through your browser when it first connects; `opencode mcp auth g1t` starts it by hand.", | |
| 69 | + | instructions: "AGENTS.md", | |
| 70 | + | }, | |
| 71 | + | cursor: { | |
| 72 | + | id: "cursor", | |
| 73 | + | label: "Cursor", | |
| 74 | + | file: ".cursor/mcp.json", | |
| 75 | + | lang: "json", | |
| 76 | + | code: json({ mcpServers: { g1t: { url: mcp } } }), | |
| 77 | + | then: "Use `~/.cursor/mcp.json` for every project. Cursor signs you in through your browser when it first connects.", | |
| 78 | + | instructions: "AGENTS.md", | |
| 79 | + | }, | |
| 80 | + | }; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The snippets for g1t.sh. */ | |
| 84 | + | export const AGENTS: Record<AgentId, AgentSetup> = agentsFor(); | |
| 75 | 85 | ||
| 76 | 86 | /** Opens Cursor with g1t ready to add, from Cursor's install-link format. */ | |
| 77 | − | export function cursorInstallLink(): string { | |
| 78 | − | const config = btoa(JSON.stringify({ url: MCP_URL })); | |
| 87 | + | export function cursorInstallLink(mcp: string = MCP_URL): string { | |
| 88 | + | const config = btoa(JSON.stringify({ url: mcp })); | |
| 79 | 89 | return `cursor://anysphere.cursor-deeplink/mcp/install?name=g1t&config=${encodeURIComponent(config)}`; | |
| 80 | 90 | } | |
| 81 | 91 |
| 39 | 39 | assert.match(meta.get("og:image") ?? "", new RegExp(`\\?path=%2Facme%2Fweb%2Fcommits&v=${OG_RENDER_VERSION}\\.[0-9a-z]+$`)); | |
| 40 | 40 | }); | |
| 41 | 41 | ||
| 42 | + | test("a page's address and card come from the root loader's addresses", () => { | |
| 43 | + | const root = { id: "root", loaderData: { addresses: { site: "http://localhost:8787", og: "http://localhost:8799" } } }; | |
| 44 | + | const meta = tags(page({ location: { pathname: "/pricing" }, matches: [root] }, { title: "Pricing · g1t" })); | |
| 45 | + | assert.equal(meta.get("og:url"), "http://localhost:8787/pricing"); | |
| 46 | + | assert.equal(meta.get("og:image"), `http://localhost:8799/image?path=%2Fpricing&v=${OG_RENDER_VERSION}`); | |
| 47 | + | assert.equal(meta.get("twitter:image"), meta.get("og:image")); | |
| 48 | + | }); | |
| 49 | + | ||
| 50 | + | test("without a card service, a page has no image tags", () => { | |
| 51 | + | const root = { id: "root", loaderData: { addresses: { site: "http://localhost:8787", og: null } } }; | |
| 52 | + | const meta = tags(page({ location: { pathname: "/" }, matches: [root] }, { title: "g1t" })); | |
| 53 | + | assert.equal(meta.get("og:url"), "http://localhost:8787/"); | |
| 54 | + | for (const name of ["og:image", "og:image:type", "og:image:width", "og:image:height", "og:image:alt", "twitter:image", "twitter:image:alt"]) { | |
| 55 | + | assert.equal(meta.has(name), false, name); | |
| 56 | + | } | |
| 57 | + | assert.equal(meta.get("twitter:card"), "summary"); | |
| 58 | + | }); | |
| 59 | + | ||
| 42 | 60 | test("with nothing particular to say, a page says what g1t is", () => { | |
| 43 | 61 | const meta = tags(page({ location: { pathname: "/login" }, matches: [] }, { title: "Sign in · g1t" })); | |
| 44 | 62 | assert.equal(meta.get("description"), DESCRIPTION); |
| 2 | 2 | * Every page's <title> and the tags a link preview reads: its description, | |
| 3 | 3 | * Open Graph and Twitter's card. The image is the page's own card from the | |
| 4 | 4 | * og service (services/og), which draws it as an anonymous visitor would | |
| 5 | − | * see the page, so a private page's card never names anything. | |
| 5 | + | * see the page, so a private page's card never names anything. A g1t without | |
| 6 | + | * the og service (`OG_URL` set empty) leaves the image tags out. | |
| 6 | 7 | * | |
| 7 | 8 | * React Router renders only the deepest route's `meta`, so each route | |
| 8 | 9 | * returns `page(args, …)` rather than a bare title. | |
| 10 | 11 | import type { MetaDescriptor } from "react-router"; | |
| 11 | 12 | import { ogVersion } from "@g1t/contracts/og"; | |
| 12 | 13 | ||
| 14 | + | import type { Addresses } from "./addresses"; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * g1t.sh's site and card service, the same as HOSTED_ADDRESSES in | |
| 18 | + | * ./addresses (kept here so this module has no imports to run in tests). | |
| 19 | + | * Pages use the addresses in the root loader's data when it has them. | |
| 20 | + | */ | |
| 13 | 21 | export const SITE = "https://g1t.sh"; | |
| 14 | 22 | export const OG = "https://og.g1t.sh"; | |
| 15 | 23 | ||
| 85 | 93 | * and every site that has fetched the old card; then, when the page has one, | |
| 86 | 94 | * a fingerprint of what the card shows. | |
| 87 | 95 | */ | |
| 88 | − | export function cardUrl(pathname: string, version?: unknown): string { | |
| 89 | − | const url = new URL("/image", OG); | |
| 96 | + | export function cardUrl(pathname: string, version?: unknown, og: string = OG): string { | |
| 97 | + | const url = new URL("/image", og); | |
| 90 | 98 | url.searchParams.set("path", pathname); | |
| 91 | 99 | url.searchParams.set("v", ogVersion(version === undefined ? undefined : fingerprint(version))); | |
| 92 | 100 | return url.toString(); | |
| 122 | 130 | const pathname = args.location.pathname.replace(/\/+$/, "") || "/"; | |
| 123 | 131 | // The title a preview shows needs no "· g1t": the site name is beside it. | |
| 124 | 132 | const shareTitle = meta.title.replace(/ · g1t$/, ""); | |
| 125 | − | const image = cardUrl(pathname, version); | |
| 133 | + | const addresses = loaded<{ addresses?: Pick<Addresses, "site" | "og"> }>(args, "root")?.addresses; | |
| 134 | + | const site = addresses?.site ?? SITE; | |
| 135 | + | const og = addresses ? addresses.og : OG; | |
| 136 | + | const image = og ? cardUrl(pathname, version, og) : null; | |
| 126 | 137 | return [ | |
| 127 | 138 | { title: meta.title }, | |
| 128 | 139 | { name: "description", content: description }, | |
| 129 | 140 | { property: "og:site_name", content: "g1t" }, | |
| 130 | 141 | { property: "og:type", content: meta.type ?? "website" }, | |
| 131 | − | { property: "og:url", content: `${SITE}${pathname === "/" ? "/" : pathname}` }, | |
| 142 | + | { property: "og:url", content: `${site}${pathname === "/" ? "/" : pathname}` }, | |
| 132 | 143 | { property: "og:title", content: shareTitle }, | |
| 133 | 144 | { property: "og:description", content: description }, | |
| 134 | − | { property: "og:image", content: image }, | |
| 135 | − | { property: "og:image:type", content: "image/png" }, | |
| 136 | − | { property: "og:image:width", content: "1200" }, | |
| 137 | − | { property: "og:image:height", content: "630" }, | |
| 138 | − | { property: "og:image:alt", content: shareTitle }, | |
| 139 | − | { name: "twitter:card", content: "summary_large_image" }, | |
| 145 | + | ...(image | |
| 146 | + | ? [ | |
| 147 | + | { property: "og:image", content: image }, | |
| 148 | + | { property: "og:image:type", content: "image/png" }, | |
| 149 | + | { property: "og:image:width", content: "1200" }, | |
| 150 | + | { property: "og:image:height", content: "630" }, | |
| 151 | + | { property: "og:image:alt", content: shareTitle }, | |
| 152 | + | ] | |
| 153 | + | : []), | |
| 154 | + | { name: "twitter:card", content: image ? "summary_large_image" : "summary" }, | |
| 140 | 155 | { name: "twitter:title", content: shareTitle }, | |
| 141 | 156 | { name: "twitter:description", content: description }, | |
| 142 | − | { name: "twitter:image", content: image }, | |
| 143 | − | { name: "twitter:image:alt", content: shareTitle }, | |
| 157 | + | ...(image | |
| 158 | + | ? [ | |
| 159 | + | { name: "twitter:image", content: image }, | |
| 160 | + | { name: "twitter:image:alt", content: shareTitle }, | |
| 161 | + | ] | |
| 162 | + | : []), | |
| 144 | 163 | ]; | |
| 145 | 164 | } |
| 59 | 59 | import { shortCache } from "./lib/cache.server"; | |
| 60 | 60 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 61 | 61 | import { registrationMode } from "./lib/registration.server"; | |
| 62 | + | import { addresses } from "./lib/addresses.server"; | |
| 62 | 63 | import { useSignUpCopy } from "./lib/registration"; | |
| 63 | 64 | ||
| 64 | 65 | ||
| 83 | 84 | user ? shellFor(user, params, chosen, context) : visitorShell(params, context), | |
| 84 | 85 | user ? Promise.resolve(null) : registrationMode(), | |
| 85 | 86 | ]); | |
| 86 | − | return { user, shell, inviteOnly: mode !== "open" }; | |
| 87 | + | // Where this g1t lives, for clone lines, agent setup and link previews. | |
| 88 | + | return { user, shell, inviteOnly: mode !== "open", addresses: addresses() }; | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 89 | 91 | /** |
| 5 | 5 | * a repository past the caps is cloned instead. | |
| 6 | 6 | */ | |
| 7 | 7 | import type { Route } from "./+types/archive"; | |
| 8 | + | import { cloneUrl } from "../../lib/addresses"; | |
| 9 | + | import { addresses } from "../../lib/addresses.server"; | |
| 8 | 10 | import { repos } from "../../lib/services.server"; | |
| 9 | 11 | import { getViewer } from "../../lib/session.server"; | |
| 10 | 12 | import { zip } from "../../lib/zip"; | |
| 30 | 32 | if (!repo?.ok) return refused(404, "There is no such repository, or you cannot see it."); | |
| 31 | 33 | const listed = await repos.listFiles(repo.value.id, ref, MAX_FILES + 1).catch(() => null); | |
| 32 | 34 | if (!listed?.commit) return refused(404, `There is no branch, tag or commit named ${ref}.`); | |
| 33 | − | const clone = `git clone https://g1t.sh/${repo.value.namespace}/${repo.value.name}.git`; | |
| 35 | + | const clone = `git clone ${cloneUrl(addresses(), `${repo.value.namespace}/${repo.value.name}`)}`; | |
| 34 | 36 | if (listed.truncated || listed.files.length > MAX_FILES) { | |
| 35 | 37 | return refused(413, `It has more than ${MAX_FILES.toLocaleString("en-US")} files, too many for a download. Clone it instead: ${clone}`); | |
| 36 | 38 | } |
| 72 | 72 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 73 | 73 | import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server"; | |
| 74 | 74 | import { shotVersion } from "./production-screenshot"; | |
| 75 | + | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 75 | 76 | import { readBranches } from "../../lib/branches.server"; | |
| 76 | 77 | ||
| 77 | 78 | const MAX_LANDED = 6; | |
| 664 | 665 | const previews = live.filter((app) => app.kind === "preview"); | |
| 665 | 666 | const latestProduction = builds.find((build) => build.kind === "production") ?? null; | |
| 666 | 667 | const busy = useNavigation().state === "submitting"; | |
| 668 | + | const addresses = useAddresses(); | |
| 667 | 669 | const source = project?.source.kind === "hosted" ? project.source : null; | |
| 668 | 670 | const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0; | |
| 669 | 671 | useLiveRefresh(moving); | |
| 1207 | 1209 | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 1208 | 1210 | <h2 className="text-sm font-semibold">Clone</h2> | |
| 1209 | 1211 | <div className="mt-3"> | |
| 1210 | − | <CopyLine text={`git clone https://g1t.sh/${source.repo.namespace}/${source.repo.name}.git`} /> | |
| 1212 | + | <CopyLine text={`git clone ${cloneUrl(addresses, `${source.repo.namespace}/${source.repo.name}`)}`} /> | |
| 1211 | 1213 | </div> | |
| 1212 | 1214 | </section> | |
| 1213 | 1215 | )} |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/pull-new"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | + | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 6 | 7 | import { Combobox } from "../../components/ui/combobox"; | |
| 7 | 8 | import { | |
| 8 | 9 | Button, | |
| 69 | 70 | ||
| 70 | 71 | export default function NewPull({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 71 | 72 | const { defaultBranch, branches, selected, issue } = loaderData; | |
| 72 | − | const remote = `https://g1t.sh/${params.owner}/${params.repo}.git`; | |
| 73 | + | const remote = cloneUrl(useAddresses(), `${params.owner}/${params.repo}`); | |
| 73 | 74 | ||
| 74 | 75 | if (branches.length === 0) { | |
| 75 | 76 | return ( |
| 41 | 41 | import type { Route } from "./+types/pull"; | |
| 42 | 42 | import { excerpt, page } from "../../lib/meta"; | |
| 43 | 43 | import { openedBy } from "../../lib/opened-by"; | |
| 44 | + | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 44 | 45 | import { DiffView } from "../../components/diff-view"; | |
| 45 | 46 | import { LifecyclePanel } from "../../components/lifecycle"; | |
| 46 | 47 | import { AgentPanel } from "../../components/agents"; | |
| 555 | 556 | error={actionData?.action === "rerun-failed" || actionData?.action === "rerun-workflow" ? actionData.error : null} | |
| 556 | 557 | /> | |
| 557 | 558 | ); | |
| 559 | + | const addresses = useAddresses(); | |
| 558 | 560 | const remote = pull.fork | |
| 559 | − | ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git` | |
| 560 | − | : `https://g1t.sh/${params.owner}/${params.repo}.git`; | |
| 561 | + | ? cloneUrl(addresses, `${pull.fork.namespace}/${pull.fork.name}`) | |
| 562 | + | : cloneUrl(addresses, `${params.owner}/${params.repo}`); | |
| 561 | 563 | const active = pull.status === "draft" || pull.status === "open"; | |
| 562 | 564 | ||
| 563 | 565 | // Follow an agent at work, or checks in progress, without a manual reload. |
| 10 | 10 | import { PullIcon } from "../../components/work-icons"; | |
| 11 | 11 | import { planStatus, type UsageGlance, usageGlance } from "../../lib/billing"; | |
| 12 | 12 | import { openedBy } from "../../lib/opened-by"; | |
| 13 | + | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 13 | 14 | import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind"; | |
| 14 | 15 | import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server"; | |
| 15 | 16 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 122 | 123 | ||
| 123 | 124 | /** What a member sees in a workspace with no projects yet. */ | |
| 124 | 125 | function GetStarted({ slug }: { slug: string }) { | |
| 126 | + | const addresses = useAddresses(); | |
| 125 | 127 | return ( | |
| 126 | 128 | <div className="rounded-2xl border border-line bg-surface p-8"> | |
| 127 | 129 | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 140 | 142 | </div> | |
| 141 | 143 | <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p> | |
| 142 | 144 | <div className="mt-2 max-w-xl"> | |
| 143 | − | <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} /> | |
| 145 | + | <CopyLine prompt text={`git push ${cloneUrl(addresses, `${slug}/my-project`)} main`} /> | |
| 144 | 146 | </div> | |
| 145 | 147 | </div> | |
| 146 | 148 | ); |
| 12 | 12 | ||
| 13 | 13 | import type { Route } from "./+types/settings"; | |
| 14 | 14 | import { page } from "../../lib/meta"; | |
| 15 | + | import { useAddresses } from "../../lib/addresses"; | |
| 15 | 16 | import { AvatarField } from "../../components/avatar-field"; | |
| 16 | 17 | import { DangerAction, DangerZone } from "../../components/danger-zone"; | |
| 17 | 18 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 296 | 297 | const [open, setOpen] = useState(false); | |
| 297 | 298 | const [confirm, setConfirm] = useState(""); | |
| 298 | 299 | const checker = useFetcher<typeof loader>(); | |
| 300 | + | const { site } = useAddresses(); | |
| 299 | 301 | const navigation = useNavigation(); | |
| 300 | 302 | const renaming = navigation.state !== "idle" && navigation.formData?.get("intent") === "rename"; | |
| 301 | 303 | ||
| 407 | 409 | <li> | |
| 408 | 410 | Update your git remotes, for example{" "} | |
| 409 | 411 | <code className="font-mono text-xs break-all text-fg"> | |
| 410 | − | git remote set-url origin https://g1t.sh/{wanted}/<repo>.git | |
| 412 | + | git remote set-url origin {site}/{wanted}/<repo>.git | |
| 411 | 413 | </code> | |
| 412 | 414 | , and any URLs written into code, API clients and MCP clients. | |
| 413 | 415 | </li> |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/tokens"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | + | import { useAddresses } from "../../lib/addresses"; | |
| 7 | 8 | import { | |
| 8 | 9 | Button, | |
| 9 | 10 | CopyLine, | |
| 65 | 66 | export default function WorkspaceTokens({ loaderData, actionData }: Route.ComponentProps) { | |
| 66 | 67 | const { slug, role, tokens } = loaderData; | |
| 67 | 68 | const created = actionData?.token; | |
| 69 | + | const { site, api } = useAddresses(); | |
| 70 | + | // With git, the token is the password in the clone address, after the scheme. | |
| 71 | + | const [scheme, rest] = site.split("://"); | |
| 68 | 72 | return ( | |
| 69 | 73 | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 70 | 74 | <div className="min-w-0"> | |
| 185 | 189 | <div className="mt-2"> | |
| 186 | 190 | <CopyLine | |
| 187 | 191 | prompt | |
| 188 | − | text={`git clone https://${slug}:$G1T_TOKEN@g1t.sh/${slug}/<repo>.git`} | |
| 192 | + | text={`git clone ${scheme}://${slug}:$G1T_TOKEN@${rest}/${slug}/<repo>.git`} | |
| 189 | 193 | /> | |
| 190 | 194 | </div> | |
| 191 | 195 | <p className="mt-4 text-muted">With the API and the MCP server:</p> | |
| 192 | 196 | <div className="mt-2"> | |
| 193 | 197 | <CopyLine | |
| 194 | 198 | prompt | |
| 195 | − | text={'curl -H "Authorization: Bearer $G1T_TOKEN" https://api.g1t.sh/user'} | |
| 199 | + | text={`curl -H "Authorization: Bearer $G1T_TOKEN" ${api}/user`} | |
| 196 | 200 | /> | |
| 197 | 201 | </div> | |
| 198 | 202 | </section> |
| 36 | 36 | AVATARS: KVNamespace; | |
| 37 | 37 | /** The self-hosted runner's releases (scripts/runner-release.mjs). Absent when self-hosted. */ | |
| 38 | 38 | DOWNLOADS?: R2Bucket; | |
| 39 | + | /** The site's own origin. Unset on g1t.sh, which is https://g1t.sh (app/lib/addresses.server.ts). */ | |
| 40 | + | SITE_URL?: string; | |
| 41 | + | /** The REST API's origin, which is also the OAuth issuer. Unset on g1t.sh. */ | |
| 42 | + | API_URL?: string; | |
| 43 | + | /** The MCP server's URL, as agents are told to add it. Unset on g1t.sh. */ | |
| 44 | + | MCP_URL?: string; | |
| 45 | + | /** The social-card image service; an empty string for none. Unset on g1t.sh. */ | |
| 46 | + | OG_URL?: string; | |
| 39 | 47 | } | |
| 40 | 48 | } | |
| 41 | 49 | interface Env extends Cloudflare.Env {} |
| 20 | 20 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ | |
| 21 | 21 | for service in identity repos work events search billing security actions webhooks integrations packages; do \ | |
| 22 | 22 | (cd services/$service && worker-build --release) || exit 1; \ | |
| 23 | − | done | |
| 23 | + | done \ | |
| 24 | + | && (cd apps/api && worker-build --release) | |
| 24 | 25 | ||
| 25 | 26 | # ── The site, built by React Router; the TypeScript services' packages ── | |
| 26 | 27 | FROM node:24-bookworm-slim AS node | |
| 39 | 40 | WORKDIR /app | |
| 40 | 41 | COPY --from=node --chown=node:node /app /app | |
| 41 | 42 | COPY --from=rust /src/services /tmp/rust-services | |
| 43 | + | COPY --from=rust --chown=node:node /src/apps/api/build apps/api/build | |
| 42 | 44 | RUN for service in identity repos work events search billing security actions webhooks integrations packages; do \ | |
| 43 | 45 | cp -r /tmp/rust-services/$service/build services/$service/build; \ | |
| 44 | 46 | done \ | |
| 49 | 51 | GITSTORE_URL=http://gitstore:8080 \ | |
| 50 | 52 | G1T_DATA=/data | |
| 51 | 53 | VOLUME /data | |
| 52 | − | EXPOSE 8787 | |
| 54 | + | EXPOSE 8787 8789 | |
| 53 | 55 | USER node | |
| 54 | 56 | CMD ["bash", "deploy/self-host/start.sh"] |
| 39 | 39 | ||
| 40 | 40 | const PUBLIC_URL = (process.env.PUBLIC_URL ?? "http://localhost:8787").replace(/\/$/, ""); | |
| 41 | 41 | ||
| 42 | + | /** | |
| 43 | + | * Where the API (REST, OAuth) is reached: API_URL, or PUBLIC_URL's host on | |
| 44 | + | * API_PORT (8789). The MCP server is a path on it: MCP_URL, or | |
| 45 | + | * `<API_URL>/mcp`. The API's OAuth issuer is API_URL. | |
| 46 | + | */ | |
| 47 | + | export function apiAddresses(env = process.env, publicUrl = PUBLIC_URL) { | |
| 48 | + | let api = (env.API_URL ?? "").trim().replace(/\/$/, ""); | |
| 49 | + | if (!api) { | |
| 50 | + | const url = new URL(publicUrl); | |
| 51 | + | url.port = env.API_PORT || "8789"; | |
| 52 | + | api = url.origin; | |
| 53 | + | } | |
| 54 | + | const mcp = (env.MCP_URL ?? "").trim().replace(/\/$/, "") || `${api}/mcp`; | |
| 55 | + | return { api, mcp }; | |
| 56 | + | } | |
| 57 | + | const { api: API_URL, mcp: MCP_URL } = apiAddresses(); | |
| 58 | + | ||
| 42 | 59 | // What runs, and what is off, is each unit's `self_host` in | |
| 43 | 60 | // deploy/stack.jsonc: the list hosted g1t deploys from. | |
| 44 | 61 | const STACK = Object.values(parseJsonc(readFileSync(join(root, "deploy/stack.jsonc"), "utf8")).units); | |
| 115 | 132 | const rel = (path) => relative(out, resolve(root, path)).replaceAll("\\", "/"); | |
| 116 | 133 | ||
| 117 | 134 | function hostedUrl(value) { | |
| 118 | − | return typeof value === "string" ? value.replace(/https:\/\/(api\.)?g1t\.sh/g, PUBLIC_URL) : value; | |
| 135 | + | return typeof value === "string" | |
| 136 | + | ? value.replace(/https:\/\/api\.g1t\.sh/g, API_URL).replace(/https:\/\/g1t\.sh/g, PUBLIC_URL) | |
| 137 | + | : value; | |
| 119 | 138 | } | |
| 120 | 139 | ||
| 121 | 140 | function selfHosted(service) { | |
| 217 | 236 | delete config.vars.R2_ACCOUNT_ID; | |
| 218 | 237 | delete config.vars.R2_BUCKET; | |
| 219 | 238 | } | |
| 239 | + | // Where this installation is reached, for the links and addresses each | |
| 240 | + | // shows: the site's clone URLs, meta tags and agent setup, the API's | |
| 241 | + | // OAuth issuer and MCP server, and identity's mail. No social cards: the | |
| 242 | + | // card service (services/og) is not run here. | |
| 243 | + | if (service.web) Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL, OG_URL: "" }); | |
| 244 | + | if (hosted.name === "g1t-api") Object.assign(config.vars, { SITE_URL: PUBLIC_URL, API_URL, MCP_URL }); | |
| 245 | + | if (hosted.name === "g1t-identity") config.vars.SITE_URL = PUBLIC_URL; | |
| 220 | 246 | // Nightly backups' bundles go to a bucket of their own on the same | |
| 221 | − | // S3-compatible store, instead of the BACKUPS R2 bucket. | |
| 247 | + | // S3-compatible store, instead of the BACKUPS R2 bucket, and so do the | |
| 248 | + | // packs kept for fresh clones (src/pack_cache.rs), instead of GIT_PACKS. | |
| 222 | 249 | if (hosted.name === "g1t-repos") { | |
| 223 | 250 | Object.assign(config.vars, { | |
| 224 | 251 | BACKUP_STORE: "s3", | |
| 225 | 252 | BACKUP_S3_BUCKET: process.env.BACKUP_S3_BUCKET ?? "g1t-backups", | |
| 253 | + | PACK_STORE: "s3", | |
| 254 | + | PACK_S3_BUCKET: process.env.PACK_S3_BUCKET ?? "g1t-git-packs", | |
| 226 | 255 | S3_ENDPOINT: process.env.S3_ENDPOINT ?? "http://minio:9000", | |
| 227 | 256 | S3_REGION: process.env.S3_REGION ?? "us-east-1", | |
| 228 | 257 | S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID ?? "", | |
| 286 | 315 | // The order Wrangler takes them in: the site first, as the one that serves. | |
| 287 | 316 | writeFileSync(join(out, "workers.txt"), `${files.map((file) => relative(out, file)).join("\n")}\n`); | |
| 288 | 317 | ||
| 318 | + | // The API (REST, MCP and OAuth) is served on a port of its own (API_PORT), | |
| 319 | + | // by a second `wrangler dev` (start.sh): not in workers.txt. Its service | |
| 320 | + | // bindings reach the Workers above through Wrangler's dev registry, as | |
| 321 | + | // any two `wrangler dev` sessions on one machine do. | |
| 322 | + | { | |
| 323 | + | const api = STACK.find((unit) => unit.worker === "g1t-api"); | |
| 324 | + | write("g1t-api", selfHosted({ name: api.worker, dir: api.path, web: false })); | |
| 325 | + | writeFileSync(join(out, "api.json"), `${JSON.stringify({ api: API_URL, mcp: MCP_URL }, null, 2)}\n`); | |
| 326 | + | } | |
| 327 | + | ||
| 289 | 328 | // What scheduler.mjs runs: each service's own crons, as hosted g1t's Cron | |
| 290 | 329 | // Triggers run them, for the services in SELF_HOST_CRONS. | |
| 291 | 330 | const schedules = RUNNING.filter((service) => SELF_HOST_CRONS.has(service.name)) |
| 3 | 3 | # docker compose -f deploy/self-host/docker-compose.yml up --build | |
| 4 | 4 | # | |
| 5 | 5 | # Then open http://localhost:8787. Mail (the confirmation link at sign-up) | |
| 6 | − | # lands in Mailpit at http://localhost:8025. | |
| 6 | + | # lands in Mailpit at http://localhost:8025. The API is at | |
| 7 | + | # http://localhost:8789 and the MCP server at http://localhost:8789/mcp. | |
| 7 | 8 | # | |
| 8 | 9 | # What runs: the site and every core service in one workerd (g1t), git | |
| 9 | − | # repositories as bare repos on a volume (gitstore), packages' files in | |
| 10 | + | # repositories as bare repos on a volume (gitstore), the API in a second | |
| 11 | + | # workerd beside it, packages' files, backups and the clone pack cache in | |
| 10 | 12 | # MinIO, and Mailpit for mail. | |
| 11 | 13 | # Agents, deployments, context search and billing are off. See | |
| 12 | 14 | # docs/SELF_HOSTING.md. | |
| 19 | 21 | dockerfile: deploy/self-host/Dockerfile | |
| 20 | 22 | ports: | |
| 21 | 23 | - "${G1T_PORT:-8787}:8787" | |
| 24 | + | - "${API_PORT:-8789}:8789" | |
| 22 | 25 | environment: | |
| 23 | − | # Where people reach this installation. Links in mail point here. | |
| 26 | + | # Where people reach this installation. Links in mail, clone URLs and | |
| 27 | + | # the site's meta tags point here. | |
| 24 | 28 | PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8787} | |
| 29 | + | # Where the API (REST, OAuth) is reached, and its OAuth issuer; empty | |
| 30 | + | # means PUBLIC_URL's host on API_PORT. MCP_URL, empty, is API_URL/mcp. | |
| 31 | + | API_URL: ${API_URL:-} | |
| 32 | + | MCP_URL: ${MCP_URL:-} | |
| 33 | + | API_PORT: ${API_PORT:-8789} | |
| 25 | 34 | GITSTORE_URL: http://gitstore:8080 | |
| 26 | 35 | GITSTORE_SECRET_FILE: /secrets/gitstore | |
| 27 | 36 | MAIL_URL: ${MAIL_URL:-http://mailpit:8025} | |
| 54 | 63 | # Nightly backups' bundles and manifests, in a bucket of their own on | |
| 55 | 64 | # the same store (docs/SELF_HOSTING.md, "Backups"). | |
| 56 | 65 | BACKUP_S3_BUCKET: ${BACKUP_S3_BUCKET:-g1t-backups} | |
| 66 | + | # Packs kept for fresh clones, so the next clone of the same commit | |
| 67 | + | # does not rebuild one; minio-setup expires them after 7 days. | |
| 68 | + | PACK_S3_BUCKET: ${PACK_S3_BUCKET:-g1t-git-packs} | |
| 57 | 69 | volumes: | |
| 58 | 70 | - g1t-data:/data | |
| 59 | 71 | - g1t-secrets:/secrets:ro | |
| 98 | 110 | # Not published: only the g1t container reaches it. | |
| 99 | 111 | restart: unless-stopped | |
| 100 | 112 | ||
| 101 | − | # Packages' files. Not published: only the g1t container reaches it, | |
| 102 | − | # unless you publish 9000 and set S3_PUBLIC_ENDPOINT. | |
| 113 | + | # Packages' files, backups and clone packs. Not published: only the g1t | |
| 114 | + | # container reaches it, unless you publish 9000 and set | |
| 115 | + | # S3_PUBLIC_ENDPOINT. MinIO no longer publishes images of its own; | |
| 116 | + | # MINIO_IMAGE is a community build of the same server, with `mc` in it. | |
| 117 | + | # Any S3-compatible store works in its place (S3_ENDPOINT). | |
| 103 | 118 | minio: | |
| 104 | − | image: minio/minio:latest | |
| 119 | + | image: ${MINIO_IMAGE:-pgsty/minio:latest} | |
| 105 | 120 | command: ["server", "/data"] | |
| 106 | 121 | environment: | |
| 107 | 122 | MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-g1t} | |
| 108 | 123 | MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-g1t-packages-secret} | |
| 124 | + | # What the health check's `mc ready local` asks. | |
| 125 | + | MC_HOST_local: http://localhost:9000 | |
| 109 | 126 | volumes: | |
| 110 | 127 | - g1t-packages:/data | |
| 111 | 128 | healthcheck: | |
| 114 | 131 | retries: 20 | |
| 115 | 132 | restart: unless-stopped | |
| 116 | 133 | ||
| 117 | − | # Makes the buckets once, then exits: packages' files, and backups. | |
| 134 | + | # Makes the buckets once, then exits: packages' files, backups, and | |
| 135 | + | # clone packs with a rule that deletes packs 7 days old. (MinIO itself | |
| 136 | + | # removes uploads left unfinished after 24 hours.) | |
| 118 | 137 | minio-setup: | |
| 119 | − | image: minio/mc:latest | |
| 138 | + | image: ${MINIO_IMAGE:-pgsty/minio:latest} | |
| 120 | 139 | depends_on: | |
| 121 | 140 | minio: | |
| 122 | 141 | condition: service_healthy | |
| 123 | 142 | entrypoint: | |
| 124 | 143 | - sh | |
| 125 | 144 | - -c | |
| 126 | − | - mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" && mc mb --ignore-existing "local/$$S3_BUCKET" && mc mb --ignore-existing "local/$$BACKUP_S3_BUCKET" | |
| 145 | + | - >- | |
| 146 | + | set -e; | |
| 147 | + | mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD"; | |
| 148 | + | mc mb --ignore-existing "local/$$S3_BUCKET"; | |
| 149 | + | mc mb --ignore-existing "local/$$BACKUP_S3_BUCKET"; | |
| 150 | + | mc mb --ignore-existing "local/$$PACK_S3_BUCKET"; | |
| 151 | + | if ! mc ilm rule ls "local/$$PACK_S3_BUCKET" >/dev/null 2>&1; then | |
| 152 | + | mc ilm rule add --prefix packs/ --expire-days 7 "local/$$PACK_S3_BUCKET"; | |
| 153 | + | fi | |
| 127 | 154 | environment: | |
| 128 | 155 | MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-g1t} | |
| 129 | 156 | MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-g1t-packages-secret} | |
| 130 | 157 | S3_BUCKET: ${S3_BUCKET:-g1t-packages} | |
| 131 | 158 | BACKUP_S3_BUCKET: ${BACKUP_S3_BUCKET:-g1t-backups} | |
| 159 | + | PACK_S3_BUCKET: ${PACK_S3_BUCKET:-g1t-git-packs} | |
| 132 | 160 | ||
| 133 | 161 | mailpit: | |
| 134 | 162 | image: axllent/mailpit:latest |
| 12 | 12 | // this runs inside the g1t container, beside it (start.sh). | |
| 13 | 13 | // | |
| 14 | 14 | // Usage: node scheduler.mjs <schedules.json> [base URL, default http://127.0.0.1:8787] | |
| 15 | + | // node scheduler.mjs --once <schedules.json> [base URL] | |
| 16 | + | // | |
| 17 | + | // --once runs every service's every cron now, says how each went, and exits | |
| 18 | + | // non-zero if any failed. A handler still running from the minute before is | |
| 19 | + | // not started again, and one is given up on after ten minutes. | |
| 15 | 20 | ||
| 16 | 21 | import { readFileSync } from "node:fs"; | |
| 17 | 22 | ||
| 67 | 72 | return schedules.flatMap(({ worker, crons }) => crons.filter((cron) => matches(cron, date)).map((cron) => ({ worker, cron }))); | |
| 68 | 73 | } | |
| 69 | 74 | ||
| 70 | − | async function run(base, { worker, cron }) { | |
| 75 | + | /** Handlers still running, by worker and cron: a slow one is not started again on top of itself. */ | |
| 76 | + | const running = new Set(); | |
| 77 | + | ||
| 78 | + | /** Runs one handler through Wrangler's local API; whether it ran and said ok. */ | |
| 79 | + | export async function run(base, { worker, cron }, { fetch: send = fetch, timeoutMs = 10 * 60_000 } = {}) { | |
| 80 | + | const key = `${worker} ${cron}`; | |
| 81 | + | if (running.has(key)) { | |
| 82 | + | console.error(`scheduler: ${worker} (${cron}) is still running from the last time; skipped`); | |
| 83 | + | return false; | |
| 84 | + | } | |
| 85 | + | running.add(key); | |
| 71 | 86 | try { | |
| 72 | − | const answer = await fetch(`${base}/cdn-cgi/local/explorer/api/local/scheduled?worker=${encodeURIComponent(worker)}`, { | |
| 87 | + | const answer = await send(`${base}/cdn-cgi/local/explorer/api/local/scheduled?worker=${encodeURIComponent(worker)}`, { | |
| 73 | 88 | method: "POST", | |
| 74 | 89 | headers: { "content-type": "application/json" }, | |
| 75 | 90 | body: JSON.stringify({ cron }), | |
| 91 | + | signal: AbortSignal.timeout(timeoutMs), | |
| 76 | 92 | }); | |
| 77 | 93 | const body = await answer.json().catch(() => ({})); | |
| 78 | 94 | if (!answer.ok || !body.success || body.result?.outcome !== "ok") { | |
| 79 | 95 | console.error(`scheduler: ${worker} (${cron}): ${answer.status} ${JSON.stringify(body.errors ?? body.result ?? body)}`); | |
| 96 | + | return false; | |
| 80 | 97 | } | |
| 98 | + | return true; | |
| 81 | 99 | } catch (error) { | |
| 82 | 100 | console.error(`scheduler: ${worker} (${cron}) could not be run: ${error.message}`); | |
| 101 | + | return false; | |
| 102 | + | } finally { | |
| 103 | + | running.delete(key); | |
| 83 | 104 | } | |
| 84 | 105 | } | |
| 85 | 106 | ||
| 86 | 107 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("deploy/self-host/scheduler.mjs")) { | |
| 87 | − | const schedules = JSON.parse(readFileSync(process.argv[2], "utf8")); | |
| 88 | − | const base = (process.argv[3] ?? "http://127.0.0.1:8787").replace(/\/$/, ""); | |
| 108 | + | const once = process.argv.includes("--once"); | |
| 109 | + | const [file, given] = process.argv.slice(2).filter((arg) => arg !== "--once"); | |
| 110 | + | const schedules = JSON.parse(readFileSync(file, "utf8")); | |
| 111 | + | const base = (given ?? "http://127.0.0.1:8787").replace(/\/$/, ""); | |
| 112 | + | if (once) { | |
| 113 | + | // Every cron of every service, now, one after another: a check that | |
| 114 | + | // each handler runs (smoke.sh), not a schedule. | |
| 115 | + | let failed = 0; | |
| 116 | + | for (const { worker, crons } of schedules) { | |
| 117 | + | for (const cron of crons) { | |
| 118 | + | const ok = await run(base, { worker, cron }); | |
| 119 | + | console.log(`${ok ? "ok " : "FAIL"} ${worker} (${cron})`); | |
| 120 | + | if (!ok) failed++; | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | process.exit(failed ? 1 : 0); | |
| 124 | + | } | |
| 89 | 125 | console.log(`scheduler: ${schedules.map((s) => `${s.worker} ${s.crons.join(", ")}`).join("; ")}`); | |
| 90 | 126 | const tick = () => { | |
| 91 | 127 | const now = new Date(); |
| 1 | 1 | #!/usr/bin/env bash | |
| 2 | 2 | # End-to-end check of a self-hosted g1t: sign up, confirm the email, make a | |
| 3 | 3 | # workspace and a repository, push and clone over HTTP, open an issue, and | |
| 4 | − | # read the code back through the site. | |
| 4 | + | # read the code back through the site. Then the API on its own port (REST, | |
| 5 | + | # OAuth metadata and MCP, with an access token), pull requests from a branch | |
| 6 | + | # and from a fork merged onto main, the merge queue taking a pull request | |
| 7 | + | # and giving it back, and every cron handler the scheduler runs. | |
| 5 | 8 | # | |
| 6 | 9 | # ./smoke.sh # against the compose stack's defaults | |
| 7 | 10 | # G1T_URL=http://localhost:8787 MAIL_LOG=wrangler.log ./smoke.sh | |
| 8 | 11 | # | |
| 9 | 12 | # The confirmation link is read from Mailpit (MAILPIT_URL, the default) or, | |
| 10 | − | # with MAIL_LOG set, from a log the mail Worker printed it to. | |
| 13 | + | # with MAIL_LOG set, from a log the mail Worker printed it to. API_URL and | |
| 14 | + | # MCP_URL are where the API is (default: G1T_URL's host on port 8789). | |
| 15 | + | # SCHEDULER_ONCE is the command that runs every cron once (scheduler.mjs | |
| 16 | + | # --once, inside the g1t container); unset, that step is skipped: | |
| 17 | + | # | |
| 18 | + | # SCHEDULER_ONCE="docker compose -f deploy/self-host/docker-compose.yml exec -T g1t \ | |
| 19 | + | # node deploy/self-host/scheduler.mjs --once /data/generated/schedules.json" ./smoke.sh | |
| 20 | + | # | |
| 21 | + | # (In Git Bash on Windows, start the command with `env MSYS_NO_PATHCONV=1` | |
| 22 | + | # so /data is not rewritten into a Windows path.) | |
| 23 | + | # PACK_CACHE=off skips the check that a second clone is served from the | |
| 24 | + | # clone pack cache. | |
| 25 | + | # | |
| 26 | + | # Needs curl, git and node (to read JSON). | |
| 11 | 27 | set -euo pipefail | |
| 12 | 28 | ||
| 13 | 29 | G1T_URL="${G1T_URL:-http://localhost:8787}" | |
| 30 | + | API_URL="${API_URL:-$(node -e 'const u = new URL(process.argv[1]); u.port = "8789"; console.log(u.origin)' "$G1T_URL")}" | |
| 31 | + | MCP_URL="${MCP_URL:-$API_URL/mcp}" | |
| 14 | 32 | MAILPIT_URL="${MAILPIT_URL:-http://localhost:8025}" | |
| 15 | 33 | MAIL_LOG="${MAIL_LOG:-}" | |
| 34 | + | SCHEDULER_ONCE="${SCHEDULER_ONCE:-}" | |
| 35 | + | # off: this installation keeps no clone packs (no PACK_STORE), so a second | |
| 36 | + | # clone is not checked for a kept one. | |
| 37 | + | PACK_CACHE="${PACK_CACHE:-on}" | |
| 16 | 38 | RUN="$(date +%s)" | |
| 17 | 39 | USER_NAME="smoke${RUN}" | |
| 18 | 40 | EMAIL="${USER_NAME}@example.com" | |
| 35 | 57 | get() { | |
| 36 | 58 | curl -sS -o "$WORK/body" -w '%{http_code}' -b "$JAR" -c "$JAR" "$G1T_URL$1" | |
| 37 | 59 | } | |
| 60 | + | # An API call with the access token: method, path, optional JSON body. The | |
| 61 | + | # answer is in $WORK/api; the status is printed. | |
| 62 | + | api() { | |
| 63 | + | local method="$1" path="$2" body="${3:-}" | |
| 64 | + | local args=(-sS -o "$WORK/api" -w '%{http_code}' -X "$method" -H "Authorization: Bearer $TOKEN") | |
| 65 | + | [ -n "$body" ] && args+=(-H "content-type: application/json" --data "$body") | |
| 66 | + | curl "${args[@]}" "$API_URL$path" | |
| 67 | + | } | |
| 68 | + | # A field of the last API answer (or of FILE), by a JavaScript path: `json pull.number`. | |
| 69 | + | json() { | |
| 70 | + | node -e 'const v = process.argv[2].split(".").reduce((o, k) => o?.[k], JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))); console.log(typeof v === "object" ? JSON.stringify(v) : v ?? "")' "${2:-$WORK/api}" "$1" | |
| 71 | + | } | |
| 72 | + | # Waits for pull request $1 to have status $2. | |
| 73 | + | until_status() { | |
| 74 | + | local status="" | |
| 75 | + | for _ in $(seq 1 30); do | |
| 76 | + | [ "$(api GET "/repos/$WORKSPACE/$REPO/pulls/$1")" = 200 ] && status="$(json pull.status)" | |
| 77 | + | [ "$status" = "$2" ] && return 0 | |
| 78 | + | sleep 1 | |
| 79 | + | done | |
| 80 | + | fail "pull request #$1 is $status, not $2: $(head -c 400 "$WORK/api")" | |
| 81 | + | } | |
| 82 | + | # Whether main, cloned fresh, has a file. | |
| 83 | + | main_has() { | |
| 84 | + | rm -rf "$WORK/main" | |
| 85 | + | git -c credential.helper= clone -q "$G1T_URL/$WORKSPACE/$REPO.git" "$WORK/main" | |
| 86 | + | [ -f "$WORK/main/$1" ] | |
| 87 | + | } | |
| 88 | + | # A commit on a new branch of $1 (a clone), pushed to $2. | |
| 89 | + | commit_file() { | |
| 90 | + | local dir="$1" remote="$2" branch="$3" file="$4" | |
| 91 | + | ( | |
| 92 | + | cd "$dir" | |
| 93 | + | git config user.name "Smoke Test" | |
| 94 | + | git config user.email "$EMAIL" | |
| 95 | + | git config commit.gpgsign false | |
| 96 | + | git switch -q -c "$branch" 2>/dev/null || git switch -q "$branch" | |
| 97 | + | mkdir -p "$(dirname "$file")" | |
| 98 | + | printf 'Changed by smoke.sh on %s.\n' "$branch" > "$file" | |
| 99 | + | git add . && git commit -qm "Add $file" | |
| 100 | + | git -c credential.helper= push -q "$remote" "HEAD:$branch" | |
| 101 | + | ) | |
| 102 | + | } | |
| 38 | 103 | ||
| 39 | 104 | step "site answers at $G1T_URL" | |
| 40 | 105 | [ "$(get /)" = 200 ] || fail "GET / did not answer 200" | |
| 94 | 159 | diff -q "$WORK/src/README.md" "$WORK/clone/README.md" || fail "clone differs" | |
| 95 | 160 | echo "clone matches" | |
| 96 | 161 | ||
| 162 | + | if [ "$PACK_CACHE" != off ]; then | |
| 163 | + | step "clone again: the pack comes from the cache" | |
| 164 | + | # The repos service says in Server-Timing whether the pack was kept. | |
| 165 | + | GIT_TRACE_CURL=1 GIT_TRACE_CURL_NO_DATA=1 git -c credential.helper= clone -q "$G1T_URL/$WORKSPACE/$REPO.git" "$WORK/again" 2> "$WORK/trace" | |
| 166 | + | grep -qi 'server-timing:.*pack;desc=hit' "$WORK/trace" || fail "the second clone's pack was not kept: $(grep -io 'pack;desc=[a-z]*' "$WORK/trace" | tr '\n' ' ')" | |
| 167 | + | diff -qr --exclude=.git "$WORK/clone" "$WORK/again" >/dev/null || fail "the kept pack differs" | |
| 168 | + | echo "hit" | |
| 169 | + | fi | |
| 170 | + | ||
| 97 | 171 | step "open an issue" | |
| 98 | 172 | out="$(post "/$WORKSPACE/$REPO/issues/new" --data-urlencode "title=It works" --data-urlencode "body=Opened by smoke.sh")" | |
| 99 | 173 | echo "$out" | |
| 109 | 183 | [ "$(get "/$WORKSPACE/$REPO/commits")" = 200 ] || fail "commits page" | |
| 110 | 184 | grep -q "First commit" "$WORK/body" || fail "commits page does not show the commit" | |
| 111 | 185 | ||
| 186 | + | step "make an access token" | |
| 187 | + | out="$(post /settings/tokens --data-urlencode "intent=add-token" --data-urlencode "label=smoke" --data-urlencode "preset=full" --data-urlencode "expires=7")" | |
| 188 | + | echo "$out" | |
| 189 | + | TOKEN="$(grep -ao 'g1t_[0-9A-Za-z_-]*' "$WORK/body" | head -1 || true)" | |
| 190 | + | [ -n "$TOKEN" ] || fail "no token in the tokens page: $out" | |
| 191 | + | echo "token ${TOKEN:0:8}…" | |
| 192 | + | ||
| 193 | + | step "the API answers at $API_URL" | |
| 194 | + | [ "$(api GET /user)" = 200 ] || fail "GET /user: $(head -c 300 "$WORK/api")" | |
| 195 | + | [ "$(json username)" = "$USER_NAME" ] || fail "GET /user names $(json username), not $USER_NAME" | |
| 196 | + | [ "$(api GET /)" = 200 ] || fail "GET /" | |
| 197 | + | [ "$(json mcp_url)" = "$MCP_URL" ] || fail "the API's index names $(json mcp_url) as MCP, not $MCP_URL" | |
| 198 | + | [ "$(json git_url)" = "$G1T_URL/{owner}/{name}.git" ] || fail "git_url is $(json git_url)" | |
| 199 | + | curl -sS -o "$WORK/api" "$API_URL/.well-known/oauth-authorization-server" | |
| 200 | + | [ "$(json issuer)" = "$API_URL" ] || fail "the OAuth issuer is $(json issuer), not $API_URL" | |
| 201 | + | [ "$(json authorization_endpoint)" = "$G1T_URL/oauth/authorize" ] || fail "people approve at $(json authorization_endpoint)" | |
| 202 | + | echo "issuer $(json issuer)" | |
| 203 | + | code="$(curl -sS -o "$WORK/api" -D "$WORK/headers" -w '%{http_code}' -X POST -H "content-type: application/json" \ | |
| 204 | + | --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$MCP_URL")" | |
| 205 | + | [ "$code" = 401 ] || fail "MCP without a token answered $code" | |
| 206 | + | grep -qi "resource_metadata=\"$API_URL/.well-known/oauth-protected-resource/mcp\"" "$WORK/headers" || fail "MCP's challenge: $(grep -i www-authenticate "$WORK/headers")" | |
| 207 | + | code="$(curl -sS -o "$WORK/api" -w '%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \ | |
| 208 | + | -H "accept: application/json, text/event-stream" --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$MCP_URL")" | |
| 209 | + | [ "$code" = 200 ] && grep -q '"tools"' "$WORK/api" || fail "MCP tools/list: $code $(head -c 300 "$WORK/api")" | |
| 210 | + | echo "MCP lists its tools at $MCP_URL" | |
| 211 | + | ||
| 212 | + | step "a pull request from a branch, merged" | |
| 213 | + | git -C "$WORK/clone" config credential.helper "" | |
| 214 | + | commit_file "$WORK/clone" "$remote" from-branch docs/branch.md | |
| 215 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"From a branch","branch":"from-branch","body":"Opened by smoke.sh"}')" = 200 ] \ | |
| 216 | + | || fail "open from a branch: $(head -c 300 "$WORK/api")" | |
| 217 | + | BRANCH_PULL="$(json pull.number)" | |
| 218 | + | echo "pull request #$BRANCH_PULL ($(json pull.status))" | |
| 219 | + | [ "$(get "/$WORKSPACE/$REPO/pull/$BRANCH_PULL")" = 200 ] && grep -q "From a branch" "$WORK/body" || fail "pull request page" | |
| 220 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$BRANCH_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")" | |
| 221 | + | until_status "$BRANCH_PULL" merged | |
| 222 | + | main_has docs/branch.md || fail "main does not have the branch's change" | |
| 223 | + | echo "merged onto main" | |
| 224 | + | ||
| 225 | + | step "a pull request from a fork, merged" | |
| 226 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"From a fork","agent":"smoke"}')" = 200 ] \ | |
| 227 | + | || fail "open with a fork: $(head -c 300 "$WORK/api")" | |
| 228 | + | FORK_PULL="$(json pull.number)" | |
| 229 | + | fork_remote="$(json git.remote)" | |
| 230 | + | echo "pull request #$FORK_PULL ($(json pull.status)), fork $fork_remote" | |
| 231 | + | case "$fork_remote" in "$G1T_URL"/*) ;; *) fail "the fork's remote is not on $G1T_URL: $fork_remote" ;; esac | |
| 232 | + | [ "$fork_remote" != "$G1T_URL/$WORKSPACE/$REPO.git" ] || fail "no fork was made" | |
| 233 | + | authed_fork="${fork_remote/:\/\//://$USER_NAME:$TOKEN@}" | |
| 234 | + | git -c credential.helper= clone -q "$authed_fork" "$WORK/fork" | |
| 235 | + | commit_file "$WORK/fork" "$authed_fork" "$(git -C "$WORK/fork" branch --show-current)" docs/fork.md | |
| 236 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$FORK_PULL/ready" '{"summary":"Adds docs/fork.md, from a fork."}')" = 200 ] \ | |
| 237 | + | || fail "ready: $(head -c 300 "$WORK/api")" | |
| 238 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$FORK_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")" | |
| 239 | + | until_status "$FORK_PULL" merged | |
| 240 | + | main_has docs/fork.md || fail "main does not have the fork's change" | |
| 241 | + | echo "merged onto main" | |
| 242 | + | ||
| 243 | + | step "the merge queue takes a pull request, and gives it back" | |
| 244 | + | [ "$(api PATCH "/repos/$WORKSPACE/$REPO/settings" '{"merge_queue":true}')" = 200 ] || fail "turn the queue on: $(head -c 300 "$WORK/api")" | |
| 245 | + | git -C "$WORK/clone" fetch -q "$G1T_URL/$WORKSPACE/$REPO.git" main | |
| 246 | + | git -C "$WORK/clone" switch -q -c queued FETCH_HEAD | |
| 247 | + | commit_file "$WORK/clone" "$remote" queued docs/queued.md | |
| 248 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls" '{"title":"Through the queue","branch":"queued"}')" = 200 ] || fail "open: $(head -c 300 "$WORK/api")" | |
| 249 | + | QUEUE_PULL="$(json pull.number)" | |
| 250 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$QUEUE_PULL/merge" '{}')" = 200 ] || fail "merge into the queue: $(head -c 300 "$WORK/api")" | |
| 251 | + | [ "$(api GET "/repos/$WORKSPACE/$REPO/queue")" = 200 ] || fail "queue: $(head -c 300 "$WORK/api")" | |
| 252 | + | [ "$(json enabled)" = true ] || fail "the queue is not on" | |
| 253 | + | node -e 'const q = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); const e = q.active.find((e) => e.number === Number(process.argv[2])); if (!e) process.exit(1); console.log(`#${e.number} is ${e.state} in the queue`)' "$WORK/api" "$QUEUE_PULL" \ | |
| 254 | + | || fail "#$QUEUE_PULL is not in the queue: $(head -c 400 "$WORK/api")" | |
| 255 | + | [ "$(get "/$WORKSPACE/$REPO/queue")" = 200 ] && grep -q "Through the queue" "$WORK/body" || fail "queue page" | |
| 256 | + | # Testing a queued state needs a sandbox, and agents are off: take it out. | |
| 257 | + | out="$(post "/$WORKSPACE/$REPO/pull/$QUEUE_PULL" --data-urlencode "action=unqueue")" | |
| 258 | + | echo "unqueue: $out" | |
| 259 | + | [ "$(api GET "/repos/$WORKSPACE/$REPO/queue")" = 200 ] || fail "queue" | |
| 260 | + | node -e 'const q = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit(q.active.some((e) => e.number === Number(process.argv[2])) ? 1 : 0)' "$WORK/api" "$QUEUE_PULL" \ | |
| 261 | + | || fail "#$QUEUE_PULL is still in the queue" | |
| 262 | + | [ "$(api PATCH "/repos/$WORKSPACE/$REPO/settings" '{"merge_queue":false}')" = 200 ] || fail "turn the queue off" | |
| 263 | + | [ "$(api POST "/repos/$WORKSPACE/$REPO/pulls/$QUEUE_PULL/merge" '{}')" = 200 ] || fail "merge: $(head -c 300 "$WORK/api")" | |
| 264 | + | until_status "$QUEUE_PULL" merged | |
| 265 | + | main_has docs/queued.md || fail "main does not have the change" | |
| 266 | + | echo "out of the queue, then merged onto main" | |
| 267 | + | ||
| 268 | + | if [ -n "$SCHEDULER_ONCE" ]; then | |
| 269 | + | step "every cron handler runs" | |
| 270 | + | $SCHEDULER_ONCE || fail "a cron handler failed" | |
| 271 | + | fi | |
| 272 | + | ||
| 112 | 273 | printf '\nAll checks passed: %s/%s/%s\n' "$G1T_URL" "$WORKSPACE" "$REPO" |
| 64 | 64 | # (schedules.json, from configs.mjs) once a minute through Wrangler's local | |
| 65 | 65 | # API, which answers only here, on localhost. | |
| 66 | 66 | node "$HERE/scheduler.mjs" schedules.json http://127.0.0.1:8787 & | |
| 67 | + | ||
| 68 | + | # The API (REST, MCP, OAuth) on a port of its own, in a second workerd. Its | |
| 69 | + | # service bindings find the Workers below through Wrangler's dev registry, | |
| 70 | + | # so it waits until they are up. It holds no data; its own KV (Actions | |
| 71 | + | # artifacts, which need the runner) is kept apart from the site's. It | |
| 72 | + | # listens on 8789 in the container; API_PORT is the port people use, which | |
| 73 | + | # API_URL is derived from (configs.mjs). | |
| 74 | + | ( | |
| 75 | + | for _ in $(seq 1 120); do | |
| 76 | + | node -e "fetch('http://127.0.0.1:8787/').then(() => process.exit(0), () => process.exit(1))" && break | |
| 77 | + | sleep 2 | |
| 78 | + | done | |
| 79 | + | echo "The API is starting on $(node -p "require('./api.json').api") (MCP: $(node -p "require('./api.json').mcp"))" | |
| 80 | + | exec "$WRANGLER" dev -c g1t-api.json \ | |
| 81 | + | --ip 0.0.0.0 --port 8789 \ | |
| 82 | + | --persist-to "$DATA/api-state" \ | |
| 83 | + | --show-interactive-dev-session=false | |
| 84 | + | ) & | |
| 85 | + | ||
| 67 | 86 | echo "g1t is starting on ${PUBLIC_URL:-http://localhost:8787}" | |
| 68 | 87 | exec "$WRANGLER" dev "${args[@]}" \ | |
| 69 | 88 | --ip 0.0.0.0 --port 8787 \ |
| 232 | 232 | "worker": "g1t-api", | |
| 233 | 233 | "stage": "edge", | |
| 234 | 234 | "secrets": [], | |
| 235 | − | "self_host": "none" | |
| 235 | + | "self_host": "separate" | |
| 236 | 236 | }, | |
| 237 | 237 | "models": { | |
| 238 | 238 | "path": "services/models", |
| 367 | 367 | | R10 | Built in repos; work unchanged | `divergence` works out the target's side once per target head per isolate (`coalesce.rs`: the head under the refs version, then the history by hash, kept 60 s), and what the target changed between two trees once per pair (10 minutes). `readCommit` and logs by hash come from the cache. Work's fan-out (`after_push`, up to 100 pull requests) is unchanged: its 100 `divergence` calls now cost one walk of the target instead of 100. | | |
| 368 | 368 | | R7 | Groundwork | `shards.rs`: bindings named in `ARTIFACTS_NAMESPACES` (JSON, binding → namespace; `ARTIFACTS` → `g1t` always there), a repository's namespace kept in its `store` column as `<namespace>/<key>` (no prefix means the `ARTIFACTS` namespace, so every existing key reads the same), new repositories placed by `ARTIFACTS_NEW_REPOS` (comma-separated, spread by an FNV hash of the repository id; names not bound are skipped), forks always in their repository's namespace, `ARTIFACTS_EU_NAMESPACE` reserved for EU residency (no workspace setting yet). Works with only `ARTIFACTS` bound, as today. | | |
| 369 | 369 | | R8 | Built | `crates/runner/src/clone.rs`: every sandbox clones at `--depth=1` (a full g1t clone took 5.4 s, depth 1 took 3.8 s). Work that merges (catch-up, the merge queue, merge checks, a review's diff) deepens 50, 500, then 5000 commits until the two sides share one, and fetches everything only as the last resort (`share_history`). `G1T_CLONE_DEPTH` (0 or `full` for everything) and `G1T_CLONE_FILTER=blob:none` change it per runner. | | |
| 370 | − | | R6 | Built; the bucket must exist before it deploys | `pack_cache.rs`: an upload-pack POST with wants and no `have` or `shallow` lines (a fresh clone, the sandboxes' `deepen 1` ones included), uncompressed and at most 1 MiB, is keyed `packs/<repo id>/<refs_version>/<sha256>` over the request normalized: protocol v2 capabilities without `agent=`/`session-id=` and its arguments, each sorted and deduplicated; v0/v1 wants sorted, the first want's capabilities split off, sorted and without `agent=`, then `deepen`/`filter` lines, a flush and `done`. Only while `refs_cache::usable` (the version known, and no push credential out of g1t's hands), so never across a refs change. Looked up after authorization, alongside the free-workspace limits and the kept refs answer; a hit streams from the bucket (`Server-Timing` `pack;desc=hit`). A miss streams the store's 200 to git through a tee that copies it to a fill in `ctx.wait_until` (at most 5 MiB queued between them, 2 fills per isolate, one per key): under 5 MiB it is one `put` once it all arrived; larger, 5 MiB multipart parts completed only after the last part and a check that it is one whole side-band pack (well-formed pkt-lines, `PACK` on channel 1, no `ERR` or channel 3, a closing flush). Over 200 MB, a queue that falls behind, git going away or the store's stream failing lets the fill go and aborts the upload; nothing partial can be read. Meters `pack_cache.hit` (with the bytes served) and `pack_cache.miss` (counted with `record`, bytes added at the end), neither an operation by default; a hit records no `git.fetch`. Storage is behind the `PackStore` port with an R2 adapter (`GIT_PACKS`, bucket `g1t-git-packs`, lifecycle: packs deleted after 7 days, unfinished uploads after 1); without the binding (self-hosted) nothing is kept. | | |
| 370 | + | | R6 | Built; the bucket must exist before it deploys | `pack_cache.rs`: an upload-pack POST with wants and no `have` or `shallow` lines (a fresh clone, the sandboxes' `deepen 1` ones included), uncompressed and at most 1 MiB, is keyed `packs/<repo id>/<refs_version>/<sha256>` over the request normalized: protocol v2 capabilities without `agent=`/`session-id=` and its arguments, each sorted and deduplicated; v0/v1 wants sorted, the first want's capabilities split off, sorted and without `agent=`, then `deepen`/`filter` lines, a flush and `done`. Only while `refs_cache::usable` (the version known, and no push credential out of g1t's hands), so never across a refs change. Looked up after authorization, alongside the free-workspace limits and the kept refs answer; a hit streams from the bucket (`Server-Timing` `pack;desc=hit`). A miss streams the store's 200 to git through a tee that copies it to a fill in `ctx.wait_until` (at most 5 MiB queued between them, 2 fills per isolate, one per key): under 5 MiB it is one `put` once it all arrived; larger, 5 MiB multipart parts completed only after the last part and a check that it is one whole side-band pack (well-formed pkt-lines, `PACK` on channel 1, no `ERR` or channel 3, a closing flush). Over 200 MB, a queue that falls behind, git going away or the store's stream failing lets the fill go and aborts the upload; nothing partial can be read. Meters `pack_cache.hit` (with the bytes served) and `pack_cache.miss` (counted with `record`, bytes added at the end), neither an operation by default; a hit records no `git.fetch`. Storage is behind the `PackStore` port, whose adapter puts the shared `BlobStore` (`crates/blobstore`) behind it: R2 hosted (`GIT_PACKS`, bucket `g1t-git-packs`, lifecycle: packs deleted after 7 days, unfinished uploads after 1), or with `PACK_STORE=s3` the S3 bucket `PACK_S3_BUCKET` names (self-hosted: MinIO's `g1t-git-packs`, packs deleted after 7 days, unfinished uploads by MinIO after 24 hours; `node services/repos/dev/clone-check.mjs --s3` checks it). With neither, nothing is kept. | | |
| 371 | 371 | | R11 | Built; not yet deployed | Nightly `git bundle` backups to the `g1t-backups` R2 bucket, and a restore drill. Migration `0013_backups.sql` (`repo_backups`, and an `operation_mapping` row). See "R11: backups and the restore drill" below. | | |
| 372 | 372 | ||
| 373 | 373 | ### R1: reading `scripts/ops/artifacts-usage.mjs` |
| 40 | 40 | answers "agents are off" instead of failing. | |
| 41 | 41 | - **Proven on this machine with Docker:** sign up, confirm the email | |
| 42 | 42 | through Mailpit, create a workspace and a repository, push and clone over | |
| 43 | − | HTTP, open an issue, and browse code, commits and files in the site. All | |
| 44 | − | of it runs against local storage. See [Phase 1: what works today](#phase-1-what-works-today). | |
| 43 | + | HTTP (the second clone from the clone pack cache in MinIO), open an | |
| 44 | + | issue, and browse code, commits and files in the site; then the REST API, | |
| 45 | + | OAuth metadata and MCP on their own port, pull requests from a branch and | |
| 46 | + | from a fork merged onto `main`, the merge queue taking a pull request and | |
| 47 | + | giving it back, and every cron handler the scheduler runs. All of it runs | |
| 48 | + | against local storage. See [Phase 1: what works today](#3-phase-1-what-works-today). | |
| 45 | 49 | - **Long term:** keep workerd as the runtime, because it is what hosted | |
| 46 | 50 | runs. Replace `wrangler dev` with a production workerd configuration. | |
| 47 | 51 | Move the binding shims into code-level ports in `g1t_kit` and a TS | |
| 79 | 83 | | **Cloudflare REST API** | deployments: script upload, list, delete, assets, GraphQL usage. Billing keeper: AI Gateway logs, `billable-usage`, GraphQL container usage. Ops scripts in `scripts/`. | thin (deployments), woven (keeper pricing) | Deployments: the app-host adapter. Keeper: off when self-hosted, because there is no bill to reconcile. | | |
| 80 | 84 | | **Email Sending** | `services/identity/src/email.rs` (`EMAIL.send({to, from, subject, text, html})`); callers: verification, password reset, `admin.rs` limit warnings | thin | Built in phase 1: a shim that logs and hands mail to Mailpit, which relays over SMTP. Later: a `Mailer` port with an SMTP adapter. | | |
| 81 | 85 | | **Cloudflare Access** | `apps/sudo/app/lib/access.ts` (verifies `Cf-Access-Jwt-Assertion` against `/cdn-cgi/access/certs`, `ACCESS_AUD`, `STAFF_EMAILS`) | woven, in sudo only | A local admin flag: `G1T_ADMINS` usernames checked against the normal g1t session. Self-hosters rarely need sudo, which is about billing. | | |
| 82 | − | | **Cron Triggers** | actions (every minute), webhooks (every minute), identity (`*/15`), security (`*/30`), repos and packages (hourly), events (daily), billing (`*/15` and daily), deployments (`*/10`), runner (`*/5`) | thin | workerd runs `scheduled()` when asked, but never on its own. `deploy/self-host/scheduler.mjs` asks: once a minute, inside the g1t container, it runs each due cron through Wrangler's local API (`POST /cdn-cgi/local/explorer/api/local/scheduled?worker=<name>`, answered only on localhost), the same handler Cron Triggers run. The services and crons come from `schedules.json`, which `configs.mjs` writes from each `wrangler.jsonc` for the services in its `SELF_HOST_CRONS`: repos, events, identity, security, webhooks and packages. Not run: actions (it would start scheduled workflows with no runner), billing (Cloudflare and Stripe) and deployments (Cloudflare's API). The status page has its own loop (`status.sh`). | | |
| 86 | + | | **Cron Triggers** | actions (every minute), webhooks (every minute), identity (`*/15`), security (`*/30`), repos and packages (hourly), events (daily), billing (`*/15` and daily), deployments (`*/10`), runner (`*/5`) | thin | workerd runs `scheduled()` when asked, but never on its own. `deploy/self-host/scheduler.mjs` asks: once a minute, inside the g1t container, it runs each due cron through Wrangler's local API (`POST /cdn-cgi/local/explorer/api/local/scheduled?worker=<name>`, answered only on localhost), the same handler Cron Triggers run. The services and crons come from `schedules.json`, which `configs.mjs` writes from each `wrangler.jsonc` for the services in its `SELF_HOST_CRONS`: repos, events, identity, security, webhooks and packages. Not run: actions (it would start scheduled workflows with no runner), billing (Cloudflare and Stripe) and deployments (Cloudflare's API). A handler still running from the minute before is not started again, and one is given up on after ten minutes. `scheduler.mjs --once` runs every cron of every service now and exits non-zero if one failed (smoke.sh uses it). The status page has its own loop (`status.sh`). | | |
| 83 | 87 | | **`cloudflare:workers` imports** | `apps/web` (`env` in 15 files), `apps/sudo`, `services/runner` (`WorkerEntrypoint`) | thin | Provided by workerd. A Node port would pass `env` through context instead. | | |
| 84 | 88 | | **Static Assets** | `apps/web` (Vite plugin build), `apps/docs`, `apps/sudo` (`run_worker_first`) | thin | workerd serves them. | | |
| 85 | 89 | | **`placement`, `observability`, routes, custom domains** | every `wrangler.jsonc` | config only | Dropped by `deploy/self-host/configs.mjs`. | | |
| 86 | 90 | | **`cf-ray`** | Used as an audit request id, with a fallback: `services/repos/src/run_access.rs:131`, `apps/api/src/audit.rs:37` | thin | Falls back already. | | |
| 87 | − | | **R2** | `services/packages` (`BLOBS`: container layers and other package files), `services/repos` (`BACKUPS`: nightly backup bundles), the API's Actions cache (`ACTIONS_CACHE`), the runner's downloads | thin | **S3-compatible storage**: the `BlobStore` port in `crates/blobstore` has an R2 adapter and an S3 one (`s3.rs`, SigV4 over fetch); each service names its own bucket (`BLOB_STORE`/`S3_BUCKET` for packages, `BACKUP_STORE`/`BACKUP_S3_BUCKET` for backups), run against MinIO in the compose file. | | |
| 91 | + | | **R2** | `services/packages` (`BLOBS`: container layers and other package files), `services/repos` (`BACKUPS`: nightly backup bundles; `GIT_PACKS`: the clone pack cache), the API's Actions cache (`ACTIONS_CACHE`), the runner's downloads | thin | **S3-compatible storage**: the `BlobStore` port in `crates/blobstore` has an R2 adapter and an S3 one (`s3.rs`, SigV4 over fetch); each service names its own bucket (`BLOB_STORE`/`S3_BUCKET` for packages, `BACKUP_STORE`/`BACKUP_S3_BUCKET` for backups, `PACK_STORE`/`PACK_S3_BUCKET` for clone packs), run against MinIO in the compose file. | | |
| 88 | 92 | | **Not used** | Hyperdrive, Workflows, Analytics Engine, Browser Rendering, Images, Turnstile, Secrets Store, `connect()`, HTMLRewriter, `request.cf` | — | — | | |
| 89 | 93 | ||
| 90 | 94 | ### By service | |
| 97 | 101 | | Service | Runs on | Cloudflare dependencies beyond Workers and D1 | Phase 1 self-hosted | | |
| 98 | 102 | | --- | --- | --- | --- | | |
| 99 | 103 | | `apps/web` | TS Worker plus assets | KV (`BLOBS`, `AVATARS`), Cache API, `cloudflare:workers` `env`, RPC to `RUNNER` | Runs unchanged | | |
| 100 | − | | `apps/api` | Rust Worker | KV `BLOBS`; hard-coded `api.g1t.sh`/`mcp.g1t.sh` issuer | Not started yet (phase 2) | | |
| 104 | + | | `apps/api` | Rust Worker | KV `BLOBS`, R2 `ACTIONS_CACHE`; `api.g1t.sh`/`mcp.g1t.sh` addresses (now the `API_URL`, `MCP_URL` and `SITE_URL` settings, hosted defaults when unset: `src/addresses.rs`) | Runs in a second workerd on its own port (`API_PORT`, 8789; `self_host: "separate"`), started by `start.sh` once the first is up. Its service bindings reach the other Workers through Wrangler's dev registry. `API_URL` (default: `PUBLIC_URL`'s host on `API_PORT`) is its OAuth issuer; MCP is the path `/mcp` on it (`MCP_URL`). Its KV is its own, apart from the site's: Actions artifacts need the runner, which is off | | |
| 101 | 105 | | `apps/sudo` | TS Worker plus assets | Access JWT | Not run | | |
| 102 | 106 | | `apps/docs` | Static | — | Not run (docs.g1t.sh serves them) | | |
| 103 | 107 | | `apps/status` | TS Worker | Email Sending, cron; bound only to billing | Runs in a process of its own (`status.sh`), so it stays up when the site does not | | |
| 104 | 108 | | `services/identity` | Rust | Email Sending, KV `AVATARS` | Runs unchanged; `EMAIL` goes to the mail shim | | |
| 105 | − | | `services/repos` | Rust | **Artifacts**, **R2** (`BACKUPS`), Cache API, optional KV `GIT_CACHE` with `REPOS_KEY`, optional R2 `GIT_PACKS` | Runs unchanged; `ARTIFACTS` goes to the git store, and backups to MinIO's `g1t-backups` bucket (`BACKUP_STORE=s3`). Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only. Its nightly cron queues backups, but bundles are cut by the runner, which is off in phase 1: none are made yet. `GIT_PACKS` (the clone pack cache, behind the `PackStore` port in `src/pack_cache.rs`) is not given, so every clone goes to the git store; an S3 adapter like packages' would turn it on | | |
| 109 | + | | `services/repos` | Rust | **Artifacts**, **R2** (`BACKUPS`), Cache API, optional KV `GIT_CACHE` with `REPOS_KEY`, optional R2 `GIT_PACKS` | Runs unchanged; `ARTIFACTS` goes to the git store, backups to MinIO's `g1t-backups` bucket (`BACKUP_STORE=s3`), and the clone pack cache to `g1t-git-packs` (`PACK_STORE=s3`: the `PackStore` port in `src/pack_cache.rs` over the shared `BlobStore`, multipart, an object only once whole; `minio-setup` gives the bucket a rule that deletes packs after 7 days, and MinIO removes unfinished uploads after 24 hours). Without `GIT_CACHE` and `REPOS_KEY`, credentials and ref listings are kept per isolate only. Its nightly cron queues backups, but bundles are cut by the runner, which is off in phase 1: none are made yet | | |
| 106 | 110 | | `services/work` | Rust | Queue consumer | Runs unchanged | | |
| 107 | 111 | | `services/events` | Rust | Queues (producer and fan-out) | Runs unchanged; the off services' queues are not produced to | | |
| 108 | 112 | | `services/projects` | TS | Queue consumer | Runs unchanged | | |
| 124 | 128 | ||
| 125 | 129 | ### Hard-coded hosted addresses | |
| 126 | 130 | ||
| 127 | − | Self-hosting needs one setting, `PUBLIC_URL`, in place of these. Phase 1 | |
| 128 | − | gets around the ones on its path: the mail shim rewrites `https://g1t.sh` | |
| 129 | − | links in mail, and `configs.mjs` rewrites `SITE_URL`/`API_URL`/`SITE` | |
| 130 | − | variables. The rest are listed here so phase 2 can make them settings: | |
| 131 | + | Self-hosting needs one setting, `PUBLIC_URL`, in place of these, and | |
| 132 | + | `configs.mjs` derives the rest from it. These are settings now, each with | |
| 133 | + | the hosted address as its default, so hosted g1t sets nothing: | |
| 131 | 134 | ||
| 132 | − | - `apps/web/app/lib/meta.ts` (`SITE`, `OG`); `clone-box.tsx` (clone URL, | |
| 133 | − | `mcp.g1t.sh`); `workers/app.ts` (`DOCS`). | |
| 134 | − | - `apps/api/src/lib.rs` (`API`); `oauth.rs` (issuer, MCP resource). | |
| 135 | − | - `services/identity/src/email.rs` (`SITE`, `FROM`). | |
| 135 | + | - The site (`apps/web/app/lib/addresses.ts`): `SITE_URL`, `API_URL`, | |
| 136 | + | `MCP_URL` and `OG_URL` (empty: no social card tags). The root loader | |
| 137 | + | hands them to the page; `meta.ts`, the clone box, agent setup, the | |
| 138 | + | pull request and merge box remotes, the tokens page and the OAuth | |
| 139 | + | consent's `iss` read them. | |
| 140 | + | - The API (`apps/api/src/addresses.rs`): `SITE_URL`, `API_URL` (the OAuth | |
| 141 | + | issuer) and `MCP_URL` (the protected resource; a path on the API's host | |
| 142 | + | self-hosted). | |
| 143 | + | - Identity's mail (`services/identity/src/email.rs`): `SITE_URL` for | |
| 144 | + | links and the logo, `MAIL_FROM` for the sender. The mail shim still | |
| 145 | + | rewrites any `https://g1t.sh` link left in a message. | |
| 146 | + | ||
| 147 | + | Still hard-coded, for phase 2: | |
| 148 | + | ||
| 149 | + | - `apps/web/workers/app.ts` (`DOCS`); defaults in | |
| 150 | + | `components/invites-section.tsx`, `components/runners.tsx`, | |
| 151 | + | `lib/invites.ts` and `lib/legal.ts`. | |
| 136 | 152 | - `services/runner/src/index.ts` (`G1T_API`, `GIT_REMOTE` and the other | |
| 137 | 153 | remotes handed to sandboxes, in 12 places). | |
| 138 | 154 | - `services/billing` (`stripe.rs`, `accounts.rs`, `limits.rs`). | |
| 177 | 193 | | `Bus` | Queues | Miniflare Queues now; SQLite outbox later | `services/events`, `g1t_kit` | Works (runtime) | | |
| 178 | 194 | | `Database` | D1 | SQLite files through workerd | — | Works (runtime) | | |
| 179 | 195 | | `Blobs` | KV | Miniflare KV now; filesystem/S3 later | — | Works (runtime) | | |
| 180 | − | | `Scheduler` | Cron Triggers | `scheduler.mjs`: a ticker that calls `scheduled()` through Wrangler's local API | `deploy/self-host` | Works for the services in `SELF_HOST_CRONS` | | |
| 196 | + | | `Scheduler` | Cron Triggers | `scheduler.mjs`: a ticker that calls `scheduled()` through Wrangler's local API | `deploy/self-host` | **Built** for the services in `SELF_HOST_CRONS`; each handler checked by `smoke.sh` | | |
| 197 | + | | `PackStore` | R2 `GIT_PACKS` | S3 (`PACK_STORE=s3`) through `crates/blobstore` | `services/repos/src/pack_cache.rs` | **Built** | | |
| 181 | 198 | | `UsageKeeper` | Cloudflare bill plus AI Gateway logs | None (billing off) | `services/billing` | Off | | |
| 182 | 199 | ||
| 183 | 200 | For Rust, the code-level ports go in `crates/kit` as traits (`g1t_kit::ports`), | |
| 315 | 332 | | Feature | Hosted (g1t.sh) | Self-hosted default | Self-hosted, when turned on | | |
| 316 | 333 | | --- | --- | --- | --- | | |
| 317 | 334 | | Accounts, workspaces, repos, git over HTTP | On | On | — | | |
| 318 | − | | Issues, pull requests, review, merge queue | On | On | — | | |
| 335 | + | | Issues, pull requests, review | On | On | — | | |
| 336 | + | | Merge queue | On | Takes pull requests; testing and landing them needs sandboxes | Phase 2 | | |
| 337 | + | | Bringing a pull request up to date before it lands (catch-up) | On | Off: needs a sandbox | Phase 2 | | |
| 338 | + | | Clone pack cache | R2 | MinIO (`g1t-git-packs`) | — | | |
| 319 | 339 | | Site search (FTS5) | On | On | — | | |
| 320 | 340 | | Email | Email Sending | Mailpit, logged | SMTP relay | | |
| 321 | 341 | | Webhooks, integrations | On | On (retries through `scheduler.mjs`) | — | | |
| 328 | 348 | | Billing, limits, Stripe, keeper | On | Off | Not planned | | |
| 329 | 349 | | sudo (staff console) | Access | Off | Phase 4: `G1T_ADMINS` | | |
| 330 | 350 | | Git over SSH | Not yet | Off | Phase 3 (`crates/sshd`, which is native already) | | |
| 331 | − | | REST API, MCP, CLI | On | Off | Phase 2 | | |
| 351 | + | | REST API, OAuth, MCP | On | On, on `API_PORT` | — | | |
| 352 | + | | CLI | On | Off | Phase 2 | | |
| 332 | 353 | ||
| 333 | 354 | ### Auth, Access and email | |
| 334 | 355 | ||
| 348 | 369 | ### Configuration, upgrades and backups | |
| 349 | 370 | ||
| 350 | 371 | - **Today:** environment variables in the compose file (`PUBLIC_URL`, | |
| 351 | − | `G1T_PORT`, `MAIL_URL`, `MAIL_FROM`). Keys (`ACTIONS_KEY`, | |
| 372 | + | `G1T_PORT`, `API_PORT`, `API_URL`, `MCP_URL`, `MAIL_URL`, `MAIL_FROM`, | |
| 373 | + | the S3 store and its buckets). Keys (`ACTIONS_KEY`, | |
| 352 | 374 | `INTEGRATIONS_KEY`, `WEBHOOKS_KEY`, the git store secret) are generated on | |
| 353 | 375 | first start and kept on volumes. | |
| 354 | 376 | - **Phase 2:** one `g1t.toml`, read by the launcher and turned into | |
| 396 | 418 | ||
| 397 | 419 | | File | What it is | | |
| 398 | 420 | | --- | --- | | |
| 399 | − | | `docker-compose.yml` | Three services: `g1t` (every core Worker in one workerd), `gitstore` (bare repositories), and `mailpit` (mail). Volumes: `g1t-data`, `g1t-git`, `g1t-secrets`. | | |
| 421 | + | | `docker-compose.yml` | `g1t` (every core Worker in one workerd on 8787, and the API in a second on 8789), `status`, `gitstore` (bare repositories), `minio` and `minio-setup` (packages, backups and clone packs, with the packs' expiry rule), and `mailpit` (mail). Volumes: `g1t-data`, `g1t-git`, `g1t-packages`, `g1t-status`, `g1t-secrets`. MinIO no longer publishes `minio/minio` or `minio/mc` images; `MINIO_IMAGE` (default `pgsty/minio`, a community build with `mc` in it) is the server. | | |
| 400 | 422 | | `Dockerfile` | Compiles the ten Rust services to WebAssembly with `worker-build`, as hosted does. Builds the site with React Router. The runtime image has Node, Wrangler, workerd and the built Workers. | | |
| 401 | 423 | | `Dockerfile.dockerignore` | Build-context rules for this image only (the root `.dockerignore` leaves out the site). | | |
| 402 | − | | `start.sh` | Makes the sealing keys once, writes the configs, applies migrations, and runs `wrangler dev` with every config on `0.0.0.0:8787`, persisting to `/data/state`. | | |
| 424 | + | | `start.sh` | Makes the sealing keys once, writes the configs, applies migrations, runs `wrangler dev` with every config on `0.0.0.0:8787`, persisting to `/data/state`, and the API's `wrangler dev` on `0.0.0.0:8789` once the first answers. Starts `scheduler.mjs`. | | |
| 425 | + | | `scheduler.mjs` | The cron ticker (see Cron Triggers above). | | |
| 403 | 426 | | `configs.mjs` | Derives each self-hosted Wrangler config from the hosted `wrangler.jsonc`. It drops routes, account and placement, rebinds `ARTIFACTS`/`EMAIL` and the off services, and rewrites hosted URLs. Derived, so it cannot drift. | | |
| 404 | 427 | | `gitstore/server.mjs`, `gitstore/Dockerfile` | The git store. | | |
| 405 | 428 | | `workers/artifacts/index.js` | The `ARTIFACTS` binding, implemented against the git store. | | |
| 406 | 429 | | `workers/mail/index.js` | The `EMAIL` binding: logs, then sends to Mailpit. | | |
| 407 | 430 | | `workers/off/index.js` | The runner and the context hub when they are off. | | |
| 408 | − | | `smoke.sh` | The end-to-end check. | | |
| 431 | + | | `smoke.sh` | The end-to-end check, including the API, pull requests, the merge queue and (with `SCHEDULER_ONCE`) every cron handler. | | |
| 409 | 432 | ||
| 410 | 433 | Workers running: the site; identity, repos, work, events, projects, search, | |
| 411 | − | billing, security, actions, webhooks, integrations and deployments; and the | |
| 412 | − | artifacts, mail and two off stand-ins. | |
| 434 | + | billing, security, actions, webhooks, integrations, packages and | |
| 435 | + | deployments; the artifacts, mail and two off stand-ins; and, in a second | |
| 436 | + | workerd, the API. | |
| 413 | 437 | ||
| 414 | 438 | ### Verified | |
| 415 | 439 | ||
| 425 | 449 | security, people, usage, explore, search, account settings and tree. | |
| 426 | 450 | The workspace context page answered 403 from the off stand-in, as | |
| 427 | 451 | intended. | |
| 428 | − | 2. **With Docker Compose.** See the [Docker run](#docker-run) section below. | |
| 452 | + | 2. **With Docker Compose** (2026-10-07, `docker compose up --build`, with | |
| 453 | + | `API_PORT=18789` because 8789 was taken on this machine). `smoke.sh` | |
| 454 | + | passed every step: the ones above; a second clone answered from the pack | |
| 455 | + | cache (`Server-Timing: pack;desc=hit`), with the packs in MinIO's | |
| 456 | + | `g1t-git-packs` and its 7-day rule in place; an access token made in the | |
| 457 | + | site; `GET /user`, the API index (`mcp_url`, `git_url`), the OAuth | |
| 458 | + | metadata (`issuer` the API's address, `authorization_endpoint` on the | |
| 459 | + | site), MCP's 401 challenge and `tools/list`; a pull request from a branch | |
| 460 | + | and one from a fork (`create_pull_request` without a branch: a fork in | |
| 461 | + | the git store, pushed to with the token, marked ready) merged onto | |
| 462 | + | `main`; the merge queue turned on, a pull request merged into it and | |
| 463 | + | shown `waiting`, taken out (`unqueue`), the queue turned off and the | |
| 464 | + | pull request merged; and `scheduler.mjs --once`, every handler `ok`. | |
| 465 | + | The repository page's clone box and MCP line named the installation's | |
| 466 | + | own addresses, with no social card tags. | |
| 467 | + | 3. **The clone pack cache against MinIO without the stack.** | |
| 468 | + | `node services/repos/dev/clone-check.mjs --s3` (MinIO in Docker): | |
| 469 | + | misses then hits for full and shallow clones over protocol v2 and v0, a | |
| 470 | + | miss after the refs version moves, five whole packs in the bucket (12 MB | |
| 471 | + | each, so uploaded in parts), no unfinished upload, and the expiry rule. | |
| 429 | 472 | ||
| 430 | 473 | ### Not verified, or not working yet | |
| 431 | 474 | ||
| 432 | − | - Pull requests between branches and forks, the merge queue and catch-up. | |
| 433 | − | They use `fork` and smart-HTTP pushes, which the git store implements, | |
| 434 | − | but they have not been exercised end to end. | |
| 475 | + | - The merge queue past `waiting`, and catch-up (bringing a pull request | |
| 476 | + | up to date before it lands): both need a sandbox, and the runner is off. | |
| 435 | 477 | - Actions schedules (`on: schedule`), and billing's and deployments' crons. | |
| 436 | − | - The REST API, MCP, the CLI, and git over SSH. | |
| 478 | + | - The CLI, and git over SSH. | |
| 479 | + | - An OAuth sign-in from start to finish (the metadata and issuer are | |
| 480 | + | checked, the consent flow is not). | |
| 437 | 481 | - Anything on an address other than `localhost` without HTTPS (the | |
| 438 | 482 | session cookie is `Secure`). | |
| 439 | 483 | - Restart durability beyond one restart, upgrades across schema changes, | |
| 446 | 490 | ||
| 447 | 491 | | Phase | Scope | Estimate | | |
| 448 | 492 | | --- | --- | --- | | |
| 449 | − | | **1. Core forge** | **Done in this change:** compose stack, git store, Artifacts/Email shims, off stand-ins, config generator, smoke test, guide. **Left:** run the API worker (REST, MCP, OAuth) on its own port with `PUBLIC_URL` issuer; make `PUBLIC_URL` a setting in identity mail, `meta.ts`, `clone-box.tsx` and the API; fire cron (a ticker calling each Worker's `scheduled`); exercise pull requests and the merge queue in `smoke.sh`; optional Caddy for HTTPS; CI job that builds the images and runs `smoke.sh`. | 1–1.5 weeks left | | |
| 493 | + | | **1. Core forge** | **Done:** compose stack, git store, Artifacts/Email shims, off stand-ins, config generator, smoke test, guide; the API (REST, MCP, OAuth) on its own port with a `PUBLIC_URL`-derived issuer; `PUBLIC_URL`-derived settings in identity mail, the site's meta tags, clone box and agent setup, and the API; the cron ticker; the clone pack cache on S3; pull requests from branches and forks and the merge queue's enqueue and removal in `smoke.sh`. **Left:** optional Caddy for HTTPS; a CI job that builds the images and runs `smoke.sh`; the remaining hard-coded addresses listed above. | 2–3 days left | | |
| 450 | 494 | | **2. Agents with Docker sandboxes** | Test Wrangler's local Containers first. Otherwise: `g1t-sandboxd` supervisor, `DockerSandbox` adapter in the runner, egress allow-list proxy and internal network, runner addresses from `PUBLIC_URL`, models through a workspace's own provider, `g1t.toml` and a launcher that replaces `wrangler dev`. | 2–3 weeks | | |
| 451 | 495 | | **3. Search, context and deployments** | `Embedder` (OpenAI-compatible) and `VectorIndex` (sqlite-vec first) ports in context; app host on workerd with Worker Loader; Caddy on-demand TLS for app and custom domains; git over SSH through `crates/sshd` plus the missing `/_internal/ssh/*` endpoints. | 3–4 weeks | | |
| 452 | 496 | | **4. Parity and upgrade path** | Code-level ports in `g1t_kit` / `@g1t/platform` replacing the binding shims (`LocalGitStore` in Rust, `Mailer` with SMTP); `AdminAuth` for sudo; online backups (Litestream or `.backup`); versioned releases with published images; an upgrade test in CI that migrates a snapshot of the previous release; a self-host column in the docs for every feature. | 3–4 weeks | | |
| 462 | 506 | network only** until the launcher in phase 2 replaces it. Its flags and | |
| 463 | 507 | behaviour can also change between Wrangler releases. Pin the Wrangler | |
| 464 | 508 | version, as the lockfile already does. | |
| 509 | + | - **The API reaches the other Workers through Wrangler's dev registry.** | |
| 510 | + | Two `wrangler dev` processes in one container find each other through | |
| 511 | + | a registry directory, a development feature like the rest. If the API | |
| 512 | + | starts and a binding says `[not connected]`, restart the container. The | |
| 513 | + | phase 2 launcher serves both from one workerd. | |
| 514 | + | - **The MinIO image.** MinIO stopped publishing `minio/minio` and | |
| 515 | + | `minio/mc`. The compose file uses a community build (`MINIO_IMAGE`, | |
| 516 | + | `pgsty/minio`); any S3-compatible store can take its place. | |
| 465 | 517 | - **Cron goes through Wrangler's local API.** `scheduler.mjs` asks | |
| 466 | 518 | `/cdn-cgi/local/explorer/api/local/scheduled`, a development endpoint | |
| 467 | 519 | that may change between Wrangler releases (pinned by the lockfile). | |
| 495 | 547 | - **Image size and build time.** The first build compiles ten Rust crates | |
| 496 | 548 | to WebAssembly and installs the site's dependencies. Expect minutes and | |
| 497 | 549 | several GB. Published images remove this for users. | |
| 498 | − | - **Hard-coded hosted URLs.** About a dozen code paths name `g1t.sh` or | |
| 499 | − | `api.g1t.sh`. Until they read `PUBLIC_URL`, some links and redirects | |
| 500 | − | (OG images, the docs link, the clone box's MCP line) point at the hosted | |
| 501 | − | service. | |
| 550 | + | - **Hard-coded hosted URLs.** The few left (listed above) point at the | |
| 551 | + | hosted service until they read a setting: the docs link, invite and | |
| 552 | + | runner defaults, and the runner's remotes. |
| 2 | 2 | import assert from "node:assert/strict"; | |
| 3 | 3 | import { test } from "node:test"; | |
| 4 | 4 | ||
| 5 | − | import { due, fieldValues, matches } from "../../deploy/self-host/scheduler.mjs"; | |
| 5 | + | import { due, fieldValues, matches, run } from "../../deploy/self-host/scheduler.mjs"; | |
| 6 | 6 | ||
| 7 | 7 | const at = (iso) => new Date(iso); | |
| 8 | 8 | ||
| 39 | 39 | { worker: "g1t-webhooks", cron: "* * * * *" }, | |
| 40 | 40 | ]); | |
| 41 | 41 | }); | |
| 42 | + | ||
| 43 | + | test("a run asks Wrangler for the handler, and a slow one is not doubled", async () => { | |
| 44 | + | const asked = []; | |
| 45 | + | let finish; | |
| 46 | + | const slow = (url, init) => { | |
| 47 | + | asked.push({ url, body: JSON.parse(init.body) }); | |
| 48 | + | return new Promise((resolve) => { | |
| 49 | + | finish = () => resolve(new Response(JSON.stringify({ success: true, result: { outcome: "ok" } }))); | |
| 50 | + | }); | |
| 51 | + | }; | |
| 52 | + | const job = { worker: "g1t-webhooks", cron: "* * * * *" }; | |
| 53 | + | const first = run("http://127.0.0.1:8787", job, { fetch: slow }); | |
| 54 | + | // The minute after, it is still running: skipped, not started again. | |
| 55 | + | assert.equal(await run("http://127.0.0.1:8787", job, { fetch: slow }), false); | |
| 56 | + | finish(); | |
| 57 | + | assert.equal(await first, true); | |
| 58 | + | assert.deepEqual(asked, [ | |
| 59 | + | { url: "http://127.0.0.1:8787/cdn-cgi/local/explorer/api/local/scheduled?worker=g1t-webhooks", body: { cron: "* * * * *" } }, | |
| 60 | + | ]); | |
| 61 | + | const failing = async () => new Response(JSON.stringify({ success: true, result: { outcome: "exception" } })); | |
| 62 | + | assert.equal(await run("http://127.0.0.1:8787", job, { fetch: failing }), false); | |
| 63 | + | }); |
| 7 | 7 | const FROM: &str = "g1t <noreply@g1t.sh>"; | |
| 8 | 8 | const SITE: &str = "https://g1t.sh"; | |
| 9 | 9 | ||
| 10 | + | /// Where links in mail point: SITE_URL, or g1t.sh when it is not set (a | |
| 11 | + | /// self-hosted installation sets it from its PUBLIC_URL). | |
| 12 | + | pub fn site(env: &Env) -> String { | |
| 13 | + | let value = env.var("SITE_URL").map(|value| value.to_string()).unwrap_or_default(); | |
| 14 | + | let value = value.trim().trim_end_matches('/'); | |
| 15 | + | if value.is_empty() { SITE.to_owned() } else { value.to_owned() } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /// Who mail is from: MAIL_FROM, or g1t.sh's address when it is not set. | |
| 19 | + | fn from(env: &Env) -> String { | |
| 20 | + | let value = env.var("MAIL_FROM").map(|value| value.to_string()).unwrap_or_default(); | |
| 21 | + | if value.trim().is_empty() { FROM.to_owned() } else { value.trim().to_owned() } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// A site's address without its scheme, as mail names it in a sentence. | |
| 25 | + | fn bare(site: &str) -> &str { | |
| 26 | + | site.split_once("://").map_or(site, |(_, rest)| rest) | |
| 27 | + | } | |
| 28 | + | ||
| 10 | 29 | #[derive(Serialize)] | |
| 11 | 30 | struct Message<'a> { | |
| 12 | 31 | to: &'a str, | |
| 30 | 49 | ||
| 31 | 50 | /// The plain text and HTML of a letter. Everything in it is escaped: | |
| 32 | 51 | /// names, notes and requests people wrote can reach every line. | |
| 33 | − | pub fn render(letter: &Letter) -> (String, String) { | |
| 52 | + | pub fn render(letter: &Letter, site: &str) -> (String, String) { | |
| 34 | 53 | let mut text = String::new(); | |
| 35 | 54 | let mut html = format!( | |
| 36 | 55 | "<div style=\"font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 16px;color:#16150f\">\ | |
| 37 | − | <p style=\"margin:0 0 20px\"><img src=\"{SITE}/brand/g1t-logo.png\" width=\"60\" height=\"28\" alt=\"g1t\" style=\"display:block;border:0\"></p>" | |
| 56 | + | <p style=\"margin:0 0 20px\"><img src=\"{site}/brand/g1t-logo.png\" width=\"60\" height=\"28\" alt=\"g1t\" style=\"display:block;border:0\"></p>" | |
| 38 | 57 | ); | |
| 39 | 58 | for paragraph in &letter.paragraphs { | |
| 40 | 59 | text.push_str(paragraph); | |
| 73 | 92 | ||
| 74 | 93 | /// Sends a letter. | |
| 75 | 94 | pub async fn send(env: &Env, to: &str, subject: &str, letter: &Letter) -> Result<()> { | |
| 76 | − | let (text, html) = render(letter); | |
| 95 | + | let (text, html) = render(letter, &site(env)); | |
| 96 | + | let from = from(env); | |
| 77 | 97 | let message = Message { | |
| 78 | 98 | to, | |
| 79 | − | from: FROM, | |
| 99 | + | from: &from, | |
| 80 | 100 | subject, | |
| 81 | 101 | text, | |
| 82 | 102 | html, | |
| 128 | 148 | "Confirm your email for g1t", | |
| 129 | 149 | &format!("Welcome to g1t, {username}. Confirm this address to finish creating your account."), | |
| 130 | 150 | "Confirm email", | |
| 131 | − | &format!("{SITE}/verify?token={token}"), | |
| 151 | + | &format!("{}/verify?token={token}", site(env)), | |
| 132 | 152 | "This link works for 24 hours. If you did not create a g1t account, you can ignore this message.", | |
| 133 | 153 | ) | |
| 134 | 154 | .await | |
| 141 | 161 | "Reset your g1t password", | |
| 142 | 162 | &format!("Someone asked to reset the password for the g1t account {username}."), | |
| 143 | 163 | "Choose a new password", | |
| 144 | − | &format!("{SITE}/reset?token={token}"), | |
| 164 | + | &format!("{}/reset?token={token}", site(env)), | |
| 145 | 165 | "This link works for 1 hour. If this was not you, ignore this message and your password stays the same.", | |
| 146 | 166 | ) | |
| 147 | 167 | .await | |
| 155 | 175 | "Confirm your email for g1t", | |
| 156 | 176 | &format!("Confirm this address to add it to the g1t account {username}."), | |
| 157 | 177 | "Confirm email", | |
| 158 | − | &format!("{SITE}/verify?token={token}"), | |
| 178 | + | &format!("{}/verify?token={token}", site(env)), | |
| 159 | 179 | "This link works for 24 hours. If you did not add this address to a g1t account, you can ignore this message.", | |
| 160 | 180 | ) | |
| 161 | 181 | .await | |
| 178 | 198 | &subject, | |
| 179 | 199 | &intro, | |
| 180 | 200 | "Review your email settings", | |
| 181 | − | &format!("{SITE}/settings/emails"), | |
| 182 | − | "If this was you, there is nothing to do. If it was not, reset your password at g1t.sh/forgot straight away and remove any address you do not recognise.", | |
| 201 | + | &format!("{}/settings/emails", site(env)), | |
| 202 | + | &format!( | |
| 203 | + | "If this was you, there is nothing to do. If it was not, reset your password at {}/forgot straight away and remove any address you do not recognise.", | |
| 204 | + | bare(&site(env)) | |
| 205 | + | ), | |
| 183 | 206 | ) | |
| 184 | 207 | .await | |
| 185 | 208 | } | |
| 200 | 223 | } | |
| 201 | 224 | ||
| 202 | 225 | /// The subject and letter of an invite email. | |
| 203 | − | pub fn invite_letter(invite: &InviteEmail) -> (String, Letter) { | |
| 226 | + | pub fn invite_letter(invite: &InviteEmail, site: &str) -> (String, Letter) { | |
| 204 | 227 | let (subject, intro) = invite_wording(invite.from, invite.workspace, invite.joins_existing_account); | |
| 205 | 228 | let action = match invite.workspace { | |
| 206 | 229 | Some(workspace) if invite.joins_existing_account => format!("Join {workspace}"), | |
| 219 | 242 | let letter = Letter { | |
| 220 | 243 | paragraphs: vec![intro], | |
| 221 | 244 | quotes, | |
| 222 | − | action: Some((action, format!("{SITE}/invite/{}", invite.code))), | |
| 245 | + | action: Some((action, format!("{site}/invite/{}", invite.code))), | |
| 223 | 246 | footer: format!( | |
| 224 | 247 | "This invite works for {} days, only for this address. If you were not expecting it, you can ignore this message.", | |
| 225 | 248 | invite.days | |
| 229 | 252 | } | |
| 230 | 253 | ||
| 231 | 254 | pub async fn send_invite(env: &Env, invite: &InviteEmail<'_>) -> Result<()> { | |
| 232 | − | let (subject, letter) = invite_letter(invite); | |
| 255 | + | let (subject, letter) = invite_letter(invite, &site(env)); | |
| 233 | 256 | send(env, invite.to, &subject, &letter).await | |
| 234 | 257 | } | |
| 235 | 258 | ||
| 237 | 260 | pub const SUDO_WAITLIST: &str = "https://sudo.g1t.sh/invites?tab=waitlist"; | |
| 238 | 261 | ||
| 239 | 262 | /// The one confirmation someone gets after asking for access. | |
| 240 | − | pub fn waitlist_confirmation() -> (String, Letter) { | |
| 263 | + | pub fn waitlist_confirmation(site: &str) -> (String, Letter) { | |
| 241 | 264 | ( | |
| 242 | 265 | "You're on the list for g1t".to_owned(), | |
| 243 | 266 | Letter { | |
| 247 | 270 | ], | |
| 248 | 271 | quotes: Vec::new(), | |
| 249 | 272 | action: None, | |
| 250 | − | footer: "You're getting this because this address asked for access at g1t.sh/register. If that wasn't you, ignore this message; nothing more is sent unless you're invited.".to_owned(), | |
| 273 | + | footer: format!("You're getting this because this address asked for access at {}/register. If that wasn't you, ignore this message; nothing more is sent unless you're invited.", bare(site)), | |
| 251 | 274 | }, | |
| 252 | 275 | ) | |
| 253 | 276 | } | |
| 254 | 277 | ||
| 255 | 278 | pub async fn send_waitlist_confirmation(env: &Env, to: &str) -> Result<()> { | |
| 256 | − | let (subject, letter) = waitlist_confirmation(); | |
| 279 | + | let (subject, letter) = waitlist_confirmation(&site(env)); | |
| 257 | 280 | send(env, to, &subject, &letter).await | |
| 258 | 281 | } | |
| 259 | 282 | ||
| 329 | 352 | ) -> Result<()> { | |
| 330 | 353 | let (subject, intro) = repo_invite_wording(from, repo, role, code.is_some()); | |
| 331 | 354 | let link = match code { | |
| 332 | − | Some(code) => format!("{SITE}/invite/{code}"), | |
| 333 | − | None => format!("{SITE}/{repo}/invitations"), | |
| 355 | + | Some(code) => format!("{}/invite/{code}", site(env)), | |
| 356 | + | None => format!("{}/{repo}/invitations", site(env)), | |
| 334 | 357 | }; | |
| 335 | 358 | send_link( | |
| 336 | 359 | env, | |
| 422 | 445 | ||
| 423 | 446 | #[test] | |
| 424 | 447 | fn an_invite_links_to_its_page_and_carries_a_note() { | |
| 425 | − | let (subject, letter) = invite_letter(&invite(Some("Welcome aboard <3"), None)); | |
| 448 | + | let (subject, letter) = invite_letter(&invite(Some("Welcome aboard <3"), None), SITE); | |
| 426 | 449 | assert_eq!(subject, "The g1t team invited you to Flagon, Inc. on g1t"); | |
| 427 | 450 | assert_eq!(letter.action.as_ref().unwrap().1, "https://g1t.sh/invite/g1t-abcd"); | |
| 428 | 451 | assert_eq!(letter.quotes, vec![("A note from the g1t team".to_owned(), "Welcome aboard <3".to_owned())]); | |
| 429 | − | let (text, html) = render(&letter); | |
| 452 | + | let (text, html) = render(&letter, SITE); | |
| 430 | 453 | assert!(text.contains("> Welcome aboard <3")); | |
| 431 | 454 | assert!(html.contains("Welcome aboard <3")); | |
| 432 | 455 | assert!(!html.contains("<3")); | |
| 433 | 456 | // No note, no quote; a blank note is no note. | |
| 434 | − | assert!(invite_letter(&invite(None, Some("Chase Pierce"))).1.quotes.is_empty()); | |
| 435 | − | assert!(invite_letter(&invite(Some(" "), Some("Chase Pierce"))).1.quotes.is_empty()); | |
| 436 | − | assert_eq!(invite_letter(&invite(Some("hi"), Some("Chase Pierce"))).1.quotes[0].0, "A note from Chase Pierce"); | |
| 457 | + | assert!(invite_letter(&invite(None, Some("Chase Pierce")), SITE).1.quotes.is_empty()); | |
| 458 | + | assert!(invite_letter(&invite(Some(" "), Some("Chase Pierce")), SITE).1.quotes.is_empty()); | |
| 459 | + | assert_eq!(invite_letter(&invite(Some("hi"), Some("Chase Pierce")), SITE).1.quotes[0].0, "A note from Chase Pierce"); | |
| 437 | 460 | } | |
| 438 | 461 | ||
| 439 | 462 | #[test] | |
| 463 | + | fn links_point_at_the_site_they_are_given() { | |
| 464 | + | let (_, letter) = invite_letter(&invite(None, None), "http://localhost:8787"); | |
| 465 | + | assert_eq!(letter.action.as_ref().unwrap().1, "http://localhost:8787/invite/g1t-abcd"); | |
| 466 | + | let (text, html) = render(&letter, "http://localhost:8787"); | |
| 467 | + | assert!(html.contains("http://localhost:8787/brand/g1t-logo.png")); | |
| 468 | + | assert!(!text.contains("g1t.sh/") && !html.contains("https://g1t.sh")); | |
| 469 | + | let (_, letter) = waitlist_confirmation("http://localhost:8787"); | |
| 470 | + | assert!(letter.footer.contains("localhost:8787/register")); | |
| 471 | + | assert_eq!(bare("https://git.example.com"), "git.example.com"); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | #[test] | |
| 440 | 475 | fn the_waitlist_confirmation_promises_no_date() { | |
| 441 | − | let (subject, letter) = waitlist_confirmation(); | |
| 476 | + | let (subject, letter) = waitlist_confirmation(SITE); | |
| 442 | 477 | assert_eq!(subject, "You're on the list for g1t"); | |
| 443 | − | let (text, _) = render(&letter); | |
| 478 | + | let (text, _) = render(&letter, SITE); | |
| 479 | + | assert!(text.contains("g1t.sh/register")); | |
| 444 | 480 | assert!(text.contains("we'll email you an invite")); | |
| 445 | 481 | assert!(text.contains("can't say exactly when")); | |
| 446 | 482 | assert!(letter.action.is_none()); | |
| 453 | 489 | assert_eq!(subject, "g1t access request from ada@example.com"); | |
| 454 | 490 | assert_eq!(letter.paragraphs, vec!["Someone asked for access to g1t."]); | |
| 455 | 491 | assert_eq!(letter.action.as_ref().unwrap().1, SUDO_WAITLIST); | |
| 456 | − | let (_, html) = render(&letter); | |
| 492 | + | let (_, html) = render(&letter, SITE); | |
| 457 | 493 | assert!(html.contains("A compiler <for> fun")); | |
| 458 | 494 | ||
| 459 | 495 | let many: Vec<Requested> = (0..25).map(|n| Requested { email: format!("p{n}@example.com"), about: None }).collect(); | |
| 474 | 510 | fn write_emails() { | |
| 475 | 511 | let Ok(dir) = std::env::var("G1T_WRITE_EMAILS") else { return }; | |
| 476 | 512 | let page = |name: &str, subject: &str, letter: &Letter| { | |
| 477 | − | let (_, html) = render(letter); | |
| 478 | − | let html = html.replace(SITE, "https://g1t.sh"); | |
| 513 | + | let (_, html) = render(letter, SITE); | |
| 479 | 514 | std::fs::write( | |
| 480 | 515 | format!("{dir}/{name}.html"), | |
| 481 | 516 | format!("<!doctype html><meta charset=utf-8><title>{}</title><body style=\"margin:0;background:#fff\">{html}", escape(subject)), | |
| 482 | 517 | ) | |
| 483 | 518 | .unwrap(); | |
| 484 | 519 | }; | |
| 485 | − | let (subject, letter) = waitlist_confirmation(); | |
| 520 | + | let (subject, letter) = waitlist_confirmation(SITE); | |
| 486 | 521 | page("waitlist-confirmation", &subject, &letter); | |
| 487 | 522 | let requests = [ | |
| 488 | 523 | Requested { email: "ada@example.com".into(), about: Some("A compiler for a teaching language, with agents writing the test suite.".into()) }, | |
| 498 | 533 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 499 | 534 | days: 30, | |
| 500 | 535 | note: None, | |
| 501 | − | }); | |
| 536 | + | }, SITE); | |
| 502 | 537 | page("workspace-invite", &subject, &letter); | |
| 503 | 538 | let (subject, letter) = invite_letter(&InviteEmail { | |
| 504 | 539 | to: "ada@example.com", | |
| 508 | 543 | code: "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk", | |
| 509 | 544 | days: 30, | |
| 510 | 545 | note: Some("Thanks for waiting. We would love to see the compiler."), | |
| 511 | − | }); | |
| 546 | + | }, SITE); | |
| 512 | 547 | page("waitlist-approved", &subject, &letter); | |
| 513 | 548 | } | |
| 514 | 549 | } |
| 11 | 11 | // cd services/repos && node ../../scripts/build-rust-worker.mjs | |
| 12 | 12 | // node dev/clone-check.mjs | |
| 13 | 13 | // | |
| 14 | − | // Needs node, git (with git-http-backend) and the repository's npm packages. | |
| 14 | + | // With `--s3`, packs are kept in MinIO instead of local R2, as a | |
| 15 | + | // self-hosted installation keeps them (PACK_STORE=s3): it starts MinIO in | |
| 16 | + | // Docker, makes the `g1t-git-packs` bucket with the same expiry rule the | |
| 17 | + | // compose file gives it, and checks that what was kept is there, whole, | |
| 18 | + | // with no upload left unfinished. | |
| 19 | + | // | |
| 20 | + | // node dev/clone-check.mjs --s3 | |
| 21 | + | // | |
| 22 | + | // GITSTORE_PORT, REPOS_PORT and MINIO_PORT move it off 8799, 8791 and 9010. | |
| 23 | + | // | |
| 24 | + | // Needs node, git (with git-http-backend) and the repository's npm | |
| 25 | + | // packages; with `--s3`, Docker too. | |
| 15 | 26 | ||
| 16 | 27 | import { spawn, spawnSync } from "node:child_process"; | |
| 17 | 28 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 27 | 38 | const work = mkdtempSync(join(tmpdir(), "g1t-clone-check-")); | |
| 28 | 39 | const persist = join(work, "state"); | |
| 29 | 40 | const SECRET = "dev-gitstore-secret-0123"; | |
| 30 | − | const STORE = "http://localhost:8799"; | |
| 31 | − | const REPOS = "http://localhost:8791"; | |
| 41 | + | // The ports, if something on this machine already has the defaults. | |
| 42 | + | const STORE_PORT = process.env.GITSTORE_PORT ?? "8799"; | |
| 43 | + | const REPOS_PORT = process.env.REPOS_PORT ?? "8791"; | |
| 44 | + | const STORE = `http://localhost:${STORE_PORT}`; | |
| 45 | + | const REPOS = `http://localhost:${REPOS_PORT}`; | |
| 46 | + | // dev/artifacts.jsonc, pointed at this run's git store. | |
| 47 | + | const ARTIFACTS_CONFIG = join(work, "artifacts.json"); | |
| 48 | + | writeFileSync( | |
| 49 | + | ARTIFACTS_CONFIG, | |
| 50 | + | JSON.stringify({ | |
| 51 | + | name: "g1t-artifacts", | |
| 52 | + | main: join(root, "deploy/self-host/workers/artifacts/index.js"), | |
| 53 | + | compatibility_date: "2026-09-26", | |
| 54 | + | vars: { GITSTORE_URL: STORE, GITSTORE_SECRET: SECRET }, | |
| 55 | + | }), | |
| 56 | + | ); | |
| 32 | 57 | const KEY = "acme--rocket"; | |
| 33 | 58 | const children = []; | |
| 59 | + | // --s3: packs in MinIO (see the top). | |
| 60 | + | const S3 = process.argv.includes("--s3"); | |
| 61 | + | const MINIO = "g1t-clone-check-minio"; | |
| 62 | + | const MINIO_PORT = process.env.MINIO_PORT ?? "9010"; | |
| 63 | + | const MINIO_USER = "g1t"; | |
| 64 | + | const MINIO_PASSWORD = "g1t-clone-check-secret"; | |
| 65 | + | const PACKS_BUCKET = "g1t-git-packs"; | |
| 66 | + | /** `mc` inside the MinIO container, against itself. */ | |
| 67 | + | const mc = (args, options = {}) => run("docker", ["exec", MINIO, "mc", ...args], options); | |
| 34 | 68 | // Wrangler from the repository's packages, run with node: no shell to quote for. | |
| 35 | 69 | const WRANGLER = join(dirname(createRequire(join(service, "package.json")).resolve("wrangler/package.json")), "bin/wrangler.js"); | |
| 36 | 70 | ||
| 99 | 133 | try { | |
| 100 | 134 | // The git store, with a repository of a few commits. | |
| 101 | 135 | start("node", [join(root, "deploy/self-host/gitstore/server.mjs")], { | |
| 102 | − | env: { ...process.env, GITSTORE_ROOT: join(work, "git"), GITSTORE_SECRET: SECRET, GITSTORE_PORT: "8799", GITSTORE_URL: STORE }, | |
| 136 | + | env: { ...process.env, GITSTORE_ROOT: join(work, "git"), GITSTORE_SECRET: SECRET, GITSTORE_PORT: STORE_PORT, GITSTORE_URL: STORE }, | |
| 103 | 137 | stdio: "inherit", | |
| 104 | 138 | }); | |
| 105 | 139 | await waitFor(`${STORE}/healthz`, "the git store"); | |
| 128 | 162 | env: { ...process.env, CI: "1" }, | |
| 129 | 163 | }); | |
| 130 | 164 | sql("INSERT INTO repos (id, namespace, name, is_private, owner_id, default_branch, refs_version) VALUES ('rep_rocket', 'acme', 'rocket', 0, 'usr_dev', 'main', 1)"); | |
| 165 | + | ||
| 166 | + | // MinIO, with the bucket and expiry rule deploy/self-host/docker-compose.yml makes. | |
| 167 | + | const s3Vars = []; | |
| 168 | + | if (S3) { | |
| 169 | + | run("docker", ["rm", "-f", MINIO], { allowFail: true }); | |
| 170 | + | run("docker", [ | |
| 171 | + | "run", "-d", "--rm", "--name", MINIO, "-p", `${MINIO_PORT}:9000`, | |
| 172 | + | "-e", `MINIO_ROOT_USER=${MINIO_USER}`, "-e", `MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}`, | |
| 173 | + | process.env.MINIO_IMAGE ?? "pgsty/minio:latest", "server", "/data", | |
| 174 | + | ]); | |
| 175 | + | await waitFor(`http://localhost:${MINIO_PORT}/minio/health/ready`, "MinIO"); | |
| 176 | + | mc(["alias", "set", "local", "http://localhost:9000", MINIO_USER, MINIO_PASSWORD]); | |
| 177 | + | mc(["mb", "--ignore-existing", `local/${PACKS_BUCKET}`]); | |
| 178 | + | mc(["ilm", "rule", "add", "--prefix", "packs/", "--expire-days", "7", `local/${PACKS_BUCKET}`]); | |
| 179 | + | for (const [name, value] of Object.entries({ | |
| 180 | + | PACK_STORE: "s3", | |
| 181 | + | PACK_S3_BUCKET: PACKS_BUCKET, | |
| 182 | + | S3_ENDPOINT: `http://localhost:${MINIO_PORT}`, | |
| 183 | + | S3_REGION: "us-east-1", | |
| 184 | + | S3_ACCESS_KEY_ID: MINIO_USER, | |
| 185 | + | S3_SECRET_ACCESS_KEY: MINIO_PASSWORD, | |
| 186 | + | })) { | |
| 187 | + | s3Vars.push("--var", `${name}:${value}`); | |
| 188 | + | } | |
| 189 | + | } | |
| 131 | 190 | start( | |
| 132 | 191 | "node", | |
| 133 | − | [WRANGLER, "dev", "-c", "dev/repos.jsonc", "-c", "dev/artifacts.jsonc", "-c", "dev/stubs.jsonc", "--local", "--persist-to", persist, "--port", "8791"], | |
| 192 | + | [WRANGLER, "dev", "-c", "dev/repos.jsonc", "-c", ARTIFACTS_CONFIG, "-c", "dev/stubs.jsonc", "--local", "--persist-to", persist, "--port", REPOS_PORT, ...s3Vars], | |
| 134 | 193 | { cwd: service, env: { ...process.env, CI: "1" }, stdio: ["ignore", "inherit", "inherit"] }, | |
| 135 | 194 | ); | |
| 136 | 195 | await waitFor(`${REPOS}/acme/rocket.git/info/refs?service=git-upload-pack`, "wrangler dev"); | |
| 146 | 205 | await new Promise((resolve) => setTimeout(resolve, 6000)); | |
| 147 | 206 | const after = clone("after-refs", ["--depth=1"]); | |
| 148 | 207 | check("after the refs version moves, a clone misses", after.pack.includes("miss"), after.pack.join(",")); | |
| 208 | + | ||
| 209 | + | if (S3) { | |
| 210 | + | // Fills finish after git has its answer: give the last one a moment. | |
| 211 | + | await new Promise((resolve) => setTimeout(resolve, 3000)); | |
| 212 | + | const listed = mc(["ls", "--recursive", "--json", `local/${PACKS_BUCKET}/packs/`]).stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); | |
| 213 | + | const sizes = listed.map((entry) => entry.size); | |
| 214 | + | // Nine clones: four kinds twice, each kept once, and one more after the refs moved. | |
| 215 | + | check("the packs are in MinIO, one per distinct clone", listed.length === 5, `${listed.length}: ${sizes.join(", ")}`); | |
| 216 | + | check("the full clone's pack went up in parts", sizes.some((size) => size > 5 * 1024 * 1024), `largest ${Math.max(...sizes)}`); | |
| 217 | + | const incomplete = mc(["ls", "--recursive", "--incomplete", `local/${PACKS_BUCKET}`]).stdout.trim(); | |
| 218 | + | check("no upload is left unfinished", incomplete === "", incomplete); | |
| 219 | + | const rules = mc(["ilm", "rule", "ls", "--json", `local/${PACKS_BUCKET}`]).stdout; | |
| 220 | + | check("the bucket expires packs after 7 days", /"Days":\s*7/.test(rules) && rules.includes("packs/")); | |
| 221 | + | } | |
| 149 | 222 | } catch (error) { | |
| 150 | 223 | console.error(error); | |
| 151 | 224 | checks.push({ what: "ran", ok: false }); | |
| 154 | 227 | if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" }); | |
| 155 | 228 | else child.kill(); | |
| 156 | 229 | } | |
| 230 | + | if (S3) run("docker", ["rm", "-f", MINIO], { allowFail: true }); | |
| 157 | 231 | try { | |
| 158 | 232 | rmSync(work, { recursive: true, force: true }); | |
| 159 | 233 | } catch {} |
| 197 | 197 | /// What isolates share: answers that list refs (refs_cache.rs). | |
| 198 | 198 | shared: Option<Rc<shared::Shared>>, | |
| 199 | 199 | /// Packs for fresh clones (pack_cache.rs); `None` without the bucket. | |
| 200 | − | packs: Option<Rc<pack_cache::R2Packs>>, | |
| 200 | + | packs: Option<Rc<pack_cache::Packs>>, | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | 203 | impl<S: GitStore> Repos<S> { | |
| 1933 | 1933 | registry: Registry { db: env.d1("DB")? }, | |
| 1934 | 1934 | store: ArtifactsStore::new(env, shared.clone())?, | |
| 1935 | 1935 | shared, | |
| 1936 | − | packs: pack_cache::R2Packs::from_env(env).map(Rc::new), | |
| 1936 | + | packs: pack_cache::Packs::from_env(env).map(Rc::new), | |
| 1937 | 1937 | events: env.service("EVENTS")?, | |
| 1938 | 1938 | security: env.service("SECURITY").ok(), | |
| 1939 | 1939 | billing: env.service("BILLING").ok(), |
| 4 | 4 | //! is a clone: a new checkout, or a sandbox's shallow `deepen 1` clone of | |
| 5 | 5 | //! a pull request's head. The git store builds the same pack for it every | |
| 6 | 6 | //! time, which takes seconds and is an operation. The answer is kept in a | |
| 7 | − | //! bucket ([`PackStore`], R2's `GIT_PACKS` on Cloudflare) under the | |
| 7 | + | //! bucket ([`PackStore`]: R2's `GIT_PACKS` on Cloudflare, or any | |
| 8 | + | //! S3-compatible store, as [`STORAGE`] says) under the | |
| 8 | 9 | //! repository's id, the version of its refs (`refs_version`, see | |
| 9 | 10 | //! registry.rs and refs_cache.rs) and a hash of the request with what does | |
| 10 | 11 | //! not change the answer taken out ([`normalize`]): the client's `agent`, | |
| 22 | 23 | //! | |
| 23 | 24 | //! Only ever served after the request was authorized, like any answer from | |
| 24 | 25 | //! the store: a private repository's packs are read only by whoever may | |
| 25 | − | //! read it. Without the bucket binding (self-hosted, or before it exists) | |
| 26 | − | //! nothing is kept and every clone goes to the store, as before. | |
| 26 | + | //! read it. Without a bucket (no `GIT_PACKS` binding, and PACK_STORE not | |
| 27 | + | //! `s3`) nothing is kept and every clone goes to the store, as before. | |
| 27 | 28 | ||
| 28 | 29 | use std::cell::RefCell; | |
| 29 | 30 | use std::collections::{BTreeSet, HashSet, VecDeque}; | |
| 33 | 34 | ||
| 34 | 35 | use futures_util::{Stream, StreamExt}; | |
| 35 | 36 | use g1t_contracts::repos::GitService; | |
| 36 | − | use worker::{Bucket, Env, Headers, Response, ResponseBody, Result, UploadedPart}; | |
| 37 | + | use g1t_blobstore::{BlobStore, Config, Part, Store}; | |
| 38 | + | use worker::{Env, Headers, Response, ResponseBody, Result}; | |
| 37 | 39 | ||
| 38 | 40 | use crate::git_http::GitRequest; | |
| 39 | 41 | ||
| 68 | 70 | const MAX_FILLS: usize = 2; | |
| 69 | 71 | pub const CONTENT_TYPE: &str = "application/x-git-upload-pack-result"; | |
| 70 | 72 | ||
| 71 | − | /// Where packs are kept: the R2 bucket on Cloudflare. A small port, so a | |
| 72 | − | /// self-hosted installation can put another store behind it. | |
| 73 | + | /// Where packs are kept. A small port over what a fill and a hit need; its | |
| 74 | + | /// adapter, [`Packs`], puts any [`BlobStore`] behind it. | |
| 73 | 75 | #[allow(async_fn_in_trait)] | |
| 74 | 76 | pub trait PackStore { | |
| 75 | 77 | /// The object's size and body, if it is there. | |
| 84 | 86 | async fn abort(&self, key: &str, upload: &str) -> Result<()>; | |
| 85 | 87 | } | |
| 86 | 88 | ||
| 87 | − | /// The R2 adapter: the `GIT_PACKS` bucket binding. | |
| 88 | − | pub struct R2Packs { | |
| 89 | − | bucket: Bucket, | |
| 89 | + | /// Where packs are kept, by the names of the service's bindings and | |
| 90 | + | /// variables: the `GIT_PACKS` R2 bucket on Cloudflare or, when PACK_STORE | |
| 91 | + | /// is `s3`, the bucket PACK_S3_BUCKET names on the installation's | |
| 92 | + | /// S3-compatible store (`g1t-git-packs` on MinIO in the self-host compose | |
| 93 | + | /// file). On either an object exists only once it is whole: a `put` makes | |
| 94 | + | /// it in one write, and a multipart upload is nothing anyone can read | |
| 95 | + | /// until it is completed. | |
| 96 | + | pub const STORAGE: Config = Config { | |
| 97 | + | kind: "PACK_STORE", | |
| 98 | + | binding: "GIT_PACKS", | |
| 99 | + | r2_signer: None, | |
| 100 | + | s3_bucket: "PACK_S3_BUCKET", | |
| 101 | + | s3_public_endpoint: None, | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | /// The adapter: packs as objects in a [`BlobStore`]. | |
| 105 | + | pub struct Packs<B: BlobStore = Store> { | |
| 106 | + | store: B, | |
| 90 | 107 | } | |
| 91 | 108 | ||
| 92 | − | impl R2Packs { | |
| 93 | − | /// `None` without the binding: nothing is kept. | |
| 94 | − | pub fn from_env(env: &Env) -> Option<R2Packs> { | |
| 95 | − | env.bucket("GIT_PACKS").ok().map(|bucket| R2Packs { bucket }) | |
| 109 | + | impl Packs<Store> { | |
| 110 | + | /// `None` when this installation keeps no packs: no `GIT_PACKS` | |
| 111 | + | /// binding, and PACK_STORE is not `s3`. Every clone then goes to the | |
| 112 | + | /// git store. | |
| 113 | + | pub fn from_env(env: &Env) -> Option<Packs<Store>> { | |
| 114 | + | match Store::from_env(env, &STORAGE) { | |
| 115 | + | Ok(store) => Some(Packs { store }), | |
| 116 | + | Err(error) => { | |
| 117 | + | // No R2 binding is the cache turned off; an S3 store asked | |
| 118 | + | // for and not configured is worth saying. | |
| 119 | + | if g1t_blobstore::var(env, STORAGE.kind) == "s3" { | |
| 120 | + | log!("pack cache is off: {error}"); | |
| 121 | + | } | |
| 122 | + | None | |
| 123 | + | } | |
| 124 | + | } | |
| 96 | 125 | } | |
| 97 | 126 | } | |
| 98 | 127 | ||
| 99 | − | impl PackStore for R2Packs { | |
| 128 | + | impl<B: BlobStore> PackStore for Packs<B> { | |
| 100 | 129 | async fn get(&self, key: &str) -> Result<Option<(u64, ResponseBody)>> { | |
| 101 | − | let Some(object) = self.bucket.get(key).execute().await? else { | |
| 102 | − | return Ok(None); | |
| 103 | − | }; | |
| 104 | − | let size = object.size(); | |
| 105 | − | let Some(body) = object.body() else { | |
| 106 | − | return Ok(None); | |
| 107 | − | }; | |
| 108 | − | Ok(Some((size, body.response_body()?))) | |
| 130 | + | Ok(self.store.get(key, None).await?.map(|got| (got.size, got.body))) | |
| 109 | 131 | } | |
| 110 | 132 | ||
| 111 | 133 | async fn put(&self, key: &str, bytes: Vec<u8>) -> Result<()> { | |
| 112 | − | self.bucket.put(key, bytes).execute().await?; | |
| 113 | − | Ok(()) | |
| 134 | + | self.store.put(key, bytes).await | |
| 114 | 135 | } | |
| 115 | 136 | ||
| 116 | 137 | async fn begin(&self, key: &str) -> Result<String> { | |
| 117 | − | let upload = self.bucket.create_multipart_upload(key).execute().await?; | |
| 118 | − | Ok(upload.upload_id().await) | |
| 138 | + | self.store.create_multipart(key).await | |
| 119 | 139 | } | |
| 120 | 140 | ||
| 121 | 141 | async fn part(&self, key: &str, upload: &str, number: u16, bytes: Vec<u8>) -> Result<String> { | |
| 122 | − | let upload = self.bucket.resume_multipart_upload(key, upload)?; | |
| 123 | − | Ok(upload.upload_part(number, bytes).await?.etag()) | |
| 142 | + | Ok(self.store.upload_part(key, upload, number, bytes).await?.etag) | |
| 124 | 143 | } | |
| 125 | 144 | ||
| 126 | 145 | async fn complete(&self, key: &str, upload: &str, parts: Vec<(u16, String)>) -> Result<()> { | |
| 127 | − | let upload = self.bucket.resume_multipart_upload(key, upload)?; | |
| 128 | − | upload | |
| 129 | − | .complete(parts.into_iter().map(|(number, etag)| UploadedPart::new(number, etag))) | |
| 130 | − | .await?; | |
| 131 | − | Ok(()) | |
| 146 | + | let parts: Vec<Part> = parts.into_iter().map(|(number, etag)| Part { number, etag }).collect(); | |
| 147 | + | self.store.complete_multipart(key, upload, &parts).await | |
| 132 | 148 | } | |
| 133 | 149 | ||
| 134 | 150 | async fn abort(&self, key: &str, upload: &str) -> Result<()> { | |
| 135 | − | self.bucket.resume_multipart_upload(key, upload)?.abort().await | |
| 151 | + | self.store.abort_multipart(key, upload).await | |
| 136 | 152 | } | |
| 137 | 153 | } | |
| 138 | 154 | ||
| 966 | 982 | ||
| 967 | 983 | /// Streams `body` through a tee in `chunk`-sized pieces, as git would | |
| 968 | 984 | /// read it, stopping after `read` chunks if given; then runs the fill. | |
| 969 | − | fn run(store: &Memory, body: &[u8], chunk: usize, read: Option<usize>, error_at: Option<usize>) -> (Vec<u8>, Filled, u64) { | |
| 985 | + | fn run<S: PackStore>(store: &S, body: &[u8], chunk: usize, read: Option<usize>, error_at: Option<usize>) -> (Vec<u8>, Filled, u64) { | |
| 970 | 986 | let mut chunks: Vec<Result<Vec<u8>>> = body.chunks(chunk).map(|c| Ok(c.to_vec())).collect(); | |
| 971 | 987 | if let Some(at) = error_at { | |
| 972 | 988 | chunks.truncate(at); | |
| 1055 | 1071 | assert!(store.objects.borrow().is_empty()); | |
| 1056 | 1072 | } | |
| 1057 | 1073 | ||
| 1074 | + | /// A blob store as S3 and R2 behave: an object can be read only once | |
| 1075 | + | /// it was put whole or its multipart upload was completed. | |
| 1076 | + | #[derive(Default)] | |
| 1077 | + | struct Blobs { | |
| 1078 | + | objects: RefCell<HashMap<String, Vec<u8>>>, | |
| 1079 | + | uploads: RefCell<HashMap<String, (String, Vec<(u16, Vec<u8>)>)>>, | |
| 1080 | + | /// Whether, at each part, the key could already be read. | |
| 1081 | + | readable_mid_upload: Cell<bool>, | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | impl BlobStore for Blobs { | |
| 1085 | + | async fn put(&self, key: &str, bytes: Vec<u8>) -> Result<()> { | |
| 1086 | + | self.objects.borrow_mut().insert(key.to_owned(), bytes); | |
| 1087 | + | Ok(()) | |
| 1088 | + | } | |
| 1089 | + | async fn get(&self, key: &str, _range: Option<g1t_blobstore::Wanted>) -> Result<Option<g1t_blobstore::Got>> { | |
| 1090 | + | Ok(self.objects.borrow().get(key).map(|bytes| g1t_blobstore::Got { | |
| 1091 | + | size: bytes.len() as u64, | |
| 1092 | + | body: ResponseBody::Body(bytes.clone()), | |
| 1093 | + | })) | |
| 1094 | + | } | |
| 1095 | + | async fn head(&self, key: &str) -> Result<Option<u64>> { | |
| 1096 | + | Ok(self.objects.borrow().get(key).map(|bytes| bytes.len() as u64)) | |
| 1097 | + | } | |
| 1098 | + | async fn delete(&self, key: &str) -> Result<()> { | |
| 1099 | + | self.objects.borrow_mut().remove(key); | |
| 1100 | + | Ok(()) | |
| 1101 | + | } | |
| 1102 | + | async fn create_multipart(&self, key: &str) -> Result<String> { | |
| 1103 | + | let id = format!("upload-{}", self.uploads.borrow().len()); | |
| 1104 | + | self.uploads.borrow_mut().insert(id.clone(), (key.to_owned(), Vec::new())); | |
| 1105 | + | Ok(id) | |
| 1106 | + | } | |
| 1107 | + | async fn upload_part(&self, key: &str, upload_id: &str, number: u16, bytes: Vec<u8>) -> Result<Part> { | |
| 1108 | + | if self.objects.borrow().contains_key(key) { | |
| 1109 | + | self.readable_mid_upload.set(true); | |
| 1110 | + | } | |
| 1111 | + | let mut uploads = self.uploads.borrow_mut(); | |
| 1112 | + | let (_, parts) = uploads.get_mut(upload_id).unwrap(); | |
| 1113 | + | parts.push((number, bytes)); | |
| 1114 | + | Ok(Part { number, etag: format!("\"etag-{number}\"") }) | |
| 1115 | + | } | |
| 1116 | + | async fn complete_multipart(&self, key: &str, upload_id: &str, parts: &[Part]) -> Result<()> { | |
| 1117 | + | let (for_key, uploaded) = self.uploads.borrow_mut().remove(upload_id).unwrap(); | |
| 1118 | + | assert_eq!(for_key, key); | |
| 1119 | + | let numbers: Vec<u16> = parts.iter().map(|part| part.number).collect(); | |
| 1120 | + | assert_eq!(numbers, uploaded.iter().map(|(number, _)| *number).collect::<Vec<_>>()); | |
| 1121 | + | assert!(parts.iter().all(|part| part.etag == format!("\"etag-{}\"", part.number))); | |
| 1122 | + | let whole: Vec<u8> = uploaded.into_iter().flat_map(|(_, bytes)| bytes).collect(); | |
| 1123 | + | self.objects.borrow_mut().insert(key.to_owned(), whole); | |
| 1124 | + | Ok(()) | |
| 1125 | + | } | |
| 1126 | + | async fn abort_multipart(&self, _key: &str, upload_id: &str) -> Result<()> { | |
| 1127 | + | self.uploads.borrow_mut().remove(upload_id); | |
| 1128 | + | Ok(()) | |
| 1129 | + | } | |
| 1130 | + | fn presign_get(&self, _key: &str, _expires: u32, _now_ms: u64) -> Option<String> { | |
| 1131 | + | None | |
| 1132 | + | } | |
| 1133 | + | } | |
| 1134 | + | ||
| 1135 | + | #[test] | |
| 1136 | + | fn the_blob_store_adapter_keeps_only_whole_packs() { | |
| 1137 | + | // A large pack through the adapter: parts, then one object. | |
| 1138 | + | let packs = Packs { store: Blobs::default() }; | |
| 1139 | + | let body = answer(&vec![3u8; 2 * PART_BYTES + 777]); | |
| 1140 | + | let (got, filled, _) = run(&packs, &body, 64 * 1024 + 1, None, None); | |
| 1141 | + | assert_eq!(got, body); | |
| 1142 | + | assert_eq!(filled, Filled::Kept { bytes: body.len() as u64 }); | |
| 1143 | + | assert!(!packs.store.readable_mid_upload.get(), "nothing to read until the upload is completed"); | |
| 1144 | + | assert!(packs.store.uploads.borrow().is_empty()); | |
| 1145 | + | let (size, kept) = block_on(packs.get("packs/r/1/k")).unwrap().unwrap(); | |
| 1146 | + | assert_eq!(size, body.len() as u64); | |
| 1147 | + | assert!(matches!(kept, ResponseBody::Body(bytes) if bytes == body)); | |
| 1148 | + | // Cut short: the upload is aborted and the key stays empty. | |
| 1149 | + | let packs = Packs { store: Blobs::default() }; | |
| 1150 | + | let (_, filled, _) = run(&packs, &body, 64 * 1024, Some(100), None); | |
| 1151 | + | assert_eq!(filled, Filled::Abandoned); | |
| 1152 | + | assert!(packs.store.objects.borrow().is_empty()); | |
| 1153 | + | assert!(packs.store.uploads.borrow().is_empty()); | |
| 1154 | + | assert!(block_on(packs.get("packs/r/1/k")).unwrap().is_none()); | |
| 1155 | + | // A small one is one put. | |
| 1156 | + | let packs = Packs { store: Blobs::default() }; | |
| 1157 | + | let small = answer(&[1u8; 3000]); | |
| 1158 | + | let (_, filled, _) = run(&packs, &small, 512, None, None); | |
| 1159 | + | assert_eq!(filled, Filled::Kept { bytes: small.len() as u64 }); | |
| 1160 | + | assert_eq!(packs.store.objects.borrow().get("packs/r/1/k"), Some(&small)); | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | #[test] | |
| 1164 | + | fn the_store_is_chosen_like_backups() { | |
| 1165 | + | assert_eq!(STORAGE.kind, "PACK_STORE"); | |
| 1166 | + | assert_eq!(STORAGE.binding, "GIT_PACKS"); | |
| 1167 | + | assert_eq!(STORAGE.s3_bucket, "PACK_S3_BUCKET"); | |
| 1168 | + | assert_ne!(STORAGE.s3_bucket, crate::backups::STORAGE.s3_bucket, "packs and backups never share a bucket"); | |
| 1169 | + | } | |
| 1170 | + | ||
| 1058 | 1171 | #[test] | |
| 1059 | 1172 | fn a_pack_past_the_cap_streams_through_unkept() { | |
| 1060 | 1173 | // The cap itself is 200 MB; the tee lets go of a slow bucket the |