Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 1 | /** |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 2 | * Invites, as the site shows them: links to an invite, and how each |
| 3 | * invitation reads in a list. | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 4 | * No Workers or React imports, so it can be tested under Node. |
| 5 | */ | |
| 6 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 7 | /** What the sign-up buttons say: Sign up. Anyone may register. */ |
| Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite | 8 | export function signUpCopy(): { primary: string; secondary: string | null } { |
| 9 | return { primary: "Sign up", secondary: null }; | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 10 | } |
| 11 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 12 | /** The address an invite link has. */ |
| 13 | export function inviteLink(code: string, origin = "https://g1t.sh"): string { | |
| 14 | return `${origin.replace(/\/+$/, "")}/invite/${code}`; | |
| 15 | } | |
| 16 | ||
| 17 | /** | |
| 18 | * An invite code from however someone pasted it: the code, a whole | |
| 19 | * invite link, or a /register?invite= address. Identity reads it again; | |
| 20 | * this only tidies what is put back into a form. | |
| 21 | */ | |
| 22 | export function cleanCode(raw: string | null | undefined): string { | |
| 23 | let text = (raw ?? "").trim(); | |
| 24 | const param = /[?&]invite=([^&#\s]+)/i.exec(text); | |
| 25 | if (param) text = decodeURIComponent(param[1]!); | |
| 26 | else if (/^https?:\/\//i.test(text)) text = text.replace(/[?#].*$/, "").split("/").filter(Boolean).pop() ?? ""; | |
| 27 | return text.replace(/\s+/g, "").slice(0, 80); | |
| 28 | } | |
| 29 | ||
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 30 | /** |
| 31 | * The `proof` an invite email's link carries, tidied: hex, or null for | |
| 32 | * anything else. Identity decides whether it is the invite's own; this only | |
| 33 | * keeps junk out of what is passed on and put back into a form. | |
| 34 | */ | |
| 35 | export function cleanProof(raw: string | null | undefined): string | null { | |
| 36 | const text = (raw ?? "").trim().toLowerCase(); | |
| 37 | return /^[0-9a-f]{16,128}$/.test(text) ? text : null; | |
| 38 | } | |
| 39 | ||
| 40 | /** An invite's page, keeping the email's proof when there is one. */ | |
| 41 | export function invitePath(code: string, proof?: string | null): string { | |
| 42 | const path = `/invite/${encodeURIComponent(code)}`; | |
| 43 | return proof ? `${path}?proof=${encodeURIComponent(proof)}` : path; | |
| 44 | } | |
| 45 | ||
| 46 | type Proven = { | |
| 47 | /** The bound address in full, or null for an invite to anyone with the code. */ | |
| 48 | address: string | null; | |
| 49 | emailProven: boolean; | |
| 50 | workspace: { name: string } | null; | |
| 51 | repository: { name: string } | null; | |
| 52 | }; | |
| 53 | ||
| 54 | /** | |
| 55 | * What signing up on an invite's page says about the email address. Opened | |
| 56 | * from the invite's own email (`emailProven`), the address is confirmed | |
| 57 | * already, so there is no code to enter; otherwise the address is confirmed | |
| 58 | * after sign-up, as it always is. | |
| 59 | */ | |
| 60 | export function inviteSignUpCopy(invite: Proven): { | |
| 61 | /** Under "Create your account". */ | |
| 62 | intro: string; | |
| 63 | /** Under the email field. */ | |
| 64 | hint: string; | |
| 65 | /** Said plainly above the form when the address is confirmed already; null otherwise. */ | |
| 66 | confirmed: string | null; | |
| 67 | } { | |
| 68 | const proven = invite.emailProven && invite.address !== null; | |
| 69 | const when = proven ? "as soon as you create it" : "as soon as you confirm your email"; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 70 | // A workspace is never joined without saying yes: the new account accepts its invitation. |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 71 | const intro = invite.workspace |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 72 | ? `You can join ${invite.workspace.name} ${when}: accept the invitation then.` |
| Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm | 73 | : invite.repository |
| 74 | ? `You get ${invite.repository.name} ${when}.` | |
| 75 | : "It takes a minute."; | |
| 76 | if (proven) { | |
| 77 | return { | |
| 78 | intro, | |
| 79 | hint: "Your invite was sent here, and you opened it from that email, so this address is confirmed already.", | |
| 80 | confirmed: `${invite.address} is confirmed: you came here from the invite we emailed to it, so there is no code to enter after you sign up.`, | |
| 81 | }; | |
| 82 | } | |
| 83 | return { | |
| 84 | intro, | |
| 85 | hint: invite.address | |
| 86 | ? "Your invite was sent here. We email it a code to confirm it before you start." | |
| 87 | : "We email it a code to confirm it before you start.", | |
| 88 | confirmed: null, | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 92 | type Listed = { |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 93 | status: "pending" | "awaiting_confirmation" | "awaiting_answer" | "redeemed" | "declined" | "expired" | "revoked"; |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 94 | redeemedBy: string | null; |
| 95 | email: string | null; | |
| 96 | workspace: string | null; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 97 | /** The account a workspace invitation is for, by username. */ |
| 98 | invitee?: string | null; | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 99 | }; |
| 100 | ||
| 101 | /** How an invite's state reads in a list. */ | |
| 102 | export function inviteState(invite: Listed): { label: string; tone: "pending" | "done" | "dead" } { | |
| 103 | switch (invite.status) { | |
| 104 | case "pending": | |
| 105 | return { label: "Pending", tone: "pending" }; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 106 | case "awaiting_confirmation": |
| 107 | // The account is made; it joins once it confirms its address. | |
| 108 | return { | |
| 109 | label: invite.redeemedBy ? `@${invite.redeemedBy} is confirming their email` : "Confirming their email", | |
| 110 | tone: "pending", | |
| 111 | }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 112 | case "awaiting_answer": { |
| 113 | // The account is made and confirmed; the workspace waits for its yes or no. | |
| 114 | const who = invite.redeemedBy ?? invite.invitee; | |
| 115 | return { label: who ? `Waiting for @${who} to accept` : "Waiting for an answer", tone: "pending" }; | |
| 116 | } | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 117 | case "redeemed": |
| 118 | return { label: invite.redeemedBy ? `Joined as @${invite.redeemedBy}` : "Used", tone: "done" }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 119 | case "declined": |
| 120 | return { label: invite.invitee ? `@${invite.invitee} declined` : "Declined", tone: "dead" }; | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 121 | case "expired": |
| 122 | return { label: "Expired", tone: "dead" }; | |
| 123 | case "revoked": | |
| 124 | return { label: "Revoked", tone: "dead" }; | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 128 | type Previewed = { |
| 129 | kind: "account" | "workspace"; | |
| 130 | invitedBy: { username: string } | null; | |
| 131 | workspace: { name: string } | null; | |
| 132 | repository: { name: string; role: string } | null; | |
| 133 | hasAccount: boolean; | |
| 134 | }; | |
| 135 | ||
| 136 | /** | |
| 137 | * What an invite's page (/invite/:code) says it is, so nobody mistakes one | |
| 138 | * kind for the other: the headline, in three parts with the place between | |
| 139 | * (shown in bold), and the line under it. "@syntaqx invited you to g1t" is | |
| 140 | * an account and no workspace; "@syntaqx invited you to join Flagon, Inc. | |
| 141 | * on g1t" is a workspace invitation, which also makes the account of | |
| 142 | * someone who has none. | |
| 143 | */ | |
| 144 | export function invitePageCopy( | |
| 145 | invite: Previewed, | |
| 146 | signedIn: boolean, | |
| 147 | ): { before: string; place: string | null; after: string; about: string } { | |
| 148 | const from = invite.invitedBy ? `@${invite.invitedBy.username}` : "The g1t team"; | |
| 149 | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 150 | const g1t = "g1t is where a team and the coding agents it already uses ship code"; |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 151 | if (invite.workspace) { |
| 152 | const name = invite.workspace.name; | |
| 153 | return { | |
| 154 | before: `${from} invited you to join `, | |
| 155 | place: name, | |
| 156 | after: " on g1t", | |
| 157 | about: `This is an invitation to join ${name}, which you accept or decline. ${ | |
| 158 | signingUp | |
| 159 | ? `You do not have a g1t account yet, so it also lets you make one: make it below, then join ${name}.` | |
| 160 | : `Accepting joins you to ${name}.` | |
| 161 | }`, | |
| 162 | }; | |
| 163 | } | |
| 164 | if (invite.repository) { | |
| 165 | return { | |
| 166 | before: `${from} invited you to collaborate on `, | |
| 167 | place: invite.repository.name, | |
| 168 | after: "", | |
| 169 | about: `${g1t}. ${signingUp ? "Make your account below and you get" : "Accepting gives you"} the ${invite.repository.role} role on ${invite.repository.name}.`, | |
| 170 | }; | |
| 171 | } | |
| 172 | return { | |
| 173 | before: `${from} invited you to g1t`, | |
| 174 | place: null, | |
| 175 | after: "", | |
| Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one. | 176 | about: `${g1t}: repositories, pull requests and checks that hold, a merge queue and deployments, with chat for the team. Sign up from this invite and your address is filled in for you. It does not add you to anyone's workspace: your account starts with a workspace of its own.`, |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 177 | }; |
| 178 | } | |
| 179 | ||
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 180 | /** What inviting someone came to (lib/member-invitations.server.ts): who was invited, or why not. */ |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 181 | export type InviteResult = { invited: string } | { error: string }; |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 182 | |
| 183 | /** What revoking or resending an invitation came to. */ | |
| 184 | export type InvitationResult = { done: "revoked" | "resent"; id: string } | { error: string; id: string }; | |
| 185 | ||
| 186 | /** | |
| 187 | * The Invitations page's filters: every invitation, or the ones in one | |
| 188 | * state. Pending gathers the three states an invitation waits in (to be | |
| 189 | * used, for the new account to confirm its address, for the person's | |
| 190 | * answer); Accepted is the ones that joined. g1t does not record an email | |
| 191 | * that could not be delivered, so an invitation nobody used shows as | |
| 192 | * Expired once its 30 days are up; there is no Failed filter until it does. | |
| 193 | */ | |
| 194 | export type InvitationFilter = "all" | "pending" | "accepted" | "declined" | "expired" | "revoked"; | |
| 195 | ||
| 196 | export const INVITATION_FILTERS: readonly { key: InvitationFilter; label: string }[] = [ | |
| 197 | { key: "all", label: "All" }, | |
| 198 | { key: "pending", label: "Pending" }, | |
| 199 | { key: "accepted", label: "Accepted" }, | |
| 200 | { key: "declined", label: "Declined" }, | |
| 201 | { key: "expired", label: "Expired" }, | |
| 202 | { key: "revoked", label: "Revoked" }, | |
| 203 | ]; | |
| 204 | ||
| 205 | /** The filter an invitation's state falls under. */ | |
| 206 | export function invitationFilterOf(status: Listed["status"]): Exclude<InvitationFilter, "all"> { | |
| 207 | switch (status) { | |
| 208 | case "pending": | |
| 209 | case "awaiting_confirmation": | |
| 210 | case "awaiting_answer": | |
| 211 | return "pending"; | |
| 212 | case "redeemed": | |
| 213 | return "accepted"; | |
| 214 | case "declined": | |
| 215 | return "declined"; | |
| 216 | case "expired": | |
| 217 | return "expired"; | |
| 218 | case "revoked": | |
| 219 | return "revoked"; | |
| 220 | } | |
| 221 | } | |
| 222 | ||
| 223 | /** The filter `?state=` names; All for anything else. */ | |
| 224 | export function invitationFilter(raw: string | null | undefined): InvitationFilter { | |
| 225 | return INVITATION_FILTERS.some((filter) => filter.key === raw) ? (raw as InvitationFilter) : "all"; | |
| 226 | } | |
| 227 | ||
| 228 | /** The invitations under `filter`. */ | |
| 229 | export function filterInvitations<T extends Pick<Listed, "status">>(invites: readonly T[], filter: InvitationFilter): T[] { | |
| 230 | if (filter === "all") return [...invites]; | |
| 231 | return invites.filter((invite) => invitationFilterOf(invite.status) === filter); | |
| 232 | } | |
| 233 | ||
| 234 | /** How many invitations each filter holds, for the counts on the chips. */ | |
| 235 | export function invitationCounts(invites: readonly Pick<Listed, "status">[]): Record<InvitationFilter, number> { | |
| 236 | const counts: Record<InvitationFilter, number> = { all: invites.length, pending: 0, accepted: 0, declined: 0, expired: 0, revoked: 0 }; | |
| 237 | for (const invite of invites) counts[invitationFilterOf(invite.status)] += 1; | |
| 238 | return counts; | |
| 239 | } | |
| 240 | ||
| 241 | /** The tone of an invitation's status badge, from how its state reads. */ | |
| 242 | export function invitationTone(status: Listed["status"]): "neutral" | "success" | "warn" | "danger" { | |
| 243 | switch (invitationFilterOf(status)) { | |
| 244 | case "pending": | |
| 245 | return "warn"; | |
| 246 | case "accepted": | |
| 247 | return "success"; | |
| 248 | case "declined": | |
| 249 | case "revoked": | |
| 250 | return "danger"; | |
| 251 | default: | |
| 252 | return "neutral"; | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 256 | /** Who an invite is for, in a list. */ |
| 257 | export function inviteFor(invite: Listed): string { | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 258 | return invite.email ?? (invite.invitee ? `@${invite.invitee}` : "Anyone with the link"); |
| 259 | } | |
| 260 | ||
| 261 | /** | |
| 262 | * Which of the two invites a listed one is, in words: an invite to g1t | |
| 263 | * (an account, and no workspace), or an invitation to join a workspace. | |
| 264 | */ | |
| 265 | export function inviteKind(invite: { workspace: string | null }): { kind: "g1t" | "workspace"; label: string } { | |
| 266 | return invite.workspace | |
| 267 | ? { kind: "workspace", label: `Invite to join ${invite.workspace}` } | |
| 268 | : { kind: "g1t", label: "Invite to g1t" }; | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 269 | } |
| 270 | ||
| Anyone on g1t can invite a friend again: Invite a friend, in the account menu with how many are left beside it, opens Settings → Invites to g1t with a dialog for their email address and a note. The friend gets an email from them whose link opens sign-up with the address filled in and, because it came from that email, confirmed; their account starts with a workspace of its own, and the page lists every invite sent with where it stands (Pending, confirming their email, Joined as @them), with Copy link, Send again and Revoke on each. It is word of mouth, never a gate: sign-ups stay open to everyone, nothing asks for an invite, and running out says friends can still sign up without one. | 271 | // --- Invite a friend ---------------------------------------------------------- |
| 272 | // | |
| 273 | // Settings → Invites to g1t (`/settings/invites`): any person invites a friend | |
| 274 | // to g1t by email. Anyone can sign up without one; the invite is a note from | |
| 275 | // someone they know, with a link that opens sign-up with their address filled | |
| 276 | // in and confirmed. Twenty each, for good: a revoked or expired one that was | |
| 277 | // never used comes back. | |
| 278 | ||
| 279 | /** The words for Invite a friend. Never says sign-up is held or needs an invite. */ | |
| 280 | export const G1T_INVITES = { | |
| 281 | /** Its name in the settings menu. */ | |
| 282 | nav: "Invites to g1t", | |
| 283 | /** The page's heading. */ | |
| 284 | heading: "Invite friends to g1t", | |
| 285 | about: | |
| 286 | "Anyone can sign up for g1t. An invite is a note from you: your friend gets an email with a link that opens sign-up with their address filled in and confirmed, and you see when they join.", | |
| 287 | /** The account menu's entry and the page's button. */ | |
| 288 | action: "Invite a friend", | |
| 289 | /** The dialog's description. */ | |
| 290 | dialog: | |
| 291 | "They get an email from you with a link to sign up. Their account starts with a workspace of its own, and the invite works for 30 days.", | |
| 292 | /** Under the count. */ | |
| 293 | comesBack: "A revoked invite that was never used comes back to you.", | |
| 294 | /** The off-by-default box that also invites the person to a workspace. */ | |
| 295 | alsoJoin: "Also invite them to a workspace", | |
| 296 | alsoJoinHint: | |
| 297 | "Once their account is made, they get an invitation to the workspace to accept or decline. Left off, the invite is to g1t only.", | |
| 298 | /** When none are left: on the disabled button. */ | |
| 299 | out: "You have used all your invites. Your friends can still sign up at g1t.sh without one.", | |
| 300 | /** When none are left: under the count, which says so already. */ | |
| 301 | outNote: "Your friends can still sign up at g1t.sh without one.", | |
| 302 | } as const; | |
| 303 | ||
| 304 | type Membership = { slug: string; name?: string | null; role: "owner" | "member" }; | |
| 305 | ||
| 306 | /** One workspace an invite to g1t can also invite its person to. */ | |
| 307 | export type BringInto = { slug: string; name: string }; | |
| 308 | ||
| 309 | /** | |
| 310 | * The workspaces Invite a friend can also invite the person to, when "Also | |
| 311 | * invite them to a workspace" is ticked: the ones the viewer owns that are | |
| 312 | * on a paid plan. A free workspace invites no one, so it is not offered, and | |
| 313 | * with none to offer the box is not shown at all. | |
| 314 | */ | |
| 315 | export function bringIntoChoices(memberships: readonly Membership[], free: readonly string[]): BringInto[] { | |
| 316 | const isFree = new Set(free.map((slug) => slug.toLowerCase())); | |
| 317 | return memberships | |
| 318 | .filter((m) => m.role === "owner" && !isFree.has(m.slug.toLowerCase())) | |
| 319 | .map((m) => ({ slug: m.slug.toLowerCase(), name: m.name?.trim() || m.slug })); | |
| 320 | } | |
| 321 | ||
| 322 | /** | |
| 323 | * What the Invite a friend form sends to identity. The workspace goes with | |
| 324 | * it only when "Also invite them to a workspace" (`also_join`) is ticked and | |
| 325 | * it is one of `offered`: unticked, the invite is to g1t alone, whatever | |
| 326 | * else the form held. | |
| 327 | */ | |
| 328 | export function inviteDraft( | |
| 329 | form: { get(name: string): unknown }, | |
| 330 | offered: readonly BringInto[] = [], | |
| 331 | ): { email: string; message: string | null; join?: string; joinRole?: "owner" | "member" } { | |
| 332 | const text = (name: string) => { | |
| 333 | const value = form.get(name); | |
| 334 | return typeof value === "string" ? value.trim() : ""; | |
| 335 | }; | |
| 336 | const draft: ReturnType<typeof inviteDraft> = { email: text("email"), message: text("message") || null }; | |
| 337 | const join = text("join").toLowerCase(); | |
| 338 | if (text("also_join") === "on" && join && offered.some((workspace) => workspace.slug === join)) { | |
| 339 | draft.join = join; | |
| 340 | draft.joinRole = text("join_role") === "owner" ? "owner" : "member"; | |
| 341 | } | |
| 342 | return draft; | |
| 343 | } | |
| 344 | ||
| 345 | /** How many invites are left, in words: "18 of 20 invites left". */ | |
| 346 | export function remainingLine(allowance: { limit: number; remaining: number }): string { | |
| 347 | if (allowance.limit === 0) return "Invites to g1t are turned off on this installation"; | |
| 348 | if (allowance.remaining === 0) return `You have used all ${allowance.limit} of your invites`; | |
| 349 | return `${allowance.remaining} of ${allowance.limit} invite${allowance.limit === 1 ? "" : "s"} left`; | |
| 350 | } | |
| 351 | ||
| 352 | /** The account menu's badge: "18 left", or nothing when the count is not known. */ | |
| 353 | export function leftBadge(left: number | null | undefined): string | null { | |
| 354 | return left == null ? null : `${left} left`; | |
| 355 | } | |
| 356 | ||
| 357 | /** Which of a person's invites to g1t can be revoked, sent again, or copied. */ | |
| 358 | export function inviteActions(invite: { status: Listed["status"]; code: string | null }): { | |
| 359 | revoke: boolean; | |
| 360 | resend: boolean; | |
| 361 | copy: boolean; | |
| 362 | } { | |
| 363 | const pending = invite.status === "pending"; | |
| 364 | return { | |
| 365 | revoke: pending || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer", | |
| 366 | resend: pending, | |
| 367 | copy: pending && invite.code != null, | |
| 368 | }; | |
| 369 | } | |
| 370 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 371 | /** The words for a workspace's People page, the invitation to join it. */ |
| 372 | export function workspaceInviteCopy(name: string): { heading: string; hint: string } { | |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 373 | return { |
| 374 | heading: `Invite to ${name}`, | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 375 | hint: `Search people on g1t by username or name, or enter an email address. They get an invitation to join ${name}, in their notifications and by email, and join only if they accept. If they do not have a g1t account yet, they sign up from the invitation first.`, |
| Merge two kinds of invite, kept apart: an invite to g1t (Settings, invite-only only, no workspace unless asked) and an invitation to a workspace (its People page) | 376 | }; |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 380 | * The Members page's filters, from its query: the role to show (`role=`: |
| 381 | * owners or members), whether two-factor authentication is on (`2fa=`: on | |
| 382 | * or off; owners only see it), a team by slug (`team=`) and words to | |
| 383 | * search by (`q=`, matched against the username and the name). Anything | |
| 384 | * else is every member. | |
| 385 | */ | |
| 386 | export type MemberFilters = { role: "all" | "owner" | "member"; twoFactor: "all" | "on" | "off"; team: string | null; query: string }; | |
| 387 | ||
| 388 | export function memberFilters(search: URLSearchParams | string): MemberFilters { | |
| 389 | const params = typeof search === "string" ? new URLSearchParams(search) : search; | |
| 390 | const role = params.get("role"); | |
| 391 | const twoFactor = params.get("2fa"); | |
| 392 | return { | |
| 393 | role: role === "owner" || role === "member" ? role : "all", | |
| 394 | twoFactor: twoFactor === "on" || twoFactor === "off" ? twoFactor : "all", | |
| 395 | team: params.get("team")?.trim().toLowerCase() || null, | |
| 396 | query: params.get("q")?.trim() ?? "", | |
| 397 | }; | |
| 398 | } | |
| 399 | ||
| 400 | type Listable = { | |
| 401 | username: string; | |
| 402 | name?: string | null; | |
| 403 | role: "owner" | "member"; | |
| 404 | two_factor?: boolean | null; | |
| 405 | }; | |
| 406 | ||
| 407 | /** The members `filters` keep, in the order given. `teams` is each member's teams, by slug. */ | |
| 408 | export function filterMembers<T extends Listable>(members: readonly T[], filters: MemberFilters, teams: Record<string, { slug: string }[]> = {}): T[] { | |
| 409 | const words = filters.query.toLowerCase().replace(/^@+/, ""); | |
| 410 | return members.filter((member) => { | |
| 411 | if (filters.role !== "all" && member.role !== filters.role) return false; | |
| 412 | if (filters.twoFactor !== "all" && member.two_factor !== (filters.twoFactor === "on")) return false; | |
| 413 | if (filters.team && !(teams[member.username] ?? []).some((team) => team.slug.toLowerCase() === filters.team)) return false; | |
| 414 | if (words && !member.username.toLowerCase().includes(words) && !(member.name ?? "").toLowerCase().includes(words)) return false; | |
| 415 | return true; | |
| 416 | }); | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 417 | } |
| 418 | ||
| 419 | /** | |
| 420 | * Whether a sign-up or access form was filled in by a bot: the hidden | |
| 421 | * `website` field people never see, or a form sent back faster than a | |
| 422 | * person types. | |
| 423 | */ | |
| 424 | export function looksAutomated(form: { get(name: string): unknown }, now = Date.now()): boolean { | |
| 425 | const trap = form.get("website"); | |
| 426 | if (typeof trap === "string" && trap.trim() !== "") return true; | |
| 427 | const started = Number(form.get("started")); | |
| 428 | return Number.isFinite(started) && started > 0 && now - started < 1500; | |
| 429 | } | |
| 430 | ||
| 431 | /** | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 432 | * A username to offer someone signing up with `email`: its local part, |
| 433 | * lowercased, with anything a username cannot hold made single hyphens, | |
| 434 | * up to 39. They can type it in any case they like. Empty when nothing | |
| 435 | * usable is left. Identity checks it is free. | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 436 | */ |
| 437 | export function suggestUsername(email: string | null | undefined): string { | |
| 438 | const local = (email ?? "").split("@")[0]?.split("+")[0] ?? ""; | |
| 439 | return local | |
| 440 | .toLowerCase() | |
| 441 | .replace(/[^a-z0-9]+/g, "-") | |
| 442 | .replace(/^-+|-+$/g, "") | |
| 443 | .slice(0, 39) | |
| 444 | .replace(/-+$/g, ""); | |
| 445 | } | |
| 446 | ||
| 447 | type Lands = { workspace: { slug: string } | null; repository: { name: string } | null }; | |
| 448 | ||
| 449 | /** | |
| 450 | * Where using an invite lands: the workspace it joins, the repository it | |
| 451 | * gives access to, or nowhere in particular. | |
| 452 | */ | |
| 453 | export function landingFor(invite: Lands): string | null { | |
| 454 | if (invite.workspace) return invite.workspace.slug.toLowerCase(); | |
| 455 | if (invite.repository) return invite.repository.name.toLowerCase(); | |
| 456 | return null; | |
| 457 | } | |
| 458 | ||
| 459 | /** What someone who just joined is welcomed into, for one page view. */ | |
| 460 | export const WELCOME_COOKIE = "g1t_welcome"; | |
| 461 | ||
| 462 | const TARGET = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9._-]+)?$/; | |
| 463 | ||
| 464 | /** The `Set-Cookie` value that welcomes the next view of `target` (a slug or `workspace/repo`). */ | |
| 465 | export function welcomeCookie(target: string, secure: boolean): string { | |
| 466 | return `${WELCOME_COOKIE}=${encodeURIComponent(target.toLowerCase())}; Path=/; Max-Age=300; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 467 | } | |
| 468 | ||
| 469 | /** The `Set-Cookie` value that ends the welcome, once it has been shown. */ | |
| 470 | export function clearWelcome(secure: boolean): string { | |
| 471 | return `${WELCOME_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`; | |
| 472 | } | |
| 473 | ||
| 474 | /** Whether the request's cookies welcome someone into `target`. */ | |
| 475 | export function welcomes(cookieHeader: string | null, target: string): boolean { | |
| 476 | for (const part of (cookieHeader ?? "").split(";")) { | |
| 477 | const [key, ...rest] = part.trim().split("="); | |
| 478 | if (key !== WELCOME_COOKIE) continue; | |
| 479 | let value: string; | |
| 480 | try { | |
| 481 | value = decodeURIComponent(rest.join("=")); | |
| 482 | } catch { | |
| 483 | return false; | |
| 484 | } | |
| 485 | return TARGET.test(value) && value === target.toLowerCase(); | |
| 486 | } | |
| 487 | return false; | |
| 488 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.