Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
165 linesCodeBlameRaw
1/**
2 * Getting started: the setup guide that opens on a workspace's Home for its
3 * owners until they finish or dismiss it (components/onboarding.tsx).
4 *
5 * Whether each step is done is read from the workspace itself, never from
6 * clicks: its AI providers and whether one works, what Ask answers with,
7 * its projects and GitHub, the person's g1t CLI sign-in and the apps they
8 * signed in to (the Claude Code plugin is one), its members and
9 * invitations, its runners. Identity keeps only what the person chose
10 * (`Onboarding`: dismissed, skipped). Pure, so it is tested on its own
11 * (onboarding.test.ts); lib/onboarding.server.ts reads the facts.
12 */
13import type { AiConnection, AskSetup, Onboarding, OnboardingStep } from "@g1t/contracts";
14
15/** What the guide reads from the workspace, each null when its service did not answer. */
16export type OnboardingFacts = {
17 /** The workspace's AI providers: how many, and how many passed their last check. */
18 providers: { total: number; working: number } | null;
19 /** Ask answers with a provider, and it has no problem. */
20 askReady: boolean | null;
21 /** The provider and model Ask answers with, as the AI page shows them. */
22 askWith: string | null;
23 /** How many projects the workspace has. */
24 projects: number | null;
25 /** Whether g1t's GitHub App is installed for it, and whether this g1t offers the app at all. */
26 github: { configured: boolean; connected: boolean } | null;
27 /** Whether the person signed the g1t CLI in. */
28 cli: boolean | null;
29 /** Whether the person signed an app in with g1t, as the Claude Code plugin does. */
30 harnessApp: boolean | null;
31 /** Members besides the person, and invitations waiting for an answer. */
32 people: { others: number; pending: number } | null;
33 /** Whether workspace invites need the plan first (a free workspace). */
34 invitesNeedPlan: boolean;
35 /** The workspace's self-hosted runners. */
36 runners: number | null;
37};
38
39/** One step, as the guide shows it. */
40export type OnboardingStepView = {
41 key: OnboardingStep;
42 title: string;
43 /** One line under the title in the rail. */
44 summary: string;
45 /** Done, from the workspace's state. */
46 done: boolean;
47 /** Skipped by the person. */
48 skipped: boolean;
49 /** Whether the person can skip it: everything but the AI provider. */
50 optional: boolean;
51};
52
53/** Everything the dialog needs. */
54export type OnboardingView = {
55 slug: string;
56 /** The workspace's name, for the dialog's title. */
57 name: string;
58 state: Onboarding;
59 /** Its AI providers, as the AI page lists them; null when integrations did not answer. */
60 connections: AiConnection[] | null;
61 /** What Ask answers with. */
62 ask: AskSetup | null;
63 facts: OnboardingFacts;
64 steps: OnboardingStepView[];
65};
66
67const TITLES: Record<OnboardingStep, { title: string; optional: boolean }> = {
68 ai: { title: "Add an AI provider", optional: false },
69 ask: { title: "Choose Ask's model", optional: true },
70 code: { title: "Bring your code", optional: true },
71 harness: { title: "Connect your harness", optional: true },
72 people: { title: "Invite your team", optional: true },
73 runner: { title: "Add a runner", optional: true },
74};
75
76/** The order the steps are shown in. Mirrors `ONBOARDING_STEPS` in the contracts. */
77export const STEP_ORDER: OnboardingStep[] = ["ai", "ask", "code", "harness", "people", "runner"];
78
79function plural(count: number, one: string, many = `${one}s`): string {
80 return `${count} ${count === 1 ? one : many}`;
81}
82
83/** Whether a step is done, and its line in the rail. */
84function judge(key: OnboardingStep, facts: OnboardingFacts): { done: boolean; summary: string } {
85 switch (key) {
86 case "ai": {
87 const providers = facts.providers;
88 if (!providers) return { done: false, summary: "Couldn't check your providers" };
89 if (providers.working > 0) return { done: true, summary: `${plural(providers.working, "provider")} working` };
90 if (providers.total > 0) return { done: false, summary: "Added, but its check failed" };
91 return { done: false, summary: "Your own key, for Ask and @g1t" };
92 }
93 case "ask":
94 if (facts.askReady) return { done: true, summary: facts.askWith ?? "Ask is ready" };
95 return { done: false, summary: "Which model answers Ask" };
96 case "code": {
97 const projects = facts.projects ?? 0;
98 if (projects > 0) return { done: true, summary: plural(projects, "project") };
99 if (facts.github?.connected) return { done: true, summary: "GitHub connected" };
100 return { done: false, summary: "New, or from GitHub" };
101 }
102 case "harness":
103 if (facts.cli && facts.harnessApp) return { done: true, summary: "CLI and plugin signed in" };
104 if (facts.cli) return { done: true, summary: "g1t CLI signed in" };
105 if (facts.harnessApp) return { done: true, summary: "Plugin signed in" };
106 return { done: false, summary: "CLI and Claude Code plugin" };
107 case "people": {
108 const people = facts.people;
109 if (people && people.others > 0) return { done: true, summary: `${plural(people.others, "other member")}` };
110 if (people && people.pending > 0) return { done: true, summary: `${plural(people.pending, "invitation")} sent` };
111 return { done: false, summary: facts.invitesNeedPlan ? "Invites come with the plan" : "Work together in one place" };
112 }
113 case "runner":
114 if ((facts.runners ?? 0) > 0) return { done: true, summary: plural(facts.runners ?? 0, "runner") };
115 return { done: false, summary: "Run jobs on your own machine" };
116 }
117}
118
119/** The steps, in order, each done or not from `facts` and skipped as the person chose. */
120export function stepsFrom(facts: OnboardingFacts, state: Onboarding): OnboardingStepView[] {
121 return STEP_ORDER.map((key) => {
122 const { done, summary } = judge(key, facts);
123 const { title, optional } = TITLES[key];
124 return { key, title, summary, done, skipped: !done && optional && state.skipped.includes(key), optional };
125 });
126}
127
128/** How far along: steps done or skipped, of all of them. */
129export function progressOf(steps: OnboardingStepView[]): { settled: number; total: number; percent: number } {
130 const settled = steps.filter((step) => step.done || step.skipped).length;
131 return { settled, total: steps.length, percent: Math.round((settled / Math.max(1, steps.length)) * 100) };
132}
133
134/** Whether every step is done or skipped. */
135export function finished(steps: OnboardingStepView[]): boolean {
136 return steps.every((step) => step.done || step.skipped);
137}
138
139/** The step to open on: the first one left, else the first. */
140export function firstOpenStep(steps: OnboardingStepView[]): OnboardingStep {
141 return (steps.find((step) => !step.done && !step.skipped) ?? steps[0])?.key ?? "ai";
142}
143
144/**
145 * Whether the guide opens by itself: for an owner who has not dismissed
146 * it. A workspace that is all set still opens it once, to say so and be
147 * finished; finishing dismisses it. `?setup` opens it whatever was chosen.
148 */
149export function opensByItself(view: Pick<OnboardingView, "state"> | null, asked: boolean): boolean {
150 if (!view) return false;
151 return asked || view.state.dismissed_at == null;
152}
153
154/** `skipped` with `step` skipped, or not, in the guide's order. */
155export function withSkipped(skipped: OnboardingStep[], step: OnboardingStep, skip: boolean): OnboardingStep[] {
156 const set = new Set(skipped);
157 if (skip) set.add(step);
158 else set.delete(step);
159 return STEP_ORDER.filter((key) => set.has(key));
160}
161
162/** Where Getting started is opened from anywhere: the workspace's Home, asked to open it. */
163export function setupPath(slug: string): string {
164 return `/${slug}/-/home?setup=1`;
165}