Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9
# Conflicts: # apps/web/app/lib/front-door-limits.test.ts # apps/web/app/lib/front-door-limits.ts
284 files+2831−13268 files not counted0/284 viewed
| 1 | + | * @syntaqx |
| 1 | + | //! Customize over REST and MCP: what shapes how a workspace's agents work, | |
| 2 | + | //! at `/workspaces/{workspace}/customize/…` and as the MCP `customize` tool. | |
| 3 | + | //! Marketplaces the workspace reads plugins from, what Discover lists, the | |
| 4 | + | //! plugins it installed and the agents they brought, and its connectors | |
| 5 | + | //! (MCP servers connected once, then given to agents), with their keys and | |
| 6 | + | //! grants. Also the bundle a job's Claude Code loads on a runner. | |
| 7 | + | //! | |
| 8 | + | //! The agents service keeps all of it and decides who may do what | |
| 9 | + | //! (`services/agents/src/customize-rpc.ts`): members see, owners change, | |
| 10 | + | //! and the bundle is read with a job's agent token or a workspace's. The | |
| 11 | + | //! bundle holds connectors' secrets: the API hands it on and never keeps | |
| 12 | + | //! or logs it. OAuth sign-in to a connector needs a browser, so it stays | |
| 13 | + | //! on the site. | |
| 14 | + | ||
| 15 | + | use g1t_contracts::rules::actor_kind; | |
| 16 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 17 | + | use serde_json::{Value, json}; | |
| 18 | + | use worker::Result; | |
| 19 | + | ||
| 20 | + | use crate::operations::Services; | |
| 21 | + | ||
| 22 | + | /// One operation on a workspace's Customize. | |
| 23 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 24 | + | pub enum CustomizeOp { | |
| 25 | + | ListMarketplaces, | |
| 26 | + | AddMarketplace, | |
| 27 | + | RemoveMarketplace, | |
| 28 | + | RefreshMarketplace, | |
| 29 | + | Discover, | |
| 30 | + | ListPlugins, | |
| 31 | + | GetPlugin, | |
| 32 | + | InstallPlugin, | |
| 33 | + | SetPluginEnabled, | |
| 34 | + | UninstallPlugin, | |
| 35 | + | UpdatePlugin, | |
| 36 | + | ListPluginAgents, | |
| 37 | + | ListConnectors, | |
| 38 | + | GetConnector, | |
| 39 | + | AddConnector, | |
| 40 | + | SetConnectorKey, | |
| 41 | + | RemoveConnector, | |
| 42 | + | RefreshConnector, | |
| 43 | + | GrantConnector, | |
| 44 | + | RevokeConnector, | |
| 45 | + | Bundle, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl CustomizeOp { | |
| 49 | + | /// Every one: `Op::ALL` lists each as `Op::Customize(…)`, which a test | |
| 50 | + | /// checks against this. | |
| 51 | + | #[cfg(test)] | |
| 52 | + | pub const ALL: [CustomizeOp; 21] = [ | |
| 53 | + | CustomizeOp::ListMarketplaces, | |
| 54 | + | CustomizeOp::AddMarketplace, | |
| 55 | + | CustomizeOp::RemoveMarketplace, | |
| 56 | + | CustomizeOp::RefreshMarketplace, | |
| 57 | + | CustomizeOp::Discover, | |
| 58 | + | CustomizeOp::ListPlugins, | |
| 59 | + | CustomizeOp::GetPlugin, | |
| 60 | + | CustomizeOp::InstallPlugin, | |
| 61 | + | CustomizeOp::SetPluginEnabled, | |
| 62 | + | CustomizeOp::UninstallPlugin, | |
| 63 | + | CustomizeOp::UpdatePlugin, | |
| 64 | + | CustomizeOp::ListPluginAgents, | |
| 65 | + | CustomizeOp::ListConnectors, | |
| 66 | + | CustomizeOp::GetConnector, | |
| 67 | + | CustomizeOp::AddConnector, | |
| 68 | + | CustomizeOp::SetConnectorKey, | |
| 69 | + | CustomizeOp::RemoveConnector, | |
| 70 | + | CustomizeOp::RefreshConnector, | |
| 71 | + | CustomizeOp::GrantConnector, | |
| 72 | + | CustomizeOp::RevokeConnector, | |
| 73 | + | CustomizeOp::Bundle, | |
| 74 | + | ]; | |
| 75 | + | ||
| 76 | + | pub fn name(self) -> &'static str { | |
| 77 | + | match self { | |
| 78 | + | CustomizeOp::ListMarketplaces => "list_marketplaces", | |
| 79 | + | CustomizeOp::AddMarketplace => "add_marketplace", | |
| 80 | + | CustomizeOp::RemoveMarketplace => "remove_marketplace", | |
| 81 | + | CustomizeOp::RefreshMarketplace => "refresh_marketplace", | |
| 82 | + | CustomizeOp::Discover => "discover", | |
| 83 | + | CustomizeOp::ListPlugins => "list_plugins", | |
| 84 | + | CustomizeOp::GetPlugin => "get_plugin", | |
| 85 | + | CustomizeOp::InstallPlugin => "install_plugin", | |
| 86 | + | CustomizeOp::SetPluginEnabled => "set_plugin_enabled", | |
| 87 | + | CustomizeOp::UninstallPlugin => "uninstall_plugin", | |
| 88 | + | CustomizeOp::UpdatePlugin => "update_plugin", | |
| 89 | + | CustomizeOp::ListPluginAgents => "list_plugin_agents", | |
| 90 | + | CustomizeOp::ListConnectors => "list_connectors", | |
| 91 | + | CustomizeOp::GetConnector => "get_connector", | |
| 92 | + | CustomizeOp::AddConnector => "add_connector", | |
| 93 | + | CustomizeOp::SetConnectorKey => "set_connector_key", | |
| 94 | + | CustomizeOp::RemoveConnector => "remove_connector", | |
| 95 | + | CustomizeOp::RefreshConnector => "refresh_connector", | |
| 96 | + | CustomizeOp::GrantConnector => "grant_connector", | |
| 97 | + | CustomizeOp::RevokeConnector => "revoke_connector", | |
| 98 | + | CustomizeOp::Bundle => "customize_bundle", | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// The agents service method it passes through to. | |
| 103 | + | fn method(self) -> &'static str { | |
| 104 | + | match self { | |
| 105 | + | CustomizeOp::ListMarketplaces => "marketplaces", | |
| 106 | + | CustomizeOp::AddMarketplace => "add_marketplace", | |
| 107 | + | CustomizeOp::RemoveMarketplace => "remove_marketplace", | |
| 108 | + | CustomizeOp::RefreshMarketplace => "refresh_marketplace", | |
| 109 | + | CustomizeOp::Discover => "discover", | |
| 110 | + | CustomizeOp::ListPlugins => "plugin_installs", | |
| 111 | + | CustomizeOp::GetPlugin => "plugin", | |
| 112 | + | CustomizeOp::InstallPlugin => "install_plugin", | |
| 113 | + | CustomizeOp::SetPluginEnabled => "set_plugin_enabled", | |
| 114 | + | CustomizeOp::UninstallPlugin => "uninstall_plugin", | |
| 115 | + | CustomizeOp::UpdatePlugin => "update_plugin", | |
| 116 | + | CustomizeOp::ListPluginAgents => "plugin_agents", | |
| 117 | + | CustomizeOp::ListConnectors => "connectors", | |
| 118 | + | CustomizeOp::GetConnector => "connector", | |
| 119 | + | CustomizeOp::AddConnector => "add_connector", | |
| 120 | + | CustomizeOp::SetConnectorKey => "set_connector_key", | |
| 121 | + | CustomizeOp::RemoveConnector => "remove_connector", | |
| 122 | + | CustomizeOp::RefreshConnector => "refresh_connector", | |
| 123 | + | CustomizeOp::GrantConnector => "grant_connector", | |
| 124 | + | CustomizeOp::RevokeConnector => "revoke_connector", | |
| 125 | + | CustomizeOp::Bundle => "customize_bundle", | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// For the API reference. | |
| 130 | + | pub fn title(self) -> &'static str { | |
| 131 | + | match self { | |
| 132 | + | CustomizeOp::ListMarketplaces => "List marketplaces", | |
| 133 | + | CustomizeOp::AddMarketplace => "Add a marketplace", | |
| 134 | + | CustomizeOp::RemoveMarketplace => "Remove a marketplace", | |
| 135 | + | CustomizeOp::RefreshMarketplace => "Refresh a marketplace", | |
| 136 | + | CustomizeOp::Discover => "Discover plugins and skills", | |
| 137 | + | CustomizeOp::ListPlugins => "List installed plugins", | |
| 138 | + | CustomizeOp::GetPlugin => "Get a plugin", | |
| 139 | + | CustomizeOp::InstallPlugin => "Install a plugin", | |
| 140 | + | CustomizeOp::SetPluginEnabled => "Switch a plugin on or off", | |
| 141 | + | CustomizeOp::UninstallPlugin => "Uninstall a plugin", | |
| 142 | + | CustomizeOp::UpdatePlugin => "Update a plugin", | |
| 143 | + | CustomizeOp::ListPluginAgents => "List plugin agents", | |
| 144 | + | CustomizeOp::ListConnectors => "List connectors", | |
| 145 | + | CustomizeOp::GetConnector => "Get a connector", | |
| 146 | + | CustomizeOp::AddConnector => "Add a connector", | |
| 147 | + | CustomizeOp::SetConnectorKey => "Set a connector's key", | |
| 148 | + | CustomizeOp::RemoveConnector => "Remove a connector", | |
| 149 | + | CustomizeOp::RefreshConnector => "Refresh a connector's tools", | |
| 150 | + | CustomizeOp::GrantConnector => "Give a connector to agents", | |
| 151 | + | CustomizeOp::RevokeConnector => "Take a connector back", | |
| 152 | + | CustomizeOp::Bundle => "Get the runner bundle", | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | pub fn description(self) -> &'static str { | |
| 157 | + | match self { | |
| 158 | + | CustomizeOp::ListMarketplaces => "The marketplaces a workspace reads plugins from, in the open plugin format (`.claude-plugin/marketplace.json`): each with its `name` (the `@marketplace` part of a plugin's ref), `source` (`github` with `repo`, `g1t` with `repo`, `git` or `url` with `url`, or `builtin`), `builtin` (every workspace has it and it cannot be removed: claude-plugins-official, anthropic-agent-skills and g1t), `about`, `owner`, how many `plugins` it lists, `fetched_at` and `problem` (why its manifest could not be read, when it could not), who added it and when, and `can_remove`. Members only.", | |
| 159 | + | CustomizeOp::AddMarketplace => "Add a marketplace by `address`: `owner/repo` on GitHub, `workspace/name` for a repository on this g1t, a git address, or a link to a `marketplace.json`. `name` overrides the one its manifest gives. Its manifest is read at once; a marketplace that cannot be read is refused with `422`, saying why. At most 20 a workspace. Owners only. Recorded in the audit log.", | |
| 160 | + | CustomizeOp::RemoveMarketplace => "Remove a marketplace by its name (`marketplace`). Plugins installed from it stay installed, but get no updates and cannot be installed again until it is added back. Built-in marketplaces cannot be removed (`409`). Owners only. Recorded in the audit log.", | |
| 161 | + | CustomizeOp::RefreshMarketplace => "Read a marketplace's manifest again now, by its name (`marketplace`), and return it as list_marketplaces gives it. Marketplaces are refreshed on their own in the background; this is for seeing a change at once. Owners only.", | |
| 162 | + | CustomizeOp::Discover => "What a workspace can add, from every marketplace it reads: `plugins`, one per listing (`ref` as `name@marketplace`, `display_name`, `description`, `version`, `author`, `category`, `tags`, how g1t would `fetch` it, and `availability`: `available`, `installed` with `enabled`, `unavailable` with `why` for sources g1t cannot read such as npm or a command, or `soon` for g1t's own announced ones), `skills`, one per skill inside those plugins (`ref` as `plugin:skill`, with `added` when the library already has it), and the `marketplaces` themselves. Members only.", | |
| 163 | + | CustomizeOp::ListPlugins => "The plugins a workspace installed, switched off ones included: each with its `ref` (`name@marketplace`), `name`, `marketplace`, `display_name`, `description`, `version`, the commit it was read at (`sha`), how it is fetched (`fetch`), what it brought (`components`: skills, agents, commands, mcp_servers, and whether it carries hooks, lsp, bin or output_styles, which only Claude Code on a runner uses), `problems` met when installing, `enabled`, who installed it and when, and `update` when the marketplace lists a newer version. Members only.", | |
| 164 | + | CustomizeOp::GetPlugin => "One plugin's page by `ref` (`name@marketplace`, URL-encoded in a path: `@` as `%40`), installed or not: its marketplace `listing`, its `install` if any, its `components` and `readme` read from its source, its `manifest`, its subagents in full (`agents`) for hiring from, `problems`, and the marketplace it is listed in (`source`). Reads the plugin's files, so it takes a moment. Members only.", | |
| 165 | + | CustomizeOp::InstallPlugin => "Install a plugin by `ref` (`name@marketplace`) from a marketplace the workspace reads, at the commit its listing names, which it stays pinned to. Its skills and commands join the workspace's library as `plugin:skill`, its subagents become templates to hire from, and the remote MCP servers of its `.mcp.json` become connectors, unconnected until an owner signs in or gives a key; its hooks, LSP servers, output styles and binaries reach Claude Code on runners and never g1t's own agents. A plugin g1t cannot fetch (npm, a command, a git host g1t does not read) is refused with `422`. At most 100 a workspace. Owners only. Recorded in the audit log.", | |
| 166 | + | CustomizeOp::SetPluginEnabled => "Switch an installed plugin on or off by `ref`: `enabled` false keeps it installed but takes its skills, agents and connectors away from agents and leaves it out of runners' bundles; true brings them back. Owners only. Recorded in the audit log.", | |
| 167 | + | CustomizeOp::UninstallPlugin => "Uninstall a plugin by `ref`: its skills leave the library, its agent templates go, and the connectors it brought are removed with their secrets. Agents hired from its templates stay. Owners only. Recorded in the audit log.", | |
| 168 | + | CustomizeOp::UpdatePlugin => "Update an installed plugin by `ref`: read it again at the commit its marketplace now lists and pin it there. Its skills get new versions and keep their attachments, skills it no longer has are archived, its agent templates and components are rewritten, and a server it already brought keeps its connector; new remote servers become connectors. A plugin already at its marketplace's commit comes back unchanged. list_plugins shows `update` when there is one to take. Owners only. Recorded in the audit log.", | |
| 169 | + | CustomizeOp::ListPluginAgents => "The subagents installed plugins brought, as templates to hire an agent from: each with its `ref` (`plugin:agent`), `plugin`, `marketplace`, `name`, `description`, the system prompt as `prompt`, and its `model`, `skills` and `tools`. Members only.", | |
| 170 | + | CustomizeOp::ListConnectors => "A workspace's connectors: MCP servers connected once and given to agents. Each has its `id` (`con_…`), `name` (tools are offered to agents as `<name>__<tool>`), `title`, `url`, `category`, `auth` (`none`, `header` or `oauth`), `state` (`connected`, `needs_sign_in`, `needs_key` or `problem`, with `problem` saying why), `from` (the directory, a plugin's server, an address, or one agent's own server), its `tools` as last listed and `checked_at`, `header_name` for a key (the key itself is never returned), who it is `signed_in_as` for OAuth, its `grants` (where it reaches: every agent, a team or one agent), and who added it. g1t's own integrations (GitHub, trackers, alert sources, model providers) are list_integrations. Members only.", | |
| 171 | + | CustomizeOp::GetConnector => "One connector by `id`, in the shape list_connectors gives. Members only.", | |
| 172 | + | CustomizeOp::AddConnector => "Add a connector. `from` says where it comes from: `{\"kind\": \"directory\", \"id\"}` for one of the directory's remote MCP servers, `{\"kind\": \"plugin\", \"ref\", \"server\"}` for a server an installed plugin names, or `{\"kind\": \"address\", \"name\", \"url\"}` for any MCP server over HTTP (`name` is 2 to 32 lowercase letters, digits and hyphens). `auth` is `none`, `header` (give `header` with the header's `name` and `value`, kept sealed and never returned) or `oauth` (an owner then signs in on the site: sign-in needs a browser, so it has no route). Its tools are listed as soon as it can be reached. At most 50 a workspace. Owners only. Recorded in the audit log.", | |
| 173 | + | CustomizeOp::SetConnectorKey => "Put a key on a connector by `id`: `value` is the header's value, kept sealed and never returned, and `header_name` the header it is sent as (the one it had, or Authorization, when left out). Makes the connector use header auth, lists its tools again, and returns it. Owners only. Recorded in the audit log.", | |
| 174 | + | CustomizeOp::RemoveConnector => "Remove a connector by `id`, with its secrets and grants. Agents that had it lose its tools at their next turn. Owners only. Recorded in the audit log.", | |
| 175 | + | CustomizeOp::RefreshConnector => "Ask a connector's server for its tools again now, by `id`, and return the connector with its `tools`, `checked_at` and `state` as they are now. Owners only.", | |
| 176 | + | CustomizeOp::GrantConnector => "Give a connector by `id` to agents: `scope` is `workspace` (every agent), `team` (with `target`, the team's slug) or `agent` (with `target`, the agent's handle). Returns the connector with its `grants`. Owners only. Recorded in the audit log.", | |
| 177 | + | CustomizeOp::RevokeConnector => "Take a connector back from where one grant reaches, by the connector's `id` and the grant's id (`grant`). Returns the connector with its `grants`. Owners only. Recorded in the audit log.", | |
| 178 | + | CustomizeOp::Bundle => "What a job's Claude Code loads from the workspace's Customize, read once per job with the job's agent token (or a workspace's token; a person's token is refused): `plugins` to fetch at their pinned commit (`ref`, `name`, `fetch`, `version`; switched-off ones left out), the library `skills` that reach the job's agent as files (each `name` with its `files`: `path`, `content`, `encoding` utf8 or base64; a plugin's skills come with the plugin), and the `connectors` that reach it and are connected, each with its `name`, `url` and the `headers` that sign it in. Secrets are in it: it is for the runner, and g1t never keeps or logs it. See self-hosted runners, \"What a run gets from Customize\".", | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | pub fn input(self) -> Value { | |
| 183 | + | let workspace = json!({ "type": "string", "description": "The workspace's slug." }); | |
| 184 | + | let marketplace = json!({ "type": "string", "description": "The marketplace's name, as list_marketplaces gives it." }); | |
| 185 | + | let plugin_ref = json!({ "type": "string", "description": "The plugin as name@marketplace, such as commit-commands@claude-plugins-official." }); | |
| 186 | + | let id = json!({ "type": "string", "description": "The connector's id (con_…), from list_connectors." }); | |
| 187 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 188 | + | CustomizeOp::ListMarketplaces | |
| 189 | + | | CustomizeOp::Discover | |
| 190 | + | | CustomizeOp::ListPlugins | |
| 191 | + | | CustomizeOp::ListPluginAgents | |
| 192 | + | | CustomizeOp::ListConnectors | |
| 193 | + | | CustomizeOp::Bundle => (json!({ "workspace": workspace }), &["workspace"]), | |
| 194 | + | CustomizeOp::AddMarketplace => ( | |
| 195 | + | json!({ | |
| 196 | + | "workspace": workspace, | |
| 197 | + | "address": { "type": "string", "description": "owner/repo on GitHub, workspace/name for a repository here, a git address, or a link to a marketplace.json." }, | |
| 198 | + | "name": { "type": "string", "description": "What to call it here. The name its manifest gives when left out." }, | |
| 199 | + | }), | |
| 200 | + | &["workspace", "address"], | |
| 201 | + | ), | |
| 202 | + | CustomizeOp::RemoveMarketplace | CustomizeOp::RefreshMarketplace => { | |
| 203 | + | (json!({ "workspace": workspace, "marketplace": marketplace }), &["workspace", "marketplace"]) | |
| 204 | + | } | |
| 205 | + | CustomizeOp::GetPlugin | CustomizeOp::InstallPlugin | CustomizeOp::UninstallPlugin | CustomizeOp::UpdatePlugin => { | |
| 206 | + | (json!({ "workspace": workspace, "ref": plugin_ref }), &["workspace", "ref"]) | |
| 207 | + | } | |
| 208 | + | CustomizeOp::SetPluginEnabled => ( | |
| 209 | + | json!({ | |
| 210 | + | "workspace": workspace, | |
| 211 | + | "ref": plugin_ref, | |
| 212 | + | "enabled": { "type": "boolean", "description": "True to switch it on, false to switch it off." }, | |
| 213 | + | }), | |
| 214 | + | &["workspace", "ref", "enabled"], | |
| 215 | + | ), | |
| 216 | + | CustomizeOp::GetConnector | CustomizeOp::RemoveConnector | CustomizeOp::RefreshConnector => (json!({ "workspace": workspace, "id": id }), &["workspace", "id"]), | |
| 217 | + | CustomizeOp::AddConnector => ( | |
| 218 | + | json!({ | |
| 219 | + | "workspace": workspace, | |
| 220 | + | "from": { | |
| 221 | + | "type": "object", | |
| 222 | + | "description": "Where it comes from: {\"kind\": \"directory\", \"id\"}, {\"kind\": \"plugin\", \"ref\", \"server\"} or {\"kind\": \"address\", \"name\", \"url\"}.", | |
| 223 | + | "properties": { | |
| 224 | + | "kind": { "type": "string", "enum": ["directory", "plugin", "address"] }, | |
| 225 | + | "id": { "type": "string", "description": "For directory: the directory entry's id." }, | |
| 226 | + | "ref": { "type": "string", "description": "For plugin: the installed plugin, as name@marketplace." }, | |
| 227 | + | "server": { "type": "string", "description": "For plugin: the server's name in its .mcp.json." }, | |
| 228 | + | "name": { "type": "string", "description": "For address: 2 to 32 lowercase letters, digits and hyphens." }, | |
| 229 | + | "url": { "type": "string", "description": "For address: the MCP server's https address." }, | |
| 230 | + | }, | |
| 231 | + | "required": ["kind"], | |
| 232 | + | }, | |
| 233 | + | "auth": { "type": "string", "enum": ["none", "header", "oauth"], "description": "How it signs in. none when left out." }, | |
| 234 | + | "header": { | |
| 235 | + | "type": ["object", "null"], | |
| 236 | + | "description": "For header auth: the header's name and value. The value is kept sealed and never returned.", | |
| 237 | + | "properties": { | |
| 238 | + | "name": { "type": "string" }, | |
| 239 | + | "value": { "type": "string" }, | |
| 240 | + | }, | |
| 241 | + | "required": ["name", "value"], | |
| 242 | + | }, | |
| 243 | + | }), | |
| 244 | + | &["workspace", "from"], | |
| 245 | + | ), | |
| 246 | + | CustomizeOp::SetConnectorKey => ( | |
| 247 | + | json!({ | |
| 248 | + | "workspace": workspace, | |
| 249 | + | "id": id, | |
| 250 | + | "header_name": { "type": "string", "description": "The header the key is sent as. The one it had, or Authorization, when left out." }, | |
| 251 | + | "value": { "type": "string", "description": "The key. Kept sealed; never returned." }, | |
| 252 | + | }), | |
| 253 | + | &["workspace", "id", "value"], | |
| 254 | + | ), | |
| 255 | + | CustomizeOp::GrantConnector => ( | |
| 256 | + | json!({ | |
| 257 | + | "workspace": workspace, | |
| 258 | + | "id": id, | |
| 259 | + | "scope": { "type": "string", "enum": ["workspace", "team", "agent"], "description": "Every agent, a team's agents, or one agent." }, | |
| 260 | + | "target": { "type": "string", "description": "The team's slug or the agent's handle; nothing for workspace." }, | |
| 261 | + | }), | |
| 262 | + | &["workspace", "id", "scope"], | |
| 263 | + | ), | |
| 264 | + | CustomizeOp::RevokeConnector => ( | |
| 265 | + | json!({ | |
| 266 | + | "workspace": workspace, | |
| 267 | + | "id": id, | |
| 268 | + | "grant": { "type": "string", "description": "The grant's id, from the connector's grants." }, | |
| 269 | + | }), | |
| 270 | + | &["workspace", "id", "grant"], | |
| 271 | + | ), | |
| 272 | + | }; | |
| 273 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | fn text(input: &Value, key: &str) -> String { | |
| 278 | + | input[key].as_str().map(str::trim).unwrap_or_default().to_owned() | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// A boolean as sent: JSON, or a word from a form. | |
| 282 | + | fn flag(value: &Value) -> Option<bool> { | |
| 283 | + | match value { | |
| 284 | + | Value::Bool(flag) => Some(*flag), | |
| 285 | + | Value::String(word) => match word.trim().to_ascii_lowercase().as_str() { | |
| 286 | + | "true" | "1" => Some(true), | |
| 287 | + | "false" | "0" => Some(false), | |
| 288 | + | _ => None, | |
| 289 | + | }, | |
| 290 | + | _ => None, | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// What an operation asks the agents service beyond the workspace and the | |
| 295 | + | /// viewer, read from the input, or why the input will not do. | |
| 296 | + | fn arguments(op: CustomizeOp, input: &Value) -> std::result::Result<Value, String> { | |
| 297 | + | let mut args = json!({}); | |
| 298 | + | let need = |key: &str, what: &str| { | |
| 299 | + | let value = text(input, key); | |
| 300 | + | if value.is_empty() { | |
| 301 | + | Err(what.to_owned()) | |
| 302 | + | } else { | |
| 303 | + | Ok(value) | |
| 304 | + | } | |
| 305 | + | }; | |
| 306 | + | match op { | |
| 307 | + | CustomizeOp::ListMarketplaces | |
| 308 | + | | CustomizeOp::Discover | |
| 309 | + | | CustomizeOp::ListPlugins | |
| 310 | + | | CustomizeOp::ListPluginAgents | |
| 311 | + | | CustomizeOp::ListConnectors | |
| 312 | + | | CustomizeOp::Bundle => {} | |
| 313 | + | CustomizeOp::AddMarketplace => { | |
| 314 | + | args["address"] = json!(need("address", "Give address: owner/repo, workspace/name, a git address or a link to marketplace.json.")?); | |
| 315 | + | if let Some(name) = input["name"].as_str().map(str::trim).filter(|name| !name.is_empty()) { | |
| 316 | + | args["name"] = json!(name); | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | CustomizeOp::RemoveMarketplace | CustomizeOp::RefreshMarketplace => { | |
| 320 | + | args["name"] = json!(need("marketplace", "Name the marketplace.")?); | |
| 321 | + | } | |
| 322 | + | CustomizeOp::GetPlugin | CustomizeOp::InstallPlugin | CustomizeOp::UninstallPlugin | CustomizeOp::UpdatePlugin => { | |
| 323 | + | args["ref"] = json!(need("ref", "Name the plugin as name@marketplace.")?); | |
| 324 | + | } | |
| 325 | + | CustomizeOp::SetPluginEnabled => { | |
| 326 | + | args["ref"] = json!(need("ref", "Name the plugin as name@marketplace.")?); | |
| 327 | + | args["enabled"] = json!(flag(&input["enabled"]).ok_or("enabled is true or false.")?); | |
| 328 | + | } | |
| 329 | + | CustomizeOp::GetConnector | CustomizeOp::RemoveConnector | CustomizeOp::RefreshConnector => { | |
| 330 | + | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 331 | + | } | |
| 332 | + | CustomizeOp::AddConnector => { | |
| 333 | + | let from = &input["from"]; | |
| 334 | + | if !matches!(from["kind"].as_str(), Some("directory" | "plugin" | "address")) { | |
| 335 | + | return Err("from.kind is directory, plugin or address.".to_owned()); | |
| 336 | + | } | |
| 337 | + | let auth = match input["auth"].as_str().map(str::trim) { | |
| 338 | + | None | Some("") | Some("none") => "none", | |
| 339 | + | Some(auth @ ("header" | "oauth")) => auth, | |
| 340 | + | Some(_) => return Err("auth is none, header or oauth.".to_owned()), | |
| 341 | + | }; | |
| 342 | + | let header = match &input["header"] { | |
| 343 | + | Value::Null => Value::Null, | |
| 344 | + | header if header["name"].is_string() && header["value"].is_string() => { | |
| 345 | + | json!({ "name": header["name"], "value": header["value"] }) | |
| 346 | + | } | |
| 347 | + | _ => return Err("header has a name and a value.".to_owned()), | |
| 348 | + | }; | |
| 349 | + | if auth == "header" && header.is_null() { | |
| 350 | + | return Err("Header auth needs header: its name and value.".to_owned()); | |
| 351 | + | } | |
| 352 | + | args["input"] = json!({ "from": from, "auth": auth, "header": header }); | |
| 353 | + | } | |
| 354 | + | CustomizeOp::SetConnectorKey => { | |
| 355 | + | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 356 | + | args["value"] = json!(need("value", "Give value: the key.")?); | |
| 357 | + | if let Some(name) = input["header_name"].as_str().map(str::trim).filter(|name| !name.is_empty()) { | |
| 358 | + | args["header_name"] = json!(name); | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | CustomizeOp::GrantConnector => { | |
| 362 | + | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 363 | + | let scope = text(input, "scope"); | |
| 364 | + | if !matches!(scope.as_str(), "workspace" | "team" | "agent") { | |
| 365 | + | return Err("scope is workspace, team or agent.".to_owned()); | |
| 366 | + | } | |
| 367 | + | if scope != "workspace" { | |
| 368 | + | args["target"] = json!(need("target", "Give target: the team's slug or the agent's handle.")?); | |
| 369 | + | } | |
| 370 | + | args["scope"] = json!(scope); | |
| 371 | + | } | |
| 372 | + | CustomizeOp::RevokeConnector => { | |
| 373 | + | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 374 | + | args["grant"] = json!(need("grant", "Name the grant by its id.")?); | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | Ok(args) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | pub async fn run(op: CustomizeOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 381 | + | let workspace = text(input, "workspace").to_lowercase(); | |
| 382 | + | if workspace.is_empty() { | |
| 383 | + | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 384 | + | } | |
| 385 | + | let actor = viewer.clone().unwrap_or_default(); | |
| 386 | + | // The bundle carries connectors' secrets: only a run's own token, or | |
| 387 | + | // the workspace's, reads it, never a person's. The agents service | |
| 388 | + | // checks this too. | |
| 389 | + | if op == CustomizeOp::Bundle && actor_kind(&actor) == "person" { | |
| 390 | + | return Ok(Outcome::fail( | |
| 391 | + | FailureCode::Forbidden, | |
| 392 | + | "The bundle is read with a job's agent token or a workspace's token, not a person's.", | |
| 393 | + | )); | |
| 394 | + | } | |
| 395 | + | let mut args = match arguments(op, input) { | |
| 396 | + | Ok(args) => args, | |
| 397 | + | Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, &why)), | |
| 398 | + | }; | |
| 399 | + | args["workspace"] = json!(workspace); | |
| 400 | + | args["viewer"] = json!(actor); | |
| 401 | + | g1t_kit::call(&services.agents, op.method(), &args).await | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[cfg(test)] | |
| 405 | + | mod tests { | |
| 406 | + | use super::*; | |
| 407 | + | use g1t_contracts::scopes::{Level, scope_for}; | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn each_operation_is_described_and_scoped() { | |
| 411 | + | for op in CustomizeOp::ALL { | |
| 412 | + | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Customize(op)), "{}", op.name()); | |
| 413 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 414 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); | |
| 415 | + | let scope = scope_for(op.name()).unwrap(); | |
| 416 | + | let changes = !matches!( | |
| 417 | + | op, | |
| 418 | + | CustomizeOp::ListMarketplaces | |
| 419 | + | | CustomizeOp::Discover | |
| 420 | + | | CustomizeOp::ListPlugins | |
| 421 | + | | CustomizeOp::GetPlugin | |
| 422 | + | | CustomizeOp::ListPluginAgents | |
| 423 | + | | CustomizeOp::ListConnectors | |
| 424 | + | | CustomizeOp::GetConnector | |
| 425 | + | | CustomizeOp::Bundle | |
| 426 | + | ); | |
| 427 | + | let expected = match op { | |
| 428 | + | CustomizeOp::Bundle => Level::Run, | |
| 429 | + | _ if changes => Level::Admin, | |
| 430 | + | _ => Level::Read, | |
| 431 | + | }; | |
| 432 | + | assert_eq!(scope.level(), expected, "{}", op.name()); | |
| 433 | + | } | |
| 434 | + | assert_eq!(scope_for("customize_bundle").unwrap().as_str(), "agents:run"); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn inputs_are_checked_before_the_call() { | |
| 439 | + | let bad = |op, input: Value| arguments(op, &input).unwrap_err(); | |
| 440 | + | assert!(bad(CustomizeOp::AddMarketplace, json!({})).starts_with("Give address")); | |
| 441 | + | assert!(bad(CustomizeOp::SetPluginEnabled, json!({ "ref": "a@b" })).contains("enabled")); | |
| 442 | + | assert!(bad(CustomizeOp::AddConnector, json!({ "from": { "kind": "elsewhere" } })).contains("from.kind")); | |
| 443 | + | assert!(bad(CustomizeOp::AddConnector, json!({ "from": { "kind": "address", "name": "x", "url": "https://x" }, "auth": "header" })).contains("header")); | |
| 444 | + | assert!(bad(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "team" })).starts_with("Give target")); | |
| 445 | + | assert!(bad(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "everyone" })).contains("scope")); | |
| 446 | + | ||
| 447 | + | let ok = |op, input: Value| arguments(op, &input).unwrap(); | |
| 448 | + | assert_eq!(ok(CustomizeOp::SetPluginEnabled, json!({ "ref": "a@b", "enabled": "false" })), json!({ "ref": "a@b", "enabled": false })); | |
| 449 | + | assert_eq!(ok(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "workspace", "target": "ignored" })), json!({ "id": "con_1", "scope": "workspace" })); | |
| 450 | + | assert_eq!( | |
| 451 | + | ok(CustomizeOp::AddConnector, json!({ "from": { "kind": "directory", "id": "linear" } })), | |
| 452 | + | json!({ "input": { "from": { "kind": "directory", "id": "linear" }, "auth": "none", "header": null } }) | |
| 453 | + | ); | |
| 454 | + | assert_eq!( | |
| 455 | + | ok(CustomizeOp::SetConnectorKey, json!({ "id": "con_1", "value": "k", "header_name": " X-Api-Key " })), | |
| 456 | + | json!({ "id": "con_1", "value": "k", "header_name": "X-Api-Key" }) | |
| 457 | + | ); | |
| 458 | + | assert_eq!(ok(CustomizeOp::GetPlugin, json!({ "ref": "commit-commands@claude-plugins-official" }))["ref"], "commit-commands@claude-plugins-official"); | |
| 459 | + | // The path names a marketplace as `marketplace`; the agents service as `name`. | |
| 460 | + | assert_eq!(ok(CustomizeOp::RemoveMarketplace, json!({ "marketplace": "flagon-tools" })), json!({ "name": "flagon-tools" })); | |
| 461 | + | assert!(bad(CustomizeOp::RefreshMarketplace, json!({ "name": "flagon-tools" })).starts_with("Name the marketplace")); | |
| 462 | + | } | |
| 463 | + | } |
| 12 | 12 | mod billing; | |
| 13 | 13 | mod blobs; | |
| 14 | 14 | mod checks; | |
| 15 | + | mod customize; | |
| 15 | 16 | mod deployments; | |
| 16 | 17 | mod deploy_keys; | |
| 17 | 18 | mod limits; |
| 11 | 11 | const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"]; | |
| 12 | 12 | ||
| 13 | 13 | const INSTRUCTIONS: &str = "g1t is a git forge where people and agents work through issues and pull requests. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers. | |
| 14 | − | Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, account, notifications. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 14 | + | Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, customize, account, notifications. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs. | |
| 15 | 15 | Find a repository: account whoami lists your workspaces; repository list or search finds one. | |
| 16 | 16 | Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Record your reasoning with pull_request record_session as you go, push, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again. | |
| 17 | 17 | Hand work to g1t's agent: agent delegate opens an issue and starts it in one step; agent assign starts it on an existing issue. Each costs the workspace money. |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::run_artifacts::ArtifactsOp; | |
| 12 | + | use crate::customize::CustomizeOp; | |
| 12 | 13 | use crate::deploy_keys::DeployKeysOp; | |
| 13 | 14 | use crate::mirrors::MirrorsOp; | |
| 14 | 15 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 284 | 285 | ], | |
| 285 | 286 | ), | |
| 286 | 287 | ( | |
| 288 | + | "Security advisories", | |
| 289 | + | "What a repository says about vulnerabilities in what it ships: advisories drafted, published and withdrawn by its maintainers, and the vulnerability reports people send them privately when the repository takes them.", | |
| 290 | + | &[ | |
| 291 | + | Op::Security(SecurityOp::ListAdvisories), | |
| 292 | + | Op::Security(SecurityOp::CreateAdvisory), | |
| 293 | + | Op::Security(SecurityOp::GetAdvisory), | |
| 294 | + | Op::Security(SecurityOp::UpdateAdvisory), | |
| 295 | + | Op::Security(SecurityOp::SetAdvisoryState), | |
| 296 | + | Op::Security(SecurityOp::ListReports), | |
| 297 | + | Op::Security(SecurityOp::ReportVulnerability), | |
| 298 | + | Op::Security(SecurityOp::ReviewReport), | |
| 299 | + | ], | |
| 300 | + | ), | |
| 301 | + | ( | |
| 287 | 302 | "Rules", | |
| 288 | 303 | "Rulesets: what may happen to a repository's branches and tags and what a pull request needs before it merges, for a repository or across a workspace; the rules that hold for one branch; and how they judged each push and merge, with insights.", | |
| 289 | 304 | &[ | |
| ⋯ | |||
| 550 | 565 | ], | |
| 551 | 566 | ), | |
| 552 | 567 | ( | |
| 568 | + | "Customize", | |
| 569 | + | "What shapes how a workspace's agents work, in the formats Claude Code reads: the marketplaces it reads plugins from, what Discover lists, the plugins it installed (their skills, subagents and remote MCP servers reach g1t's agents; the rest reaches Claude Code on a runner), and its connectors: MCP servers connected once, then given to every agent, a team or one agent. The bundle is what a job's Claude Code loads on a runner.", | |
| 570 | + | &[ | |
| 571 | + | Op::Customize(CustomizeOp::ListMarketplaces), | |
| 572 | + | Op::Customize(CustomizeOp::AddMarketplace), | |
| 573 | + | Op::Customize(CustomizeOp::RemoveMarketplace), | |
| 574 | + | Op::Customize(CustomizeOp::RefreshMarketplace), | |
| 575 | + | Op::Customize(CustomizeOp::Discover), | |
| 576 | + | Op::Customize(CustomizeOp::ListPlugins), | |
| 577 | + | Op::Customize(CustomizeOp::GetPlugin), | |
| 578 | + | Op::Customize(CustomizeOp::InstallPlugin), | |
| 579 | + | Op::Customize(CustomizeOp::SetPluginEnabled), | |
| 580 | + | Op::Customize(CustomizeOp::UninstallPlugin), | |
| 581 | + | Op::Customize(CustomizeOp::UpdatePlugin), | |
| 582 | + | Op::Customize(CustomizeOp::ListPluginAgents), | |
| 583 | + | Op::Customize(CustomizeOp::ListConnectors), | |
| 584 | + | Op::Customize(CustomizeOp::GetConnector), | |
| 585 | + | Op::Customize(CustomizeOp::AddConnector), | |
| 586 | + | Op::Customize(CustomizeOp::SetConnectorKey), | |
| 587 | + | Op::Customize(CustomizeOp::RemoveConnector), | |
| 588 | + | Op::Customize(CustomizeOp::RefreshConnector), | |
| 589 | + | Op::Customize(CustomizeOp::GrantConnector), | |
| 590 | + | Op::Customize(CustomizeOp::RevokeConnector), | |
| 591 | + | Op::Customize(CustomizeOp::Bundle), | |
| 592 | + | ], | |
| 593 | + | ), | |
| 594 | + | ( | |
| 553 | 595 | "Artifacts", | |
| 554 | 596 | "A workspace's docs, slides, designs and dashboards (Artifacts mode): listing and searching the ones you can open, reading and editing their content in Markdown, making, moving, trashing and restoring them, their versions, and who can open them. Each answers for your own role on it. Not workflow runs' artifacts, which are under Actions. Slides, designs and dashboards answer that they are not here yet.", | |
| 555 | 597 | &[ | |
| ⋯ | |||
| 794 | 836 | Op::Mirrors(op) => op.title(), | |
| 795 | 837 | Op::Packages(op) => op.title(), | |
| 796 | 838 | Op::Folios(op) => op.title(), | |
| 839 | + | Op::Customize(op) => op.title(), | |
| 797 | 840 | } | |
| 798 | 841 | } | |
| 799 | 842 | ||
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::run_artifacts::ArtifactsOp; | |
| 32 | + | use crate::customize::CustomizeOp; | |
| 32 | 33 | use crate::deploy_keys::DeployKeysOp; | |
| 33 | 34 | use crate::mirrors::MirrorsOp; | |
| 34 | 35 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 338 | 339 | /// Artifacts mode's docs, slides, designs and dashboards, kept by the | |
| 339 | 340 | /// artifacts service: folios.rs. | |
| 340 | 341 | Folios(FoliosOp), | |
| 342 | + | /// A workspace's Customize: marketplaces, plugins, connectors and the | |
| 343 | + | /// bundle a runner loads, kept by the agents service: customize.rs. | |
| 344 | + | Customize(CustomizeOp), | |
| 341 | 345 | } | |
| 342 | 346 | ||
| 343 | 347 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 707 | 711 | } | |
| 708 | 712 | ||
| 709 | 713 | impl Op { | |
| 710 | − | pub const ALL: [Op; 351] = [ | |
| 714 | + | pub const ALL: [Op; 380] = [ | |
| 711 | 715 | Op::Whoami, | |
| 712 | 716 | Op::GetWorkspace, | |
| 713 | 717 | Op::CreateWorkspace, | |
| ⋯ | |||
| 936 | 940 | Op::Security(SecurityOp::GetWorkspaceSettings), | |
| 937 | 941 | Op::Security(SecurityOp::UpdateWorkspaceSettings), | |
| 938 | 942 | Op::Security(SecurityOp::GetOverview), | |
| 943 | + | Op::Security(SecurityOp::ListAdvisories), | |
| 944 | + | Op::Security(SecurityOp::GetAdvisory), | |
| 945 | + | Op::Security(SecurityOp::CreateAdvisory), | |
| 946 | + | Op::Security(SecurityOp::UpdateAdvisory), | |
| 947 | + | Op::Security(SecurityOp::SetAdvisoryState), | |
| 948 | + | Op::Security(SecurityOp::ListReports), | |
| 949 | + | Op::Security(SecurityOp::ReportVulnerability), | |
| 950 | + | Op::Security(SecurityOp::ReviewReport), | |
| 939 | 951 | Op::Rules(RulesOp::ListRepoRulesets), | |
| 940 | 952 | Op::Rules(RulesOp::GetRepoRuleset), | |
| 941 | 953 | Op::Rules(RulesOp::CreateRepoRuleset), | |
| ⋯ | |||
| 995 | 1007 | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 996 | 1008 | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 997 | 1009 | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 1010 | + | Op::Customize(CustomizeOp::ListMarketplaces), | |
| 1011 | + | Op::Customize(CustomizeOp::AddMarketplace), | |
| 1012 | + | Op::Customize(CustomizeOp::RemoveMarketplace), | |
| 1013 | + | Op::Customize(CustomizeOp::RefreshMarketplace), | |
| 1014 | + | Op::Customize(CustomizeOp::Discover), | |
| 1015 | + | Op::Customize(CustomizeOp::ListPlugins), | |
| 1016 | + | Op::Customize(CustomizeOp::GetPlugin), | |
| 1017 | + | Op::Customize(CustomizeOp::InstallPlugin), | |
| 1018 | + | Op::Customize(CustomizeOp::SetPluginEnabled), | |
| 1019 | + | Op::Customize(CustomizeOp::UninstallPlugin), | |
| 1020 | + | Op::Customize(CustomizeOp::UpdatePlugin), | |
| 1021 | + | Op::Customize(CustomizeOp::ListPluginAgents), | |
| 1022 | + | Op::Customize(CustomizeOp::ListConnectors), | |
| 1023 | + | Op::Customize(CustomizeOp::GetConnector), | |
| 1024 | + | Op::Customize(CustomizeOp::AddConnector), | |
| 1025 | + | Op::Customize(CustomizeOp::SetConnectorKey), | |
| 1026 | + | Op::Customize(CustomizeOp::RemoveConnector), | |
| 1027 | + | Op::Customize(CustomizeOp::RefreshConnector), | |
| 1028 | + | Op::Customize(CustomizeOp::GrantConnector), | |
| 1029 | + | Op::Customize(CustomizeOp::RevokeConnector), | |
| 1030 | + | Op::Customize(CustomizeOp::Bundle), | |
| 998 | 1031 | Op::Mirrors(MirrorsOp::GetMirror), | |
| 999 | 1032 | Op::Mirrors(MirrorsOp::GetHandBackPlan), | |
| 1000 | 1033 | Op::Mirrors(MirrorsOp::TakeOver), | |
| ⋯ | |||
| 1277 | 1310 | Op::Mirrors(op) => op.name(), | |
| 1278 | 1311 | Op::Packages(op) => op.name(), | |
| 1279 | 1312 | Op::Folios(op) => op.name(), | |
| 1313 | + | Op::Customize(op) => op.name(), | |
| 1280 | 1314 | } | |
| 1281 | 1315 | } | |
| 1282 | 1316 | ||
| ⋯ | |||
| 1849 | 1883 | Op::Mirrors(op) => op.description(), | |
| 1850 | 1884 | Op::Packages(op) => op.description(), | |
| 1851 | 1885 | Op::Folios(op) => op.description(), | |
| 1886 | + | Op::Customize(op) => op.description(), | |
| 1852 | 1887 | } | |
| 1853 | 1888 | } | |
| 1854 | 1889 | ||
| ⋯ | |||
| 3342 | 3377 | Op::Mirrors(op) => op.input(), | |
| 3343 | 3378 | Op::Packages(op) => op.input(), | |
| 3344 | 3379 | Op::Folios(op) => op.input(), | |
| 3380 | + | Op::Customize(op) => op.input(), | |
| 3345 | 3381 | } | |
| 3346 | 3382 | } | |
| 3347 | 3383 | ||
| ⋯ | |||
| 3420 | 3456 | if let Op::Folios(_) = self { | |
| 3421 | 3457 | return false; | |
| 3422 | 3458 | } | |
| 3459 | + | // So does its Customize. | |
| 3460 | + | if let Op::Customize(_) = self { | |
| 3461 | + | return false; | |
| 3462 | + | } | |
| 3423 | 3463 | if let Op::About(op) = self { | |
| 3424 | 3464 | return op.needs_repo(); | |
| 3425 | 3465 | } | |
| ⋯ | |||
| 4694 | 4734 | } | |
| 4695 | 4735 | } | |
| 4696 | 4736 | let status = current.as_ref().map(|pull| pull.status); | |
| 4697 | − | let mut answer = current.map(|pull| serde_json::to_value(pull)).transpose()?; | |
| 4737 | + | let mut answer = current.map(serde_json::to_value).transpose()?; | |
| 4698 | 4738 | if wanted.as_deref() == Some("open") && status == Some(PullStatus::Closed) { | |
| 4699 | 4739 | match pass(work, "reopen_pull", &pull_action()).await? { | |
| 4700 | 4740 | Outcome::Ok(pull) => answer = Some(pull), | |
| ⋯ | |||
| 5697 | 5737 | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5698 | 5738 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 5699 | 5739 | Op::Folios(op) => crate::folios::run(op, services, viewer, input).await, | |
| 5740 | + | Op::Customize(op) => crate::customize::run(op, services, viewer, input).await, | |
| 5700 | 5741 | Op::ReopenSecurityAlert => { | |
| 5701 | 5742 | let changed: Outcome<AlertChange> = call( | |
| 5702 | 5743 | &services.security, | |
A large diff (1,306 lines) is not shown until you ask for it.
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::run_artifacts::ArtifactsOp; | |
| 7 | + | use crate::customize::CustomizeOp; | |
| 7 | 8 | use crate::deploy_keys::DeployKeysOp; | |
| 8 | 9 | use crate::mirrors::MirrorsOp; | |
| 9 | 10 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 255 | 256 | route("GET", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::GetWorkspaceSettings), &[]), | |
| 256 | 257 | route("PATCH", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), &[]), | |
| 257 | 258 | route("GET", "/workspaces/:workspace/security/overview", Op::Security(SecurityOp::GetOverview), &[("days", "days")]), | |
| 259 | + | // Reporting: a repository's advisories, and the vulnerability reports sent to its maintainers. | |
| 260 | + | route("GET", "/repos/:owner/:name/security/advisories", Op::Security(SecurityOp::ListAdvisories), &[("state", "state")]), | |
| 261 | + | route("POST", "/repos/:owner/:name/security/advisories", Op::Security(SecurityOp::CreateAdvisory), &[]), | |
| 262 | + | route("GET", "/repos/:owner/:name/security/advisories/:id", Op::Security(SecurityOp::GetAdvisory), &[]), | |
| 263 | + | route("PATCH", "/repos/:owner/:name/security/advisories/:id", Op::Security(SecurityOp::UpdateAdvisory), &[]), | |
| 264 | + | route("POST", "/repos/:owner/:name/security/advisories/:id/state", Op::Security(SecurityOp::SetAdvisoryState), &[]), | |
| 265 | + | route("GET", "/repos/:owner/:name/security/reports", Op::Security(SecurityOp::ListReports), &[("state", "state")]), | |
| 266 | + | route("POST", "/repos/:owner/:name/security/reports", Op::Security(SecurityOp::ReportVulnerability), &[]), | |
| 267 | + | route("PATCH", "/repos/:owner/:name/security/reports/:id", Op::Security(SecurityOp::ReviewReport), &[]), | |
| 258 | 268 | route("GET", "/repos", Op::ListRepos, &[("q", "query")]), | |
| 259 | 269 | route( | |
| 260 | 270 | "GET", | |
| ⋯ | |||
| 651 | 661 | Op::TestIntegration, | |
| 652 | 662 | &[], | |
| 653 | 663 | ), | |
| 664 | + | // Customize: marketplaces, Discover, plugins and connectors. A plugin's | |
| 665 | + | // ref is `name@marketplace`, URL-encoded (`@` as `%40`). | |
| 666 | + | route("GET", "/workspaces/:workspace/customize/marketplaces", Op::Customize(CustomizeOp::ListMarketplaces), &[]), | |
| 667 | + | route("POST", "/workspaces/:workspace/customize/marketplaces", Op::Customize(CustomizeOp::AddMarketplace), &[]), | |
| 668 | + | route("DELETE", "/workspaces/:workspace/customize/marketplaces/:marketplace", Op::Customize(CustomizeOp::RemoveMarketplace), &[]), | |
| 669 | + | route("POST", "/workspaces/:workspace/customize/marketplaces/:marketplace/refresh", Op::Customize(CustomizeOp::RefreshMarketplace), &[]), | |
| 670 | + | route("GET", "/workspaces/:workspace/customize/discover", Op::Customize(CustomizeOp::Discover), &[]), | |
| 671 | + | route("GET", "/workspaces/:workspace/customize/plugins", Op::Customize(CustomizeOp::ListPlugins), &[]), | |
| 672 | + | route("POST", "/workspaces/:workspace/customize/plugins", Op::Customize(CustomizeOp::InstallPlugin), &[]), | |
| 673 | + | route("GET", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::GetPlugin), &[]), | |
| 674 | + | route("PATCH", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::SetPluginEnabled), &[]), | |
| 675 | + | route("DELETE", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::UninstallPlugin), &[]), | |
| 676 | + | route("POST", "/workspaces/:workspace/customize/plugins/:ref/update", Op::Customize(CustomizeOp::UpdatePlugin), &[]), | |
| 677 | + | route("GET", "/workspaces/:workspace/customize/agents", Op::Customize(CustomizeOp::ListPluginAgents), &[]), | |
| 678 | + | route("GET", "/workspaces/:workspace/customize/connectors", Op::Customize(CustomizeOp::ListConnectors), &[]), | |
| 679 | + | route("POST", "/workspaces/:workspace/customize/connectors", Op::Customize(CustomizeOp::AddConnector), &[]), | |
| 680 | + | route("GET", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::GetConnector), &[]), | |
| 681 | + | route("PATCH", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::SetConnectorKey), &[]), | |
| 682 | + | route("DELETE", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::RemoveConnector), &[]), | |
| 683 | + | route("POST", "/workspaces/:workspace/customize/connectors/:id/refresh", Op::Customize(CustomizeOp::RefreshConnector), &[]), | |
| 684 | + | route("POST", "/workspaces/:workspace/customize/connectors/:id/grants", Op::Customize(CustomizeOp::GrantConnector), &[]), | |
| 685 | + | route("DELETE", "/workspaces/:workspace/customize/connectors/:id/grants/:grant", Op::Customize(CustomizeOp::RevokeConnector), &[]), | |
| 686 | + | route("GET", "/workspaces/:workspace/customize/bundle", Op::Customize(CustomizeOp::Bundle), &[]), | |
| 654 | 687 | route( | |
| 655 | 688 | "GET", | |
| 656 | 689 | "/repos/:owner/:name/actions/workflows", | |
| ⋯ | |||
| 995 | 1028 | /// Whether the route answers success with `204` and no body, as | |
| 996 | 1029 | /// GitHub's address for the same does. MCP still answers `true`. | |
| 997 | 1030 | pub fn no_content(&self) -> bool { | |
| 998 | − | self.op == Op::DeleteComment | |
| 1031 | + | matches!( | |
| 1032 | + | self.op, | |
| 1033 | + | Op::DeleteComment | |
| 1034 | + | | Op::Customize(CustomizeOp::RemoveMarketplace | CustomizeOp::UninstallPlugin | CustomizeOp::RemoveConnector) | |
| 1035 | + | ) | |
| 999 | 1036 | } | |
| 1000 | 1037 | ||
| 1001 | 1038 | /// The names of the route's path parameters, in order. | |
| 12 | 12 | //! need the g1t plan, which Security and quality comes with (a 402 says so); this module | |
| 13 | 13 | //! reads the input and gives each answer its public shape. | |
| 14 | 14 | ||
| 15 | + | use g1t_contracts::advisories::{ | |
| 16 | + | Advisory, AdvisoryInput, CreateAdvisoryArgs, GetAdvisoryArgs, ListAdvisoriesArgs, ListReportsArgs, ReportVulnerabilityArgs, ReviewReportArgs, | |
| 17 | + | SetAdvisoryStateArgs, UpdateAdvisoryArgs, | |
| 18 | + | }; | |
| 15 | 19 | use g1t_contracts::repos::RepoPath; | |
| 16 | 20 | use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability}; | |
| 17 | 21 | use g1t_contracts::security_suite::*; | |
| ⋯ | |||
| 57 | 61 | GetWorkspaceSettings, | |
| 58 | 62 | UpdateWorkspaceSettings, | |
| 59 | 63 | GetOverview, | |
| 64 | + | ListAdvisories, | |
| 65 | + | GetAdvisory, | |
| 66 | + | CreateAdvisory, | |
| 67 | + | UpdateAdvisory, | |
| 68 | + | SetAdvisoryState, | |
| 69 | + | ListReports, | |
| 70 | + | ReportVulnerability, | |
| 71 | + | ReviewReport, | |
| 60 | 72 | } | |
| 61 | 73 | ||
| 62 | 74 | impl SecurityOp { | |
| 63 | 75 | /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test | |
| 64 | 76 | /// checks against this. | |
| 65 | 77 | #[cfg(test)] | |
| 66 | − | pub const ALL: [SecurityOp; 31] = [ | |
| 78 | + | pub const ALL: [SecurityOp; 39] = [ | |
| 67 | 79 | SecurityOp::ListSecretAlerts, | |
| 68 | 80 | SecurityOp::GetSecretAlert, | |
| 69 | 81 | SecurityOp::UpdateSecretAlert, | |
| ⋯ | |||
| 95 | 107 | SecurityOp::GetWorkspaceSettings, | |
| 96 | 108 | SecurityOp::UpdateWorkspaceSettings, | |
| 97 | 109 | SecurityOp::GetOverview, | |
| 110 | + | SecurityOp::ListAdvisories, | |
| 111 | + | SecurityOp::GetAdvisory, | |
| 112 | + | SecurityOp::CreateAdvisory, | |
| 113 | + | SecurityOp::UpdateAdvisory, | |
| 114 | + | SecurityOp::SetAdvisoryState, | |
| 115 | + | SecurityOp::ListReports, | |
| 116 | + | SecurityOp::ReportVulnerability, | |
| 117 | + | SecurityOp::ReviewReport, | |
| 98 | 118 | ]; | |
| 99 | 119 | ||
| 100 | 120 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 130 | 150 | SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings", | |
| 131 | 151 | SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings", | |
| 132 | 152 | SecurityOp::GetOverview => "get_security_overview", | |
| 153 | + | SecurityOp::ListAdvisories => "list_security_advisories", | |
| 154 | + | SecurityOp::GetAdvisory => "get_security_advisory", | |
| 155 | + | SecurityOp::CreateAdvisory => "create_security_advisory", | |
| 156 | + | SecurityOp::UpdateAdvisory => "update_security_advisory", | |
| 157 | + | SecurityOp::SetAdvisoryState => "set_security_advisory_state", | |
| 158 | + | SecurityOp::ListReports => "list_vulnerability_reports", | |
| 159 | + | SecurityOp::ReportVulnerability => "report_vulnerability", | |
| 160 | + | SecurityOp::ReviewReport => "review_vulnerability_report", | |
| 133 | 161 | } | |
| 134 | 162 | } | |
| 135 | 163 | ||
| ⋯ | |||
| 167 | 195 | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings", | |
| 168 | 196 | SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings", | |
| 169 | 197 | SecurityOp::GetOverview => "Get the security overview", | |
| 198 | + | SecurityOp::ListAdvisories => "List security advisories", | |
| 199 | + | SecurityOp::GetAdvisory => "Get a security advisory", | |
| 200 | + | SecurityOp::CreateAdvisory => "Create a security advisory", | |
| 201 | + | SecurityOp::UpdateAdvisory => "Update a security advisory", | |
| 202 | + | SecurityOp::SetAdvisoryState => "Publish or withdraw a security advisory", | |
| 203 | + | SecurityOp::ListReports => "List vulnerability reports", | |
| 204 | + | SecurityOp::ReportVulnerability => "Report a vulnerability privately", | |
| 205 | + | SecurityOp::ReviewReport => "Accept or decline a vulnerability report", | |
| 170 | 206 | } | |
| 171 | 207 | } | |
| 172 | 208 | ||
| ⋯ | |||
| 198 | 234 | SecurityOp::GetDependencyGraph => "Get the dependency graph: every package the lockfiles on the default branch resolve, per lockfile, with whether it is direct or transitive (where the lockfile says), for development, its license when recorded, its package URL and its open vulnerability alerts.", | |
| 199 | 235 | SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.", | |
| 200 | 236 | SecurityOp::CompareDependencies => "Compare the dependencies at two commits, branches or tags (basehead, as base...head): what was added and removed per lockfile, with the known vulnerabilities of what was added and whether it passes the repository's dependency review policy. Needs the g1t plan on a private repository.", | |
| 201 | − | SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, its workspace's settings, and whether the paid features are on for it.", | |
| 202 | − | SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids) and review_comment. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.", | |
| 237 | + | SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, whether secrets in the history open alerts, whether it takes private vulnerability reports, its workspace's settings, and whether the paid features are on for it.", | |
| 238 | + | SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids), review_comment, history_alerts (whether secrets found in the history open alerts; push protection stays on) and private_vulnerability_reporting. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.", | |
| 203 | 239 | SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings (delegated bypass, validity checks) and whether it has Security and quality, which comes with the g1t plan.", | |
| 204 | 240 | SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.", | |
| 205 | 241 | SecurityOp::GetOverview => "Get a workspace's security overview: open alerts by type and severity, how many opened and closed in the last days (7 to 90, 30 by default), a daily trend, and for each repository which features are on and what is open, most in need first. Private repositories count only on the g1t plan.", | |
| 242 | + | SecurityOp::ListAdvisories => "List a repository's security advisories, newest first: what it says about vulnerabilities in what it ships. Anyone who can see the repository sees the published ones; those who see its findings (Write, or a security manager) see drafts and withdrawn ones too. Filter by state (draft, published, withdrawn).", | |
| 243 | + | SecurityOp::GetAdvisory => "Get one security advisory by its adv_… id or its G1TSA-… id, with the same visibility as the list.", | |
| 244 | + | SecurityOp::CreateAdvisory => "Create a draft security advisory: title, description (Markdown), severity (critical, high, medium or low), and optionally cvss_vector and cvss_score, cve_id, the package affected (ecosystem, package, affected_versions, patched_versions) and credits. Admin on the repository, or a security manager of its workspace. Nobody is told of a draft.", | |
| 245 | + | SecurityOp::UpdateAdvisory => "Change an advisory's fields, in any state; what is not given stays as it is. A published advisory's change is visible at once. Admin, or a security manager.", | |
| 246 | + | SecurityOp::SetAdvisoryState => "Publish a draft (state published), withdraw a published advisory (state withdrawn), or publish a withdrawn one again. Publishing needs a description, tells webhooks (security_advisory.published) and the repository's watchers who chose security alerts, and makes the advisory visible to anyone who can see the repository. Admin, or a security manager.", | |
| 247 | + | SecurityOp::ListReports => "List the vulnerability reports people sent the repository's maintainers privately, new ones first. Those who see findings (Write, or a security manager). Filter by state (new, accepted, declined). Reporters never see this list.", | |
| 248 | + | SecurityOp::ReportVulnerability => "Report a vulnerability privately to a repository's maintainers: title, description and, if you like, severity. Needs private vulnerability reporting on for the repository. Anyone who can see the repository may report; on a public one you may report without a token by giving an email the maintainers can answer at. At most 5 reports a day per reporter and repository. The workspace's owners and security managers are told at once. You see only your own report.", | |
| 249 | + | SecurityOp::ReviewReport => "Accept a report (decision accept), which makes a draft advisory from it crediting the reporter, or decline it (decision decline), with an optional note. Admin, or a security manager.", | |
| 206 | 250 | } | |
| 207 | 251 | } | |
| 208 | 252 | ||
| ⋯ | |||
| 248 | 292 | properties | |
| 249 | 293 | }; | |
| 250 | 294 | let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." }); | |
| 295 | + | let advisory_fields = |mut properties: Value| { | |
| 296 | + | properties["title"] = json!({ "type": "string", "description": "One line: what the vulnerability is. At most 200 characters." }); | |
| 297 | + | properties["description"] = json!({ "type": "string", "description": "Markdown: what is affected, how it is exploited, what to do. At most 20,000 characters." }); | |
| 298 | + | properties["severity"] = json!({ "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad it is." }); | |
| 299 | + | properties["cvss_vector"] = json!({ "type": "string", "description": "A CVSS vector string, when scored: CVSS:3.1/AV:N/…" }); | |
| 300 | + | properties["cvss_score"] = json!({ "type": "number", "description": "The CVSS base score, 0 to 10." }); | |
| 301 | + | properties["cve_id"] = json!({ "type": "string", "description": "CVE-2026-12345, when one was issued elsewhere." }); | |
| 302 | + | properties["ecosystem"] = json!({ "type": "string", "description": "How the affected package is distributed: npm, crates.io, Go, PyPI, or how the project ships." }); | |
| 303 | + | properties["package"] = json!({ "type": "string", "description": "The package affected." }); | |
| 304 | + | properties["affected_versions"] = json!({ "type": "string", "description": "The versions affected, as a range: >= 1.2.0, < 1.4.1" }); | |
| 305 | + | properties["patched_versions"] = json!({ "type": "string", "description": "The versions that fix it: >= 1.4.1" }); | |
| 306 | + | properties["credits"] = json!({ "type": "array", "items": { "type": "string" }, "description": "Who found or fixed it: usernames or names, up to 20." }); | |
| 307 | + | properties | |
| 308 | + | }; | |
| 309 | + | let advisory_id = || json!({ "type": "string", "description": "The advisory's id: adv_…, or its G1TSA-… id." }); | |
| 251 | 310 | let (properties, required): (Value, &[&str]) = match self { | |
| 252 | 311 | SecurityOp::ListSecretAlerts => ( | |
| 253 | 312 | either(json!({ | |
| ⋯ | |||
| 394 | 453 | "review_fail_on": { "type": "string", "enum": ["critical", "high", "medium", "low", "none"], "description": "The lowest severity of a known vulnerability in an added package that fails the review." }, | |
| 395 | 454 | "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." }, | |
| 396 | 455 | "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." }, | |
| 456 | + | "history_alerts": { "type": "boolean", "description": "Whether secrets found in the history, and in pushes too large to scan first, open alerts and tell people. Push protection stays on either way." }, | |
| 457 | + | "private_vulnerability_reporting": { "type": "boolean", "description": "Whether anyone who can see the repository may report a vulnerability privately to its maintainers." }, | |
| 397 | 458 | }), | |
| 398 | 459 | &["repo"], | |
| 399 | 460 | ), | |
| ⋯ | |||
| 410 | 471 | json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }), | |
| 411 | 472 | &["workspace"], | |
| 412 | 473 | ), | |
| 474 | + | SecurityOp::ListAdvisories => ( | |
| 475 | + | json!({ "repo": repo(), "state": { "type": "string", "enum": ["draft", "published", "withdrawn"], "description": "Only advisories in this state." } }), | |
| 476 | + | &["repo"], | |
| 477 | + | ), | |
| 478 | + | SecurityOp::GetAdvisory => (json!({ "repo": repo(), "id": advisory_id() }), &["repo", "id"]), | |
| 479 | + | SecurityOp::CreateAdvisory => (advisory_fields(json!({ "repo": repo() })), &["repo", "title", "severity"]), | |
| 480 | + | SecurityOp::UpdateAdvisory => (advisory_fields(json!({ "repo": repo(), "id": advisory_id() })), &["repo", "id"]), | |
| 481 | + | SecurityOp::SetAdvisoryState => ( | |
| 482 | + | json!({ | |
| 483 | + | "repo": repo(), | |
| 484 | + | "id": advisory_id(), | |
| 485 | + | "state": { "type": "string", "enum": ["published", "withdrawn"], "description": "published to publish (or publish again), withdrawn to withdraw." }, | |
| 486 | + | }), | |
| 487 | + | &["repo", "id", "state"], | |
| 488 | + | ), | |
| 489 | + | SecurityOp::ListReports => ( | |
| 490 | + | json!({ "repo": repo(), "state": { "type": "string", "enum": ["new", "accepted", "declined"], "description": "Only reports in this state." } }), | |
| 491 | + | &["repo"], | |
| 492 | + | ), | |
| 493 | + | SecurityOp::ReportVulnerability => ( | |
| 494 | + | json!({ | |
| 495 | + | "repo": repo(), | |
| 496 | + | "title": { "type": "string", "description": "One line: what you found. At most 200 characters." }, | |
| 497 | + | "description": { "type": "string", "description": "What is affected and how to reproduce it. At most 20,000 characters." }, | |
| 498 | + | "severity": { "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad you think it is, if you like." }, | |
| 499 | + | "email": { "type": "string", "description": "Without a token: where the maintainers can answer you." }, | |
| 500 | + | }), | |
| 501 | + | &["repo", "title", "description"], | |
| 502 | + | ), | |
| 503 | + | SecurityOp::ReviewReport => ( | |
| 504 | + | json!({ | |
| 505 | + | "repo": repo(), | |
| 506 | + | "id": { "type": "string", "description": "The report's id: vrp_…" }, | |
| 507 | + | "decision": { "type": "string", "enum": ["accept", "decline"], "description": "accept makes a draft advisory from it; decline closes it." }, | |
| 508 | + | "note": { "type": "string", "description": "A note for the record, in a sentence. At most 1,000 characters." }, | |
| 509 | + | }), | |
| 510 | + | &["repo", "id", "decision"], | |
| 511 | + | ), | |
| 413 | 512 | }; | |
| 414 | 513 | let mut schema = json!({ "type": "object", "properties": properties }); | |
| 415 | 514 | if !required.is_empty() { | |
| ⋯ | |||
| 601 | 700 | crate::operations::repo_path(input) | |
| 602 | 701 | } | |
| 603 | 702 | ||
| 703 | + | /// An advisory's fields as given, over `current` for what is not. | |
| 704 | + | pub(crate) fn advisory_input(input: &Value, current: &AdvisoryInput) -> AdvisoryInput { | |
| 705 | + | let given = |key: &str, now: &Option<String>| if input.get(key).is_some() { text(input, key) } else { now.clone() }; | |
| 706 | + | AdvisoryInput { | |
| 707 | + | title: text(input, "title").unwrap_or_else(|| current.title.clone()), | |
| 708 | + | description: input["description"].as_str().map(str::to_owned).unwrap_or_else(|| current.description.clone()), | |
| 709 | + | severity: text(input, "severity").map(|severity| severity.to_lowercase()).unwrap_or_else(|| current.severity.clone()), | |
| 710 | + | cvss_vector: given("cvss_vector", ¤t.cvss_vector), | |
| 711 | + | cvss_score: if input.get("cvss_score").is_some() { input["cvss_score"].as_f64() } else { current.cvss_score }, | |
| 712 | + | cve_id: given("cve_id", ¤t.cve_id), | |
| 713 | + | ecosystem: given("ecosystem", ¤t.ecosystem), | |
| 714 | + | package: given("package", ¤t.package), | |
| 715 | + | affected_versions: given("affected_versions", ¤t.affected_versions), | |
| 716 | + | patched_versions: given("patched_versions", ¤t.patched_versions), | |
| 717 | + | credits: if input["credits"].is_array() { strings(input, "credits") } else { current.credits.clone() }, | |
| 718 | + | } | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | /// An advisory as it stands, as input to change it from. | |
| 722 | + | fn advisory_as_input(advisory: &Advisory) -> AdvisoryInput { | |
| 723 | + | AdvisoryInput { | |
| 724 | + | title: advisory.title.clone(), | |
| 725 | + | description: advisory.description.clone(), | |
| 726 | + | severity: advisory.severity.clone(), | |
| 727 | + | cvss_vector: advisory.cvss_vector.clone(), | |
| 728 | + | cvss_score: advisory.cvss_score, | |
| 729 | + | cve_id: advisory.cve_id.clone(), | |
| 730 | + | ecosystem: advisory.ecosystem.clone(), | |
| 731 | + | package: advisory.package.clone(), | |
| 732 | + | affected_versions: advisory.affected_versions.clone(), | |
| 733 | + | patched_versions: advisory.patched_versions.clone(), | |
| 734 | + | credits: advisory.credits.clone(), | |
| 735 | + | } | |
| 736 | + | } | |
| 737 | + | ||
| 604 | 738 | pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 605 | 739 | let actor = || viewer.clone().unwrap_or_default(); | |
| 606 | 740 | let repo = path_of(input); | |
| ⋯ | |||
| 945 | 1079 | if let Some(on) = flag(input, "review_comment") { | |
| 946 | 1080 | settings.review_comment = on; | |
| 947 | 1081 | } | |
| 1082 | + | if let Some(on) = flag(input, "history_alerts") { | |
| 1083 | + | settings.history_alerts = on; | |
| 1084 | + | } | |
| 1085 | + | if let Some(on) = flag(input, "private_vulnerability_reporting") { | |
| 1086 | + | settings.private_vulnerability_reporting = on; | |
| 1087 | + | } | |
| 948 | 1088 | pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await | |
| 949 | 1089 | } | |
| 1090 | + | SecurityOp::ListAdvisories => { | |
| 1091 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1092 | + | let state = match one_of(input, "state", &["draft", "published", "withdrawn"]) { | |
| 1093 | + | Ok(state) => state, | |
| 1094 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 1095 | + | }; | |
| 1096 | + | pass(services, "list_advisories", &ListAdvisoriesArgs { viewer: viewer.clone(), repo, state }).await | |
| 1097 | + | } | |
| 1098 | + | SecurityOp::GetAdvisory => { | |
| 1099 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1100 | + | pass(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await | |
| 1101 | + | } | |
| 1102 | + | SecurityOp::CreateAdvisory => { | |
| 1103 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1104 | + | let advisory = advisory_input(input, &AdvisoryInput::default()); | |
| 1105 | + | pass(services, "create_advisory", &CreateAdvisoryArgs { actor: actor(), repo, advisory }).await | |
| 1106 | + | } | |
| 1107 | + | SecurityOp::UpdateAdvisory => { | |
| 1108 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1109 | + | let id = text(input, "id").unwrap_or_default(); | |
| 1110 | + | // What is not given stays as it is. | |
| 1111 | + | let current: Outcome<Advisory> = call(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo: repo.clone(), id: id.clone() }).await?; | |
| 1112 | + | let current = match current { | |
| 1113 | + | Outcome::Ok(advisory) => advisory, | |
| 1114 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 1115 | + | }; | |
| 1116 | + | let advisory = advisory_input(input, &advisory_as_input(¤t)); | |
| 1117 | + | pass(services, "update_advisory", &UpdateAdvisoryArgs { actor: actor(), repo, id, advisory }).await | |
| 1118 | + | } | |
| 1119 | + | SecurityOp::SetAdvisoryState => { | |
| 1120 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1121 | + | let state = match one_of(input, "state", &["published", "withdrawn"]) { | |
| 1122 | + | Ok(Some(state)) => state, | |
| 1123 | + | Ok(None) => return failed(FailureCode::Invalid, "state is published or withdrawn."), | |
| 1124 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 1125 | + | }; | |
| 1126 | + | pass(services, "set_advisory_state", &SetAdvisoryStateArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), state }).await | |
| 1127 | + | } | |
| 1128 | + | SecurityOp::ListReports => { | |
| 1129 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1130 | + | let state = match one_of(input, "state", &["new", "accepted", "declined"]) { | |
| 1131 | + | Ok(state) => state, | |
| 1132 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 1133 | + | }; | |
| 1134 | + | pass(services, "list_vulnerability_reports", &ListReportsArgs { viewer: viewer.clone(), repo, state }).await | |
| 1135 | + | } | |
| 1136 | + | SecurityOp::ReportVulnerability => { | |
| 1137 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1138 | + | let args = ReportVulnerabilityArgs { | |
| 1139 | + | viewer: viewer.clone(), | |
| 1140 | + | repo, | |
| 1141 | + | title: text(input, "title").unwrap_or_default(), | |
| 1142 | + | description: text(input, "description").unwrap_or_default(), | |
| 1143 | + | severity: text(input, "severity").map(|severity| severity.to_lowercase()), | |
| 1144 | + | email: text(input, "email"), | |
| 1145 | + | }; | |
| 1146 | + | pass(services, "report_vulnerability", &args).await | |
| 1147 | + | } | |
| 1148 | + | SecurityOp::ReviewReport => { | |
| 1149 | + | let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) }; | |
| 1150 | + | let args = ReviewReportArgs { | |
| 1151 | + | actor: actor(), | |
| 1152 | + | repo, | |
| 1153 | + | id: text(input, "id").unwrap_or_default(), | |
| 1154 | + | decision: text(input, "decision").unwrap_or_default().to_lowercase(), | |
| 1155 | + | note: text(input, "note").unwrap_or_default(), | |
| 1156 | + | }; | |
| 1157 | + | pass(services, "review_vulnerability_report", &args).await | |
| 1158 | + | } | |
| 950 | 1159 | SecurityOp::GetWorkspaceSettings => { | |
| 951 | 1160 | let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") }; | |
| 952 | 1161 | pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::run_artifacts::ArtifactsOp; | |
| 23 | + | use crate::customize::CustomizeOp; | |
| 23 | 24 | use crate::deploy_keys::DeployKeysOp; | |
| 24 | 25 | use crate::mirrors::MirrorsOp; | |
| 25 | 26 | use crate::deployments::DeploymentsOp; | |
| ⋯ | |||
| 433 | 434 | ], | |
| 434 | 435 | }, | |
| 435 | 436 | Tool { | |
| 437 | + | name: "customize", | |
| 438 | + | title: "Customize", | |
| 439 | + | description: "What shapes how a workspace's agents work, in the formats Claude Code reads: the marketplaces it reads plugins from (`.claude-plugin/marketplace.json`), what Discover lists, the plugins it installed as `name@marketplace` (their skills join the library as `plugin:skill`, their subagents become templates, their remote MCP servers become connectors), and its connectors: MCP servers connected once with a key or a sign-in, then given to every agent, a team or one agent. Members read; owners change. `bundle` is what a job's Claude Code loads on a runner, for the job's own token.", | |
| 440 | + | default_action: None, | |
| 441 | + | actions: &[ | |
| 442 | + | a("list_marketplaces", Op::Customize(CustomizeOp::ListMarketplaces), "The marketplaces it reads plugins from"), | |
| 443 | + | a("add_marketplace", Op::Customize(CustomizeOp::AddMarketplace), "Add one by owner/repo, workspace/name, a git address or a marketplace.json link"), | |
| 444 | + | a("remove_marketplace", Op::Customize(CustomizeOp::RemoveMarketplace), "Remove one; its installed plugins stay"), | |
| 445 | + | a("refresh_marketplace", Op::Customize(CustomizeOp::RefreshMarketplace), "Read its manifest again now"), | |
| 446 | + | a("discover", Op::Customize(CustomizeOp::Discover), "Every plugin and skill it can add, with availability"), | |
| 447 | + | a("list_plugins", Op::Customize(CustomizeOp::ListPlugins), "Installed plugins: pinned commit, components, updates"), | |
| 448 | + | a("get_plugin", Op::Customize(CustomizeOp::GetPlugin), "One plugin's listing, install, components, README and agents"), | |
| 449 | + | a("install_plugin", Op::Customize(CustomizeOp::InstallPlugin), "Install one at its listed commit"), | |
| 450 | + | a("set_plugin_enabled", Op::Customize(CustomizeOp::SetPluginEnabled), "Switch an installed one on or off"), | |
| 451 | + | a("uninstall_plugin", Op::Customize(CustomizeOp::UninstallPlugin), "Uninstall one, with its skills, templates and connectors"), | |
| 452 | + | a("update_plugin", Op::Customize(CustomizeOp::UpdatePlugin), "Read one again at its marketplace's current commit"), | |
| 453 | + | a("list_plugin_agents", Op::Customize(CustomizeOp::ListPluginAgents), "Subagents plugins brought, as templates to hire from"), | |
| 454 | + | a("list_connectors", Op::Customize(CustomizeOp::ListConnectors), "MCP servers connected to the workspace, with state, tools and grants"), | |
| 455 | + | a("get_connector", Op::Customize(CustomizeOp::GetConnector), "One connector"), | |
| 456 | + | a("add_connector", Op::Customize(CustomizeOp::AddConnector), "Add one from the directory, a plugin's server or an address"), | |
| 457 | + | a("set_connector_key", Op::Customize(CustomizeOp::SetConnectorKey), "Put a key on one; never returned"), | |
| 458 | + | a("remove_connector", Op::Customize(CustomizeOp::RemoveConnector), "Remove one with its secrets and grants"), | |
| 459 | + | a("refresh_connector", Op::Customize(CustomizeOp::RefreshConnector), "List its tools again now"), | |
| 460 | + | a("grant_connector", Op::Customize(CustomizeOp::GrantConnector), "Give it to every agent, a team or one agent"), | |
| 461 | + | a("revoke_connector", Op::Customize(CustomizeOp::RevokeConnector), "Take one grant back"), | |
| 462 | + | a("bundle", Op::Customize(CustomizeOp::Bundle), "What a job's Claude Code loads: plugins, library skills, connectors with headers"), | |
| 463 | + | ], | |
| 464 | + | }, | |
| 465 | + | Tool { | |
| 436 | 466 | name: "billing", | |
| 437 | 467 | title: "Billing", | |
| 438 | 468 | description: "A workspace's billing: its usage by product, project and day, its budget (the monthly spend limit, alerts and whether usage pauses at it), its AI credit, its invoices, and its AI Gateway requests. Amounts are whole millionths of a dollar (`_micros`), or cents (`_cents`) where named. Members read it; changing the budget and buying credit are for owners, as people, and never for g1t's agents.", | |
| ⋯ | |||
| 451 | 481 | Tool { | |
| 452 | 482 | name: "security", | |
| 453 | 483 | title: "Security", | |
| 454 | − | description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, and a workspace's overview. Fix an alert with g1t. Findings are shown to those who can change the code only. Give `repo` (owner/name), or `workspace` for lists across one.", | |
| 484 | + | description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, a workspace's overview, security advisories and private vulnerability reports. Fix an alert with g1t. Findings are shown to those who can change the code only; published advisories to anyone who can see the repository. Give `repo` (owner/name), or `workspace` for lists across one.", | |
| 455 | 485 | default_action: Some("secret_alerts"), | |
| 456 | 486 | actions: &[ | |
| 457 | 487 | a("secret_alerts", Op::Security(SecurityOp::ListSecretAlerts), "Secret scanning alerts; by state, secret_type, validity, bypassed"), | |
| ⋯ | |||
| 485 | 515 | a("workspace_settings", Op::Security(SecurityOp::GetWorkspaceSettings), "A workspace's delegated bypass and validity checks"), | |
| 486 | 516 | a("update_workspace_settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), "Turn delegated bypass or validity checks on or off"), | |
| 487 | 517 | a("overview", Op::Security(SecurityOp::GetOverview), "A workspace's alerts, trends and coverage"), | |
| 518 | + | a("advisories", Op::Security(SecurityOp::ListAdvisories), "A repository's security advisories; by state"), | |
| 519 | + | a("advisory", Op::Security(SecurityOp::GetAdvisory), "One advisory by adv_… or G1TSA-… id"), | |
| 520 | + | a("create_advisory", Op::Security(SecurityOp::CreateAdvisory), "Draft a security advisory"), | |
| 521 | + | a("update_advisory", Op::Security(SecurityOp::UpdateAdvisory), "Change an advisory's fields"), | |
| 522 | + | a("set_advisory_state", Op::Security(SecurityOp::SetAdvisoryState), "Publish or withdraw an advisory"), | |
| 523 | + | a("reports", Op::Security(SecurityOp::ListReports), "Vulnerability reports sent to the maintainers; by state"), | |
| 524 | + | a("report_vulnerability", Op::Security(SecurityOp::ReportVulnerability), "Report a vulnerability privately to a repository's maintainers"), | |
| 525 | + | a("review_report", Op::Security(SecurityOp::ReviewReport), "Accept a report as a draft advisory, or decline it"), | |
| 488 | 526 | ], | |
| 489 | 527 | }, | |
| 490 | 528 | Tool { | |
| ⋯ | |||
| 594 | 632 | | Op::DeleteRunnerGroup | |
| 595 | 633 | | Op::UpdateRunnerSettings | |
| 596 | 634 | | Op::Folios(FoliosOp::SetAccess | FoliosOp::Purge) | |
| 635 | + | | Op::Customize(CustomizeOp::RemoveMarketplace | CustomizeOp::UninstallPlugin | CustomizeOp::RemoveConnector) | |
| 597 | 636 | ) | |
| 598 | 637 | } | |
| 599 | 638 | ||
| ⋯ | |||
| 844 | 883 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 845 | 884 | } | |
| 846 | 885 | } | |
| 847 | − | assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len()); | |
| 886 | + | assert!(TOOLS.len() <= 20, "{} tools", TOOLS.len()); | |
| 848 | 887 | } | |
| 849 | 888 | ||
| 850 | 889 | #[test] | |
| 1 | 1 | // The launcher's work: ask the app for its server, check that it answers, | |
| 2 | − | // go there. `?change` opens the choice of server instead. Everything it | |
| 2 | + | // go there, as the desktop app (`?app=desktop`, which takes someone signed | |
| 3 | + | // out to the sign-in page rather than the site's landing page). `?change` opens the choice of server instead. Everything it | |
| 3 | 4 | // asks is a command in src/bridge.rs, answered only to this page. | |
| 4 | 5 | (function () { | |
| 5 | 6 | var sections = { connecting: byId("connecting"), offline: byId("offline"), choose: byId("choose") }; | |
| ⋯ | |||
| 29 | 30 | } | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 33 | + | function appUrl(server) { | |
| 34 | + | try { | |
| 35 | + | var url = new URL(server); | |
| 36 | + | url.searchParams.set("app", "desktop"); | |
| 37 | + | return url.href; | |
| 38 | + | } catch (error) { | |
| 39 | + | return server; | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 32 | 43 | function connect(server) { | |
| 33 | 44 | show("connecting"); | |
| 34 | 45 | byId("connecting-text").textContent = "Connecting to " + hostOf(server) + "…"; | |
| 35 | 46 | invoke("reachable", { url: server }) | |
| 36 | 47 | .then(function (ok) { | |
| 37 | − | if (ok) location.replace(server); | |
| 48 | + | if (ok) location.replace(appUrl(server)); | |
| 38 | 49 | else offline(server); | |
| 39 | 50 | }) | |
| 40 | 51 | .catch(function () { | |
| 1 | + | /* The settings window, in the launcher's colours (launcher.css): rows of | |
| 2 | + | settings under small headings, each with its switch on the right. */ | |
| 3 | + | :root { | |
| 4 | + | --bg: #0f0f11; | |
| 5 | + | --fg: #ededf0; | |
| 6 | + | --muted: #9a9aa6; | |
| 7 | + | --line: #26262c; | |
| 8 | + | --surface: #17171b; | |
| 9 | + | --accent: #b6a8ff; | |
| 10 | + | --accent-ink: #15112e; | |
| 11 | + | --error: #ff8a8a; | |
| 12 | + | color-scheme: dark; | |
| 13 | + | } | |
| 14 | + | * { | |
| 15 | + | box-sizing: border-box; | |
| 16 | + | } | |
| 17 | + | html, | |
| 18 | + | body { | |
| 19 | + | margin: 0; | |
| 20 | + | } | |
| 21 | + | body { | |
| 22 | + | background: var(--bg); | |
| 23 | + | color: var(--fg); | |
| 24 | + | font: 14px/1.45 system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; | |
| 25 | + | -webkit-font-smoothing: antialiased; | |
| 26 | + | user-select: none; | |
| 27 | + | cursor: default; | |
| 28 | + | } | |
| 29 | + | /* g1t's own title bar, when the window has no system frame: fixed at the top, 36px tall. */ | |
| 30 | + | .titlebar { | |
| 31 | + | position: fixed; | |
| 32 | + | inset: 0 0 auto 0; | |
| 33 | + | z-index: 10; | |
| 34 | + | height: 36px; | |
| 35 | + | display: flex; | |
| 36 | + | align-items: center; | |
| 37 | + | justify-content: space-between; | |
| 38 | + | padding-left: 14px; | |
| 39 | + | background: #050507; | |
| 40 | + | color: var(--muted); | |
| 41 | + | font-size: 12px; | |
| 42 | + | font-weight: 500; | |
| 43 | + | } | |
| 44 | + | .titlebar[hidden] { | |
| 45 | + | display: none; | |
| 46 | + | } | |
| 47 | + | .mac .titlebar { | |
| 48 | + | padding-left: 78px; | |
| 49 | + | } | |
| 50 | + | .mac .titlebar .caption { | |
| 51 | + | display: none; | |
| 52 | + | } | |
| 53 | + | .titlebar .caption { | |
| 54 | + | all: unset; | |
| 55 | + | display: inline-flex; | |
| 56 | + | align-items: center; | |
| 57 | + | justify-content: center; | |
| 58 | + | width: 46px; | |
| 59 | + | height: 36px; | |
| 60 | + | } | |
| 61 | + | .titlebar .caption:hover { | |
| 62 | + | background: #c42b1c; | |
| 63 | + | color: #fff; | |
| 64 | + | } | |
| 65 | + | /* Under it, the page scrolls on its own, so its scrollbar starts below the bar. */ | |
| 66 | + | body.framed .page { | |
| 67 | + | position: fixed; | |
| 68 | + | inset: 36px 0 0 0; | |
| 69 | + | overflow-y: auto; | |
| 70 | + | } | |
| 71 | + | main { | |
| 72 | + | max-width: 36rem; | |
| 73 | + | margin: 0 auto; | |
| 74 | + | padding: 1.5rem 1.25rem 1.25rem; | |
| 75 | + | } | |
| 76 | + | h1 { | |
| 77 | + | font-size: 1.25rem; | |
| 78 | + | font-weight: 600; | |
| 79 | + | margin: 0 0 0.25rem; | |
| 80 | + | } | |
| 81 | + | h2 { | |
| 82 | + | font-size: 0.75rem; | |
| 83 | + | font-weight: 600; | |
| 84 | + | letter-spacing: 0.06em; | |
| 85 | + | text-transform: uppercase; | |
| 86 | + | color: var(--muted); | |
| 87 | + | margin: 0 0 0.25rem; | |
| 88 | + | } | |
| 89 | + | p { | |
| 90 | + | margin: 0; | |
| 91 | + | } | |
| 92 | + | .lede { | |
| 93 | + | margin-bottom: 1.5rem; | |
| 94 | + | } | |
| 95 | + | .muted { | |
| 96 | + | color: var(--muted); | |
| 97 | + | } | |
| 98 | + | .small { | |
| 99 | + | font-size: 0.8125rem; | |
| 100 | + | } | |
| 101 | + | section { | |
| 102 | + | margin-bottom: 1.5rem; | |
| 103 | + | } | |
| 104 | + | .setting { | |
| 105 | + | display: flex; | |
| 106 | + | align-items: center; | |
| 107 | + | justify-content: space-between; | |
| 108 | + | gap: 1rem; | |
| 109 | + | padding: 0.75rem 0; | |
| 110 | + | border-bottom: 1px solid var(--line); | |
| 111 | + | } | |
| 112 | + | section .setting:last-child { | |
| 113 | + | border-bottom: 0; | |
| 114 | + | } | |
| 115 | + | .setting > span { | |
| 116 | + | display: flex; | |
| 117 | + | flex-direction: column; | |
| 118 | + | gap: 0.125rem; | |
| 119 | + | min-width: 0; | |
| 120 | + | } | |
| 121 | + | .setting strong { | |
| 122 | + | font-weight: 500; | |
| 123 | + | overflow-wrap: anywhere; | |
| 124 | + | } | |
| 125 | + | .setting .muted { | |
| 126 | + | font-size: 0.8125rem; | |
| 127 | + | } | |
| 128 | + | .setting.off { | |
| 129 | + | opacity: 0.5; | |
| 130 | + | } | |
| 131 | + | input[type="checkbox"][role="switch"] { | |
| 132 | + | appearance: none; | |
| 133 | + | flex: none; | |
| 134 | + | width: 36px; | |
| 135 | + | height: 20px; | |
| 136 | + | margin: 0; | |
| 137 | + | border-radius: 999px; | |
| 138 | + | background: var(--line); | |
| 139 | + | position: relative; | |
| 140 | + | transition: background 0.15s; | |
| 141 | + | } | |
| 142 | + | input[type="checkbox"][role="switch"]::after { | |
| 143 | + | content: ""; | |
| 144 | + | position: absolute; | |
| 145 | + | top: 2px; | |
| 146 | + | left: 2px; | |
| 147 | + | width: 16px; | |
| 148 | + | height: 16px; | |
| 149 | + | border-radius: 50%; | |
| 150 | + | background: var(--fg); | |
| 151 | + | transition: transform 0.15s; | |
| 152 | + | } | |
| 153 | + | input[type="checkbox"][role="switch"]:checked { | |
| 154 | + | background: var(--accent); | |
| 155 | + | } | |
| 156 | + | input[type="checkbox"][role="switch"]:checked::after { | |
| 157 | + | transform: translateX(16px); | |
| 158 | + | background: var(--accent-ink); | |
| 159 | + | } | |
| 160 | + | input:disabled { | |
| 161 | + | opacity: 0.5; | |
| 162 | + | } | |
| 163 | + | @media (prefers-reduced-motion: reduce) { | |
| 164 | + | input[type="checkbox"][role="switch"], | |
| 165 | + | input[type="checkbox"][role="switch"]::after { | |
| 166 | + | transition: none; | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | button, | |
| 170 | + | select { | |
| 171 | + | flex: none; | |
| 172 | + | font: inherit; | |
| 173 | + | font-size: 0.8125rem; | |
| 174 | + | font-weight: 500; | |
| 175 | + | padding: 0.375rem 0.75rem; | |
| 176 | + | border-radius: 8px; | |
| 177 | + | border: 1px solid var(--line); | |
| 178 | + | background: var(--surface); | |
| 179 | + | color: var(--fg); | |
| 180 | + | cursor: default; | |
| 181 | + | } | |
| 182 | + | button:hover, | |
| 183 | + | select:hover { | |
| 184 | + | border-color: #3a3a44; | |
| 185 | + | } | |
| 186 | + | .setting > .buttons { | |
| 187 | + | flex-direction: row; | |
| 188 | + | flex: none; | |
| 189 | + | gap: 0.5rem; | |
| 190 | + | } | |
| 191 | + | button.primary { | |
| 192 | + | background: var(--accent); | |
| 193 | + | border-color: var(--accent); | |
| 194 | + | color: var(--accent-ink); | |
| 195 | + | } | |
| 196 | + | button.primary:hover { | |
| 197 | + | filter: brightness(1.06); | |
| 198 | + | } | |
| 199 | + | button[hidden] { | |
| 200 | + | display: none; | |
| 201 | + | } | |
| 202 | + | button:disabled { | |
| 203 | + | opacity: 0.6; | |
| 204 | + | } | |
| 205 | + | button:focus-visible, | |
| 206 | + | select:focus-visible, | |
| 207 | + | input:focus-visible, | |
| 208 | + | a:focus-visible { | |
| 209 | + | outline: 2px solid var(--accent); | |
| 210 | + | outline-offset: 2px; | |
| 211 | + | } | |
| 212 | + | a { | |
| 213 | + | color: var(--muted); | |
| 214 | + | text-decoration: underline; | |
| 215 | + | text-underline-offset: 3px; | |
| 216 | + | } | |
| 217 | + | a:hover { | |
| 218 | + | color: var(--fg); | |
| 219 | + | } | |
| 220 | + | .error { | |
| 221 | + | color: var(--error); | |
| 222 | + | font-size: 0.875rem; | |
| 223 | + | margin-bottom: 1rem; | |
| 224 | + | } | |
| 225 | + | .foot { | |
| 226 | + | text-align: center; | |
| 227 | + | } |
| 1 | + | <!doctype html> | |
| 2 | + | <!-- | |
| 3 | + | The desktop app's settings, in a window of their own: opened from the | |
| 4 | + | menu, the tray, or the site's account menu (src/settings.rs). Each | |
| 5 | + | change is kept as soon as it is made; there is nothing to save. | |
| 6 | + | --> | |
| 7 | + | <html lang="en"> | |
| 8 | + | <head> | |
| 9 | + | <meta charset="utf-8" /> | |
| 10 | + | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 11 | + | <meta name="color-scheme" content="dark" /> | |
| 12 | + | <title>g1t settings</title> | |
| 13 | + | <link rel="stylesheet" href="settings.css" /> | |
| 14 | + | </head> | |
| 15 | + | <body> | |
| 16 | + | <!-- Its title bar, when g1t draws its own (src/frame.rs): drag it to move the window. --> | |
| 17 | + | <header class="titlebar" id="titlebar" hidden> | |
| 18 | + | <span>Settings</span> | |
| 19 | + | <button type="button" id="close-window" class="caption" aria-label="Close" title="Close" tabindex="-1"> | |
| 20 | + | <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" aria-hidden="true"> | |
| 21 | + | <line x1="4" y1="4" x2="12" y2="12" /> | |
| 22 | + | <line x1="12" y1="4" x2="4" y2="12" /> | |
| 23 | + | </svg> | |
| 24 | + | </button> | |
| 25 | + | </header> | |
| 26 | + | <div class="page"> | |
| 27 | + | <main> | |
| 28 | + | <h1>Settings</h1> | |
| 29 | + | <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p> | |
| 30 | + | ||
| 31 | + | <section aria-labelledby="general-heading"> | |
| 32 | + | <h2 id="general-heading">Starting and closing</h2> | |
| 33 | + | <label class="setting"> | |
| 34 | + | <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span> | |
| 35 | + | <input type="checkbox" role="switch" id="openAtLogin" /> | |
| 36 | + | </label> | |
| 37 | + | <label class="setting"> | |
| 38 | + | <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span> | |
| 39 | + | <input type="checkbox" role="switch" id="startHidden" /> | |
| 40 | + | </label> | |
| 41 | + | <label class="setting"> | |
| 42 | + | <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span> | |
| 43 | + | <input type="checkbox" role="switch" id="closeToTray" /> | |
| 44 | + | </label> | |
| 45 | + | </section> | |
| 46 | + | ||
| 47 | + | <section aria-labelledby="notifications-heading"> | |
| 48 | + | <h2 id="notifications-heading">Notifications</h2> | |
| 49 | + | <label class="setting"> | |
| 50 | + | <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span> | |
| 51 | + | <input type="checkbox" role="switch" id="notifications" /> | |
| 52 | + | </label> | |
| 53 | + | <label class="setting"> | |
| 54 | + | <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span> | |
| 55 | + | <input type="checkbox" role="switch" id="flash" /> | |
| 56 | + | </label> | |
| 57 | + | <label class="setting"> | |
| 58 | + | <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span> | |
| 59 | + | <input type="checkbox" role="switch" id="badge" /> | |
| 60 | + | </label> | |
| 61 | + | </section> | |
| 62 | + | ||
| 63 | + | <section aria-labelledby="window-heading"> | |
| 64 | + | <h2 id="window-heading">Window</h2> | |
| 65 | + | <label class="setting"> | |
| 66 | + | <span | |
| 67 | + | ><strong>Use the system's title bar</strong | |
| 68 | + | ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span | |
| 69 | + | > | |
| 70 | + | <input type="checkbox" role="switch" id="systemTitleBar" /> | |
| 71 | + | </label> | |
| 72 | + | <div class="setting" id="restart-row" hidden> | |
| 73 | + | <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span> | |
| 74 | + | <button type="button" id="restart-app" class="primary">Restart now</button> | |
| 75 | + | </div> | |
| 76 | + | <label class="setting"> | |
| 77 | + | <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span> | |
| 78 | + | <select id="zoom"> | |
| 79 | + | <option value="0.67">67%</option> | |
| 80 | + | <option value="0.75">75%</option> | |
| 81 | + | <option value="0.8">80%</option> | |
| 82 | + | <option value="0.9">90%</option> | |
| 83 | + | <option value="1">100%</option> | |
| 84 | + | <option value="1.1">110%</option> | |
| 85 | + | <option value="1.25">125%</option> | |
| 86 | + | <option value="1.5">150%</option> | |
| 87 | + | <option value="1.75">175%</option> | |
| 88 | + | <option value="2">200%</option> | |
| 89 | + | </select> | |
| 90 | + | </label> | |
| 91 | + | </section> | |
| 92 | + | ||
| 93 | + | <section aria-labelledby="updates-heading"> | |
| 94 | + | <h2 id="updates-heading">Updates</h2> | |
| 95 | + | <label class="setting"> | |
| 96 | + | <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span> | |
| 97 | + | <input type="checkbox" role="switch" id="autoUpdate" /> | |
| 98 | + | </label> | |
| 99 | + | <div class="setting" id="updates-row"> | |
| 100 | + | <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite"> </span></span> | |
| 101 | + | <span class="buttons"> | |
| 102 | + | <button type="button" id="restart" class="primary" hidden>Restart now</button> | |
| 103 | + | <button type="button" id="check">Check for updates</button> | |
| 104 | + | </span> | |
| 105 | + | </div> | |
| 106 | + | </section> | |
| 107 | + | ||
| 108 | + | <section aria-labelledby="server-heading"> | |
| 109 | + | <h2 id="server-heading">Server</h2> | |
| 110 | + | <div class="setting"> | |
| 111 | + | <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span> | |
| 112 | + | <button type="button" id="change-server">Change…</button> | |
| 113 | + | </div> | |
| 114 | + | </section> | |
| 115 | + | ||
| 116 | + | <p class="error" id="error" role="alert" hidden></p> | |
| 117 | + | <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p> | |
| 118 | + | </main> | |
| 119 | + | </div> | |
| 120 | + | <script src="settings.js"></script> | |
| 121 | + | </body> | |
| 122 | + | </html> |
| 1 | + | // The settings window's work: show what the app has, and keep each change | |
| 2 | + | // the moment it is made. Everything it asks is a command in | |
| 3 | + | // src/bridge.rs, answered only to this window. | |
| 4 | + | (function () { | |
| 5 | + | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 6 | + | var current = null; | |
| 7 | + | ||
| 8 | + | function byId(id) { | |
| 9 | + | return document.getElementById(id); | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | function invoke(command, args) { | |
| 13 | + | var core = window.__TAURI__ && window.__TAURI__.core; | |
| 14 | + | if (core && typeof core.invoke === "function") return core.invoke(command, args || {}); | |
| 15 | + | return Promise.reject(new Error("This page belongs inside the g1t desktop app.")); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | function fail(error) { | |
| 19 | + | var box = byId("error"); | |
| 20 | + | box.textContent = typeof error === "string" ? error : (error && error.message) || "That could not be changed."; | |
| 21 | + | box.hidden = false; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | function hostOf(server) { | |
| 25 | + | try { | |
| 26 | + | return new URL(server).host; | |
| 27 | + | } catch (error) { | |
| 28 | + | return server; | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // What changes between Windows, macOS and Linux. | |
| 33 | + | function platform(name) { | |
| 34 | + | byId("flash-label").textContent = | |
| 35 | + | name === "macos" ? "Bounce the dock icon" : name === "windows" ? "Flash the taskbar icon" : "Ask for attention"; | |
| 36 | + | // macOS keeps its menu bar at the top of the screen, whichever title bar the window has. | |
| 37 | + | if (name === "macos") byId("system-title-bar-note").textContent = "In place of g1t's own, which sits under the window's buttons."; | |
| 38 | + | if (name === "macos") byId("zoom-note").textContent = "The View menu changes it too: ⌘+, ⌘− and ⌘0."; | |
| 39 | + | else byId("zoom-note").textContent = "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0."; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | function render(view) { | |
| 43 | + | current = view; | |
| 44 | + | byId("error").hidden = true; | |
| 45 | + | byId("openAtLogin").checked = !!view.openAtLogin; | |
| 46 | + | SWITCHES.forEach(function (key) { | |
| 47 | + | byId(key).checked = !!view[key]; | |
| 48 | + | }); | |
| 49 | + | // Starting in the tray only means something when g1t starts at sign-in. | |
| 50 | + | byId("startHidden").disabled = !view.openAtLogin; | |
| 51 | + | byId("startHidden").closest(".setting").classList.toggle("off", !view.openAtLogin); | |
| 52 | + | // The title bar changes when g1t restarts. | |
| 53 | + | byId("restart-row").hidden = !view.titleBarChanged; | |
| 54 | + | var zoom = byId("zoom"); | |
| 55 | + | zoom.value = String(view.zoom); | |
| 56 | + | if (zoom.value === "") zoom.value = "1"; | |
| 57 | + | byId("version").textContent = "g1t " + view.version; | |
| 58 | + | if (!checking) updateStatus(view); | |
| 59 | + | byId("server").textContent = hostOf(view.server); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | function settingsOf() { | |
| 63 | + | var settings = {}; | |
| 64 | + | SWITCHES.forEach(function (key) { | |
| 65 | + | settings[key] = byId(key).checked; | |
| 66 | + | }); | |
| 67 | + | settings.zoom = Number(byId("zoom").value) || 1; | |
| 68 | + | return settings; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | function save() { | |
| 72 | + | invoke("save_settings", { settings: settingsOf() }).then(render, function (error) { | |
| 73 | + | fail(error); | |
| 74 | + | if (current) render(current); | |
| 75 | + | }); | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | SWITCHES.forEach(function (key) { | |
| 79 | + | byId(key).addEventListener("change", save); | |
| 80 | + | }); | |
| 81 | + | byId("zoom").addEventListener("change", save); | |
| 82 | + | byId("openAtLogin").addEventListener("change", function (event) { | |
| 83 | + | invoke("set_open_at_login", { on: event.target.checked }).then(render, function (error) { | |
| 84 | + | fail(error); | |
| 85 | + | if (current) render(current); | |
| 86 | + | }); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | // Updates: where this copy stands, said in the window, so a check from | |
| 90 | + | // the menu or the tray is answered here and cannot be missed. | |
| 91 | + | var checking = false; | |
| 92 | + | ||
| 93 | + | function when(ms) { | |
| 94 | + | var date = new Date(ms); | |
| 95 | + | var today = new Date().toDateString() === date.toDateString(); | |
| 96 | + | var time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); | |
| 97 | + | return today ? "today at " + time : date.toLocaleDateString([], { month: "short", day: "numeric" }) + " at " + time; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | function ready(version) { | |
| 101 | + | byId("update-status").textContent = "Version " + version + " is downloaded and installs when g1t restarts."; | |
| 102 | + | byId("restart").hidden = false; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | function updateStatus(view) { | |
| 106 | + | if (view.updateReady) return ready(view.updateReady); | |
| 107 | + | byId("restart").hidden = true; | |
| 108 | + | byId("update-status").textContent = view.checkedAt | |
| 109 | + | ? "Up to date. Last checked " + when(view.checkedAt) + "." | |
| 110 | + | : view.autoUpdate | |
| 111 | + | ? "Not checked yet since g1t started; it checks on its own shortly." | |
| 112 | + | : "Checking on its own is off."; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | function check() { | |
| 116 | + | if (checking) return; | |
| 117 | + | checking = true; | |
| 118 | + | var button = byId("check"); | |
| 119 | + | var status = byId("update-status"); | |
| 120 | + | button.disabled = true; | |
| 121 | + | status.textContent = "Checking, and downloading any new version…"; | |
| 122 | + | invoke("check_for_updates") | |
| 123 | + | .then(function (answer) { | |
| 124 | + | if (answer.indexOf("ready:") === 0) ready(answer.slice(6)); | |
| 125 | + | else status.textContent = "Up to date. Last checked " + when(Date.now()) + "."; | |
| 126 | + | }) | |
| 127 | + | .catch(function (error) { | |
| 128 | + | var text = typeof error === "string" ? error : ""; | |
| 129 | + | status.textContent = /sending request|connect|dns|timed out/i.test(text) || !text | |
| 130 | + | ? "Could not reach g1t.sh to check. Check your connection and try again." | |
| 131 | + | : "Could not check: " + text; | |
| 132 | + | }) | |
| 133 | + | .then(function () { | |
| 134 | + | checking = false; | |
| 135 | + | button.disabled = false; | |
| 136 | + | }); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | byId("check").addEventListener("click", check); | |
| 140 | + | byId("restart").addEventListener("click", function () { | |
| 141 | + | byId("restart").disabled = true; | |
| 142 | + | byId("update-status").textContent = "Installing…"; | |
| 143 | + | invoke("restart_to_update").catch(function (error) { | |
| 144 | + | byId("restart").disabled = false; | |
| 145 | + | fail(error); | |
| 146 | + | }); | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | // The menu's or tray's "Check for updates…" with this window open, and a | |
| 150 | + | // download finishing in the background (src/settings.rs). | |
| 151 | + | var events = window.__TAURI__ && window.__TAURI__.event; | |
| 152 | + | if (events && typeof events.listen === "function") { | |
| 153 | + | events.listen("g1t-check-updates", check).catch(function () {}); | |
| 154 | + | events | |
| 155 | + | .listen("g1t-update-ready", function (event) { | |
| 156 | + | if (!checking && typeof event.payload === "string") ready(event.payload); | |
| 157 | + | }) | |
| 158 | + | .catch(function () {}); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | byId("restart-app").addEventListener("click", function () { | |
| 162 | + | byId("restart-app").disabled = true; | |
| 163 | + | invoke("restart").catch(function (error) { | |
| 164 | + | byId("restart-app").disabled = false; | |
| 165 | + | fail(error); | |
| 166 | + | }); | |
| 167 | + | }); | |
| 168 | + | ||
| 169 | + | byId("change-server").addEventListener("click", function () { | |
| 170 | + | invoke("change_server").catch(fail); | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | // Links out, in the system's browser (src/bridge.js does the same on the site). | |
| 174 | + | document.addEventListener("click", function (event) { | |
| 175 | + | var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null; | |
| 176 | + | if (!anchor) return; | |
| 177 | + | event.preventDefault(); | |
| 178 | + | invoke("open_url", { url: anchor.href }).catch(function () {}); | |
| 179 | + | }); | |
| 180 | + | ||
| 181 | + | // g1t's own title bar, when the window has no system frame. | |
| 182 | + | function frame(view) { | |
| 183 | + | if (!view.customFrame) return; | |
| 184 | + | if (view.platform === "macos") document.documentElement.classList.add("mac"); | |
| 185 | + | var bar = byId("titlebar"); | |
| 186 | + | bar.hidden = false; | |
| 187 | + | document.body.classList.add("framed"); | |
| 188 | + | bar.addEventListener("mousedown", function (event) { | |
| 189 | + | if (event.button !== 0 || event.target.closest("button")) return; | |
| 190 | + | invoke("window_control", { action: "drag" }).catch(function () {}); | |
| 191 | + | }); | |
| 192 | + | byId("close-window").addEventListener("click", function () { | |
| 193 | + | invoke("window_control", { action: "close" }).catch(function () {}); | |
| 194 | + | }); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | invoke("get_settings") | |
| 198 | + | .then(function (view) { | |
| 199 | + | frame(view); | |
| 200 | + | platform(view.platform); | |
| 201 | + | render(view); | |
| 202 | + | // Opened by "Check for updates…" in a menu. | |
| 203 | + | if (new URLSearchParams(location.search).has("check")) { | |
| 204 | + | byId("updates-row").scrollIntoView({ block: "center" }); | |
| 205 | + | check(); | |
| 206 | + | } | |
| 207 | + | }) | |
| 208 | + | .catch(fail); | |
| 209 | + | })(); |
| 1 | + | /* The title bar, in the colours of the page under it (titlebar.js sets | |
| 2 | + | them): g1t's rail on g1t's pages, light or dark. 36px tall. */ | |
| 3 | + | :root { | |
| 4 | + | --bg: #050507; | |
| 5 | + | --fg: #ededef; | |
| 6 | + | --muted: #a0a0a8; | |
| 7 | + | --accent: #b6a8ff; | |
| 8 | + | --hover: rgba(237, 237, 239, 0.1); | |
| 9 | + | --pill: rgba(237, 237, 239, 0.09); | |
| 10 | + | --pill-hover: rgba(237, 237, 239, 0.14); | |
| 11 | + | --pill-edge: rgba(237, 237, 239, 0.08); | |
| 12 | + | } | |
| 13 | + | * { | |
| 14 | + | box-sizing: border-box; | |
| 15 | + | } | |
| 16 | + | html, | |
| 17 | + | body { | |
| 18 | + | height: 100%; | |
| 19 | + | margin: 0; | |
| 20 | + | overflow: hidden; | |
| 21 | + | background: var(--bg); | |
| 22 | + | } | |
| 23 | + | .bar { | |
| 24 | + | height: 100%; | |
| 25 | + | display: flex; | |
| 26 | + | align-items: center; | |
| 27 | + | gap: 2px; | |
| 28 | + | padding-left: 6px; | |
| 29 | + | color: var(--muted); | |
| 30 | + | font: 500 12px/1 system-ui, "Segoe UI", -apple-system, Roboto, sans-serif; | |
| 31 | + | user-select: none; | |
| 32 | + | -webkit-user-select: none; | |
| 33 | + | cursor: default; | |
| 34 | + | } | |
| 35 | + | .mac .bar { | |
| 36 | + | padding-left: 78px; | |
| 37 | + | } | |
| 38 | + | .side { | |
| 39 | + | display: flex; | |
| 40 | + | align-items: center; | |
| 41 | + | gap: 2px; | |
| 42 | + | flex: 1 1 0; | |
| 43 | + | min-width: 0; | |
| 44 | + | } | |
| 45 | + | .end { | |
| 46 | + | justify-content: flex-end; | |
| 47 | + | } | |
| 48 | + | .middle { | |
| 49 | + | flex: 0 1 560px; | |
| 50 | + | min-width: 0; | |
| 51 | + | display: flex; | |
| 52 | + | justify-content: center; | |
| 53 | + | } | |
| 54 | + | button { | |
| 55 | + | all: unset; | |
| 56 | + | box-sizing: border-box; | |
| 57 | + | display: inline-flex; | |
| 58 | + | align-items: center; | |
| 59 | + | justify-content: center; | |
| 60 | + | color: inherit; | |
| 61 | + | } | |
| 62 | + | .icon { | |
| 63 | + | width: 28px; | |
| 64 | + | height: 26px; | |
| 65 | + | border-radius: 6px; | |
| 66 | + | } | |
| 67 | + | .icon:hover { | |
| 68 | + | background: var(--hover); | |
| 69 | + | color: var(--fg); | |
| 70 | + | } | |
| 71 | + | .icon:disabled { | |
| 72 | + | opacity: 0.35; | |
| 73 | + | background: none; | |
| 74 | + | color: inherit; | |
| 75 | + | } | |
| 76 | + | .search { | |
| 77 | + | width: 100%; | |
| 78 | + | height: 24px; | |
| 79 | + | gap: 8px; | |
| 80 | + | padding: 0 10px; | |
| 81 | + | justify-content: flex-start; | |
| 82 | + | border-radius: 6px; | |
| 83 | + | background: var(--pill); | |
| 84 | + | box-shadow: inset 0 0 0 1px var(--pill-edge); | |
| 85 | + | } | |
| 86 | + | .search:hover { | |
| 87 | + | background: var(--pill-hover); | |
| 88 | + | color: var(--fg); | |
| 89 | + | } | |
| 90 | + | .search span, | |
| 91 | + | .title { | |
| 92 | + | overflow: hidden; | |
| 93 | + | text-overflow: ellipsis; | |
| 94 | + | white-space: nowrap; | |
| 95 | + | } | |
| 96 | + | .title { | |
| 97 | + | padding: 0 8px; | |
| 98 | + | } | |
| 99 | + | .caption { | |
| 100 | + | width: 46px; | |
| 101 | + | height: 36px; | |
| 102 | + | } | |
| 103 | + | .caption:hover { | |
| 104 | + | background: var(--hover); | |
| 105 | + | color: var(--fg); | |
| 106 | + | } | |
| 107 | + | .caption.close:hover { | |
| 108 | + | background: #c42b1c; | |
| 109 | + | color: #fff; | |
| 110 | + | } | |
| 111 | + | button:focus-visible { | |
| 112 | + | outline: 2px solid var(--accent); | |
| 113 | + | outline-offset: -2px; | |
| 114 | + | } |
| 1 | + | <!doctype html> | |
| 2 | + | <!-- | |
| 3 | + | The title bar: a page of its own, across the top of the main window, | |
| 4 | + | above the site's (src/frame.rs). It shows what the site's page reports | |
| 5 | + | (its title, its search, whether there is a way back, its colours), and | |
| 6 | + | its buttons act on the window and on that page through the app. | |
| 7 | + | --> | |
| 8 | + | <html lang="en"> | |
| 9 | + | <head> | |
| 10 | + | <meta charset="utf-8" /> | |
| 11 | + | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 12 | + | <title>g1t</title> | |
| 13 | + | <link rel="stylesheet" href="titlebar.css" /> | |
| 14 | + | </head> | |
| 15 | + | <body> | |
| 16 | + | <div class="bar" id="bar" role="toolbar" aria-label="Window"> | |
| 17 | + | <div class="side" id="left"></div> | |
| 18 | + | <div class="middle" id="middle"></div> | |
| 19 | + | <div class="side end" id="right"></div> | |
| 20 | + | </div> | |
| 21 | + | <script src="titlebar.js"></script> | |
| 22 | + | </body> | |
| 23 | + | </html> |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…