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.
20 files+1235−1540/20 viewed
| 63 | 63 | Production starts building at once from the default branch. Every pull | |
| 64 | 64 | request opened or pushed to from then on gets a preview. | |
| 65 | 65 | ||
| 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 | + | ||
| 66 | 73 | While payments on g1t are in test mode, no real card is charged: use the | |
| 67 | 74 | test card `4242 4242 4242 4242` with any future date and any code. | |
| 68 | 75 |
| 32 | 32 | ||
| 33 | 33 | | Page | Address | | | |
| 34 | 34 | | --- | --- | --- | | |
| 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). | | |
| 36 | 36 | | **Code** | `…/code` | The repository's files, commits and branches. | | |
| 37 | 37 | | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. | | |
| 38 | 38 | | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). | | |
| 50 | 50 | ||
| 51 | 51 | | Part | What it shows | | |
| 52 | 52 | | --- | --- | | |
| 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. | | |
| 55 | 56 | | **Right now** | Agents at work, and open pull requests moving from working to landed. | | |
| 56 | 57 | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 57 | 58 | | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). | | |
| 76 | 77 | The card goes away when every step is done. To hide it sooner, choose | |
| 77 | 78 | **×** on it. That hides it for this project in this browser only. | |
| 78 | 79 | ||
| 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 | + | ||
| 79 | 137 | ### Active branches | |
| 80 | 138 | ||
| 81 | 139 | Up to five branches other than the default, the most recently changed | |
| 95 | 153 | ||
| 96 | 154 | | Tab | What it holds | | |
| 97 | 155 | | --- | --- | | |
| 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. | | |
| 99 | 157 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 100 | 158 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 101 | 159 | | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). | |
| 13 | 13 | }; | |
| 14 | 14 | ||
| 15 | 15 | /** | |
| 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. | |
| 19 | 19 | */ | |
| 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 | + | }) { | |
| 21 | 30 | const hidden = useSyncExternalStore( | |
| 22 | 31 | subscribe, | |
| 23 | 32 | useCallback(() => isDismissed(storage, base), [base]), | |
| 31 | 40 | <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface"> | |
| 32 | 41 | <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6"> | |
| 33 | 42 | <h2 id="checklist-title" className="text-sm font-semibold"> | |
| 34 | − | Get to production | |
| 43 | + | {title} | |
| 35 | 44 | </h2> | |
| 36 | 45 | <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted"> | |
| 37 | 46 | {done}/{total} |
| 10 | 10 | isDismissed, | |
| 11 | 11 | productionChecklist, | |
| 12 | 12 | progress, | |
| 13 | + | releaseChecklist, | |
| 13 | 14 | } from "./checklist.ts"; | |
| 14 | 15 | ||
| 15 | 16 | const fresh: ChecklistFacts = { | |
| 96 | 97 | assert.equal(isDismissed(() => blocked, "/acme/web"), false); | |
| 97 | 98 | assert.equal(dismiss(() => blocked, "/acme/web"), false); | |
| 98 | 99 | }); | |
| 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 | + | }); |
| 1 | 1 | /** | |
| 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. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | 8 | export type ChecklistFacts = { | |
| 25 | 25 | }; | |
| 26 | 26 | ||
| 27 | 27 | export type ChecklistItem = { | |
| 28 | − | key: "code" | "deploy" | "domain" | "preview" | "instructions" | "agent"; | |
| 28 | + | key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "instructions" | "agent"; | |
| 29 | 29 | title: string; | |
| 30 | 30 | detail: string; | |
| 31 | 31 | done: boolean; | |
| 89 | 89 | ]; | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 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 | + | ||
| 92 | 132 | /** `3/6`, for the card's heading. */ | |
| 93 | 133 | export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } { | |
| 94 | 134 | const done = items.filter((item) => item.done).length; |
| 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 | + | }); |
| 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 | + | } |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 12 | 12 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 13 | − | import { billing, deployments } from "../../lib/services.server"; | |
| 13 | + | import { billing, deployments, projects } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 15 | 15 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 16 | 16 | import { whyNot } from "../../lib/access"; | |
| 24 | 24 | // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role. | |
| 25 | 25 | const { access } = await requireRepo(context, params, "read"); | |
| 26 | 26 | 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([ | |
| 28 | 28 | deployments.settings(ref, viewer), | |
| 29 | 29 | deployments.list(ref, viewer), | |
| 30 | 30 | billing.features(params.owner, viewer), | |
| 31 | 31 | // Builds are compute: said before a deploy is refused for it. | |
| 32 | 32 | computeNoteFor(params.owner, "deploy"), | |
| 33 | + | projects.get(params.owner, params.repo, viewer).catch(() => null), | |
| 33 | 34 | ]); | |
| 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; | |
| 34 | 37 | // Someone outside the workspace does not see its plans; the page works without. | |
| 35 | 38 | 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 }; | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 114 | 117 | gets its own preview. Workers projects (a <code className="font-mono text-fg">wrangler.jsonc</code>) and | |
| 115 | 118 | static sites deploy without configuration. | |
| 116 | 119 | </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 | + | )} | |
| 123 | 137 | </section> | |
| 124 | 138 | ) : ( | |
| 125 | 139 | <> |
| 14 | 14 | Loader2, | |
| 15 | 15 | Lock, | |
| 16 | 16 | Network, | |
| 17 | + | Package as PackageGlyph, | |
| 17 | 18 | Pin, | |
| 18 | 19 | Rocket, | |
| 19 | 20 | RotateCw, | |
| 21 | 22 | import { type ReactNode, Suspense } from "react"; | |
| 22 | 23 | import { Await, Form, Link, useNavigation } from "react-router"; | |
| 23 | 24 | ||
| 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"; | |
| 25 | 36 | ||
| 26 | 37 | import type { Route } from "./+types/overview"; | |
| 27 | 38 | import { host, StatusDot } from "../../components/deploy"; | |
| 30 | 41 | import { ActivityFeed, DeployStrip, Meter, NeedsList, Panel, Quiet, Unavailable, percent } from "../../components/mission"; | |
| 31 | 42 | import { type ActiveBranch, ActiveBranches } from "../../components/branches"; | |
| 32 | 43 | import { ProductionChecklist } from "../../components/checklist"; | |
| 44 | + | import { PackageIcon } from "../../components/package-icon"; | |
| 33 | 45 | import { Skeleton, SkeletonRows } from "../../components/ui/skeleton"; | |
| 34 | 46 | import { ProductionShot } from "../../components/production-shot"; | |
| 35 | 47 | import { GithubLinkStrip } from "../../components/github"; | |
| 53 | 65 | stuckMinutes, | |
| 54 | 66 | } from "../../lib/mission"; | |
| 55 | 67 | 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"; | |
| 58 | 72 | import { madeByG1t } from "../../lib/opened-by"; | |
| 59 | 73 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 60 | 74 | import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server"; | |
| 67 | 81 | /** How far back each branch's history, and the default branch's, is read to count ahead and behind. */ | |
| 68 | 82 | const BRANCH_DEPTH = 40; | |
| 69 | 83 | const MAIN_DEPTH = 120; | |
| 84 | + | /** A library's packages shown on its overview; the rest are a link away. */ | |
| 85 | + | const PACKAGES_SHOWN = 3; | |
| 70 | 86 | ||
| 71 | 87 | /** | |
| 72 | 88 | * The overview streams: the layout's header and tabs (one repository | |
| 96 | 112 | const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> => | |
| 97 | 113 | memberP.then((member) => (member ? soft(start()) : null)); | |
| 98 | 114 | 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)); | |
| 99 | 123 | const eventsP = repoP.then((repo) => (repo?.ok ? soft(eventLog.list({ repoId: repo.value.id, limit: 150 })) : null)); | |
| 100 | 124 | const minePullsP = repoP.then((repo) => | |
| 101 | 125 | repo?.ok && viewer | |
| 148 | 172 | ); | |
| 149 | 173 | // Active branches read several logs each: streamed, so the rest shows first. | |
| 150 | 174 | 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([ | |
| 152 | 176 | accessP, | |
| 153 | − | soft(projects.get(params.owner, params.repo, viewer)), | |
| 177 | + | projectP, | |
| 154 | 178 | forMembers(() => deployments.settings(ref, viewer)), | |
| 155 | 179 | listP, | |
| 156 | 180 | openP, | |
| 167 | 191 | // For the checklist, which only members see. | |
| 168 | 192 | forMembers(() => deployments.domains(ref, viewer)), | |
| 169 | 193 | forMembers(() => repos.tree(path, viewer, null, "")), | |
| 194 | + | packagesP, | |
| 195 | + | workflowsP, | |
| 170 | 196 | ]); | |
| 171 | 197 | const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null); | |
| 172 | 198 | ||
| 294 | 320 | const liveApps = ok(list)?.live ?? []; | |
| 295 | 321 | const commitNow = ok(log)?.[0] ?? null; | |
| 296 | 322 | 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, | |
| 301 | 348 | deploysEnabled: ok(settings)?.enabled ?? false, | |
| 302 | 349 | productionDeployed: | |
| 303 | 350 | liveApps.some((app) => app.kind === "production") || | |
| 305 | 352 | domains: ok(domains)?.domains.length ?? null, | |
| 306 | 353 | previewOpened: | |
| 307 | 354 | 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 | + | }); | |
| 316 | 356 | ||
| 317 | 357 | const github = await githubP; | |
| 318 | 358 | return { | |
| 344 | 384 | memoryCount: memoryList.length, | |
| 345 | 385 | memoriesLoaded: memories?.ok ?? false, | |
| 346 | 386 | checklist, | |
| 387 | + | library: isLibrary | |
| 388 | + | ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false } | |
| 389 | + | : null, | |
| 347 | 390 | branches, | |
| 348 | 391 | }; | |
| 349 | 392 | } | |
| 461 | 504 | ); | |
| 462 | 505 | } | |
| 463 | 506 | ||
| 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 | + | ||
| 464 | 609 | function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) { | |
| 465 | 610 | const body = ( | |
| 466 | 611 | <> | |
| 532 | 677 | actionData: Route.ComponentProps["actionData"]; | |
| 533 | 678 | params: Route.ComponentProps["params"]; | |
| 534 | 679 | }) { | |
| 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 } = | |
| 536 | 681 | loaderData; | |
| 537 | 682 | const base = `/${params.owner}/${params.repo}`; | |
| 538 | 683 | const production = live.find((app) => app.kind === "production") ?? null; | |
| 552 | 697 | {loaderData.github && <GithubLinkStrip link={loaderData.github} />} | |
| 553 | 698 | {/* What the project is, running, and where its code is. */} | |
| 554 | 699 | <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 | + | /> | |
| 611 | 711 | )} | |
| 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 | + | </> | |
| 620 | 759 | )} | |
| 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 | − | )} | |
| 634 | 760 | </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> | |
| 636 | 785 | </div> | |
| 637 | − | </div> | |
| 786 | + | )} | |
| 638 | 787 | {actionData && ( | |
| 639 | 788 | <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-accent"}`}> | |
| 640 | 789 | {"error" in actionData ? actionData.error : actionData.notice} | |
| 683 | 832 | } | |
| 684 | 833 | /> | |
| 685 | 834 | <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`} | |
| 688 | 837 | value={ | |
| 689 | − | member && settings?.enabled ? ( | |
| 838 | + | member && settings?.enabled && !library ? ( | |
| 690 | 839 | latestProduction ? ( | |
| 691 | 840 | <span className="inline-flex items-center gap-2"> | |
| 692 | 841 | <StatusDot status={latestProduction.status} /> | |
| 711 | 860 | )} | |
| 712 | 861 | </section> | |
| 713 | 862 | ||
| 714 | − | {checklist && <ProductionChecklist base={base} items={checklist} />} | |
| 863 | + | {checklist && <ProductionChecklist base={base} items={checklist} title={library ? "Ship a release" : undefined} />} | |
| 715 | 864 | ||
| 716 | 865 | <Panel | |
| 717 | 866 | title="Right now" | |
| 976 | 1125 | : "No checks have run recently."} | |
| 977 | 1126 | </p> | |
| 978 | 1127 | </div> | |
| 979 | − | {member && ( | |
| 1128 | + | {member && (!library || builds.length > 0) && ( | |
| 980 | 1129 | <div> | |
| 981 | 1130 | <div className="flex items-baseline justify-between"> | |
| 982 | 1131 | <span className="text-muted">Deploys</span> |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | 8 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 9 | 9 | import { SwitchCard } from "../../components/ui/switch"; | |
| 10 | − | import { deployments } from "../../lib/services.server"; | |
| 10 | + | import { deployments, projects } from "../../lib/services.server"; | |
| 11 | 11 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 12 | 12 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 13 | 13 | ||
| 19 | 19 | const viewer = getViewer(context); | |
| 20 | 20 | // Admins; to anyone without a role here the page does not exist. | |
| 21 | 21 | 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 }; | |
| 24 | 29 | } | |
| 25 | 30 | ||
| 26 | 31 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 90 | 95 | } | |
| 91 | 96 | ||
| 92 | 97 | export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 93 | − | const { settings } = loaderData; | |
| 98 | + | const { settings, notDeploying } = loaderData; | |
| 94 | 99 | const busy = useNavigation().state === "submitting"; | |
| 95 | 100 | const base = `/${params.owner}/${params.repo}`; | |
| 96 | 101 | return ( | |
| 112 | 117 | <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span> and a preview for | |
| 113 | 118 | every pull request. The workspace needs the g1t plan, under Billing. | |
| 114 | 119 | </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 | + | )} | |
| 120 | 136 | </section> | |
| 121 | 137 | ) : ( | |
| 122 | 138 | <> |
| 1 | − | import { Box, Code2, GitBranch } from "lucide-react"; | |
| 1 | + | import { Box, Code2, GitBranch, Rocket } from "lucide-react"; | |
| 2 | + | import { useState } from "react"; | |
| 2 | 3 | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | 4 | ||
| 5 | + | import type { DeploysSetting } from "@g1t/contracts"; | |
| 6 | + | ||
| 4 | 7 | import type { Route } from "./+types/settings"; | |
| 5 | 8 | import { page } from "../../lib/meta"; | |
| 6 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 7 | 10 | 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"; | |
| 9 | 14 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 10 | 15 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 11 | 16 | ||
| 17 | 22 | const viewer = getViewer(context); | |
| 18 | 23 | // Maintain and up; to anyone without a role here the page does not exist. | |
| 19 | 24 | 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 }; | |
| 21 | 31 | } | |
| 22 | 32 | ||
| 23 | 33 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 30 | 40 | // Blank, or the reset, and it follows the repository's description again. | |
| 31 | 41 | description: form.get("inherit") === "description" ? null : String(form.get("description") ?? ""), | |
| 32 | 42 | rootDir: String(form.get("rootDir") ?? ""), | |
| 43 | + | deploys: (form.get("deploys") as DeploysSetting | null) ?? undefined, | |
| 33 | 44 | }); | |
| 34 | 45 | return saved.ok ? { saved: true as const } : { error: saved.error.message }; | |
| 35 | 46 | } | |
| 36 | 47 | ||
| 37 | 48 | 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; | |
| 39 | 53 | const saving = useNavigation().state === "submitting"; | |
| 40 | 54 | const base = `/${params.owner}/${params.repo}`; | |
| 41 | 55 | const source = project.source.kind === "hosted" ? project.source : null; | |
| 117 | 131 | </Field> | |
| 118 | 132 | </section> | |
| 119 | 133 | ||
| 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 | + | ||
| 120 | 179 | <div className="flex items-center gap-3"> | |
| 121 | − | <Button type="submit" disabled={saving}> | |
| 180 | + | <Button type="submit" disabled={saving || blocked}> | |
| 122 | 181 | Save | |
| 123 | 182 | </Button> | |
| 124 | 183 | {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>} |
| 1 | 1 | import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { Project, ProjectDeploys, User } from "@g1t/contracts"; | |
| 4 | + | import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/overview"; | |
| 7 | 7 | import { host, StatusDot } from "../../components/deploy"; | |
| 10 | 10 | import { PullIcon } from "../../components/work-icons"; | |
| 11 | 11 | import { planStatus, type UsageGlance, usageGlance } from "../../lib/billing"; | |
| 12 | 12 | import { openedBy } from "../../lib/opened-by"; | |
| 13 | − | import { 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"; | |
| 14 | 15 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 15 | 16 | ||
| 16 | 17 | /** Projects whose open issues and pull requests are counted. */ | |
| 56 | 57 | const viewer = getViewer(context); | |
| 57 | 58 | const slug = params.owner.toLowerCase(); | |
| 58 | 59 | const role = roleIn(viewer, slug); | |
| 59 | − | const [listed, members, deploys, usage] = await Promise.all([ | |
| 60 | + | const [listed, members, deploys, usage, published] = await Promise.all([ | |
| 60 | 61 | projectsApi.list(slug, viewer), | |
| 61 | 62 | role ? identity.listMembers(slug, viewer) : null, | |
| 62 | 63 | role ? deployments.overview(slug, viewer) : null, | |
| 63 | 64 | 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), | |
| 64 | 67 | ]); | |
| 65 | 68 | const projects = listed.ok ? listed.value : []; | |
| 66 | 69 | ||
| 84 | 87 | const bySlug: Record<string, ProjectDeploys> = {}; | |
| 85 | 88 | for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry; | |
| 86 | 89 | ||
| 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 | + | ||
| 87 | 101 | return { | |
| 88 | 102 | slug, | |
| 89 | 103 | role, | |
| 90 | 104 | projects, | |
| 91 | 105 | open, | |
| 92 | 106 | deploys: bySlug, | |
| 107 | + | packageOf, | |
| 93 | 108 | pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })), | |
| 94 | 109 | members: members?.ok ? members.value : null, | |
| 95 | 110 | usage, | |
| 135 | 150 | project, | |
| 136 | 151 | open, | |
| 137 | 152 | deploys, | |
| 153 | + | pkg, | |
| 138 | 154 | member, | |
| 139 | 155 | }: { | |
| 140 | 156 | project: Project; | |
| 141 | 157 | open: { issues: number; pulls: number } | undefined; | |
| 142 | 158 | deploys: ProjectDeploys | undefined; | |
| 159 | + | /** For a library, the package it publishes. */ | |
| 160 | + | pkg: PackageSummary | undefined; | |
| 143 | 161 | member: boolean; | |
| 144 | 162 | }) { | |
| 163 | + | const library = project.kind === "library"; | |
| 145 | 164 | const base = `/${project.workspace}/${project.slug}`; | |
| 146 | 165 | const production = deploys?.production ?? null; | |
| 147 | 166 | const latest = deploys?.latest ?? null; | |
| 164 | 183 | {host(production.url)} | |
| 165 | 184 | <ArrowUpRight size={11} className="shrink-0" /> | |
| 166 | 185 | </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> | |
| 167 | 193 | ) : ( | |
| 168 | 194 | <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")} | |
| 170 | 196 | </p> | |
| 171 | 197 | )} | |
| 172 | 198 | </div> | |
| 178 | 204 | )} | |
| 179 | 205 | </div> | |
| 180 | 206 | ||
| 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>} | |
| 182 | 208 | ||
| 183 | 209 | <div className="mt-auto pt-5"> | |
| 184 | 210 | {member && latest && ( | |
| 257 | 283 | project={project} | |
| 258 | 284 | open={open[project.id]} | |
| 259 | 285 | deploys={deploys[project.slug]} | |
| 286 | + | pkg={loaderData.packageOf[project.id]} | |
| 260 | 287 | member={role != null} | |
| 261 | 288 | /> | |
| 262 | 289 | ))} |
| 622 | 622 | byRepo: (repoId) => call("by_repo", { repoId }), | |
| 623 | 623 | create: (actor, workspace, input) => call("create", { actor, workspace, input }), | |
| 624 | 624 | update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }), | |
| 625 | + | deploymentsChanged: (projectId, enabled) => call("deployments_changed", { projectId, enabled }), | |
| 625 | 626 | dependencies: (workspace, slug, viewer) => call("dependencies", { workspace, slug, viewer }), | |
| 626 | 627 | addDependency: (actor, workspace, slug, on, as) => call("add_dependency", { actor, workspace, slug, on, as }), | |
| 627 | 628 | removeDependency: (actor, workspace, slug, on) => call("remove_dependency", { actor, workspace, slug, on }), |
| 36 | 36 | archived: boolean; | |
| 37 | 37 | /** Whether it is the project its repository's workflows read secrets from. */ | |
| 38 | 38 | 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; | |
| 39 | 49 | createdBy: string; | |
| 40 | 50 | /** RFC 3339. */ | |
| 41 | 51 | createdAt: string; | |
| 42 | 52 | updatedAt: string; | |
| 43 | 53 | }; | |
| 44 | 54 | ||
| 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 | + | ||
| 45 | 75 | export type NewProject = { | |
| 46 | 76 | name: string; | |
| 47 | 77 | description?: string | null; | |
| 83 | 113 | actor: User, | |
| 84 | 114 | workspace: string, | |
| 85 | 115 | slug: string, | |
| 86 | − | changes: { name?: string; description?: string | null; rootDir?: string }, | |
| 116 | + | changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting }, | |
| 87 | 117 | ): Promise<Result<Project>>; | |
| 118 | + | /** For deployments: Deployments were turned on or off for the project. */ | |
| 119 | + | deploymentsChanged(projectId: string, enabled: boolean): Promise<void>; | |
| 88 | 120 | /** What a project uses and what uses it. Whoever may see the project. */ | |
| 89 | 121 | dependencies(workspace: string, slug: string, viewer: Viewer): Promise<Result<Dependencies>>; | |
| 90 | 122 | /** |
| 452 | 452 | const project = found.value; | |
| 453 | 453 | const before = await this.toSettings(project, await this.settingsRow(project.id)); | |
| 454 | 454 | 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 | + | } | |
| 455 | 458 | if (next.enabled && !before.enabled) { | |
| 456 | 459 | // Turning it on starts paid work: only with the workspace's plan. | |
| 457 | 460 | const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments"); | |
| 482 | 485 | now(), | |
| 483 | 486 | ) | |
| 484 | 487 | .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 | + | } | |
| 485 | 492 | // What was turned off comes down now; nothing keeps running unasked. | |
| 486 | 493 | if (!next.enabled) await this.takeDownWhere(project.id, null); | |
| 487 | 494 | else { | |
| 495 | 502 | return ok(await this.toSettings(project, await this.settingsRow(project.id))); | |
| 496 | 503 | } | |
| 497 | 504 | ||
| 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 | + | ||
| 498 | 510 | async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> { | |
| 499 | 511 | const project = await this.projectFor(a.project, a.viewer, "list"); | |
| 500 | 512 | if (!project.ok) return project; | |
| 2156 | 2168 | switch (method) { | |
| 2157 | 2169 | case "settings": | |
| 2158 | 2170 | return service.settings(args); | |
| 2171 | + | case "is_enabled": | |
| 2172 | + | return service.isEnabled(args); | |
| 2159 | 2173 | case "update_settings": | |
| 2160 | 2174 | return service.updateSettings(args); | |
| 2161 | 2175 | case "list": |
| 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; |
| 16 | 16 | ||
| 17 | 17 | import { NEEDS, repoRef } from "./access"; | |
| 18 | 18 | import { effectiveDescription, ownDescription } from "./description"; | |
| 19 | + | import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind"; | |
| 19 | 20 | import { renameStatements } from "./rename"; | |
| 20 | 21 | import { moveStatements, slugOf, strandedQuery } from "./transfer"; | |
| 21 | 22 | ||
| 31 | 32 | newId, | |
| 32 | 33 | ok, | |
| 33 | 34 | openD1, | |
| 35 | + | packagesClient, | |
| 34 | 36 | permission, | |
| 35 | 37 | repoMove, | |
| 36 | 38 | reposClient, | |
| 37 | 39 | staleMovedPaths, | |
| 38 | 40 | type Dependencies, | |
| 39 | 41 | type DependencyLink, | |
| 42 | + | type DeploysSetting, | |
| 43 | + | type Ecosystem, | |
| 40 | 44 | type G1tEvent, | |
| 41 | 45 | type NewProject, | |
| 42 | 46 | type Project, | |
| 47 | + | type ProjectEcosystem, | |
| 43 | 48 | type ProjectGraph, | |
| 44 | 49 | type Repo, | |
| 45 | 50 | type Result, | |
| 52 | 57 | DB: D1Database; | |
| 53 | 58 | REPOS: ServiceBinding; | |
| 54 | 59 | IDENTITY: ServiceBinding; | |
| 60 | + | PACKAGES: ServiceBinding; | |
| 61 | + | DEPLOYMENTS: ServiceBinding; | |
| 55 | 62 | }; | |
| 56 | 63 | ||
| 57 | 64 | type Row = { | |
| 78 | 85 | repo_deleted_at: string | null; | |
| 79 | 86 | /** When its repository was archived; null while it is not. */ | |
| 80 | 87 | 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; | |
| 81 | 97 | }; | |
| 82 | 98 | ||
| 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 | + | ||
| 83 | 111 | const now = () => new Date().toISOString(); | |
| 84 | 112 | ||
| 85 | 113 | /** A variable's name for a dependency's address, spelled as secrets' names are. */ | |
| 92 | 120 | ||
| 93 | 121 | function toProject(row: Row): Project { | |
| 94 | 122 | 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 }); | |
| 95 | 134 | return { | |
| 96 | 135 | id: row.id, | |
| 97 | 136 | workspace: row.workspace, | |
| 109 | 148 | private: !!row.repo_private, | |
| 110 | 149 | archived: !!row.repo_archived_at, | |
| 111 | 150 | primary: !!row.is_primary, | |
| 151 | + | deploys, | |
| 152 | + | kind, | |
| 153 | + | kindReason: reason, | |
| 154 | + | detected: auto, | |
| 155 | + | ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null, | |
| 112 | 156 | createdBy: row.created_by, | |
| 113 | 157 | createdAt: row.created_at, | |
| 114 | 158 | updatedAt: row.updated_at, | |
| 120 | 164 | } | |
| 121 | 165 | ||
| 122 | 166 | 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 | + | ) {} | |
| 124 | 172 | ||
| 125 | 173 | private get db() { | |
| 126 | 174 | return this.env.DB; | |
| 233 | 281 | .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE") | |
| 234 | 282 | .bind(workspace) | |
| 235 | 283 | .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))))); | |
| 236 | 288 | return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject)); | |
| 237 | 289 | } | |
| 238 | 290 | ||
| 244 | 296 | .bind(workspace, a.slug.toLowerCase()) | |
| 245 | 297 | .first<Row>(); | |
| 246 | 298 | 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 | + | } | |
| 247 | 304 | return ok(toProject(row)); | |
| 248 | 305 | } | |
| 249 | 306 | ||
| 342 | 399 | actor: User; | |
| 343 | 400 | workspace: string; | |
| 344 | 401 | slug: string; | |
| 345 | − | changes: { name?: string; description?: string | null; rootDir?: string }; | |
| 402 | + | changes: { name?: string; description?: string | null; rootDir?: string; deploys?: DeploysSetting }; | |
| 346 | 403 | }): Promise<Result<Project>> { | |
| 347 | 404 | const workspace = a.workspace.toLowerCase(); | |
| 348 | 405 | const row = await this.db | |
| 357 | 414 | const description = ownDescription(row.description, a.changes.description); | |
| 358 | 415 | const rootDir = a.changes.rootDir === undefined ? row.root_dir : a.changes.rootDir.trim().replace(/^\/+|\/+$/g, ""); | |
| 359 | 416 | 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 | + | } | |
| 360 | 424 | 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) | |
| 363 | 427 | .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)); | |
| 365 | 432 | } | |
| 366 | 433 | ||
| 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 | + | ||
| 367 | 535 | // ---- Dependencies ------------------------------------------------------ | |
| 368 | 536 | ||
| 369 | 537 | private async row(workspace: string, slug: string): Promise<Row | null> { | |
| 637 | 805 | .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?") | |
| 638 | 806 | .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId) | |
| 639 | 807 | .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"); | |
| 640 | 819 | return; | |
| 641 | 820 | } | |
| 642 | 821 | if (event.type === "workspace.deleting") { | |
| 677 | 856 | } | |
| 678 | 857 | if (event.type === "git.push" && event.data.defaultBranch) { | |
| 679 | 858 | 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 | + | } | |
| 681 | 863 | return; | |
| 682 | 864 | } | |
| 683 | 865 | if (event.type !== "repo.created") return; | |
| 703 | 885 | return Response.json(await service.create(args)); | |
| 704 | 886 | case "update": | |
| 705 | 887 | return Response.json(await service.update(args)); | |
| 888 | + | case "deployments_changed": | |
| 889 | + | await service.deploymentsChanged(args); | |
| 890 | + | return Response.json(null); | |
| 706 | 891 | case "dependencies": | |
| 707 | 892 | return Response.json(await service.dependencies(args)); | |
| 708 | 893 | case "add_dependency": | |
| 719 | 904 | } | |
| 720 | 905 | ||
| 721 | 906 | export default { | |
| 722 | − | async fetch(request: Request, env: Env): Promise<Response> { | |
| 907 | + | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 723 | 908 | const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/); | |
| 724 | 909 | if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 }); | |
| 725 | 910 | // A replica near the caller when it asks for one (@g1t/contracts d1.ts). | |
| 726 | 911 | 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)); | |
| 728 | 913 | const args = (await request.json().catch(() => ({}))) as any; | |
| 729 | 914 | return opened.finish(await answer(service, match[1], args)); | |
| 730 | 915 | }, |
| 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 | + | }); |
| 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 | + | } |
| 17 | 17 | ], | |
| 18 | 18 | "services": [ | |
| 19 | 19 | { "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" } | |
| 21 | 26 | ], | |
| 22 | 27 | // A repository created is given its own project. | |
| 23 | 28 | "queues": { |