Commit

A project is an app or a library: libraries show their package and how to ship a release, not production

Each project has a Deploys setting (detect, yes, no). On detect, Deployments being on makes it an app; a package its repository publishes, or root manifests that say library (composer.json, Cargo.toml, go.mod, pyproject, package.json), make it a library. Detection is cached by commit and refreshed on push, default-branch change and package events. A library's overview has a Packages card and a Ship a release checklist; workspace cards show its package. Deployments refuse to turn on for a project set not to deploy.

syntaqxcommitted Parent0ea1f5eBrowse files
20 files+1235−1540/20 viewed
+7−0
6363 Production starts building at once from the default branch. Every pull
6464 request opened or pushed to from then on gets a preview.
6565
66+A project g1t takes for a library or a tool, such as a Composer package,
67+does not offer to deploy on its overview; it shows its packages instead.
68+Its **Deployments** page still turns them on, and doing so makes it an
69+app. A project set to **Doesn't deploy** cannot have deployments turned
70+on until the setting changes. See
71+[apps and libraries](/guides/projects/#apps-and-libraries).
72+
6673 While payments on g1t are in test mode, no real card is charged: use the
6774 test card `4242 4242 4242 4242` with any future date and any code.
6875
+62−4
3232
3333 | Page | Address | |
3434 | --- | --- | --- |
35−| **Overview** | `g1t.sh/<workspace>/<project>` | Production with a screenshot, the steps left to get to production, what is in progress, active branches, live previews, recent builds, and the source. See [the overview](#the-overview). |
35+| **Overview** | `g1t.sh/<workspace>/<project>` | Production with a screenshot, or for a library its packages; the steps left to get to production or to a first release; what is in progress, active branches, live previews, recent builds, and the source. See [the overview](#the-overview). |
3636 | **Code** | `…/code` | The repository's files, commits and branches. |
3737 | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. |
3838 | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). |
5050
5151 | Part | What it shows |
5252 | --- | --- |
53−| **Production** | A screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. See [the production screenshot](/guides/deployments/#the-production-screenshot). |
54−| **Get to production** | The checklist below, until every step is done or you dismiss it. |
53+| **Production** | For an app: a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. See [the production screenshot](/guides/deployments/#the-production-screenshot). |
54+| **Packages** | For a [library or a tool](#apps-and-libraries), in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. |
55+| **Get to production**, or **Ship a release** for a library | The checklists below, until every step is done or you dismiss it. |
5556 | **Right now** | Agents at work, and open pull requests moving from working to landed. |
5657 | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. |
5758 | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). |
7677 The card goes away when every step is done. To hide it sooner, choose
7778 **×** on it. That hides it for this project in this browser only.
7879
80+### Ship a release
81+
82+A [library or a tool](#apps-and-libraries) does not deploy, so its card
83+counts the steps to a first release instead:
84+
85+| Step | Done when | Links to |
86+| --- | --- | --- |
87+| Connect a source or push code | As above. | **Code**. |
88+| Add checks on pull requests | The repository has a workflow in `.g1t/workflows`. Its runs are every pull request's checks. | **Actions**, which offers to add a starter workflow. |
89+| Tag a release or publish a package | A package its repository publishes has a version. For [Composer](/guides/composer/) and [Go](/guides/go/), pushing a tag such as `v1.0.0` is the release. | The package's page, or the guide for its registry. |
90+| Set up repository instructions | As above. | The **Agents** page. |
91+| Assign a first issue to g1t | As above. | A new issue. |
92+
93+## Apps and libraries
94+
95+Every project is either an **app**, which deploys, or a **library or a
96+tool**, which is published and installed. An app's overview shows
97+production and the steps to get there; a library's shows its packages
98+and the steps to a first release, and never offers to turn on
99+deployments. Its **Deployments** page stays in the sidebar either way.
100+
101+g1t works it out for itself, in this order:
102+
103+1. If [Deployments](/guides/deployments/) are on for the project, it is an app.
104+2. If its repository publishes a package other than a container image,
105+ such as a [Composer](/guides/composer/) or [npm](/guides/npm/)
106+ package, it is a library.
107+3. If the files at the root of its default branch (or of its root
108+ directory) say it is a library, it is one:
109+
110+ | File | A library when |
111+ | --- | --- |
112+ | `composer.json` | Its `type` is anything but `project`, such as `library`; or it has no `type`, has `autoload`, and has no `public/index.php`. |
113+ | `Cargo.toml` | It builds a library (`[lib]` or `src/lib.rs`) and no binary (`[[bin]]` or `src/main.rs`). |
114+ | `go.mod` | No `.go` file at the root is `package main`. |
115+ | `pyproject.toml` | It has a build backend and depends on no app framework, such as Django, Flask or FastAPI. |
116+ | `package.json` | It has `main`, `exports`, `module`, `files` or `bin`, no `start` or `dev` script, and no app framework such as Next.js, Astro, Nuxt, Remix or SvelteKit. |
117+
118+ The language's own manifest is read before `package.json`, which many
119+ projects carry only for tooling. A Workers config (`wrangler.jsonc`,
120+ `wrangler.json` or `wrangler.toml`) or an `index.html` at the root
121+ makes it an app.
122+4. Anything else is an app.
123+
124+The files are read again on every push to the default branch.
125+
126+To decide for yourself, open **Settings**, then **General**, and under
127+**Deployments for this project** choose:
128+
129+- **Detect automatically**: the rules above. It shows what was detected
130+ and why.
131+- **Deploys**: an app, whatever its files say.
132+- **Doesn't deploy**: a library or a tool. Its overview offers no
133+ deploying. If Deployments are on for it, turn them off first under
134+ **Settings**, then **Deployments**; g1t does not turn them off for you.
135+ While it is set this way, Deployments cannot be turned on.
136+
79137 ### Active branches
80138
81139 Up to five branches other than the default, the most recently changed
95153
96154 | Tab | What it holds |
97155 | --- | --- |
98−| **General** | The project's name and description, its source, and its **root directory**. A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
156+| **General** | The project's name and description, its source, its **root directory**, and whether it deploys (see [apps and libraries](#apps-and-libraries)). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
99157 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
100158 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
101159 | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). |
+14−5
1313 };
1414
1515 /**
16− * The steps to production for one project, as `3/6`, until they are all
17− * done or someone dismisses it. Dismissing is per project and remembered by
18− * this browser.
16+ * The steps to production, or to a library's first release, for one
17+ * project, as `3/6`, until they are all done or someone dismisses it.
18+ * Dismissing is per project and remembered by this browser.
1919 */
20−export function ProductionChecklist({ base, items }: { base: string; items: ChecklistItem[] }) {
20+export function ProductionChecklist({
21+ base,
22+ items,
23+ title = "Get to production",
24+}: {
25+ base: string;
26+ items: ChecklistItem[];
27+ /** "Ship a release" for a library. */
28+ title?: string;
29+}) {
2130 const hidden = useSyncExternalStore(
2231 subscribe,
2332 useCallback(() => isDismissed(storage, base), [base]),
3140 <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface">
3241 <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6">
3342 <h2 id="checklist-title" className="text-sm font-semibold">
34− Get to production
43+ {title}
3544 </h2>
3645 <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted">
3746 {done}/{total}
+24−0
1010 isDismissed,
1111 productionChecklist,
1212 progress,
13+ releaseChecklist,
1314 } from "./checklist.ts";
1415
1516 const fresh: ChecklistFacts = {
9697 assert.equal(isDismissed(() => blocked, "/acme/web"), false);
9798 assert.equal(dismiss(() => blocked, "/acme/web"), false);
9899 });
100+
101+test("a library's checklist ships a release instead of deploying", () => {
102+ const facts = {
103+ base: "/flagon-io/php-log",
104+ hasCode: true,
105+ instructions: false,
106+ agentAssigned: false,
107+ hasWorkflow: false,
108+ released: true,
109+ releaseTo: "/flagon-io/-/packages/composer/psr/log",
110+ };
111+ const items = releaseChecklist(facts);
112+ assert.deepEqual(
113+ items.map((item) => item.key),
114+ ["code", "checks", "release", "instructions", "agent"],
115+ );
116+ assert.deepEqual(progress(items), { done: 2, total: 5, complete: false });
117+ assert.equal(items.find((item) => item.key === "checks")?.to, "/flagon-io/php-log/actions");
118+ assert.equal(items.find((item) => item.key === "release")?.to, "/flagon-io/-/packages/composer/psr/log");
119+ assert.ok(items.every((item) => !/deploy|production|domain|preview/i.test(item.title)));
120+ // An unknown workflow list counts as not done.
121+ assert.equal(releaseChecklist({ ...facts, hasWorkflow: null }).find((item) => item.key === "checks")?.done, false);
122+});
+45−5
11 /**
2− * The steps that take a project to production, each worked out from what
3− * the project has done, with where to do the ones it has not. Shown on the
4− * project's overview to its members until they are all done or it is
5− * dismissed.
2+ * The steps that take a project to production, or for a library to its
3+ * first release, each worked out from what the project has done, with
4+ * where to do the ones it has not. Shown on the project's overview to its
5+ * members until they are all done or it is dismissed.
66 */
77
88 export type ChecklistFacts = {
2525 };
2626
2727 export type ChecklistItem = {
28− key: "code" | "deploy" | "domain" | "preview" | "instructions" | "agent";
28+ key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "instructions" | "agent";
2929 title: string;
3030 detail: string;
3131 done: boolean;
8989 ];
9090 }
9191
92+export type ReleaseFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & {
93+ /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */
94+ hasWorkflow: boolean | null;
95+ /** A package its repository publishes has a version. */
96+ released: boolean;
97+ /** Where publishing a first version is explained: its package's page or its registry's guide. */
98+ releaseTo: string;
99+};
100+
101+/**
102+ * The steps for a library or a tool, which ships as releases rather than
103+ * deploying: the same first and last steps as production, with checks and
104+ * a first version in between.
105+ */
106+export function releaseChecklist(facts: ReleaseFacts): ChecklistItem[] {
107+ const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false });
108+ const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!;
109+ return [
110+ step("code"),
111+ {
112+ key: "checks",
113+ title: "Add checks on pull requests",
114+ detail: "A workflow that builds and tests it. Its runs are every pull request's checks.",
115+ done: facts.hasWorkflow === true,
116+ to: `${facts.base}/actions`,
117+ action: "Add CI",
118+ },
119+ {
120+ key: "release",
121+ title: "Tag a release or publish a package",
122+ detail: "Publish a first version to the workspace's registry for others to install.",
123+ done: facts.released,
124+ to: facts.releaseTo,
125+ action: "How",
126+ },
127+ step("instructions"),
128+ step("agent"),
129+ ];
130+}
131+
92132 /** `3/6`, for the card's heading. */
93133 export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } {
94134 const done = items.filter((item) => item.done).length;
+41−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { hasRelease, libraryPackages, packageLine, packagePath, publishGuide } from "./project-kind.ts";
5+
6+const pkg = (over: Record<string, unknown>) => ({
7+ ecosystem: "composer" as const,
8+ name: "psr/log",
9+ workspace: "flagon-io",
10+ latest: "3.0.2",
11+ versions: 3,
12+ updated_at: "2026-10-01T00:00:00Z",
13+ ...over,
14+});
15+
16+test("a package reads as its registry, its name and its latest version", () => {
17+ assert.equal(packageLine(pkg({})), "Composer · psr/log 3.0.2");
18+ assert.equal(packageLine(pkg({ ecosystem: "npm", name: "ui", latest: null })), "npm · @flagon-io/ui");
19+ assert.equal(packagePath(pkg({})), "/flagon-io/-/packages/composer/psr/log");
20+});
21+
22+test("what a library publishes comes before an image, released ones first", () => {
23+ const list = [
24+ pkg({ ecosystem: "container", name: "img", updated_at: "2026-10-05T00:00:00Z" }),
25+ pkg({ name: "empty", versions: 0, updated_at: "2026-10-04T00:00:00Z" }),
26+ pkg({}),
27+ ];
28+ assert.deepEqual(libraryPackages(list).map((p) => p.name), ["psr/log", "empty", "img"]);
29+ assert.equal(hasRelease(list), true);
30+ assert.equal(hasRelease([pkg({ versions: 0 })]), false);
31+ assert.equal(hasRelease([]), false);
32+});
33+
34+test("each ecosystem links to how it publishes", () => {
35+ assert.equal(publishGuide("composer")?.guide, "https://docs.g1t.sh/guides/composer/");
36+ assert.equal(publishGuide("composer")?.start, "git tag v1.0.0 && git push --tags");
37+ assert.equal(publishGuide("npm")?.start, "npm publish");
38+ assert.equal(publishGuide("go")?.guide, "https://docs.g1t.sh/guides/go/");
39+ assert.equal(publishGuide("python")?.guide, "https://docs.g1t.sh/guides/packages/");
40+ assert.equal(publishGuide(null), null);
41+});
+82−0
1+/**
2+ * Words and links for a project that is a library or a tool rather than an
3+ * app: which of its repository's packages to show, and how to publish a
4+ * first one. Whether a project is one is decided by the projects service
5+ * (`Project.kind`); this only says it on the pages.
6+ */
7+import type { DeploysSetting, Ecosystem, PackageSummary, ProjectEcosystem } from "@g1t/contracts";
8+
9+/** As lib/packages.ts names each registry; repeated so this module stands alone in tests. */
10+const REGISTRY: Record<Ecosystem, string> = { container: "Container", npm: "npm", composer: "Composer", cargo: "Cargo", go: "Go" };
11+
12+const DOCS = "https://docs.g1t.sh";
13+
14+/** The choices for "Deployments for this project" in a project's settings. */
15+export const DEPLOYS_CHOICES: { value: DeploysSetting; label: string; hint: string }[] = [
16+ { value: "auto", label: "Detect automatically", hint: "From whether Deployments are on, the packages it publishes and the files at its root." },
17+ { value: "yes", label: "Deploys", hint: "An app: its overview shows production, previews and domains." },
18+ { value: "no", label: "Doesn't deploy", hint: "A library or a tool: its overview shows its packages and releases, and offers no deploying." },
19+];
20+
21+/** A registry's guide, and the command that publishes a first version there. */
22+export type PublishGuide = { label: string; guide: string; start: string | null };
23+
24+/** The registries with a guide, for a library whose files do not say which it is. */
25+export const PUBLISH_GUIDES: PublishGuide[] = [
26+ { label: "Composer", guide: `${DOCS}/guides/composer/`, start: "git tag v1.0.0 && git push --tags" },
27+ { label: "npm", guide: `${DOCS}/guides/npm/`, start: "npm publish" },
28+ { label: "Go", guide: `${DOCS}/guides/go/`, start: "git tag v1.0.0 && git push --tags" },
29+ { label: "Containers", guide: `${DOCS}/guides/containers/`, start: null },
30+];
31+
32+/** How a library of `ecosystem` publishes its first version; null when its files do not say. */
33+export function publishGuide(ecosystem: ProjectEcosystem | null): PublishGuide | null {
34+ switch (ecosystem) {
35+ case "composer":
36+ return PUBLISH_GUIDES[0]!;
37+ case "npm":
38+ return PUBLISH_GUIDES[1]!;
39+ case "go":
40+ return PUBLISH_GUIDES[2]!;
41+ case "cargo":
42+ return { label: "Cargo", guide: `${DOCS}/guides/packages/`, start: null };
43+ case "python":
44+ return { label: "Python", guide: `${DOCS}/guides/packages/`, start: null };
45+ default:
46+ return null;
47+ }
48+}
49+
50+/** A package as its own tool names it: `@acme/ui` for npm, the name otherwise. */
51+export function packageName(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
52+ return pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name;
53+}
54+
55+/** `Composer · psr/log 3.0.2`, for a project's card. */
56+export function packageLine(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace" | "latest">): string {
57+ return `${REGISTRY[pkg.ecosystem]} · ${packageName(pkg)}${pkg.latest ? ` ${pkg.latest}` : ""}`;
58+}
59+
60+/** The package's page. */
61+export function packagePath(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
62+ return `/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`;
63+}
64+
65+/**
66+ * The packages a library's overview shows, from its repository's: what it
67+ * publishes for others to install first, a container image last, as an
68+ * image is how apps ship too.
69+ */
70+export function libraryPackages<T extends Pick<PackageSummary, "ecosystem" | "versions" | "updated_at">>(list: T[]): T[] {
71+ return [...list].sort(
72+ (a, b) =>
73+ Number(a.ecosystem === "container") - Number(b.ecosystem === "container") ||
74+ Number(b.versions > 0) - Number(a.versions > 0) ||
75+ b.updated_at.localeCompare(a.updated_at),
76+ );
77+}
78+
79+/** Whether any of the packages has a version: a release is out. */
80+export function hasRelease(list: Pick<PackageSummary, "versions">[]): boolean {
81+ return list.some((pkg) => pkg.versions > 0);
82+}
+23−9
1010 import { page } from "../../lib/meta";
1111 import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1212 import { computeNoteFor } from "../../lib/compute.server";
13−import { billing, deployments } from "../../lib/services.server";
13+import { billing, deployments, projects } from "../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1515 import { refusal, requireRepo } from "../../lib/access.server";
1616 import { whyNot } from "../../lib/access";
2424 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
2525 const { access } = await requireRepo(context, params, "read");
2626 const ref = { workspace: params.owner, slug: params.repo };
27− const [settings, list, features, computeNote] = await Promise.all([
27+ const [settings, list, features, computeNote, project] = await Promise.all([
2828 deployments.settings(ref, viewer),
2929 deployments.list(ref, viewer),
3030 billing.features(params.owner, viewer),
3131 // Builds are compute: said before a deploy is refused for it.
3232 computeNoteFor(params.owner, "deploy"),
33+ projects.get(params.owner, params.repo, viewer).catch(() => null),
3334 ]);
35+ // Set not to deploy, in General settings: turning them on waits for that to change.
36+ const notDeploying = project?.ok ? project.value.deploys === "no" : false;
3437 // Someone outside the workspace does not see its plans; the page works without.
3538 const plan = features.ok ? (features.value.find((state) => state.plan.feature === "deployments") ?? null) : null;
36− return { can: access.can, settings: unwrap(settings), ...unwrap(list), plan, computeNote };
39+ return { can: access.can, settings: unwrap(settings), ...unwrap(list), plan, computeNote, notDeploying };
3740 }
3841
3942 export async function action({ request, params, context }: Route.ActionArgs) {
114117 gets its own preview. Workers projects (a <code className="font-mono text-fg">wrangler.jsonc</code>) and
115118 static sites deploy without configuration.
116119 </p>
117− <Form method="post" className="mt-4">
118− <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy || !can.manage_integrations} title={whyNot(can, "manage_integrations")}>
119− <Rocket size={14} />
120− Turn on deployments
121− </Button>
122− </Form>
120+ {loaderData.notDeploying ? (
121+ <p className="mt-4 max-w-2xl text-sm text-muted">
122+ This project is set as one that doesn't deploy, a library or a tool. To deploy it, choose Deploys or Detect
123+ automatically in{" "}
124+ <Link to={`/${params.owner}/${params.repo}/settings#deploys`} className="text-fg underline underline-offset-4">
125+ its settings
126+ </Link>{" "}
127+ first.
128+ </p>
129+ ) : (
130+ <Form method="post" className="mt-4">
131+ <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy || !can.manage_integrations} title={whyNot(can, "manage_integrations")}>
132+ <Rocket size={14} />
133+ Turn on deployments
134+ </Button>
135+ </Form>
136+ )}
123137 </section>
124138 ) : (
125139 <>
+251−102
1414 Loader2,
1515 Lock,
1616 Network,
17+ Package as PackageGlyph,
1718 Pin,
1819 Rocket,
1920 RotateCw,
2122 import { type ReactNode, Suspense } from "react";
2223 import { Await, Form, Link, useNavigation } from "react-router";
2324
24−import { type AgentRun, type G1tEvent, type Memory, type Pull, RUN_KIND_LABEL, isActiveRun, workOwner } from "@g1t/contracts";
25+import {
26+ type AgentRun,
27+ type G1tEvent,
28+ type Memory,
29+ type PackageSummary,
30+ type Project,
31+ type Pull,
32+ RUN_KIND_LABEL,
33+ isActiveRun,
34+ workOwner,
35+} from "@g1t/contracts";
2536
2637 import type { Route } from "./+types/overview";
2738 import { host, StatusDot } from "../../components/deploy";
3041 import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission";
3142 import { type ActiveBranch, ActiveBranches } from "../../components/branches";
3243 import { ProductionChecklist } from "../../components/checklist";
44+import { PackageIcon } from "../../components/package-icon";
3345 import { Skeleton, SkeletonRows } from "../../components/ui/skeleton";
3446 import { ProductionShot } from "../../components/production-shot";
3547 import { GithubLinkStrip } from "../../components/github";
5365 stuckMinutes,
5466 } from "../../lib/mission";
5567 import { drift } from "../../lib/branches";
56−import { agentWasAssigned, hasInstructions, productionChecklist } from "../../lib/checklist";
57−import { agents, deployments, events as eventLog, projects, repos, work } from "../../lib/services.server";
68+import { agentWasAssigned, hasInstructions, productionChecklist, releaseChecklist } from "../../lib/checklist";
69+import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
70+import { PUBLISH_GUIDES, hasRelease, libraryPackages, packageName, packagePath, publishGuide } from "../../lib/project-kind";
71+import { actions, agents, deployments, events as eventLog, packages, projects, repos, work } from "../../lib/services.server";
5872 import { madeByG1t } from "../../lib/opened-by";
5973 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
6074 import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server";
6781 /** How far back each branch's history, and the default branch's, is read to count ahead and behind. */
6882 const BRANCH_DEPTH = 40;
6983 const MAIN_DEPTH = 120;
84+/** A library's packages shown on its overview; the rest are a link away. */
85+const PACKAGES_SHOWN = 3;
7086
7187 /**
7288 * The overview streams: the layout's header and tabs (one repository
96112 const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> =>
97113 memberP.then((member) => (member ? soft(start()) : null));
98114 const repoP = soft(repoFor(context, params));
115+ const projectP = soft(projects.get(params.owner, params.repo, viewer));
116+ // A library shows its packages where an app shows production, and its
117+ // checklist counts a workflow and a release instead of deploys.
118+ const libraryRepoP = Promise.all([projectP, repoP]).then(([project, repo]) =>
119+ project?.ok && project.value.kind === "library" && repo?.ok ? repo.value : null,
120+ );
121+ const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null));
122+ const workflowsP = libraryRepoP.then((repo) => (repo ? forMembers(() => actions.workflows(path, viewer)) : null));
99123 const eventsP = repoP.then((repo) => (repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, limit: 150 })) : null));
100124 const minePullsP = repoP.then((repo) =>
101125 repo?.ok && viewer
148172 );
149173 // Active branches read several logs each: streamed, so the rest shows first.
150174 const branches = branchesP.catch(() => null);
151− const [{ insider: member, can }, project, settings, list, open, closed, log, counts, deps, runs, queue, issues, memories, recent, mine, domains, root] = await Promise.all([
175+ const [{ insider: member, can }, project, settings, list, open, closed, log, counts, deps, runs, queue, issues, memories, recent, mine, domains, root, packageList, workflows] = await Promise.all([
152176 accessP,
153− soft(projects.get(params.owner, params.repo, viewer)),
177+ projectP,
154178 forMembers(() => deployments.settings(ref, viewer)),
155179 listP,
156180 openP,
167191 // For the checklist, which only members see.
168192 forMembers(() => deployments.domains(ref, viewer)),
169193 forMembers(() => repos.tree(path, viewer, null, "")),
194+ packagesP,
195+ workflowsP,
170196 ]);
171197 const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
172198
294320 const liveApps = ok(list)?.live ?? [];
295321 const commitNow = ok(log)?.[0] ?? null;
296322 const projectValue = ok(project);
297− const checklist = member
298− ? productionChecklist({
299− base,
300− hasCode: commitNow != null || projectValue?.source.kind === "mirror",
323+ const isLibrary = projectValue?.kind === "library";
324+ const published = libraryPackages(ok(packageList) ?? []);
325+ const firstSteps = {
326+ base,
327+ hasCode: commitNow != null || projectValue?.source.kind === "mirror",
328+ instructions: commitNow == null ? false : root?.ok ? hasInstructions(root.value.entries.map((entry) => entry.name)) : null,
329+ agentAssigned: agentWasAssigned({
330+ runAgents: runList.map((run) => run.agent),
331+ pullAgents: [...openPulls, ...(ok(closed) ?? [])].map((pull) => pull.agent),
332+ issues: openIssues ?? [],
333+ }),
334+ };
335+ const checklist = !member
336+ ? null
337+ : isLibrary
338+ ? releaseChecklist({
339+ ...firstSteps,
340+ hasWorkflow: workflows?.ok ? workflows.value.length > 0 : null,
341+ released: hasRelease(published),
342+ releaseTo: published[0]
343+ ? packagePath(published[0])
344+ : (publishGuide(projectValue?.ecosystem ?? null)?.guide ?? "https://docs.g1t.sh/guides/packages/"),
345+ })
346+ : productionChecklist({
347+ ...firstSteps,
301348 deploysEnabled: ok(settings)?.enabled ?? false,
302349 productionDeployed:
303350 liveApps.some((app) => app.kind === "production") ||
305352 domains: ok(domains)?.domains.length ?? null,
306353 previewOpened:
307354 liveApps.some((app) => app.kind === "preview") || builds.some((build) => build.kind === "preview" && wentLive(build.status)),
308− instructions: commitNow == null ? false : root?.ok ? hasInstructions(root.value.entries.map((entry) => entry.name)) : null,
309− agentAssigned: agentWasAssigned({
310− runAgents: runList.map((run) => run.agent),
311− pullAgents: [...openPulls, ...(ok(closed) ?? [])].map((pull) => pull.agent),
312− issues: openIssues ?? [],
313− }),
314− })
315− : null;
355+ });
316356
317357 const github = await githubP;
318358 return {
344384 memoryCount: memoryList.length,
345385 memoriesLoaded: memories?.ok ?? false,
346386 checklist,
387+ library: isLibrary
388+ ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false }
389+ : null,
347390 branches,
348391 };
349392 }
461504 );
462505 }
463506
507+/**
508+ * Where an app's overview shows production, a library's shows what it
509+ * publishes: each package with its latest version and how to install it,
510+ * or, before there is one, how to publish a first version.
511+ */
512+function LibraryHead({
513+ library,
514+ project,
515+ base,
516+ canChange,
517+ workspace,
518+}: {
519+ library: { packages: PackageSummary[]; total: number; loaded: boolean };
520+ project: Project | null;
521+ base: string;
522+ canChange: boolean;
523+ workspace: string;
524+}) {
525+ const guide = publishGuide(project?.ecosystem ?? null);
526+ const name = project?.name ?? base.split("/").pop();
527+ return (
528+ <div className="p-5 sm:p-6">
529+ <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
530+ <PackageGlyph size={13} className="text-accent" />
531+ Packages
532+ </p>
533+ {library.packages.length > 0 ? (
534+ <>
535+ <ul className="mt-3 space-y-4">
536+ {library.packages.map((pkg) => {
537+ const commands = installCommands(pkg, pkg.latest, "you");
538+ return (
539+ <li key={pkg.id} className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:gap-5">
540+ <Link to={packagePath(pkg)} className="group flex min-w-0 items-center gap-3 lg:w-72 lg:shrink-0">
541+ <PackageIcon ecosystem={pkg.ecosystem} size={32} />
542+ <span className="min-w-0">
543+ <span className="block truncate font-mono text-[0.9375rem] font-medium group-hover:text-accent">{packageName(pkg)}</span>
544+ <span className="block text-xs text-muted">
545+ {ECOSYSTEM_LABEL[pkg.ecosystem]} · {pkg.latest ?? "no versions yet"}
546+ {pkg.versions > 1 && ` · ${pkg.versions} versions`}
547+ </span>
548+ </span>
549+ </Link>
550+ <div className="min-w-0 grow space-y-1.5">
551+ {/* The workspace's registry first, or the tool installs a package of the same name from elsewhere. */}
552+ {commands.registry && <CopyLine prompt text={commands.registry} />}
553+ <CopyLine prompt text={commands.install} />
554+ </div>
555+ </li>
556+ );
557+ })}
558+ </ul>
559+ {library.total > library.packages.length && (
560+ <Link to={`/${workspace}/-/packages`} className="mt-3 inline-block text-xs text-muted hover:text-fg">
561+ All {library.total} packages
562+ </Link>
563+ )}
564+ </>
565+ ) : (
566+ <>
567+ <p className="mt-2 text-lg font-medium">{library.loaded ? "Not published yet" : "Packages could not be loaded just now"}</p>
568+ {guide ? (
569+ <>
570+ <p className="mt-1 max-w-lg text-sm text-muted">
571+ Publish a first version of {name} to the workspace's {guide.label} registry, for others to install
572+ {guide.start ? ":" : "."}
573+ </p>
574+ {guide.start && (
575+ <div className="mt-3 max-w-lg">
576+ <CopyLine prompt text={guide.start} />
577+ </div>
578+ )}
579+ <a href={guide.guide} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
580+ Publishing to {guide.label} <ArrowUpRight size={13} />
581+ </a>
582+ </>
583+ ) : (
584+ <>
585+ <p className="mt-1 max-w-lg text-sm text-muted">Publish a first version of {name} to one of the workspace's registries:</p>
586+ <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">
587+ {PUBLISH_GUIDES.map((one) => (
588+ <a key={one.label} href={one.guide} className="inline-flex items-center gap-1 text-accent hover:underline">
589+ {one.label} <ArrowUpRight size={13} />
590+ </a>
591+ ))}
592+ </p>
593+ </>
594+ )}
595+ </>
596+ )}
597+ {canChange && (
598+ <p className="mt-4 text-xs text-faint">
599+ Deploys?{" "}
600+ <Link to={`${base}/settings#deploys`} className="underline-offset-4 hover:text-fg hover:underline">
601+ Change it in Settings.
602+ </Link>
603+ </p>
604+ )}
605+ </div>
606+ );
607+}
608+
464609 function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) {
465610 const body = (
466611 <>
532677 actionData: Route.ComponentProps["actionData"];
533678 params: Route.ComponentProps["params"];
534679 }) {
535− const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches } =
680+ const { member, project, settings, builds, live, commit, open, dependencies, agentsLive, columns, needs, landed, groups, health, knows, checklist, branches, library } =
536681 loaderData;
537682 const base = `/${params.owner}/${params.repo}`;
538683 const production = live.find((app) => app.kind === "production") ?? null;
552697 {loaderData.github && <GithubLinkStrip link={loaderData.github} />}
553698 {/* What the project is, running, and where its code is. */}
554699 <section className="overflow-hidden rounded-2xl border border-line bg-surface">
555− <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
556− {member && (production || settings?.enabled) && (
557− <ProductionShot
558− src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
559− href={production ? (productionUrl ?? production.url) : null}
560− label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
561− className="w-full shrink-0 sm:w-60 lg:w-72"
562− />
563− )}
564− <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
565− <div className="min-w-0">
566− <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
567− <Rocket size={13} className="text-accent" />
568− Production
569− </p>
570− {production ? (
571− <>
572− <a
573− href={productionUrl ?? production.url}
574− className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"
575− >
576− {host(productionUrl ?? production.url)}
577− <ArrowUpRight size={16} className="shrink-0 text-faint" />
578− </a>
579− <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
580− {latestProduction && <StatusDot status={latestProduction.status} label={latestProduction.status === "ready" ? "Live" : undefined} />}
581− <span className="inline-flex items-center gap-1 font-mono">
582− <GitCommitHorizontal size={13} className="text-faint" />
583− {production.commit.slice(0, 7)}
584− </span>
585− <span>
586− deployed <TimeAgo at={production.deployedAt} />
587− </span>
588− {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>}
589− </p>
590− </>
591− ) : settings?.enabled ? (
592− <>
593− <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p>
594− <p className="mt-1.5 text-xs text-muted">
595− {latestProduction ? (
596− <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} />
597− ) : (
598− "Not deployed yet. Push to the default branch, or deploy it now."
599− )}
600− </p>
601− </>
602− ) : (
603− <>
604− <p className="mt-2 text-lg font-medium">Deployments are off</p>
605− <p className="mt-1 max-w-lg text-sm text-muted">
606− Nothing builds or runs until you turn them on. Then {project?.name ?? params.repo} goes up on g1t.page:
607− production from the default branch, and a live preview for every pull request. It runs only while
608− someone visits.
609− </p>
610− </>
700+ {library ? (
701+ <LibraryHead library={library} project={project} base={base} canChange={loaderData.can.manage_settings} workspace={params.owner} />
702+ ) : (
703+ <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6">
704+ {member && (production || settings?.enabled) && (
705+ <ProductionShot
706+ src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null}
707+ href={production ? (productionUrl ?? production.url) : null}
708+ label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))}
709+ className="w-full shrink-0 sm:w-60 lg:w-72"
710+ />
611711 )}
612− </div>
613− {member && (
614− <div className="flex shrink-0 items-center gap-2">
615− {production && (
616− <ButtonLink to={productionUrl ?? production.url} variant="accent" reloadDocument>
617− Visit
618− <ArrowUpRight size={14} />
619− </ButtonLink>
712+ <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4">
713+ <div className="min-w-0">
714+ <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase">
715+ <Rocket size={13} className="text-accent" />
716+ Production
717+ </p>
718+ {production ? (
719+ <>
720+ <a
721+ href={productionUrl ?? production.url}
722+ className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"
723+ >
724+ {host(productionUrl ?? production.url)}
725+ <ArrowUpRight size={16} className="shrink-0 text-faint" />
726+ </a>
727+ <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
728+ {latestProduction && <StatusDot status={latestProduction.status} label={latestProduction.status === "ready" ? "Live" : undefined} />}
729+ <span className="inline-flex items-center gap-1 font-mono">
730+ <GitCommitHorizontal size={13} className="text-faint" />
731+ {production.commit.slice(0, 7)}
732+ </span>
733+ <span>
734+ deployed <TimeAgo at={production.deployedAt} />
735+ </span>
736+ {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>}
737+ </p>
738+ </>
739+ ) : settings?.enabled ? (
740+ <>
741+ <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p>
742+ <p className="mt-1.5 text-xs text-muted">
743+ {latestProduction ? (
744+ <StatusDot status={latestProduction.status} label={latestProduction.status === "failed" ? "The last build failed" : undefined} />
745+ ) : (
746+ "Not deployed yet. Push to the default branch, or deploy it now."
747+ )}
748+ </p>
749+ </>
750+ ) : (
751+ <>
752+ <p className="mt-2 text-lg font-medium">Deployments are off</p>
753+ <p className="mt-1 max-w-lg text-sm text-muted">
754+ Nothing builds or runs until you turn them on. Then {project?.name ?? params.repo} goes up on g1t.page:
755+ production from the default branch, and a live preview for every pull request. It runs only while
756+ someone visits.
757+ </p>
758+ </>
620759 )}
621− {settings?.enabled ? (
622− loaderData.can.run && <Form method="post">
623− <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch">
624− <RotateCw size={14} />
625− Redeploy
626− </Button>
627− </Form>
628− ) : loaderData.can.manage_integrations && (
629− <ButtonLink to={`${base}/settings/deployments`} variant="accent">
630− <Rocket size={14} />
631− Turn on deployments
632− </ButtonLink>
633− )}
634760 </div>
635− )}
761+ {member && (
762+ <div className="flex shrink-0 items-center gap-2">
763+ {production && (
764+ <ButtonLink to={productionUrl ?? production.url} variant="accent" reloadDocument>
765+ Visit
766+ <ArrowUpRight size={14} />
767+ </ButtonLink>
768+ )}
769+ {settings?.enabled ? (
770+ loaderData.can.run && <Form method="post">
771+ <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch">
772+ <RotateCw size={14} />
773+ Redeploy
774+ </Button>
775+ </Form>
776+ ) : loaderData.can.manage_integrations && (
777+ <ButtonLink to={`${base}/settings/deployments`} variant="accent">
778+ <Rocket size={14} />
779+ Turn on deployments
780+ </ButtonLink>
781+ )}
782+ </div>
783+ )}
784+ </div>
636785 </div>
637− </div>
786+ )}
638787 {actionData && (
639788 <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-accent"}`}>
640789 {"error" in actionData ? actionData.error : actionData.notice}
683832 }
684833 />
685834 <Stat
686− label={member && settings?.enabled ? "Last deploy" : "Open"}
687− to={member && settings?.enabled ? `${base}/deployments` : `${base}/pulls`}
835+ label={member && settings?.enabled && !library ? "Last deploy" : "Open"}
836+ to={member && settings?.enabled && !library ? `${base}/deployments` : `${base}/pulls`}
688837 value={
689− member && settings?.enabled ? (
838+ member && settings?.enabled && !library ? (
690839 latestProduction ? (
691840 <span className="inline-flex items-center gap-2">
692841 <StatusDot status={latestProduction.status} />
711860 )}
712861 </section>
713862
714− {checklist && <ProductionChecklist base={base} items={checklist} />}
863+ {checklist && <ProductionChecklist base={base} items={checklist} title={library ? "Ship a release" : undefined} />}
715864
716865 <Panel
717866 title="Right now"
9761125 : "No checks have run recently."}
9771126 </p>
9781127 </div>
979− {member && (
1128+ {member && (!library || builds.length > 0) && (
9801129 <div>
9811130 <div className="flex items-baseline justify-between">
9821131 <span className="text-muted">Deploys</span>
+25−9
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
88 import { Button, ErrorText, Field, Input } from "../../components/ui";
99 import { SwitchCard } from "../../components/ui/switch";
10−import { deployments } from "../../lib/services.server";
10+import { deployments, projects } from "../../lib/services.server";
1111 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1212 import { requireCapability, requireInsider } from "../../lib/access.server";
1313
1919 const viewer = getViewer(context);
2020 // Admins; to anyone without a role here the page does not exist.
2121 await requireInsider(context, params, "manage_integrations");
22− const settings = await deployments.settings({ workspace: params.owner, slug: params.repo }, viewer);
23− return { settings: unwrap(settings) };
22+ const [settings, project] = await Promise.all([
23+ deployments.settings({ workspace: params.owner, slug: params.repo }, viewer),
24+ projects.get(params.owner, params.repo, viewer).catch(() => null),
25+ ]);
26+ // Set not to deploy in General settings: turning them on is refused until that changes.
27+ const notDeploying = project?.ok ? project.value.deploys === "no" : false;
28+ return { settings: unwrap(settings), notDeploying };
2429 }
2530
2631 export async function action({ request, params, context }: Route.ActionArgs) {
9095 }
9196
9297 export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
93− const { settings } = loaderData;
98+ const { settings, notDeploying } = loaderData;
9499 const busy = useNavigation().state === "submitting";
95100 const base = `/${params.owner}/${params.repo}`;
96101 return (
112117 <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span> and a preview for
113118 every pull request. The workspace needs the g1t plan, under Billing.
114119 </p>
115− <Form method="post" className="mt-4">
116− <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
117− Turn on deployments
118− </Button>
119− </Form>
120+ {notDeploying ? (
121+ <p className="mt-4 text-sm text-muted">
122+ This project is set as one that doesn't deploy, a library or a tool. To deploy it, choose Deploys or Detect
123+ automatically under{" "}
124+ <Link to={`${base}/settings#deploys`} className="text-fg underline underline-offset-4">
125+ General
126+ </Link>{" "}
127+ first.
128+ </p>
129+ ) : (
130+ <Form method="post" className="mt-4">
131+ <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
132+ Turn on deployments
133+ </Button>
134+ </Form>
135+ )}
120136 </section>
121137 ) : (
122138 <>
+64−5
1−import { Box, Code2, GitBranch } from "lucide-react";
1+import { Box, Code2, GitBranch, Rocket } from "lucide-react";
2+import { useState } from "react";
23 import { Form, Link, useNavigation } from "react-router";
34
5+import type { DeploysSetting } from "@g1t/contracts";
6+
47 import type { Route } from "./+types/settings";
58 import { page } from "../../lib/meta";
69 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
710 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
8−import { projects } from "../../lib/services.server";
11+import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
12+import { DEPLOYS_CHOICES } from "../../lib/project-kind";
13+import { deployments, projects } from "../../lib/services.server";
914 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1015 import { requireCapability, requireInsider } from "../../lib/access.server";
1116
1722 const viewer = getViewer(context);
1823 // Maintain and up; to anyone without a role here the page does not exist.
1924 await requireInsider(context, params, "manage_settings");
20− return { project: unwrap(await projects.get(params.owner, params.repo, viewer)) };
25+ const [project, deploys] = await Promise.all([
26+ projects.get(params.owner, params.repo, viewer),
27+ // Whether Deployments are on: needs Admin, so unknown (null) below it.
28+ deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null),
29+ ]);
30+ return { project: unwrap(project), deploymentsOn: deploys?.ok ? deploys.value.enabled : null };
2131 }
2232
2333 export async function action({ request, params, context }: Route.ActionArgs) {
3040 // Blank, or the reset, and it follows the repository's description again.
3141 description: form.get("inherit") === "description" ? null : String(form.get("description") ?? ""),
3242 rootDir: String(form.get("rootDir") ?? ""),
43+ deploys: (form.get("deploys") as DeploysSetting | null) ?? undefined,
3344 });
3445 return saved.ok ? { saved: true as const } : { error: saved.error.message };
3546 }
3647
3748 export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) {
38− const { project } = loaderData;
49+ const { project, deploymentsOn } = loaderData;
50+ const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys);
51+ // Not deploying while Deployments are on is refused: they are turned off first.
52+ const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true;
3953 const saving = useNavigation().state === "submitting";
4054 const base = `/${params.owner}/${params.repo}`;
4155 const source = project.source.kind === "hosted" ? project.source : null;
117131 </Field>
118132 </section>
119133
134+ <section className="space-y-4" id="deploys">
135+ <h2 className="flex items-center gap-2 text-sm font-medium">
136+ <Rocket size={15} className="text-accent" />
137+ Deployments for this project
138+ </h2>
139+ <RadioGroup
140+ name="deploys"
141+ value={deploys}
142+ onValueChange={(value) => setDeploys(value as DeploysSetting)}
143+ aria-label="Deployments for this project"
144+ className="gap-3"
145+ >
146+ {DEPLOYS_CHOICES.map((choice) => (
147+ <RadioOption
148+ key={choice.value}
149+ value={choice.value}
150+ label={choice.label}
151+ description={
152+ choice.value === "auto" ? (
153+ <>
154+ {project.detected.kind === "library" ? "Detected: a library, so it doesn't deploy." : "Detected: an app, so it deploys."}{" "}
155+ {project.detected.reason.detail}
156+ </>
157+ ) : (
158+ choice.hint
159+ )
160+ }
161+ />
162+ ))}
163+ </RadioGroup>
164+ {blocked && (
165+ <p className="rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
166+ Deployments are on for {project.name}. Turn them off in{" "}
167+ <Link to={`${base}/settings/deployments`} className="text-fg underline underline-offset-4">
168+ Deployments settings
169+ </Link>{" "}
170+ first, then choose Doesn't deploy here.
171+ </p>
172+ )}
173+ <p className="text-xs text-faint">
174+ A project that doesn't deploy shows its packages and releases on its overview instead of production. Its
175+ Deployments page stays in the sidebar.
176+ </p>
177+ </section>
178+
120179 <div className="flex items-center gap-3">
121− <Button type="submit" disabled={saving}>
180+ <Button type="submit" disabled={saving || blocked}>
122181 Save
123182 </Button>
124183 {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>}
+32−5
11 import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
22 import { Link } from "react-router";
33
4−import type { Project, ProjectDeploys, User } from "@g1t/contracts";
4+import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts";
55
66 import type { Route } from "./+types/overview";
77 import { host, StatusDot } from "../../components/deploy";
1010 import { PullIcon } from "../../components/work-icons";
1111 import { planStatus, type UsageGlance, usageGlance } from "../../lib/billing";
1212 import { openedBy } from "../../lib/opened-by";
13−import { billing, deployments, identity, projects as projectsApi, work } from "../../lib/services.server";
13+import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind";
14+import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
1415 import { getViewer, roleIn } from "../../lib/session.server";
1516
1617 /** Projects whose open issues and pull requests are counted. */
5657 const viewer = getViewer(context);
5758 const slug = params.owner.toLowerCase();
5859 const role = roleIn(viewer, slug);
59− const [listed, members, deploys, usage] = await Promise.all([
60+ const [listed, members, deploys, usage, published] = await Promise.all([
6061 projectsApi.list(slug, viewer),
6162 role ? identity.listMembers(slug, viewer) : null,
6263 role ? deployments.overview(slug, viewer) : null,
6364 role ? usageFor(slug, viewer) : null,
65+ // One listing for every card: a library's card shows its package where an app's shows production.
66+ packages.list(slug, viewer).catch(() => null),
6467 ]);
6568 const projects = listed.ok ? listed.value : [];
6669
8487 const bySlug: Record<string, ProjectDeploys> = {};
8588 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
8689
90+ const byRepo = new Map<string, PackageSummary[]>();
91+ for (const pkg of published?.ok ? published.value : []) {
92+ if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
93+ }
94+ const packageOf: Record<string, PackageSummary> = {};
95+ for (const project of projects) {
96+ if (project.kind !== "library" || project.source.kind !== "hosted") continue;
97+ const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
98+ if (first) packageOf[project.id] = first;
99+ }
100+
87101 return {
88102 slug,
89103 role,
90104 projects,
91105 open,
92106 deploys: bySlug,
107+ packageOf,
93108 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
94109 members: members?.ok ? members.value : null,
95110 usage,
135150 project,
136151 open,
137152 deploys,
153+ pkg,
138154 member,
139155 }: {
140156 project: Project;
141157 open: { issues: number; pulls: number } | undefined;
142158 deploys: ProjectDeploys | undefined;
159+ /** For a library, the package it publishes. */
160+ pkg: PackageSummary | undefined;
143161 member: boolean;
144162 }) {
163+ const library = project.kind === "library";
145164 const base = `/${project.workspace}/${project.slug}`;
146165 const production = deploys?.production ?? null;
147166 const latest = deploys?.latest ?? null;
164183 {host(production.url)}
165184 <ArrowUpRight size={11} className="shrink-0" />
166185 </a>
186+ ) : library && pkg ? (
187+ <Link
188+ to={packagePath(pkg)}
189+ className="relative z-10 mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent"
190+ >
191+ {packageLine(pkg)}
192+ </Link>
167193 ) : (
168194 <p className="mt-0.5 truncate text-xs text-faint">
169− {project.description ?? (deploys?.enabled ? "Not deployed yet" : "Deployments are off")}
195+ {project.description ?? (library ? "Not published yet" : deploys?.enabled ? "Not deployed yet" : "Deployments are off")}
170196 </p>
171197 )}
172198 </div>
178204 )}
179205 </div>
180206
181− {project.description && production && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
207+ {project.description && (production || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
182208
183209 <div className="mt-auto pt-5">
184210 {member && latest && (
257283 project={project}
258284 open={open[project.id]}
259285 deploys={deploys[project.slug]}
286+ pkg={loaderData.packageOf[project.id]}
260287 member={role != null}
261288 />
262289 ))}
+1−0
622622 byRepo: (repoId) => call("by_repo", { repoId }),
623623 create: (actor, workspace, input) => call("create", { actor, workspace, input }),
624624 update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }),
625+ deploymentsChanged: (projectId, enabled) => call("deployments_changed", { projectId, enabled }),
625626 dependencies: (workspace, slug, viewer) => call("dependencies", { workspace, slug, viewer }),
626627 addDependency: (actor, workspace, slug, on, as) => call("add_dependency", { actor, workspace, slug, on, as }),
627628 removeDependency: (actor, workspace, slug, on) => call("remove_dependency", { actor, workspace, slug, on }),
+33−1
3636 archived: boolean;
3737 /** Whether it is the project its repository's workflows read secrets from. */
3838 primary: boolean;
39+ /** Whether it deploys, as set in its settings: `auto` decides from the project itself. */
40+ deploys: DeploysSetting;
41+ /** What it is, from `deploys`, or for `auto` from its deployments, packages and files. */
42+ kind: ProjectKind;
43+ /** Why it is that kind. */
44+ kindReason: KindReason;
45+ /** What `auto` decides, whatever the setting is, to show beside it. */
46+ detected: { kind: ProjectKind; reason: KindReason };
47+ /** The ecosystem its files say it publishes to, for how to publish; null when none says. */
48+ ecosystem: ProjectEcosystem | null;
3949 createdBy: string;
4050 /** RFC 3339. */
4151 createdAt: string;
4252 updatedAt: string;
4353 };
4454
55+/** Whether a project deploys: decided from the project, or set by a person. */
56+export type DeploysSetting = "auto" | "yes" | "no";
57+
58+/**
59+ * An app deploys and gets production, previews and domains; a library (or
60+ * a tool) is published and installed, so its pages offer releases and
61+ * packages instead.
62+ */
63+export type ProjectKind = "app" | "library";
64+
65+/**
66+ * What decided the kind: the setting, Deployments being on, a package its
67+ * repository publishes, its files, or nothing (an app, by default).
68+ * `detail` says it in a sentence.
69+ */
70+export type KindReason = { by: "set" | "deployments" | "packages" | "files" | "default"; detail: string };
71+
72+/** Where a library's files say it is published. */
73+export type ProjectEcosystem = "composer" | "npm" | "cargo" | "go" | "python";
74+
4575 export type NewProject = {
4676 name: string;
4777 description?: string | null;
83113 actor: User,
84114 workspace: string,
85115 slug: string,
86− changes: { name?: string; description?: string | null; rootDir?: string },
116+ changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting },
87117 ): Promise<Result<Project>>;
118+ /** For deployments: Deployments were turned on or off for the project. */
119+ deploymentsChanged(projectId: string, enabled: boolean): Promise<void>;
88120 /** What a project uses and what uses it. Whoever may see the project. */
89121 dependencies(workspace: string, slug: string, viewer: Viewer): Promise<Result<Dependencies>>;
90122 /**
+14−0
452452 const project = found.value;
453453 const before = await this.toSettings(project, await this.settingsRow(project.id));
454454 const next = { ...before, ...a.changes };
455+ if (next.enabled && !before.enabled && project.deploys === "no") {
456+ return fail("conflict", "This project is set not to deploy. Change that in its General settings first.");
457+ }
455458 if (next.enabled && !before.enabled) {
456459 // Turning it on starts paid work: only with the workspace's plan.
457460 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
482485 now(),
483486 )
484487 .run();
488+ // Projects keeps whether it deploys, so a project with Deployments on is an app.
489+ if (next.enabled !== before.enabled) {
490+ await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
491+ }
485492 // What was turned off comes down now; nothing keeps running unasked.
486493 if (!next.enabled) await this.takeDownWhere(project.id, null);
487494 else {
495502 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
496503 }
497504
505+ /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
506+ async isEnabled(a: { projectId: string }): Promise<boolean> {
507+ return !!(await this.settingsRow(a.projectId))?.enabled;
508+ }
509+
498510 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
499511 const project = await this.projectFor(a.project, a.viewer, "list");
500512 if (!project.ok) return project;
21562168 switch (method) {
21572169 case "settings":
21582170 return service.settings(args);
2171+ case "is_enabled":
2172+ return service.isEnabled(args);
21592173 case "update_settings":
21602174 return service.updateSettings(args);
21612175 case "list":
+21−0
1+-- Whether a project deploys (an app) or not (a library or a tool), so its
2+-- pages offer production or releases. `deploys` is the setting: auto, yes
3+-- or no. On auto the kind is worked out from the columns after it, which
4+-- the service keeps (services/projects/src/kind.ts).
5+ALTER TABLE projects ADD COLUMN deploys TEXT NOT NULL DEFAULT 'auto';
6+
7+-- What the manifests at its root say, read at `detected_commit` of the
8+-- default branch: app, library, or null when none says. '' for the commit
9+-- of a repository with no commits yet; null until first read.
10+ALTER TABLE projects ADD COLUMN detected_kind TEXT;
11+ALTER TABLE projects ADD COLUMN detected_detail TEXT;
12+ALTER TABLE projects ADD COLUMN detected_ecosystem TEXT;
13+ALTER TABLE projects ADD COLUMN detected_commit TEXT;
14+
15+-- A package its repository publishes, other than a container image, as
16+-- `Composer package psr/log`; null for none. Kept from package events.
17+ALTER TABLE projects ADD COLUMN linked_package TEXT;
18+
19+-- Whether Deployments are on for it, kept from the deployments service;
20+-- null until it has said.
21+ALTER TABLE projects ADD COLUMN deployments_on INTEGER;
+193−8
1616
1717 import { NEEDS, repoRef } from "./access";
1818 import { effectiveDescription, ownDescription } from "./description";
19+import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind";
1920 import { renameStatements } from "./rename";
2021 import { moveStatements, slugOf, strandedQuery } from "./transfer";
2122
3132 newId,
3233 ok,
3334 openD1,
35+ packagesClient,
3436 permission,
3537 repoMove,
3638 reposClient,
3739 staleMovedPaths,
3840 type Dependencies,
3941 type DependencyLink,
42+ type DeploysSetting,
43+ type Ecosystem,
4044 type G1tEvent,
4145 type NewProject,
4246 type Project,
47+ type ProjectEcosystem,
4348 type ProjectGraph,
4449 type Repo,
4550 type Result,
5257 DB: D1Database;
5358 REPOS: ServiceBinding;
5459 IDENTITY: ServiceBinding;
60+ PACKAGES: ServiceBinding;
61+ DEPLOYMENTS: ServiceBinding;
5562 };
5663
5764 type Row = {
7885 repo_deleted_at: string | null;
7986 /** When its repository was archived; null while it is not. */
8087 repo_archived_at?: string | null;
88+ /** auto, yes or no: whether it deploys (migrations/0007_deploys.sql). */
89+ deploys?: string | null;
90+ detected_kind?: string | null;
91+ detected_detail?: string | null;
92+ detected_ecosystem?: string | null;
93+ /** The default branch's commit its files were read at; null until they have been. */
94+ detected_commit?: string | null;
95+ linked_package?: string | null;
96+ deployments_on?: number | null;
8197 };
8298
99+/** How a package its repository publishes is named in why a project is a library. */
100+const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
101+ container: "container image",
102+ npm: "npm package",
103+ composer: "Composer package",
104+ cargo: "crate",
105+ go: "Go module",
106+};
107+
108+/** How many projects one listing reads the files of, in the background, before it has. */
109+const DETECT_PER_LIST = 10;
110+
83111 const now = () => new Date().toISOString();
84112
85113 /** A variable's name for a dependency's address, spelled as secrets' names are. */
92120
93121 function toProject(row: Row): Project {
94122 const { description, inherited } = effectiveDescription(row);
123+ const deploys = deploysSetting(row.deploys);
124+ const facts: Omit<KindFacts, "deploys"> = {
125+ deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
126+ linkedPackage: row.linked_package ?? null,
127+ detected:
128+ row.detected_commit == null
129+ ? null
130+ : { kind: row.detected_kind === "app" || row.detected_kind === "library" ? row.detected_kind : null, detail: row.detected_detail ?? "" },
131+ };
132+ const { kind, reason } = resolveKind({ deploys, ...facts });
133+ const auto = resolveKind({ deploys: "auto", ...facts });
95134 return {
96135 id: row.id,
97136 workspace: row.workspace,
109148 private: !!row.repo_private,
110149 archived: !!row.repo_archived_at,
111150 primary: !!row.is_primary,
151+ deploys,
152+ kind,
153+ kindReason: reason,
154+ detected: auto,
155+ ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
112156 createdBy: row.created_by,
113157 createdAt: row.created_at,
114158 updatedAt: row.updated_at,
120164 }
121165
122166 class Projects {
123− constructor(private readonly env: Env) {}
167+ /** `defer` runs work after the answer is sent: the request's waitUntil. */
168+ constructor(
169+ private readonly env: Env,
170+ private readonly defer: (work: Promise<unknown>) => void = () => {},
171+ ) {}
124172
125173 private get db() {
126174 return this.env.DB;
233281 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
234282 .bind(workspace)
235283 .all<Row>();
284+ // Projects whose files have not been read yet are read after this answer,
285+ // a few at a time; until then they show as apps, as before.
286+ const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
287+ if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
236288 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
237289 }
238290
244296 .bind(workspace, a.slug.toLowerCase())
245297 .first<Row>();
246298 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
299+ if (row.detected_commit == null) {
300+ // Once per project: what its files say, read now so its first page is right.
301+ const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
302+ if (read) return ok(toProject(read));
303+ }
247304 return ok(toProject(row));
248305 }
249306
342399 actor: User;
343400 workspace: string;
344401 slug: string;
345− changes: { name?: string; description?: string | null; rootDir?: string };
402+ changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting };
346403 }): Promise<Result<Project>> {
347404 const workspace = a.workspace.toLowerCase();
348405 const row = await this.db
357414 const description = ownDescription(row.description, a.changes.description);
358415 const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, "");
359416 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
417+ const deploys = a.changes.deploys === undefined ? deploysSetting(row.deploys) : deploysSetting(a.changes.deploys);
418+ // Not deploying while Deployments run would leave apps up that no page
419+ // offers: a person turns them off first, in Deployments settings.
420+ const deploying = deploys === "no" && deploysSetting(row.deploys) !== "no" ? (row.deployments_on ?? (await this.deploymentsOn(row.id))) : false;
421+ if (deploying) {
422+ return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
423+ }
360424 await this.db
361− .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, updated_at = ? WHERE id = ?")
362− .bind(name.slice(0, 100), description, rootDir, now(), row.id)
425+ .prepare("UPDATE projects SET name = ?, description = ?, root_dir = ?, deploys = ?, updated_at = ? WHERE id = ?")
426+ .bind(name.slice(0, 100), description, rootDir, deploys, now(), row.id)
363427 .run();
364− return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!));
428+ let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
429+ // Another root has other files: read them again.
430+ if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
431+ return ok(toProject(saved));
365432 }
366433
434+ async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
435+ await this.db.prepare("UPDATE projects SET deployments_on = ? WHERE id = ?").bind(a.enabled ? 1 : 0, a.projectId).run();
436+ }
437+
438+ // ---- App or library --------------------------------------------------
439+
440+ /**
441+ * Reads what decides whether the project is a library: the manifests at
442+ * its root at `commit` (the default branch's head when null), a package
443+ * its repository publishes, and, the first time, whether Deployments are
444+ * on. Files are read again only for a commit they were not read at.
445+ * `packages` reads only the packages. Returns the row as it is now.
446+ */
447+ private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
448+ const actor = await this.workspaceActor(row.workspace);
449+ if (!actor) return row;
450+ const repo = { namespace: row.repo_namespace, name: row.repo_name };
451+ const sets: string[] = [];
452+ const values: unknown[] = [];
453+ const set = (column: string, value: unknown) => {
454+ sets.push(`${column} = ?`);
455+ values.push(value);
456+ };
457+ const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
458+ const [files, linked, deploying] = await Promise.all([
459+ filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
460+ this.linkedPackage(row, actor),
461+ row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
462+ ]);
463+ if (files) {
464+ const found = files.commit ? detectKind(files.root) : null;
465+ set("detected_kind", found?.kind ?? null);
466+ set("detected_detail", found?.detail ?? null);
467+ set("detected_ecosystem", found?.ecosystem ?? null);
468+ set("detected_commit", files.commit);
469+ }
470+ if (linked !== undefined) set("linked_package", linked);
471+ if (deploying != null) set("deployments_on", deploying ? 1 : 0);
472+ if (sets.length === 0) return row;
473+ await this.db
474+ .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
475+ .bind(...values, row.id)
476+ .run();
477+ return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
478+ }
479+
480+ /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
481+ private async readRoot(
482+ repo: { namespace: string; name: string },
483+ actor: User,
484+ commit: string | null,
485+ rootDir: string,
486+ ): Promise<{ commit: string; root: RootFiles } | null> {
487+ const client = reposClient(this.env.REPOS);
488+ const empty = { commit: "", root: { entries: [], text: {} } };
489+ const tree = await client.tree(repo, actor, commit, rootDir);
490+ if (!tree.ok) return null;
491+ const head = commit ?? tree.value.head?.hash ?? "";
492+ if (!head) return empty;
493+ const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
494+ const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
495+ const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
496+ const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
497+ const [texts, src, pub] = await Promise.all([
498+ Promise.all(
499+ names.map(async (name) => {
500+ const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
501+ return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
502+ }),
503+ ),
504+ entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
505+ entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
506+ ]);
507+ const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
508+ return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
509+ }
510+
511+ /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
512+ private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
513+ const listed = await packagesClient(this.env.PACKAGES)
514+ .list(row.workspace, actor, { repoId: row.repo_id })
515+ .catch(() => null);
516+ if (!listed?.ok) return undefined;
517+ // An image is how an app ships too; it says nothing about being a library.
518+ const pkg = listed.value.find((p) => p.ecosystem !== "container");
519+ if (!pkg) return null;
520+ return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
521+ }
522+
523+ /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
524+ private async deploymentsOn(projectId: string): Promise<boolean | null> {
525+ const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
526+ method: "POST",
527+ headers: { "content-type": "application/json" },
528+ body: JSON.stringify({ projectId }),
529+ }).catch(() => null);
530+ if (!answer?.ok) return null;
531+ const value = (await answer.json().catch(() => null)) as unknown;
532+ return typeof value === "boolean" ? value : null;
533+ }
534+
367535 // ---- Dependencies ------------------------------------------------------
368536
369537 private async row(workspace: string, slug: string): Promise<Row | null> {
637805 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
638806 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
639807 .run();
808+ // Another branch has other files: they are read again from its head.
809+ const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
810+ for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
811+ return;
812+ }
813+ if (
814+ (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
815+ event.data.repoId
816+ ) {
817+ const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
818+ for (const row of rows.results) await this.detect(row, null, "packages");
640819 return;
641820 }
642821 if (event.type === "workspace.deleting") {
677856 }
678857 if (event.type === "git.push" && event.data.defaultBranch) {
679858 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
680− for (const row of rows.results) await this.syncFile(row, event.data.after);
859+ for (const row of rows.results) {
860+ await this.syncFile(row, event.data.after);
861+ await this.detect(row, event.data.after);
862+ }
681863 return;
682864 }
683865 if (event.type !== "repo.created") return;
703885 return Response.json(await service.create(args));
704886 case "update":
705887 return Response.json(await service.update(args));
888+ case "deployments_changed":
889+ await service.deploymentsChanged(args);
890+ return Response.json(null);
706891 case "dependencies":
707892 return Response.json(await service.dependencies(args));
708893 case "add_dependency":
719904 }
720905
721906 export default {
722− async fetch(request: Request, env: Env): Promise<Response> {
907+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
723908 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
724909 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
725910 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
726911 const opened = openD1(env.DB, request);
727− const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env);
912+ const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
728913 const args = (await request.json().catch(() => ({}))) as any;
729914 return opened.finish(await answer(service, match[1], args));
730915 },
+112−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import { type KindFacts, type RootFiles, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind.ts";
5+
6+const root = (text: Record<string, string>, more: Partial<RootFiles> = {}): RootFiles => ({
7+ entries: [...Object.keys(text), ...(more.entries ?? [])],
8+ text,
9+ ...(more.src ? { src: more.src } : {}),
10+ ...(more.public ? { public: more.public } : {}),
11+});
12+
13+// psr/log's composer.json, as it is at 3.0.2: no type, an autoload.
14+const PSR_LOG = JSON.stringify({
15+ name: "psr/log",
16+ description: "Common interface for logging libraries",
17+ license: "MIT",
18+ require: { php: ">=8.0.0" },
19+ autoload: { "psr-4": { "Psr\\Log\\": "src" } },
20+});
21+
22+test("a Composer package is a library; a Composer project is an app", () => {
23+ assert.deepEqual(detectKind(root({ "composer.json": PSR_LOG }, { entries: ["src/", "README.md"] })), {
24+ kind: "library",
25+ detail: "composer.json has autoload and no public/index.php.",
26+ ecosystem: "composer",
27+ });
28+ assert.equal(detectKind(root({ "composer.json": '{"type":"library"}' })).kind, "library");
29+ assert.equal(detectKind(root({ "composer.json": '{"type":"symfony-bundle"}' })).detail, 'composer.json says "type": "symfony-bundle".');
30+ assert.equal(detectKind(root({ "composer.json": '{"type":"project","autoload":{}}' })).kind, "app");
31+ // Laravel and the like: no type, but a front controller to serve.
32+ const laravel = root({ "composer.json": '{"autoload":{"psr-4":{}}}' }, { entries: ["public/"], public: ["index.php", ".htaccess"] });
33+ assert.equal(detectKind(laravel).kind, "app");
34+});
35+
36+test("package.json: an entry point without a start or dev script is a library", () => {
37+ assert.equal(detectKind(root({ "package.json": '{"name":"x","main":"dist/index.js","scripts":{"build":"tsc"}}' })).kind, "library");
38+ assert.equal(detectKind(root({ "package.json": '{"exports":{".":"./index.js"}}' })).detail, 'package.json has "exports" and no start or dev script.');
39+ assert.equal(detectKind(root({ "package.json": '{"main":"index.js","scripts":{"start":"node index.js"}}' })).kind, "app");
40+ assert.equal(detectKind(root({ "package.json": '{"main":"index.js","scripts":{"dev":"vite"}}' })).kind, "app");
41+ assert.equal(detectKind(root({ "package.json": '{"files":["dist"],"dependencies":{"next":"15"}}' })).detail, "package.json depends on next.");
42+ assert.equal(detectKind(root({ "package.json": '{"files":["dist"],"devDependencies":{"@sveltejs/kit":"2"}}' })).kind, "app");
43+ // A library built with Vite stays a library; a Vite site has index.html.
44+ assert.equal(detectKind(root({ "package.json": '{"main":"dist/x.js","devDependencies":{"vite":"6"}}' })).kind, "library");
45+ assert.equal(detectKind(root({ "package.json": '{"main":"x.js","devDependencies":{"vite":"6"}}' }, { entries: ["index.html"] })).kind, "app");
46+ // Nothing to tell from.
47+ assert.equal(detectKind(root({ "package.json": '{"name":"x"}' })).kind, null);
48+ assert.equal(detectKind(root({ "package.json": "not json" })).kind, null);
49+});
50+
51+test("Cargo: a library and no binary is a library", () => {
52+ assert.equal(detectKind(root({ "Cargo.toml": "[package]\nname = \"x\"\n\n[lib]\n" }, { entries: ["src/"], src: ["lib.rs"] })).kind, "library");
53+ assert.equal(detectKind(root({ "Cargo.toml": "[package]\nname = \"x\"\n" }, { entries: ["src/"], src: ["lib.rs"] })).kind, "library");
54+ assert.equal(detectKind(root({ "Cargo.toml": "[package]\n[lib]\n" }, { entries: ["src/"], src: ["lib.rs", "main.rs"] })).kind, "app");
55+ assert.equal(detectKind(root({ "Cargo.toml": "[package]\n[lib]\n[[bin]]\nname = \"x\"\n" })).kind, "app");
56+});
57+
58+test("Go: a module with no package main at its root is a library", () => {
59+ const lib = root({ "go.mod": "module g1t.sh/acme/log\n", "log.go": "// Package log logs.\npackage log\n" });
60+ assert.deepEqual(detectKind(lib), { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem: "go" });
61+ const app = root({ "go.mod": "module x\n", "main.go": "package main\n\nfunc main() {}\n" });
62+ assert.deepEqual(detectKind(app), { kind: "app", detail: "main.go at the root is package main.", ecosystem: "go" });
63+ assert.equal(detectKind(root({ "go.mod": "module x\n" }, { entries: ["cmd/"] })).kind, null);
64+ assert.deepEqual(goFilesToRead(["a.go", "a_test.go", "b.go", "c.go", "d.go", "go.mod"]), ["a.go", "b.go", "c.go"]);
65+});
66+
67+test("Python: a build backend and no app framework is a library", () => {
68+ const lib = '[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n[project]\ndependencies = ["httpx"]\n';
69+ assert.equal(detectKind(root({ "pyproject.toml": lib })).kind, "library");
70+ const app = '[build-system]\nbuild-backend = "hatchling.build"\n[project]\ndependencies = ["django>=5", "flask-login"]\n';
71+ assert.equal(detectKind(root({ "pyproject.toml": app })).detail, "pyproject.toml depends on django.");
72+ // A plugin named after a framework is not the framework.
73+ assert.equal(detectKind(root({ "pyproject.toml": '[build-system]\nbuild-backend = "x"\ndependencies = ["flask-login"]\n' })).kind, "library");
74+});
75+
76+test("the language's manifest decides before package.json, and a Workers config is always an app", () => {
77+ const php = root({ "composer.json": PSR_LOG, "package.json": '{"scripts":{"dev":"vite"}}' });
78+ assert.equal(detectKind(php).ecosystem, "composer");
79+ assert.equal(detectKind(php).kind, "library");
80+ assert.equal(detectKind(root({ "package.json": '{"main":"x.js"}' }, { entries: ["wrangler.jsonc"] })).kind, "app");
81+ assert.equal(detectKind(root({}, { entries: ["index.html", "style.css"] })).kind, "app");
82+ assert.deepEqual(detectKind(root({}, { entries: ["README.md"] })), { kind: null, detail: "No manifest at the root says what it is.", ecosystem: null });
83+});
84+
85+const auto: KindFacts = { deploys: "auto", deploymentsOn: false, linkedPackage: null, detected: null };
86+
87+test("the setting wins over everything", () => {
88+ assert.equal(resolveKind({ ...auto, deploys: "yes", linkedPackage: "Composer package psr/log" }).kind, "app");
89+ assert.deepEqual(resolveKind({ ...auto, deploys: "no", deploymentsOn: true }), {
90+ kind: "library",
91+ reason: { by: "set", detail: "Set in its settings: it doesn't deploy." },
92+ });
93+});
94+
95+test("on auto: Deployments on, then packages, then files, then an app", () => {
96+ const library = { kind: "library" as const, detail: "composer.json has autoload and no public/index.php." };
97+ assert.equal(resolveKind({ ...auto, deploymentsOn: true, linkedPackage: "Composer package psr/log", detected: library }).reason.by, "deployments");
98+ assert.deepEqual(resolveKind({ ...auto, linkedPackage: "Composer package psr/log", detected: library }), {
99+ kind: "library",
100+ reason: { by: "packages", detail: "Its repository publishes the Composer package psr/log." },
101+ });
102+ assert.deepEqual(resolveKind({ ...auto, detected: library }), { kind: "library", reason: { by: "files", ...{ detail: library.detail } } });
103+ assert.equal(resolveKind({ ...auto, detected: { kind: "app", detail: "package.json has a start script." } }).reason.by, "files");
104+ assert.deepEqual(resolveKind({ ...auto, deploymentsOn: null, detected: { kind: null, detail: "" } }).kind, "app");
105+ assert.equal(resolveKind(auto).reason.by, "default");
106+});
107+
108+test("an unknown setting is auto", () => {
109+ assert.equal(deploysSetting("no"), "no");
110+ assert.equal(deploysSetting(null), "auto");
111+ assert.equal(deploysSetting("sometimes"), "auto");
112+});
+185−0
1+/**
2+ * Whether a project is an app, which deploys, or a library (or a tool),
3+ * which is published and installed. A person can say so in its settings;
4+ * left on `auto`, it is worked out here from what the project has: Deployments
5+ * being on, packages its repository publishes, and the manifests at its
6+ * root. Both functions are pure so the rules can be tested on their own;
7+ * the service reads the files and keeps the detection by commit.
8+ */
9+
10+import type { DeploysSetting, KindReason, ProjectEcosystem, ProjectKind } from "@g1t/contracts";
11+
12+/** The manifests whose contents detection reads, when they are at the root. */
13+export const MANIFESTS = ["composer.json", "Cargo.toml", "go.mod", "pyproject.toml", "package.json"] as const;
14+/** At most this many root `.go` files are read to find `package main`. */
15+export const GO_FILES_READ = 3;
16+
17+/** What detection is given: the project's root, and what was read from it. */
18+export type RootFiles = {
19+ /** Names at the project's root; directories end in `/`. */
20+ entries: string[];
21+ /** Contents of the manifests and root `.go` files read, by name. */
22+ text: Record<string, string>;
23+ /** Names in `src/`, when the root has a Cargo.toml. */
24+ src?: string[];
25+ /** Names in `public/`, when the root has a composer.json. */
26+ public?: string[];
27+};
28+
29+/** What the files say: a kind with why, or null when nothing says either way. */
30+export type Detection = { kind: ProjectKind | null; detail: string; ecosystem: ProjectEcosystem | null };
31+
32+/** Root `.go` files detection reads, tests left out. */
33+export function goFilesToRead(entries: string[]): string[] {
34+ return entries.filter((name) => name.endsWith(".go") && !name.endsWith("_test.go")).slice(0, GO_FILES_READ);
35+}
36+
37+const has = (files: RootFiles, name: string) => files.entries.includes(name);
38+
39+/** Frameworks that make a package.json an app. Hosting a server alone does not: a start script does. */
40+const JS_APP_FRAMEWORKS = ["next", "astro", "nuxt", "@remix-run/dev", "@remix-run/node", "@react-router/dev", "@sveltejs/kit", "gatsby", "@angular/core", "expo", "react-scripts"];
41+/** Python frameworks that make a pyproject an app. */
42+const PY_APP_FRAMEWORKS = ["django", "flask", "fastapi", "streamlit", "gradio", "starlette", "uvicorn"];
43+
44+function composer(files: RootFiles, text: string): Detection {
45+ const ecosystem = "composer";
46+ let json: { type?: unknown; autoload?: unknown };
47+ try {
48+ json = JSON.parse(text);
49+ } catch {
50+ return { kind: null, detail: "composer.json could not be read.", ecosystem };
51+ }
52+ const type = typeof json.type === "string" ? json.type : null;
53+ if (type === "project") return { kind: "app", detail: 'composer.json says "type": "project".', ecosystem };
54+ if (type) return { kind: "library", detail: `composer.json says "type": "${type}".`, ecosystem };
55+ const servesIndex = files.public?.includes("index.php") || has(files, "index.php");
56+ if (json.autoload && !servesIndex) {
57+ return { kind: "library", detail: "composer.json has autoload and no public/index.php.", ecosystem };
58+ }
59+ if (servesIndex) return { kind: "app", detail: "It has a composer.json and an index.php to serve.", ecosystem };
60+ return { kind: null, detail: "composer.json has no type or autoload.", ecosystem };
61+}
62+
63+function cargo(files: RootFiles, text: string): Detection {
64+ const ecosystem = "cargo";
65+ const lib = /^\s*\[lib\]/m.test(text) || !!files.src?.includes("lib.rs");
66+ const bin = /^\s*\[\[bin\]\]/m.test(text) || !!files.src?.includes("main.rs");
67+ if (bin) return { kind: "app", detail: "Cargo.toml builds a binary.", ecosystem };
68+ if (lib) return { kind: "library", detail: "Cargo.toml builds a library and no binary.", ecosystem };
69+ return { kind: null, detail: "Cargo.toml builds neither a library nor a binary here.", ecosystem };
70+}
71+
72+function go(files: RootFiles): Detection {
73+ const ecosystem = "go";
74+ const sources = goFilesToRead(files.entries);
75+ const main = sources.find((name) => /^\s*package\s+main\b/m.test(files.text[name] ?? ""));
76+ if (main) return { kind: "app", detail: `${main} at the root is package main.`, ecosystem };
77+ if (sources.length > 0) return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
78+ // A module whose code is all in directories: commands live in cmd/.
79+ if (has(files, "cmd/")) return { kind: null, detail: "go.mod, with commands in cmd/.", ecosystem };
80+ return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
81+}
82+
83+function python(text: string): Detection {
84+ const ecosystem = "python";
85+ const backend = /^\s*build-backend\s*=/m.test(text) || /^\s*\[tool\.poetry\]/m.test(text);
86+ const framework = PY_APP_FRAMEWORKS.find((name) => new RegExp(`["'\\s]${name}(?![\\w-])`, "i").test(text));
87+ if (framework) return { kind: "app", detail: `pyproject.toml depends on ${framework}.`, ecosystem };
88+ if (backend) return { kind: "library", detail: "pyproject.toml has a build backend and no app framework.", ecosystem };
89+ return { kind: null, detail: "pyproject.toml has no build backend.", ecosystem };
90+}
91+
92+function npm(files: RootFiles, text: string): Detection {
93+ const ecosystem = "npm";
94+ let json: {
95+ scripts?: Record<string, unknown>;
96+ dependencies?: Record<string, unknown>;
97+ devDependencies?: Record<string, unknown>;
98+ main?: unknown;
99+ module?: unknown;
100+ exports?: unknown;
101+ files?: unknown;
102+ bin?: unknown;
103+ };
104+ try {
105+ json = JSON.parse(text);
106+ } catch {
107+ return { kind: null, detail: "package.json could not be read.", ecosystem };
108+ }
109+ const script = ["start", "dev"].find((name) => typeof json.scripts?.[name] === "string");
110+ if (script) return { kind: "app", detail: `package.json has a ${script} script.`, ecosystem };
111+ const deps = { ...json.devDependencies, ...json.dependencies };
112+ const framework = JS_APP_FRAMEWORKS.find((name) => name in deps);
113+ if (framework) return { kind: "app", detail: `package.json depends on ${framework}.`, ecosystem };
114+ // Vite builds libraries too; with an index.html at the root it is a site.
115+ if ("vite" in deps && has(files, "index.html")) return { kind: "app", detail: "A Vite site, with index.html at the root.", ecosystem };
116+ const entry = ["exports", "main", "module", "files", "bin"].find((key) => json[key as keyof typeof json] != null);
117+ if (entry) return { kind: "library", detail: `package.json has "${entry}" and no start or dev script.`, ecosystem };
118+ return { kind: null, detail: "package.json has no entry point or start script.", ecosystem };
119+}
120+
121+/**
122+ * What a project's root says it is. A Workers config or a root index.html
123+ * is something to serve. Otherwise the first manifest present that says
124+ * either way decides, the language's own before package.json, which many
125+ * projects carry only for tooling.
126+ */
127+export function detectKind(files: RootFiles): Detection {
128+ const workers = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].find((name) => has(files, name));
129+ if (workers) return { kind: "app", detail: `${workers} at the root.`, ecosystem: null };
130+ let first: Detection | null = null;
131+ for (const name of MANIFESTS) {
132+ if (!has(files, name)) continue;
133+ const text = files.text[name] ?? "";
134+ const found =
135+ name === "composer.json"
136+ ? composer(files, text)
137+ : name === "Cargo.toml"
138+ ? cargo(files, text)
139+ : name === "go.mod"
140+ ? go(files)
141+ : name === "pyproject.toml"
142+ ? python(text)
143+ : npm(files, text);
144+ if (found.kind) return found;
145+ first ??= found;
146+ }
147+ if (has(files, "index.html")) return { kind: "app", detail: "index.html at the root.", ecosystem: first?.ecosystem ?? null };
148+ return first ?? { kind: null, detail: "No manifest at the root says what it is.", ecosystem: null };
149+}
150+
151+/** What resolution is given, from the project's row. */
152+export type KindFacts = {
153+ deploys: DeploysSetting;
154+ /** Whether Deployments are on for it; null while unknown. */
155+ deploymentsOn: boolean | null;
156+ /** A package its repository publishes, other than a container image, as `Composer package psr/log`; null for none. */
157+ linkedPackage: string | null;
158+ /** What its files say; null before they have been read. */
159+ detected: { kind: ProjectKind | null; detail: string } | null;
160+};
161+
162+/**
163+ * The kind a project is. The setting wins. On `auto`: Deployments being on
164+ * makes it an app; then a package its repository publishes, or files that
165+ * say library, make it a library; anything else is an app, so nothing that
166+ * deploys loses its production card to a guess.
167+ */
168+export function resolveKind(facts: KindFacts): { kind: ProjectKind; reason: KindReason } {
169+ if (facts.deploys === "yes") return { kind: "app", reason: { by: "set", detail: "Set in its settings: it deploys." } };
170+ if (facts.deploys === "no") return { kind: "library", reason: { by: "set", detail: "Set in its settings: it doesn't deploy." } };
171+ if (facts.deploymentsOn) return { kind: "app", reason: { by: "deployments", detail: "Deployments are on for it." } };
172+ if (facts.linkedPackage) {
173+ return { kind: "library", reason: { by: "packages", detail: `Its repository publishes the ${facts.linkedPackage}.` } };
174+ }
175+ if (facts.detected?.kind === "library") return { kind: "library", reason: { by: "files", detail: facts.detected.detail } };
176+ if (facts.detected?.kind === "app") return { kind: "app", reason: { by: "files", detail: facts.detected.detail } };
177+ return { kind: "app", reason: { by: "default", detail: "Nothing in it says it is a library, so it is taken to deploy." } };
178+}
179+
180+const SETTINGS: readonly DeploysSetting[] = ["auto", "yes", "no"];
181+
182+/** A stored or submitted setting, `auto` for anything else. */
183+export function deploysSetting(value: unknown): DeploysSetting {
184+ return SETTINGS.includes(value as DeploysSetting) ? (value as DeploysSetting) : "auto";
185+}
+6−1
1717 ],
1818 "services": [
1919 { "binding": "REPOS", "service": "g1t-repos" },
20− { "binding": "IDENTITY", "service": "g1t-identity" }
20+ { "binding": "IDENTITY", "service": "g1t-identity" },
21+ // Whether a project is a library: the packages its repository
22+ // publishes, and whether Deployments are on (asked once; after that
23+ // deployments says when it changes).
24+ { "binding": "PACKAGES", "service": "g1t-packages" },
25+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
2126 ],
2227 // A repository created is given its own project.
2328 "queues": {