Vectrum
Sign In

Move data to PocketBase and add Actions with self-hosted runners

- Users, sessions, keys, tokens, repositories and issues live in
  PocketBase (schema in pb_migrations/). Vectrum starts bin/pocketbase
  itself unless PB_URL points at a server of its own; the registry keeps
  its records in SQLite.
- Workflows in .vectrum/workflows/*.yml (a subset of GitHub Actions
  syntax) run on push or by hand, on runners registered for one
  repository or for all of a user's repositories, with live logs,
  cancel, re-run and CI status marks in the commit list.
- Docker image and compose file, SIGNUPS_DISABLED and
  scripts/create-user.mjs for closed instances, and /api/health.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent ddc1331commit8cc88bb

Showing 86 changed files with 4380 additions and 946 deletions.

+11−0.dockerignoreaddedView File
@@ -0,0 +1,11 @@
1+ .git
2+ .next
3+ node_modules
4+ bin
5+ data
6+ .env*
7+ *.tsbuildinfo
8+ next-env.d.ts
9+ Dockerfile
10+ docker-compose.yml
11+ .dockerignore
+3−0.gitignoreView File
@@ -42,3 +42,6 @@ next-env.d.ts
4242
4343 # git repositories and issue data
4444 /data/
45+
46+ # the PocketBase server binary (scripts/install-pocketbase.mjs)
47+ /bin/
+47−0DockerfileaddedView File
@@ -0,0 +1,47 @@
1+ # syntax=docker/dockerfile:1
2+
3+ # ---- build: install dependencies (which also downloads PocketBase into bin/) and build Next.js
4+ FROM node:22-bookworm-slim AS build
5+ WORKDIR /app
6+ COPY --from=oven/bun:1.4.2 /usr/local/bin/bun /usr/local/bin/bun
7+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates unzip && rm -rf /var/lib/apt/lists/*
8+
9+ COPY package.json bun.lock ./
10+ COPY scripts ./scripts
11+ RUN bun install --frozen-lockfile
12+
13+ COPY . .
14+ ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone
15+ RUN bun run build
16+
17+ # ---- runtime: the standalone server, PocketBase, git
18+ FROM node:22-bookworm-slim
19+ RUN apt-get update && apt-get install -y --no-install-recommends git tini ca-certificates && rm -rf /var/lib/apt/lists/*
20+ WORKDIR /app
21+
22+ COPY --from=build --chown=node:node /app/.next/standalone ./
23+ COPY --from=build --chown=node:node /app/.next/static ./.next/static
24+ COPY --from=build --chown=node:node /app/public ./public
25+ COPY --from=build --chown=node:node /app/pb_migrations ./pb_migrations
26+ COPY --from=build --chown=node:node /app/bin ./bin
27+ COPY --from=build --chown=node:node /app/scripts/create-user.mjs ./scripts/
28+
29+ RUN mkdir /data && chown node:node /data
30+ VOLUME /data
31+
32+ ENV NODE_ENV=production \
33+ NEXT_TELEMETRY_DISABLED=1 \
34+ HOSTNAME=0.0.0.0 \
35+ PORT=3000 \
36+ DATA_ROOT=/data \
37+ SSH_PORT=2222
38+
39+ USER node
40+ EXPOSE 3000 2222
41+
42+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --start-interval=2s \
43+ CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
44+
45+ # tini forwards stop signals to the whole process group, so PocketBase shuts down cleanly too.
46+ ENTRYPOINT ["/usr/bin/tini", "-g", "--"]
47+ CMD ["node", "server.js"]
+61−7README.mdView File
@@ -6,7 +6,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or
66
77 ## Features
88
9- - **Accounts**: sign up, sign in, profiles, password change (scrypt-hashed passwords, database-backed sessions)
9+ - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
1010 - **Repositories**: public or private, rename, delete, optional README on creation
1111 - **Collaborators**: give other users push access to a repository
1212 - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys*
@@ -23,19 +23,36 @@ git repositories on disk, and you push to them with plain git over **SSH** or
2323 - Quick-create dialog and keyboard shortcuts: `C` new issue, `J`/`K` move, `Enter` open, `S`/`P`/`A`/`L` set status, priority, assignee, labels
2424 - Inline editing of every property, with an activity feed of changes next to the markdown comments
2525 - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu
26+ - **Actions with self-hosted runners**: workflows in `.vectrum/workflows/*.yml` (a subset of GitHub Actions syntax) run on push or by hand, on runners you register for one repository or for all of yours. Live logs, cancel and re-run, and CI status marks in the commit list
2627 - Light and dark mode
2728
2829 ## Getting started
2930
30- Requires Node.js 22.5+ (for the built-in `node:sqlite`) and `git` on the `PATH`.
31+ Requires Node.js 22+ and `git` on the `PATH`.
3132
3233 ```bash
33- bun install
34+ bun install # also downloads the PocketBase server into bin/
3435 bun dev
3536 ```
3637
3738 Open http://localhost:3000, sign up, and create a repository.
3839
40+ The data lives in [PocketBase](https://pocketbase.io). Vectrum starts `bin/pocketbase` itself on `127.0.0.1:8090`, with its data in `data/pb_data` and the schema from `pb_migrations/`; its superuser login is generated into `data/pocketbase-superuser.json` (also good for the PocketBase dashboard at http://127.0.0.1:8090/_/). Run `bun run pocketbase:install` if the binary is missing. To use a PocketBase server of your own instead, set `PB_URL` and the superuser credentials; it needs the migrations in `pb_migrations/` (`pocketbase serve --migrationsDir pb_migrations`).
41+
42+ ### Running with Docker
43+
44+ ```bash
45+ docker compose up -d --build
46+ ```
47+
48+ The image runs the web UI on port 3000 and the SSH server on port 2222, with all data in the `vectrum-data` volume (mounted at `/data`). Settings go under `environment:` in `docker-compose.yml` (see [Configuration](#configuration)). To close registration, set `SIGNUPS_DISABLED: "1"` before you deploy, and create accounts from the command line instead:
49+
50+ ```bash
51+ docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> [name]
52+ ```
53+
54+ It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet.
55+
3956 ### Pushing over SSH
4057
4158 1. Add your public key (`~/.ssh/id_ed25519.pub`) under **Settings → SSH keys**.
@@ -76,28 +93,64 @@ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add
7693 the host to Docker's `insecure-registries`). A reverse proxy in front of
7794 Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`).
7895
96+ ### Running workflows
97+
98+ 1. Register a runner under **Settings → Runners** (for all your repositories) or under a repository's **Settings → Runners**, and copy its token.
99+ 2. Start it on any machine with Node.js 18+ and git:
100+ ```bash
101+ curl -fsSLO http://localhost:3000/vectrum-runner.mjs
102+ node vectrum-runner.mjs --url http://localhost:3000 --token vrn_…
103+ ```
104+ 3. Add `.vectrum/workflows/ci.yml` and push:
105+ ```yaml
106+ name: CI
107+ on:
108+ push:
109+ branches: [main]
110+ workflow_dispatch: # adds a "Run workflow" button
111+ jobs:
112+ test:
113+ runs-on: self-hosted # labels the runner must have
114+ steps:
115+ - run: npm ci
116+ - name: Test
117+ run: npm test
118+ ```
119+
120+ Supported: `on: push` (with `branches`, `tags`, `paths` and their `-ignore` forms) and `workflow_dispatch`; `env` at every level; jobs with `runs-on`, `needs`, `timeout-minutes`; steps with `run`, `name`, `shell`, `working-directory`, `env`, `if: success()/failure()/always()`, `continue-on-error` and `timeout-minutes`. The repository is checked out before the first step (`uses: actions/checkout` is accepted and skipped). Other `uses:` actions, `${{ }}` expressions, matrices, services and secrets are not supported; steps get `VECTRUM_*` environment variables (`VECTRUM_SHA`, `VECTRUM_REF_NAME`, `VECTRUM_REPOSITORY`, …) instead.
121+
122+ Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound port. Every runner has the labels `self-hosted`, its OS (`linux`, `macos`, `windows`) and architecture (`x64`, `arm64`), plus any you give it. Each job gets a short-lived, read-only token that can only clone its own repository. Steps run as the user that started the runner, with no sandbox, so only run runners for repositories whose workflows you trust.
123+
79124 ## Configuration
80125
81126 | Variable | Default | Purpose |
82127 | ----------------- | ----------- | --------------------------------------------------------- |
83128 | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live |
129+ | `PB_PORT` | `8090` | Port of the PocketBase server Vectrum runs |
130+ | `PB_URL` | — | Use this PocketBase server instead of running one |
131+ | `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | generated | Superuser login; required with `PB_URL` |
132+ | `PB_BINARY` | `bin/pocketbase` | PocketBase executable to run |
84133 | `SSH_PORT` | `2222` | Port of the built-in SSH server |
85134 | `SSH_LISTEN_HOST` | `0.0.0.0` | Interface the SSH server binds to |
86135 | `SSH_HOST` | request host | Host name shown in SSH clone URLs, e.g. `vectrum.cc` |
87136 | `SSH_DISABLED` | — | Set to `1` to not start the SSH server |
137+ | `SIGNUPS_DISABLED` | — | Set to `1` to close registration (add users with `scripts/create-user.mjs`) |
88138
89139 Data layout:
90140
91141 ```
92142 data/
93- app.db users, sessions, keys, tokens, repos, issues (SQLite)
143+ pb_data/ PocketBase: users, keys, tokens, repos, issues, workflow runs and logs
144+ pocketbase-superuser.json the generated PocketBase superuser login
94145 ssh_host_ed25519_key generated on first start
95146 repos/<owner>/<repo>.git bare git repositories
147+ registry.db container image manifests, tags and blob records (SQLite)
96148 registry/blobs/sha256/… container image layers, shared by all repositories
97149 registry/uploads/ image uploads in progress
98150 ```
99151
100- Image manifests and tags are stored in the database. A blob is deleted once no
152+ The registry keeps its records in SQLite rather than PocketBase so that storing a
153+ blob and pruning unused ones never interleave. A blob is deleted once no
101154 manifest of any repository references it (blobs pushed in the last 24 hours are
102155 kept so pushes in progress don't lose their layers).
103156
@@ -109,12 +162,13 @@ kept so pushes in progress don't lose their layers).
109162
110163 ## Layout
111164
112- - `lib/db.ts`: SQLite schema and connection
113- - `lib/users.ts`, `lib/auth.ts`: accounts, password hashing, sessions
165+ - `lib/db.ts`: starting and connecting to PocketBase; `pb_migrations/`: the schema
166+ - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens)
114167 - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
115168 - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
116169 - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
117170 - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories
118171 - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`)
119172 - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2`
173+ - `lib/workflows.ts`, `lib/ci.ts`, `lib/runs.ts`, `lib/runners.ts`: workflow parsing, starting runs on push, the job queue and runner registration; `app/api/runner/*` is the runner API and `public/vectrum-runner.mjs` the runner
120174 - `app/actions/*`: server actions, each re-checking the session and permissions
+171−0app/[owner]/[repo]/actions/page.tsxaddedView File
@@ -0,0 +1,171 @@
1+ import Link from "next/link";
2+ import type { Metadata } from "next";
3+ import { dispatchWorkflowAction } from "@/app/actions/ci";
4+ import { DispatchButton } from "@/components/ci/dispatch-form";
5+ import { LiveRefresh } from "@/components/ci/live-refresh";
6+ import { RunStatusIcon } from "@/components/ci/run-status";
7+ import { refName, runDuration, triggerText } from "@/components/ci/run-text";
8+ import { WorkflowNav } from "@/components/ci/workflow-nav";
9+ import { ChevronLeftIcon, ChevronRightIcon, PlayIcon } from "@/components/icons";
10+ import { liftRowClass } from "@/components/repo-rows";
11+ import { Alert, Blankslate, Box, ButtonLink, PageTitle, RefChip } from "@/components/ui";
12+ import { readWorkflows } from "@/lib/ci";
13+ import { timeAgo } from "@/lib/format";
14+ import { listBranches, revParse } from "@/lib/git";
15+ import { getRepo } from "@/lib/repo";
16+ import { runnerSummary } from "@/lib/runners";
17+ import { countRuns, isFinished, listRunWorkflows, listRuns, queuedJobCount, sweepStaleJobs } from "@/lib/runs";
18+ import { WORKFLOW_DIR } from "@/lib/workflows";
19+
20+ export const metadata: Metadata = { title: "Actions" };
21+
22+ const PAGE_SIZE = 25;
23+
24+ const EXAMPLE = `name: CI
25+ on:
26+ push:
27+ branches: [main]
28+ workflow_dispatch:
29+ jobs:
30+ test:
31+ runs-on: self-hosted
32+ steps:
33+ - run: echo "Hello from $VECTRUM_REPOSITORY at $VECTRUM_SHA"
34+ - name: Test
35+ run: npm ci && npm test`;
36+
37+ export default async function ActionsPage({ params, searchParams }: PageProps<"/[owner]/[repo]/actions">) {
38+ const { owner, repo: repoParam } = await params;
39+ const query = await searchParams;
40+ const repo = await getRepo(owner, repoParam);
41+ const workflowPath = typeof query.workflow === "string" ? query.workflow : null;
42+ const page = Math.max(1, Number(query.page) || 1);
43+
44+ await sweepStaleJobs();
45+ const headSha = repo.defaultBranch ? await revParse(repo.dir, `refs/heads/${repo.defaultBranch}`) : null;
46+ const [files, branches, pastWorkflows] = await Promise.all([
47+ headSha ? readWorkflows(repo.dir, headSha) : [],
48+ listBranches(repo.dir),
49+ listRunWorkflows(repo.id),
50+ ]);
51+
52+ // The workflows on the default branch, then ones that only exist in past runs (deleted or on other branches).
53+ const workflows = files.map((f) => ({ path: f.path, name: "workflow" in f ? f.workflow.name : f.name }));
54+ for (const w of pastWorkflows) if (!workflows.some((x) => x.path === w.path)) workflows.push(w);
55+ const current = workflows.find((w) => w.path === workflowPath) ?? null;
56+ const currentFile = files.find((f) => f.path === current?.path);
57+
58+ const [total, runs, runners, waiting] = await Promise.all([
59+ countRuns(repo.id, current?.path),
60+ listRuns(repo.id, { workflowPath: current?.path, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
61+ runnerSummary(repo.id, repo.ownerId),
62+ queuedJobCount(repo.id),
63+ ]);
64+ const pageHref = (p: number) => `${repo.base}/actions?${new URLSearchParams({ ...(current ? { workflow: current.path } : {}), page: String(p) })}`;
65+
66+ return (
67+ <div className="grid gap-8 md:grid-cols-[220px_1fr]">
68+ <LiveRefresh active={runs.some((r) => !isFinished(r.status))} interval={4000} />
69+ <WorkflowNav base={repo.base} workflows={workflows} current={current?.path ?? null} />
70+
71+ <div className="min-w-0">
72+ <PageTitle
73+ title={current?.name ?? "All workflows"}
74+ description={current ? <RefChip>{current.path}</RefChip> : "Runs of this repository’s workflows, newest first."}
75+ actions={
76+ current &&
77+ currentFile &&
78+ "workflow" in currentFile &&
79+ currentFile.workflow.dispatch &&
80+ repo.canWrite &&
81+ repo.defaultBranch && (
82+ <DispatchButton
83+ action={dispatchWorkflowAction.bind(null, repo.owner, repo.name, current.path)}
84+ branches={branches.map((b) => b.name)}
85+ defaultBranch={repo.defaultBranch}
86+ />
87+ )
88+ }
89+ />
90+
91+ {waiting > 0 && runners.online === 0 && (
92+ <div className="mb-5">
93+ <Alert kind="info">
94+ {waiting === 1 ? "A job is" : `${waiting} jobs are`} waiting for a runner, but none{" "}
95+ {runners.total === 0 ? "is registered" : "is online"}.{" "}
96+ {repo.isAdmin ? (
97+ <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2">
98+ {runners.total === 0 ? "Add a runner" : "See runners"}
99+ </Link>
100+ ) : (
101+ "Ask the owner to add one."
102+ )}
103+ </Alert>
104+ </div>
105+ )}
106+
107+ <Box className="p-1.5">
108+ {runs.length === 0 ? (
109+ <Blankslate icon={<PlayIcon />} title={current ? "This workflow hasn’t run yet" : "No workflow runs yet"}>
110+ {!current && (
111+ <span className="flex flex-col items-center gap-3">
112+ <span>
113+ Add a workflow file under <RefChip>{WORKFLOW_DIR}/</RefChip> and push. Jobs run on your own machines: register one
114+ under {repo.isAdmin ? <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2">Settings → Runners</Link> : "Settings → Runners"}.
115+ </span>
116+ <pre className="well w-full max-w-md overflow-x-auto rounded-row px-3.5 py-3 text-left font-mono text-[12px] leading-5 text-ink">
117+ {EXAMPLE}
118+ </pre>
119+ </span>
120+ )}
121+ </Blankslate>
122+ ) : (
123+ <ul>
124+ {runs.map((run) => {
125+ const href = `${repo.base}/actions/runs/${run.number}`;
126+ const duration = runDuration(run);
127+ return (
128+ <li key={run.id} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}>
129+ <RunStatusIcon status={run.status} size={18} />
130+ <div className="min-w-0 flex-1">
131+ <Link href={href} className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline">
132+ {run.title}
133+ </Link>
134+ <div className="mt-0.5 truncate type-meta text-ink3">
135+ <span className="font-medium text-ink2">{run.workflowName}</span> #{run.number}: {triggerText(run)}
136+ {run.error && <span className="text-red"> · Invalid workflow file</span>}
137+ </div>
138+ </div>
139+ <RefChip className="hidden max-w-40 truncate sm:inline-flex">{refName(run.ref)}</RefChip>
140+ <div className="hidden w-28 shrink-0 flex-col items-end gap-0.5 text-right md:flex">
141+ <time dateTime={new Date(run.createdAt).toISOString()} className="type-meta text-ink2">
142+ {timeAgo(run.createdAt)}
143+ </time>
144+ {duration && <span className="type-readout text-ink3">{duration}</span>}
145+ </div>
146+ </li>
147+ );
148+ })}
149+ </ul>
150+ )}
151+ </Box>
152+
153+ {(page > 1 || page * PAGE_SIZE < total) && (
154+ <div className="mt-8 flex justify-center gap-2">
155+ {page > 1 && (
156+ <ButtonLink href={pageHref(page - 1)} icon={<ChevronLeftIcon />}>
157+ Newer
158+ </ButtonLink>
159+ )}
160+ {page * PAGE_SIZE < total && (
161+ <ButtonLink href={pageHref(page + 1)}>
162+ Older
163+ <ChevronRightIcon />
164+ </ButtonLink>
165+ )}
166+ </div>
167+ )}
168+ </div>
169+ </div>
170+ );
171+ }
+29−0app/[owner]/[repo]/actions/runs/[number]/logs/[job]/route.tsaddedView File
@@ -0,0 +1,29 @@
1+ import { getViewer } from "@/lib/auth";
2+ import { normalizeRepoName } from "@/lib/git";
3+ import { findRepo, repoAccess } from "@/lib/repos";
4+ import { getRun, listJobs, listSteps, readJobLogs } from "@/lib/runs";
5+
6+ /** A job's whole log as plain text, every step under a heading. */
7+ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/actions/runs/[number]/logs/[job]">) {
8+ const { owner, repo: repoParam, number, job: jobKey } = await ctx.params;
9+ const repo = await findRepo(owner, normalizeRepoName(repoParam));
10+ const viewer = await getViewer();
11+ const notFound = new Response("Not found", { status: 404 });
12+ if (!repo || !(await repoAccess(repo, viewer?.id))) return notFound;
13+ const run = await getRun(repo.id, Number(number));
14+ const job = run && (await listJobs(run.id)).find((j) => j.key === decodeURIComponent(jobKey));
15+ if (!run || !job) return notFound;
16+
17+ const [logs, steps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]);
18+ const text = steps
19+ .map((s) => `##[${s.status}] ${s.name}\n${logs.get(s.index) ?? ""}`.replace(/\n?$/, "\n"))
20+ .join("\n");
21+ return new Response(text, {
22+ headers: {
23+ "Content-Type": "text/plain; charset=utf-8",
24+ "Content-Disposition": `inline; filename="${repo.name}-run${run.number}-${job.key}.log"`,
25+ "Cache-Control": "private, no-store",
26+ "X-Content-Type-Options": "nosniff",
27+ },
28+ });
29+ }
+202−0app/[owner]/[repo]/actions/runs/[number]/page.tsxaddedView File
@@ -0,0 +1,202 @@
1+ import Link from "next/link";
2+ import { notFound } from "next/navigation";
3+ import { cancelRunAction, rerunAction } from "@/app/actions/ci";
4+ import { ConfirmButton } from "@/components/confirm-button";
5+ import { JobSteps, type StepView } from "@/components/ci/job-steps";
6+ import { LiveRefresh } from "@/components/ci/live-refresh";
7+ import { RunStatusIcon, statusLed, statusText } from "@/components/ci/run-status";
8+ import { refName, runDuration, triggerText } from "@/components/ci/run-text";
9+ import { ShaChip } from "@/components/file-table";
10+ import { ChevronLeftIcon, DownloadIcon, PlayIcon, ServerIcon } from "@/components/icons";
11+ import { liftRowClass, plateHeadingClass } from "@/components/repo-rows";
12+ import { Alert, Box, Button, ButtonLink, RefChip } from "@/components/ui";
13+ import { StatusBadge } from "@unlocalhosted/metalui";
14+ import { formatDate, timeAgo } from "@/lib/format";
15+ import { logTail } from "@/lib/logs";
16+ import { getRepo } from "@/lib/repo";
17+ import { runnerSummary } from "@/lib/runners";
18+ import { getRun, isFinished, listJobs, listSteps, readJobLogs, sweepStaleJobs, type Job } from "@/lib/runs";
19+
20+ export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/actions/runs/[number]">) {
21+ const { owner, repo: repoParam, number } = await params;
22+ const repo = await getRepo(owner, repoParam);
23+ const run = await getRun(repo.id, Number(number));
24+ return { title: run ? `${run.title} · ${run.workflowName} #${run.number}` : "Run" };
25+ }
26+
27+ /** The job to show first: one that is running, else one that failed, else the first. */
28+ function defaultJob(jobs: Job[]) {
29+ return jobs.find((j) => j.status === "in_progress") ?? jobs.find((j) => j.status === "failure") ?? jobs[0];
30+ }
31+
32+ export default async function RunPage({ params, searchParams }: PageProps<"/[owner]/[repo]/actions/runs/[number]">) {
33+ const { owner, repo: repoParam, number } = await params;
34+ const { job: jobParam } = await searchParams;
35+ const repo = await getRepo(owner, repoParam);
36+ await sweepStaleJobs();
37+ const run = await getRun(repo.id, Number(number));
38+ if (!run) notFound();
39+
40+ const jobs = await listJobs(run.id);
41+ const job = jobs.find((j) => j.key === jobParam) ?? defaultJob(jobs);
42+ const finished = isFinished(run.status);
43+ const runHref = `${repo.base}/actions/runs/${run.number}`;
44+
45+ let steps: StepView[] = [];
46+ if (job) {
47+ const [logs, jobSteps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]);
48+ steps = jobSteps.map((s) => ({
49+ index: s.index,
50+ name: s.name,
51+ status: s.status,
52+ exitCode: s.exitCode,
53+ duration: runDuration(s),
54+ ...logTail(logs.get(s.index) ?? ""),
55+ }));
56+ }
57+ const runners = job?.status === "queued" ? await runnerSummary(repo.id, repo.ownerId) : null;
58+
59+ return (
60+ <div>
61+ <LiveRefresh active={!finished} />
62+ <Link href={`${repo.base}/actions`} className="mb-3 inline-flex items-center gap-1 type-ui text-ink2 transition-colors hover:text-ink">
63+ <ChevronLeftIcon width={14} height={14} /> All runs
64+ </Link>
65+
66+ <div className="mb-6 flex flex-wrap items-start gap-4">
67+ <RunStatusIcon status={run.status} size={26} className="mt-0.5" />
68+ <div className="min-w-0 flex-1">
69+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">
70+ {run.title} <span className="font-normal text-ink3">#{run.number}</span>
71+ </h1>
72+ <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 type-meta text-ink3">
73+ <StatusBadge led={statusLed(run.status)}>{statusText(run.status)}</StatusBadge>
74+ <Link href={`${repo.base}/actions?workflow=${encodeURIComponent(run.workflowPath)}`} className="font-medium text-ink2 hover:underline">
75+ {run.workflowName}
76+ </Link>
77+ <span>·</span>
78+ <span>{triggerText(run)}</span>
79+ <RefChip>{refName(run.ref)}</RefChip>
80+ <ShaChip sha={run.sha} href={`${repo.base}/commit/${run.sha}`} />
81+ <span>·</span>
82+ <time dateTime={new Date(run.createdAt).toISOString()} title={formatDate(run.createdAt)}>
83+ {timeAgo(run.createdAt)}
84+ </time>
85+ {runDuration(run) && (
86+ <>
87+ <span>·</span>
88+ <span className="type-readout">{runDuration(run)}</span>
89+ </>
90+ )}
91+ </div>
92+ </div>
93+ {repo.canWrite && (
94+ <div className="flex shrink-0 gap-2">
95+ {!finished ? (
96+ <ConfirmButton
97+ action={cancelRunAction.bind(null, repo.owner, repo.name, run.number)}
98+ message={`Cancel run #${run.number}? Running steps are stopped and the remaining jobs won’t start.`}
99+ variant="default"
100+ >
101+ Cancel Run
102+ </ConfirmButton>
103+ ) : (
104+ <form action={rerunAction.bind(null, repo.owner, repo.name, run.number)}>
105+ <Button icon={<PlayIcon />}>Re-run</Button>
106+ </form>
107+ )}
108+ </div>
109+ )}
110+ </div>
111+
112+ {run.error && (
113+ <div className="mb-6">
114+ <Alert>
115+ <p className="font-medium">Invalid workflow file {run.workflowPath}</p>
116+ <p className="mt-1 font-mono text-[12.5px] whitespace-pre-wrap">{run.error}</p>
117+ </Alert>
118+ </div>
119+ )}
120+
121+ {job && (
122+ <div className="grid gap-6 md:grid-cols-[240px_1fr]">
123+ <Box className="self-start p-1.5">
124+ <h2 className={plateHeadingClass}>Jobs</h2>
125+ <ul>
126+ {jobs.map((j) => (
127+ <li key={j.id} className={`${liftRowClass} ${j.id === job.id ? "recipe-row-list-hover" : ""}`}>
128+ <Link
129+ href={`${runHref}?job=${encodeURIComponent(j.key)}`}
130+ aria-current={j.id === job.id ? "page" : undefined}
131+ className="flex h-10 items-center gap-2.5 rounded-row px-3 outline-none focus-visible:focus-ring"
132+ >
133+ <RunStatusIcon status={j.status} size={15} />
134+ <span className="min-w-0 flex-1 truncate type-ui text-ink">{j.name}</span>
135+ {runDuration(j) && <span className="shrink-0 type-readout text-ink3">{runDuration(j)}</span>}
136+ </Link>
137+ </li>
138+ ))}
139+ </ul>
140+ </Box>
141+
142+ <Box className="min-w-0 p-1.5">
143+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-2 pb-3">
144+ <h2 className="type-title text-ink">{job.name}</h2>
145+ <span className="type-meta text-ink3">
146+ {job.status === "waiting" && `Waiting for ${job.needs.join(", ")}`}
147+ {job.status === "skipped" && "Skipped because a job it needs didn’t succeed"}
148+ {job.runnerName && (
149+ <span className="inline-flex items-center gap-1">
150+ <ServerIcon width={12} height={12} /> {job.runnerName}
151+ </span>
152+ )}
153+ </span>
154+ <span className="ml-auto flex items-center gap-1.5">
155+ {job.runsOn.map((l) => (
156+ <RefChip key={l}>{l}</RefChip>
157+ ))}
158+ {(job.startedAt || steps.some((s) => s.lines.length)) && (
159+ <ButtonLink
160+ href={`${runHref}/logs/${encodeURIComponent(job.key)}`}
161+ variant="invisible"
162+ icon={<DownloadIcon width={14} height={14} />}
163+ prefetch={false}
164+ >
165+ Raw log
166+ </ButtonLink>
167+ )}
168+ </span>
169+ </div>
170+ {job.status === "queued" && runners && (
171+ <div className="px-3 pb-3">
172+ <Alert kind="info">
173+ Waiting for a runner with the label{job.runsOn.length === 1 ? "" : "s"} {job.runsOn.join(", ")} to pick up this job.
174+ {runners.online === 0 && (
175+ <>
176+ {" "}
177+ {runners.total === 0 ? "No runners are registered for this repository" : "None of this repository’s runners are online"}
178+ {repo.isAdmin && (
179+ <>
180+ {" "}
181+ ·{" "}
182+ <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2">
183+ Runners
184+ </Link>
185+ </>
186+ )}
187+ .
188+ </>
189+ )}
190+ </Alert>
191+ </div>
192+ )}
193+ {job.cancelRequested && job.status === "in_progress" && (
194+ <p className="px-3 pb-3 type-meta text-ink3">Cancelling: waiting for the runner to stop…</p>
195+ )}
196+ <JobSteps steps={steps} rawHref={`${runHref}/logs/${encodeURIComponent(job.key)}`} />
197+ </Box>
198+ </div>
199+ )}
200+ </div>
201+ );
202+ }
+8−0app/[owner]/[repo]/commits/[...segments]/page.tsxView File
@@ -1,6 +1,7 @@
11 import Link from "next/link";
22 import { Tooltip } from "@unlocalhosted/metalui";
33 import { BranchSelect } from "@/components/branch-select";
4+ import { RunStatusIcon } from "@/components/ci/run-status";
45 import { CopyButton } from "@/components/clone-button";
56 import { ShaChip } from "@/components/file-table";
67 import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon } from "@/components/icons";
@@ -9,6 +10,7 @@ import { Avatar, Blankslate, Box, ButtonLink, RefChip } from "@/components/ui";
910 import { listBranches, log, type Commit } from "@/lib/git";
1011 import { encodePath, formatDate, timeAgo } from "@/lib/format";
1112 import { getRefAndPath, getRepo } from "@/lib/repo";
13+ import { commitStatuses } from "@/lib/runs";
1214
1315 const PAGE_SIZE = 35;
1416
@@ -32,6 +34,7 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/
3234 listBranches(repo.dir),
3335 ]);
3436 const hasNext = commits.length > PAGE_SIZE;
37+ const statuses = await commitStatuses(repo.id, commits.map((c) => c.sha));
3538 const selfHref = `${repo.base}/commits/${encodePath(ref)}${path ? `/${encodePath(path)}` : ""}`;
3639
3740 return (
@@ -74,6 +77,11 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/
7477 <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3">
7578 <span className="font-medium text-ink2">{c.authorName}</span> committed{" "}
7679 <time dateTime={new Date(c.date).toISOString()}>{timeAgo(c.date)}</time>
80+ {statuses.has(c.sha) && (
81+ <Link href={`${repo.base}/actions/runs/${statuses.get(c.sha)!.runNumber}`} className="ml-1 transition-opacity hover:opacity-80">
82+ <RunStatusIcon status={statuses.get(c.sha)!.status} size={13} />
83+ </Link>
84+ )}
7785 </div>
7886 </div>
7987 <div className="flex shrink-0 items-center gap-1">
+3−3app/[owner]/[repo]/image/route.tsView File
@@ -5,11 +5,11 @@ import { findRepo, getRepoImage, repoAccess } from "@/lib/repos";
55 /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */
66 export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) {
77 const { owner, repo: repoParam } = await ctx.params;
8- const repo = findRepo(owner, normalizeRepoName(repoParam));
8+ const repo = await findRepo(owner, normalizeRepoName(repoParam));
99 const viewer = await getViewer();
10- if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 });
10+ if (!repo || !(await repoAccess(repo, viewer?.id))) return new Response("Not found", { status: 404 });
1111
12- const image = getRepoImage(repo.id);
12+ const image = await getRepoImage(repo.id);
1313 if (!image) return new Response("Not found", { status: 404 });
1414 return new Response(new Uint8Array(image.data), {
1515 headers: {
+5−5app/[owner]/[repo]/issues/[number]/page.tsxView File
@@ -19,7 +19,7 @@ import { trackerProps } from "@/lib/tracker";
1919 export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) {
2020 const { owner, repo: repoParam, number } = await params;
2121 const repo = await getRepo(owner, repoParam);
22- const issue = getIssue(repo.id, Number(number));
22+ const issue = await getIssue(repo.id, Number(number));
2323 return { title: issue ? `${issue.title} · Issue #${issue.number}` : "Issue" };
2424 }
2525
@@ -120,20 +120,20 @@ function describe(e: IssueEvent): { icon: ReactNode; text: ReactNode } {
120120 export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) {
121121 const { owner, repo: repoParam, number } = await params;
122122 const repo = await getRepo(owner, repoParam);
123- const issue = getIssue(repo.id, Number(number));
123+ const issue = await getIssue(repo.id, Number(number));
124124 if (!issue) notFound();
125125 const viewer = repo.viewer;
126126 const action = commentAction.bind(null, repo.owner, repo.name, issue.number);
127127
128128 // Comments and property changes, oldest first.
129129 const activity = [
130- ...listComments(issue.id).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })),
131- ...listEvents(issue.id).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })),
130+ ...(await listComments(issue.id)).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })),
131+ ...(await listEvents(issue.id)).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })),
132132 ].sort((a, b) => a.at - b.at);
133133
134134 return (
135135 <div className="mx-auto max-w-6xl">
136- <TrackerProvider {...trackerProps(repo)}>
136+ <TrackerProvider {...await trackerProps(repo)}>
137137 <IssueDetail
138138 issue={issue}
139139 description={<Markdown>{issue.body}</Markdown>}
+1−1app/[owner]/[repo]/issues/new/page.tsxView File
@@ -15,7 +15,7 @@ export default async function NewIssuePage({ params }: PageProps<"/[owner]/[repo
1515 return (
1616 <div className="max-w-3xl">
1717 <PageTitle title="Create new issue" />
18- <TrackerProvider {...trackerProps(repo)}>
18+ <TrackerProvider {...await trackerProps(repo)}>
1919 <NewIssueForm />
2020 </TrackerProvider>
2121 </div>
+2−2app/[owner]/[repo]/issues/page.tsxView File
@@ -13,8 +13,8 @@ export default async function IssuesPage({ params, searchParams }: PageProps<"/[
1313 const repo = await getRepo(owner, repoParam);
1414
1515 return (
16- <TrackerProvider {...trackerProps(repo)}>
17- <IssueTracker issues={listIssues(repo.id)} initialView={parseView(await searchParams)} />
16+ <TrackerProvider {...await trackerProps(repo)}>
17+ <IssueTracker issues={await listIssues(repo.id)} initialView={parseView(await searchParams)} />
1818 </TrackerProvider>
1919 );
2020 }
+1−1app/[owner]/[repo]/layout.tsxView File
@@ -38,7 +38,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
3838 <VisibilityLabel visibility={repo.visibility} />
3939 {repo.access === "write" && <Label tone="accent">Collaborator</Label>}
4040 </div>
41- <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} />
41+ <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} />
4242 </div>
4343 </div>
4444 <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div>
+2−2app/[owner]/[repo]/page.tsxView File
@@ -145,7 +145,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
145145 ref ? listFilesWithSizes(repo.dir, ref) : [],
146146 ref ? listContributors(repo.dir, ref) : [],
147147 ]);
148- const collaborators = listCollaborators(repo.id);
148+ const collaborators = await listCollaborators(repo.id);
149149 const languages = languageBreakdown(files);
150150 const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`;
151151
@@ -171,7 +171,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">)
171171 <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} />
172172 <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} />
173173 <Stat href={`${repo.base}/branches`} icon={TagIcon} label="Tags" value={tags.length} />
174- <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={openIssueCount(repo.id)} />
174+ <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={await openIssueCount(repo.id)} />
175175 <Stat
176176 href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`}
177177 icon={PeopleIcon}
+2−2app/[owner]/[repo]/raw/[...segments]/route.tsView File
@@ -5,9 +5,9 @@ import { findRepo, repoAccess } from "@/lib/repos";
55
66 export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/raw/[...segments]">) {
77 const { owner, repo: repoParam, segments } = await ctx.params;
8- const repo = findRepo(owner, normalizeRepoName(repoParam));
8+ const repo = await findRepo(owner, normalizeRepoName(repoParam));
99 const viewer = await getViewer();
10- if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 });
10+ if (!repo || !(await repoAccess(repo, viewer?.id))) return new Response("Not found", { status: 404 });
1111
1212 const resolved = await resolveRefAndPath(repo.dir, segments);
1313 if (!resolved || (await objectType(repo.dir, resolved.ref, resolved.path)) !== "blob") {
+1−1app/[owner]/[repo]/settings/access/page.tsxView File
@@ -17,7 +17,7 @@ export const metadata: Metadata = { title: "Collaborators" };
1717 export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) {
1818 const { owner, repo: repoParam } = await params;
1919 const repo = await getRepo(owner, repoParam);
20- const collaborators = listCollaborators(repo.id);
20+ const collaborators = await listCollaborators(repo.id);
2121
2222 return (
2323 <div>
+1−2app/[owner]/[repo]/settings/labels/page.tsxView File
@@ -16,8 +16,7 @@ export const metadata: Metadata = { title: "Labels" };
1616 export default async function LabelsPage({ params }: PageProps<"/[owner]/[repo]/settings/labels">) {
1717 const { owner, repo: repoParam } = await params;
1818 const repo = await getRepo(owner, repoParam);
19- const labels = listLabels(repo.id);
20- const usage = labelUsage(repo.id);
19+ const [labels, usage] = await Promise.all([listLabels(repo.id), labelUsage(repo.id)]);
2120
2221 return (
2322 <div>
+2−1app/[owner]/[repo]/settings/layout.tsxView File
@@ -1,6 +1,6 @@
11 import { forbidden } from "next/navigation";
22 import { SettingsNav } from "@/components/settings-nav";
3- import { GearIcon, PeopleIcon, TagIcon } from "@/components/icons";
3+ import { GearIcon, PeopleIcon, ServerIcon, TagIcon } from "@/components/icons";
44 import { getRepo } from "@/lib/repo";
55
66 export default async function RepoSettingsLayout({ params, children }: LayoutProps<"/[owner]/[repo]/settings">) {
@@ -15,6 +15,7 @@ export default async function RepoSettingsLayout({ params, children }: LayoutPro
1515 { href: `${repo.base}/settings`, label: "General", icon: <GearIcon /> },
1616 { href: `${repo.base}/settings/access`, label: "Collaborators", icon: <PeopleIcon /> },
1717 { href: `${repo.base}/settings/labels`, label: "Labels", icon: <TagIcon /> },
18+ { href: `${repo.base}/settings/runners`, label: "Runners", icon: <ServerIcon /> },
1819 ]}
1920 />
2021 <div className="min-w-0">{children}</div>
+45−0app/[owner]/[repo]/settings/runners/page.tsxaddedView File
@@ -0,0 +1,45 @@
1+ import type { Metadata } from "next";
2+ import Link from "next/link";
3+ import { createRunnerAction, deleteRunnerAction } from "@/app/actions/ci";
4+ import { NewRunnerForm } from "@/components/ci/new-runner-form";
5+ import { RunnerList } from "@/components/ci/runner-list";
6+ import { PlateSection } from "@/components/settings-plate";
7+ import { PageTitle } from "@/components/ui";
8+ import { getRepo } from "@/lib/repo";
9+ import { serverOrigin } from "@/lib/request-origin";
10+ import { listRunners } from "@/lib/runners";
11+
12+ export const metadata: Metadata = { title: "Runners" };
13+
14+ export default async function RepoRunnersPage({ params }: PageProps<"/[owner]/[repo]/settings/runners">) {
15+ const { owner, repo: repoParam } = await params;
16+ const repo = await getRepo(owner, repoParam);
17+ const target = { owner: repo.owner, name: repo.name };
18+
19+ return (
20+ <div>
21+ <PageTitle
22+ title="Runners"
23+ description={
24+ <>
25+ Machines that run this repository’s workflow jobs. Runners under{" "}
26+ <Link href="/settings/runners" className="underline underline-offset-2">
27+ your account settings
28+ </Link>{" "}
29+ run jobs for all your repositories.
30+ </>
31+ }
32+ />
33+ <div className="flex flex-col gap-7">
34+ <RunnerList runners={await listRunners({ repoId: repo.id })} deleteAction={deleteRunnerAction.bind(null, target)} />
35+ <PlateSection title="Register a runner">
36+ <NewRunnerForm
37+ action={createRunnerAction.bind(null, target)}
38+ origin={await serverOrigin()}
39+ scopeText={`This runner will only pick up jobs from ${repo.owner}/${repo.name}.`}
40+ />
41+ </PlateSection>
42+ </div>
43+ </div>
44+ );
45+ }
+3−3app/[owner]/page.tsxView File
@@ -11,18 +11,18 @@ import { listReposByOwner } from "@/lib/repos";
1111 import { getUserByUsername } from "@/lib/users";
1212
1313 export async function generateMetadata({ params }: PageProps<"/[owner]">) {
14- const user = getUserByUsername((await params).owner);
14+ const user = await getUserByUsername((await params).owner);
1515 return { title: user ? (user.name ? `${user.username} (${user.name})` : user.username) : "Not found" };
1616 }
1717
1818 const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink";
1919
2020 export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) {
21- const user = getUserByUsername((await params).owner);
21+ const user = await getUserByUsername((await params).owner);
2222 if (!user) notFound();
2323 const q = String((await searchParams).q ?? "").trim().toLowerCase();
2424 const viewer = await getViewer();
25- const all = await withActivity(listReposByOwner(user.id, viewer?.id ?? null));
25+ const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null));
2626 const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all;
2727 const isSelf = viewer?.id === user.id;
2828 const publicCount = all.filter((r) => r.visibility === "public").length;
+8−6app/actions/auth.tsView File
@@ -1,19 +1,22 @@
11 "use server";
22
33 import { redirect } from "next/navigation";
4- import { endSession, safeNext, startSession } from "@/lib/auth";
5- import { authenticateUser, createUser, UserError } from "@/lib/users";
4+ import { endSession, safeNext, signIn } from "@/lib/auth";
5+ import { SIGNUPS_ENABLED } from "@/lib/signups";
6+ import { createUser, UserError } from "@/lib/users";
67 import type { FormState } from "./types";
78
89 export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> {
10+ if (!SIGNUPS_ENABLED) return { error: "Sign-ups are closed." };
11+ const password = String(formData.get("password") ?? "");
912 try {
1013 const user = await createUser({
1114 username: String(formData.get("username") ?? ""),
1215 email: String(formData.get("email") ?? ""),
13- password: String(formData.get("password") ?? ""),
16+ password,
1417 name: String(formData.get("name") ?? ""),
1518 });
16- await startSession(user.id);
19+ await signIn(user.email, password);
1720 } catch (e) {
1821 if (e instanceof UserError) return { error: e.message };
1922 throw e;
@@ -22,9 +25,8 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis
2225 }
2326
2427 export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
25- const user = await authenticateUser(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
28+ const user = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
2629 if (!user) return { error: "Incorrect username or password." };
27- await startSession(user.id);
2830 redirect(safeNext(formData.get("next")));
2931 }
3032
+69−0app/actions/ci.tsaddedView File
@@ -0,0 +1,69 @@
1+ "use server";
2+
3+ import { revalidatePath } from "next/cache";
4+ import { redirect } from "next/navigation";
5+ import { CiError, dispatchWorkflow, rerunWorkflow } from "@/lib/ci";
6+ import { createRunner, deleteRunner, RunnerError, type RunnerScope } from "@/lib/runners";
7+ import { cancelRun, getRun, isFinished } from "@/lib/runs";
8+ import { ActionError, repoForAction, viewerOrThrow } from "./guard";
9+ import type { FormState } from "./types";
10+
11+ /** A repository's runners (`owner/name`, managed by its owner) or the viewer's own (null). */
12+ async function runnerScope(repo: { owner: string; name: string } | null): Promise<{ scope: RunnerScope; path: string }> {
13+ if (repo) {
14+ const { repo: r } = await repoForAction(repo.owner, repo.name, "admin");
15+ return { scope: { repoId: r.id }, path: `/${r.owner}/${r.name}/settings/runners` };
16+ }
17+ const viewer = await viewerOrThrow();
18+ return { scope: { userId: viewer.id }, path: "/settings/runners" };
19+ }
20+
21+ export async function createRunnerAction(repo: { owner: string; name: string } | null, _prev: FormState, formData: FormData) {
22+ const { scope, path } = await runnerScope(repo);
23+ try {
24+ const token = await createRunner(scope, { name: String(formData.get("name") ?? ""), labels: String(formData.get("labels") ?? "") });
25+ revalidatePath(path);
26+ return { token };
27+ } catch (e) {
28+ if (e instanceof RunnerError) return { error: e.message };
29+ throw e;
30+ }
31+ }
32+
33+ export async function deleteRunnerAction(repo: { owner: string; name: string } | null, runnerId: string) {
34+ const { scope, path } = await runnerScope(repo);
35+ await deleteRunner(scope, String(runnerId));
36+ revalidatePath(path);
37+ }
38+
39+ export async function dispatchWorkflowAction(owner: string, name: string, workflowPath: string, _prev: FormState, formData: FormData) {
40+ let destination: string;
41+ try {
42+ const { repo, viewer } = await repoForAction(owner, name, "write");
43+ const number = await dispatchWorkflow(repo, workflowPath, String(formData.get("branch") ?? ""), viewer.id);
44+ destination = `/${repo.owner}/${repo.name}/actions/runs/${number}`;
45+ } catch (e) {
46+ if (e instanceof CiError || e instanceof ActionError) return { error: e.message };
47+ throw e;
48+ }
49+ redirect(destination);
50+ }
51+
52+ async function runForAction(owner: string, name: string, number: number) {
53+ const { repo, viewer } = await repoForAction(owner, name, "write");
54+ const run = await getRun(repo.id, number);
55+ if (!run) throw new ActionError("That run doesn't exist.");
56+ return { repo, viewer, run };
57+ }
58+
59+ export async function cancelRunAction(owner: string, name: string, number: number) {
60+ const { repo, run } = await runForAction(owner, name, number);
61+ if (!isFinished(run.status)) await cancelRun(run.id);
62+ revalidatePath(`/${repo.owner}/${repo.name}/actions`, "layout");
63+ }
64+
65+ export async function rerunAction(owner: string, name: string, number: number) {
66+ const { repo, viewer, run } = await runForAction(owner, name, number);
67+ const next = await rerunWorkflow(repo, run, viewer.id);
68+ redirect(`/${repo.owner}/${repo.name}/actions/runs/${next}`);
69+ }
+10−3app/actions/files.tsView File
@@ -1,6 +1,7 @@
11 "use server";
22
33 import { redirect } from "next/navigation";
4+ import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci";
45 import { git, GitError, objectType, revParse } from "@/lib/git";
56 import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write";
67 import { encodePath } from "@/lib/format";
@@ -53,14 +54,20 @@ async function commitFromForm(
5354 const author = { name: viewer.name || viewer.username, email: viewer.email };
5455
5556 let target = branch;
57+ let commit: string;
5658 if (formData.get("target") === "branch") {
5759 target = str(formData, "newBranch");
5860 if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`);
59- await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author });
61+ commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author });
6062 } else {
61- await commitFiles(repo.dir, { branch, parent: head, changes, message, author });
63+ commit = await commitFiles(repo.dir, { branch, parent: head, changes, message, author });
6264 }
63- markPushed(repo.id);
65+ await markPushed(repo.id);
66+ // A commit from the browser starts workflows just like a push of it would.
67+ const ref = `refs/heads/${target}`;
68+ const before = new Map<string, string>();
69+ if (head && target === branch) before.set(ref, head);
70+ startWorkflowsInBackground(onRefsUpdated(repo, before, new Map([[ref, commit]]), viewer.id));
6471 return target;
6572 }
6673
+2−2app/actions/guard.tsView File
@@ -13,8 +13,8 @@ export async function viewerOrThrow() {
1313 /** Loads a repository for a server action and checks the viewer's access level. */
1414 export async function repoForAction(owner: string, name: string, need: "read" | "write" | "admin") {
1515 const viewer = await viewerOrThrow();
16- const repo = findRepo(owner, name);
17- const access: Access = repo ? repoAccess(repo, viewer.id) : null;
16+ const repo = await findRepo(owner, name);
17+ const access: Access = repo ? await repoAccess(repo, viewer.id) : null;
1818 if (!repo || !access) throw new ActionError("Repository not found.");
1919 if (need === "write" && !canWrite(access)) throw new ActionError("You don't have push access to this repository.");
2020 if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings.");
+15−15app/actions/issues.tsView File
@@ -37,7 +37,7 @@ function revalidateIssues(repo: RepoRecord) {
3737 * Validates untrusted issue properties from the browser. Only the fields that
3838 * are present are checked; anything invalid throws.
3939 */
40- function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch {
40+ async function validate(repo: RepoRecord, patch: IssuePatch): Promise<IssuePatch> {
4141 const out: IssuePatch = {};
4242 if (patch.title !== undefined) {
4343 const title = String(patch.title).trim().slice(0, 256);
@@ -54,7 +54,7 @@ function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch {
5454 out.priority = patch.priority;
5555 }
5656 if (patch.assigneeId !== undefined) {
57- if (patch.assigneeId !== null && !listMembers(repo.id, repo.ownerId).some((m) => m.id === patch.assigneeId)) {
57+ if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) {
5858 throw new ActionError("Only the owner and collaborators can be assigned.");
5959 }
6060 out.assigneeId = patch.assigneeId;
@@ -69,7 +69,7 @@ function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch {
6969 }
7070 if (patch.labelIds !== undefined) {
7171 if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels.");
72- const known = new Set(listLabels(repo.id).map((l) => l.id));
72+ const known = new Set((await listLabels(repo.id)).map((l) => l.id));
7373 const ids = [...new Set(patch.labelIds)];
7474 if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label.");
7575 out.labelIds = ids;
@@ -92,9 +92,9 @@ export async function createIssueAction(
9292 ): Promise<ActionResult & { number?: number }> {
9393 try {
9494 const { repo, viewer, access } = await repoForAction(owner, name, "read");
95- const { title, body, ...properties } = validate(repo, { ...input, sortOrder: undefined });
95+ const { title, body, ...properties } = await validate(repo, { ...input, sortOrder: undefined });
9696 if (!title) return { error: "Title can't be blank." };
97- const number = createIssue(repo.id, viewer.id, {
97+ const number = await createIssue(repo.id, viewer.id, {
9898 title,
9999 body: body ?? "",
100100 ...(canWrite(access) ? properties : {}),
@@ -110,11 +110,11 @@ export async function createIssueAction(
110110 export async function updateIssueAction(owner: string, name: string, number: number, patch: IssuePatch): Promise<ActionResult> {
111111 try {
112112 const { repo, viewer, access } = await repoForAction(owner, name, "read");
113- const issue = getIssue(repo.id, number);
113+ const issue = await getIssue(repo.id, number);
114114 if (!issue) return { error: "Issue not found." };
115115 if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." };
116- const clean = validate(repo, patch);
117- updateIssue(issue, clean, viewer.id);
116+ const clean = await validate(repo, patch);
117+ await updateIssue(issue, clean, viewer.id);
118118 revalidateIssues(repo);
119119 return {};
120120 } catch (e) {
@@ -125,21 +125,21 @@ export async function updateIssueAction(owner: string, name: string, number: num
125125
126126 export async function commentAction(owner: string, name: string, number: number, formData: FormData) {
127127 const { repo, viewer } = await repoForAction(owner, name, "read");
128- const issue = getIssue(repo.id, number);
128+ const issue = await getIssue(repo.id, number);
129129 if (!issue) throw new ActionError("Issue not found");
130130 const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY);
131- if (body) addComment(issue.id, { body, authorId: viewer.id });
131+ if (body) await addComment(issue.id, { body, authorId: viewer.id });
132132 revalidateIssues(repo);
133133 }
134134
135135 export async function deleteIssueAction(owner: string, name: string, number: number) {
136136 const { repo, viewer, access } = await repoForAction(owner, name, "read");
137- const issue = getIssue(repo.id, number);
137+ const issue = await getIssue(repo.id, number);
138138 if (!issue) throw new ActionError("Issue not found.");
139139 if (issue.authorId !== viewer.id && access !== "admin") {
140140 throw new ActionError("Only the issue's creator and the repository owner can delete it.");
141141 }
142- deleteIssue(issue.id);
142+ await deleteIssue(issue.id);
143143 revalidateIssues(repo);
144144 redirect(`/${repo.owner}/${repo.name}/issues`);
145145 }
@@ -151,7 +151,7 @@ export async function createLabelAction(
151151 ): Promise<ActionResult & { label?: IssueLabel }> {
152152 try {
153153 const { repo } = await repoForAction(owner, name, "write");
154- const label = createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") });
154+ const label = await createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") });
155155 revalidateIssues(repo);
156156 return { label };
157157 } catch (e) {
@@ -169,8 +169,8 @@ export async function createLabelFormAction(owner: string, name: string, _prev:
169169 return result.error ? { error: result.error } : { success: `Created label "${result.label!.name}".` };
170170 }
171171
172- export async function deleteLabelAction(owner: string, name: string, labelId: number) {
172+ export async function deleteLabelAction(owner: string, name: string, labelId: string) {
173173 const { repo } = await repoForAction(owner, name, "write");
174- deleteLabel(repo.id, labelId);
174+ await deleteLabel(repo.id, String(labelId));
175175 revalidateIssues(repo);
176176 }
+6−6app/actions/repo.tsView File
@@ -47,7 +47,7 @@ export async function createRepoAction(_prev: FormState, formData: FormData): Pr
4747 export async function updateRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
4848 return attempt(async () => {
4949 const { repo } = await repoForAction(owner, name, "admin");
50- updateRepo(repo.id, {
50+ await updateRepo(repo.id, {
5151 description: String(formData.get("description") ?? ""),
5252 visibility: visibilityOf(formData.get("visibility")),
5353 });
@@ -61,7 +61,7 @@ export async function updateRepoImageAction(owner: string, name: string, _prev:
6161 const { repo } = await repoForAction(owner, name, "admin");
6262 const file = formData.get("image");
6363 if (!(file instanceof File)) throw new RepoError("Choose an image to upload.");
64- setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer()));
64+ await setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer()));
6565 // The image shows up in sidebars and lists everywhere, not just on this repository's pages.
6666 revalidatePath("/", "layout");
6767 return { success: "Repository image updated." };
@@ -70,7 +70,7 @@ export async function updateRepoImageAction(owner: string, name: string, _prev:
7070
7171 export async function removeRepoImageAction(owner: string, name: string) {
7272 const { repo } = await repoForAction(owner, name, "admin");
73- removeRepoImage(repo.id);
73+ await removeRepoImage(repo.id);
7474 revalidatePath("/", "layout");
7575 }
7676
@@ -101,14 +101,14 @@ export async function addCollaboratorAction(owner: string, name: string, _prev:
101101 return attempt(async () => {
102102 const { repo } = await repoForAction(owner, name, "admin");
103103 const username = String(formData.get("username") ?? "").trim();
104- addCollaborator(repo, username);
104+ await addCollaborator(repo, username);
105105 revalidatePath(`/${repo.owner}/${repo.name}/settings`);
106106 return { success: `${username} can now push to this repository.` };
107107 });
108108 }
109109
110- export async function removeCollaboratorAction(owner: string, name: string, userId: number) {
110+ export async function removeCollaboratorAction(owner: string, name: string, userId: string) {
111111 const { repo } = await repoForAction(owner, name, "admin");
112- removeCollaborator(repo.id, userId);
112+ await removeCollaborator(repo.id, String(userId));
113113 revalidatePath(`/${repo.owner}/${repo.name}/settings`);
114114 }
+19−16app/actions/settings.tsView File
@@ -1,6 +1,7 @@
11 "use server";
22
33 import { revalidatePath } from "next/cache";
4+ import { renewSession, signIn } from "@/lib/auth";
45 import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys";
56 import { canWrite, findRepoById, repoAccess } from "@/lib/repos";
67 import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens";
@@ -11,11 +12,13 @@ import type { FormState } from "./types";
1112 export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> {
1213 const viewer = await viewerOrThrow();
1314 try {
14- updateProfile(viewer.id, {
15+ const { emailChanged } = await updateProfile(viewer.id, {
1516 name: String(formData.get("name") ?? ""),
1617 email: String(formData.get("email") ?? ""),
1718 bio: String(formData.get("bio") ?? ""),
1819 });
20+ // PocketBase revokes a user's tokens when their email changes; keep this browser signed in.
21+ if (emailChanged) await renewSession(viewer.id);
1922 } catch (e) {
2023 if (e instanceof UserError) return { error: e.message };
2124 throw e;
@@ -32,14 +35,16 @@ export async function changePasswordAction(_prev: FormState, formData: FormData)
3235 if (next !== formData.get("confirm")) return { error: "The new passwords don't match." };
3336 const problem = validatePassword(next);
3437 if (problem) return { error: problem };
38+ // This signs the user out everywhere, so sign this browser back in.
3539 await setPassword(viewer.id, next);
40+ await signIn(viewer.email, next);
3641 return { success: "Password changed." };
3742 }
3843
3944 export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> {
4045 const viewer = await viewerOrThrow();
4146 try {
42- addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? ""));
47+ await addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? ""));
4348 } catch (e) {
4449 if (e instanceof KeyError) return { error: e.message };
4550 throw e;
@@ -48,9 +53,9 @@ export async function addSshKeyAction(_prev: FormState, formData: FormData): Pro
4853 return { success: "SSH key added." };
4954 }
5055
51- export async function deleteSshKeyAction(keyId: number) {
56+ export async function deleteSshKeyAction(keyId: string) {
5257 const viewer = await viewerOrThrow();
53- deleteSshKey(viewer.id, keyId);
58+ await deleteSshKey(viewer.id, String(keyId));
5459 revalidatePath("/settings/keys");
5560 }
5661
@@ -58,19 +63,17 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P
5863 const viewer = await viewerOrThrow();
5964 const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write";
6065 const expiry = String(formData.get("expiry") ?? "30");
61- let repoIds: number[] | null = null;
66+ let repoIds: string[] | null = null;
6267 if (formData.get("scope") === "selected") {
63- repoIds = formData
64- .getAll("repos")
65- .map(Number)
66- .filter((id) => {
67- const repo = findRepoById(id);
68- const access = repo ? repoAccess(repo, viewer.id) : null;
69- return access && (permission === "read" || canWrite(access));
70- });
68+ repoIds = [];
69+ for (const id of new Set(formData.getAll("repos").map(String))) {
70+ const repo = await findRepoById(id);
71+ const access = repo ? await repoAccess(repo, viewer.id) : null;
72+ if (access && (permission === "read" || canWrite(access))) repoIds.push(id);
73+ }
7174 }
7275 try {
73- const token = createToken(viewer.id, {
76+ const token = await createToken(viewer.id, {
7477 name: String(formData.get("name") ?? ""),
7578 permission,
7679 expiresInDays: expiry === "never" ? null : Number(expiry) || 30,
@@ -84,8 +87,8 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P
8487 }
8588 }
8689
87- export async function deleteTokenAction(tokenId: number) {
90+ export async function deleteTokenAction(tokenId: string) {
8891 const viewer = await viewerOrThrow();
89- deleteToken(viewer.id, tokenId);
92+ await deleteToken(viewer.id, String(tokenId));
9093 revalidatePath("/settings/tokens");
9194 }
+12−0app/api/health/route.tsaddedView File
@@ -0,0 +1,12 @@
1+ import { db } from "@/lib/db";
2+
3+ /** Liveness check for containers and load balancers. Also starts PocketBase if it isn't running yet. */
4+ export async function GET() {
5+ try {
6+ await db();
7+ return Response.json({ ok: true });
8+ } catch (e) {
9+ console.error("[health]", e);
10+ return Response.json({ ok: false }, { status: 503 });
11+ }
12+ }
+31−0app/api/runner/claim/route.tsaddedView File
@@ -0,0 +1,31 @@
1+ import { json, originOf, readBody, runnerFromRequest } from "@/lib/runner-api";
2+ import { touchRunner } from "@/lib/runners";
3+ import { claimJob } from "@/lib/runs";
4+
5+ /** How long a claim waits for a job to turn up before answering "nothing yet". */
6+ const MAX_WAIT_MS = 25_000;
7+
8+ const sleep = (ms: number, signal: AbortSignal) =>
9+ new Promise<void>((resolve) => {
10+ const timer = setTimeout(resolve, ms);
11+ signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
12+ });
13+
14+ /** A runner asking for work: answers with a job as soon as one it can run is queued (long polling). */
15+ export async function POST(request: Request) {
16+ const info = await readBody(request);
17+ const origin = originOf(request);
18+ const deadline = Date.now() + MAX_WAIT_MS;
19+
20+ for (;;) {
21+ // Checked every round: the runner's labels may be edited, or the runner removed, while it waits.
22+ const runner = await runnerFromRequest(request);
23+ if (runner instanceof Response) return runner;
24+ await touchRunner(runner.id, info);
25+ if (request.signal.aborted) return new Response(null, { status: 204 });
26+ const job = await claimJob(runner, origin);
27+ if (job) return json({ job });
28+ if (Date.now() >= deadline) return new Response(null, { status: 204 });
29+ await sleep(1000, request.signal);
30+ }
31+ }
+28−0app/api/runner/jobs/[id]/route.tsaddedView File
@@ -0,0 +1,28 @@
1+ import { json, readBody, runnerFromRequest } from "@/lib/runner-api";
2+ import { touchRunner } from "@/lib/runners";
3+ import { updateJob, type StepStatus } from "@/lib/runs";
4+
5+ const STEP_STATUSES = new Set<StepStatus>(["pending", "in_progress", "success", "failure", "cancelled", "skipped"]);
6+ const JOB_RESULTS = new Set(["success", "failure", "cancelled"]);
7+
8+ /** A runner reporting progress on the job it holds: step states, output, and finally the result. */
9+ export async function POST(request: Request, ctx: RouteContext<"/api/runner/jobs/[id]">) {
10+ const runner = await runnerFromRequest(request);
11+ if (runner instanceof Response) return runner;
12+ await touchRunner(runner.id, {});
13+ const body = await readBody(request);
14+
15+ const steps = (Array.isArray(body.steps) ? body.steps : []).flatMap((s) =>
16+ Number.isInteger(s?.index) && STEP_STATUSES.has(s?.status)
17+ ? [{ index: s.index as number, status: s.status as StepStatus, exitCode: Number.isInteger(s.exitCode) ? (s.exitCode as number) : null }]
18+ : [],
19+ );
20+ const logs = (Array.isArray(body.logs) ? body.logs : []).flatMap((l) =>
21+ Number.isInteger(l?.step) && typeof l?.text === "string" ? [{ step: l.step as number, text: l.text as string }] : [],
22+ );
23+ const status = typeof body.status === "string" && JOB_RESULTS.has(body.status) ? (body.status as "success" | "failure" | "cancelled") : undefined;
24+
25+ const result = await updateJob(runner, (await ctx.params).id, { steps, logs, status });
26+ if (!result) return json({ error: "This runner doesn't hold that job (it finished, was cancelled or timed out)." }, 409);
27+ return json(result);
28+ }
+1−1app/explore/page.tsxView File
@@ -13,7 +13,7 @@ export const metadata: Metadata = { title: "Explore" };
1313 export default async function ExplorePage({ searchParams }: PageProps<"/explore">) {
1414 const q = String((await searchParams).q ?? "").trim();
1515 const viewer = await getViewer();
16- const repos = await withActivity(searchRepos(q, viewer?.id ?? null));
16+ const repos = await withActivity(await searchRepos(q, viewer?.id ?? null));
1717
1818 return (
1919 <main className="w-full">
+7−4app/login/page.tsxView File
@@ -6,6 +6,7 @@ import { LoginForm } from "@/components/auth-forms";
66 import { BRAND, LogoMark } from "@/components/brand";
77 import { Box } from "@/components/ui";
88 import { getViewer, safeNext } from "@/lib/auth";
9+ import { SIGNUPS_ENABLED } from "@/lib/signups";
910
1011 export const metadata: Metadata = { title: "Sign in" };
1112
@@ -28,10 +29,12 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
2829 <Box className="mt-8 p-6">
2930 <LoginForm next={next} />
3031 </Box>
31- <p className="mt-6 text-center type-body text-ink2">
32- New to {BRAND.name}?{" "}
33- <Link render={<NextLink href={`/signup${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>Create an account</Link>
34- </p>
32+ {SIGNUPS_ENABLED && (
33+ <p className="mt-6 text-center type-body text-ink2">
34+ New to {BRAND.name}?{" "}
35+ <Link render={<NextLink href={`/signup${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>Create an account</Link>
36+ </p>
37+ )}
3538 </div>
3639 </main>
3740 );
+14−7app/page.tsxView File
@@ -24,6 +24,7 @@ import { getViewer } from "@/lib/auth";
2424 import { shortSha, timeAgo } from "@/lib/format";
2525 import { recentActivity, withActivity } from "@/lib/listing";
2626 import { listReposForUser, searchRepos } from "@/lib/repos";
27+ import { SIGNUPS_ENABLED } from "@/lib/signups";
2728 import { sshCloneUrl } from "@/lib/ssh-config";
2829 import type { User } from "@/lib/users";
2930
@@ -63,9 +64,15 @@ function Landing({ sshUrl }: { sshUrl: string }) {
6364 {BRAND.name} hosts git repositories for your whole team. Push over SSH or HTTPS, edit in the browser, and keep private work private.
6465 </p>
6566 <div className="mt-9 flex flex-wrap justify-center gap-3">
66- <ButtonLink href="/signup" variant="primary" icon={<PlusIcon />}>
67- Create Your Account
68- </ButtonLink>
67+ {SIGNUPS_ENABLED ? (
68+ <ButtonLink href="/signup" variant="primary" icon={<PlusIcon />}>
69+ Create Your Account
70+ </ButtonLink>
71+ ) : (
72+ <ButtonLink href="/login" variant="primary" icon={<ArrowRightIcon />}>
73+ Sign In
74+ </ButtonLink>
75+ )}
6976 <ButtonLink href="/explore" icon={<CompassIcon />}>
7077 Explore Repositories
7178 </ButtonLink>
@@ -100,8 +107,8 @@ function Landing({ sshUrl }: { sshUrl: string }) {
100107 <p className="type-label engraved">Get started</p>
101108 <h2 className={`mt-3 text-[30px] leading-[1.1] ${displayHeading}`}>Plant your first repository</h2>
102109 <p className="mt-2 type-lead text-ink2">It takes less than a minute.</p>
103- <ButtonLink href="/signup" variant="primary" icon={<ArrowRightIcon />} className="mt-7">
104- Get Started for Free
110+ <ButtonLink href={SIGNUPS_ENABLED ? "/signup" : "/login"} variant="primary" icon={<ArrowRightIcon />} className="mt-7">
111+ {SIGNUPS_ENABLED ? "Get Started for Free" : "Sign In"}
105112 </ButtonLink>
106113 </Surface>
107114 </section>
@@ -118,10 +125,10 @@ function greeting() {
118125 const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush";
119126
120127 async function Dashboard({ viewer }: { viewer: User }) {
121- const mine = listReposForUser(viewer.id);
128+ const [mine, recent] = await Promise.all([listReposForUser(viewer.id), searchRepos("", viewer.id, 30)]);
122129 const [activity, explore] = await Promise.all([
123130 recentActivity(mine),
124- withActivity(searchRepos("", viewer.id, 30).filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)),
131+ withActivity(recent.filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)),
125132 ]);
126133
127134 return (
+1−1app/settings/keys/page.tsxView File
@@ -18,7 +18,7 @@ export const metadata: Metadata = { title: "SSH keys" };
1818
1919 export default async function KeysPage() {
2020 const viewer = await requireViewer();
21- const keys = listSshKeys(viewer.id);
21+ const keys = await listSshKeys(viewer.id);
2222 const host = (await headers()).get("host") ?? "localhost:3000";
2323 const test = sshTestCommand(host);
2424
+2−1app/settings/layout.tsxView File
@@ -1,5 +1,5 @@
11 import { SettingsNav } from "@/components/settings-nav";
2- import { KeyIcon, PersonIcon, ShieldLockIcon } from "@/components/icons";
2+ import { KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon } from "@/components/icons";
33 import { Avatar } from "@/components/ui";
44 import { requireViewer } from "@/lib/auth";
55
@@ -21,6 +21,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting
2121 { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> },
2222 { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> },
2323 { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> },
24+ { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> },
2425 ]}
2526 />
2627 <div className="min-w-0">{children}</div>
+33−0app/settings/runners/page.tsxaddedView File
@@ -0,0 +1,33 @@
1+ import type { Metadata } from "next";
2+ import { createRunnerAction, deleteRunnerAction } from "@/app/actions/ci";
3+ import { NewRunnerForm } from "@/components/ci/new-runner-form";
4+ import { RunnerList } from "@/components/ci/runner-list";
5+ import { PlateSection } from "@/components/settings-plate";
6+ import { PageTitle } from "@/components/ui";
7+ import { requireViewer } from "@/lib/auth";
8+ import { serverOrigin } from "@/lib/request-origin";
9+ import { listRunners } from "@/lib/runners";
10+
11+ export const metadata: Metadata = { title: "Runners" };
12+
13+ export default async function RunnersPage() {
14+ const viewer = await requireViewer("/settings/runners");
15+ return (
16+ <div>
17+ <PageTitle
18+ title="Runners"
19+ description="Machines that run workflow jobs for every repository you own. A repository can also have its own runners, under its settings."
20+ />
21+ <div className="flex flex-col gap-7">
22+ <RunnerList runners={await listRunners({ userId: viewer.id })} deleteAction={deleteRunnerAction.bind(null, null)} />
23+ <PlateSection title="Register a runner">
24+ <NewRunnerForm
25+ action={createRunnerAction.bind(null, null)}
26+ origin={await serverOrigin()}
27+ scopeText="This runner will pick up jobs from all repositories you own, including ones you create later."
28+ />
29+ </PlateSection>
30+ </div>
31+ </div>
32+ );
33+ }
+1−1app/settings/tokens/form.tsxView File
@@ -8,7 +8,7 @@ import { ChoiceCards, CopyIconButton, TextField } from "@/components/settings-fi
88 import { PlateBody, PlateFooter } from "@/components/settings-plate";
99 import { Alert, Button } from "@/components/ui";
1010
11- type RepoOption = { id: number; fullName: string; writable: boolean };
11+ type RepoOption = { id: string; fullName: string; writable: boolean };
1212
1313 const expiries = [
1414 { value: "7", label: "7 days" },
+8−6app/settings/tokens/page.tsxView File
@@ -32,12 +32,14 @@ function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boo
3232
3333 export default async function TokensPage() {
3434 const viewer = await requireViewer();
35- const tokens = listTokens(viewer.id);
36- const repos = listReposForUser(viewer.id).map((r) => ({
37- id: r.id,
38- fullName: `${r.owner}/${r.name}`,
39- writable: canWrite(repoAccess(r, viewer.id)),
40- }));
35+ const tokens = await listTokens(viewer.id);
36+ const repos = await Promise.all(
37+ (await listReposForUser(viewer.id)).map(async (r) => ({
38+ id: r.id,
39+ fullName: `${r.owner}/${r.name}`,
40+ writable: canWrite(await repoAccess(r, viewer.id)),
41+ })),
42+ );
4143 const h = await headers();
4244 const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("host") ?? "localhost:3000"}`;
4345
+11−1app/signup/page.tsxView File
@@ -1,10 +1,13 @@
11 import type { Metadata } from "next";
2+ import NextLink from "next/link";
23 import { redirect } from "next/navigation";
4+ import { Link } from "@unlocalhosted/metalui";
35 import { SignupForm } from "@/components/auth-forms";
46 import { BRAND } from "@/components/brand";
57 import { KeyIcon, PencilIcon, PeopleIcon } from "@/components/icons";
68 import { Box } from "@/components/ui";
79 import { getViewer, safeNext } from "@/lib/auth";
10+ import { SIGNUPS_ENABLED } from "@/lib/signups";
811
912 export const metadata: Metadata = { title: "Join" };
1013
@@ -46,7 +49,14 @@ export default async function SignupPage({ searchParams }: PageProps<"/signup">)
4649 </div>
4750 <Box className="animate-fade-up p-7 [animation-delay:100ms]">
4851 <h2 className="mb-5 type-display text-ink">Create your account</h2>
49- <SignupForm next={next} />
52+ {SIGNUPS_ENABLED ? (
53+ <SignupForm next={next} />
54+ ) : (
55+ <p className="type-body text-ink2">
56+ Sign-ups are closed on this instance. Ask an administrator for an account, or{" "}
57+ <Link render={<NextLink href={`/login${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>sign in</Link>.
58+ </p>
59+ )}
5060 </Box>
5161 </div>
5262 </main>
+6−0bun.lockView File
@@ -13,6 +13,7 @@
1313 "@uiw/react-codemirror": "^4.25.12",
1414 "@unlocalhosted/metalui": "^0.3.3",
1515 "next": "16.3.7",
16+ "pocketbase": "^0.28.1",
1617 "react": "19.2.8",
1718 "react-dom": "19.2.8",
1819 "react-markdown": "^10.1.0",
@@ -22,6 +23,7 @@
2223 "server-only": "^0.0.1",
2324 "shiki": "^4.4.3",
2425 "ssh2": "^1.17.0",
26+ "yaml": "^2.9.1",
2527 },
2628 "devDependencies": {
2729 "@tailwindcss/postcss": "^4",
@@ -1055,6 +1057,8 @@
10551057
10561058 "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
10571059
1060+ "pocketbase": ["pocketbase@0.28.1", "", {}, "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA=="],
1061+
10581062 "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
10591063
10601064 "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
@@ -1269,6 +1273,8 @@
12691273
12701274 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
12711275
1276+ "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
1277+
12721278 "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
12731279
12741280 "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
+52−0components/ci/dispatch-form.tsxaddedView File
@@ -0,0 +1,52 @@
1+ "use client";
2+
3+ import { useActionState } from "react";
4+ import { Popover, Select } from "@unlocalhosted/metalui";
5+ import type { FormState } from "@/app/actions/types";
6+ import { BranchIcon, ChevronDownIcon, PlayIcon } from "@/components/icons";
7+ import { Alert, Button } from "@/components/ui";
8+
9+ /** "Run workflow": a popover choosing the branch to run a `workflow_dispatch` workflow on. */
10+ export function DispatchButton({
11+ action,
12+ branches,
13+ defaultBranch,
14+ }: {
15+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
16+ branches: string[];
17+ defaultBranch: string;
18+ }) {
19+ const [state, formAction, pending] = useActionState(action, undefined);
20+ return (
21+ <Popover>
22+ <Popover.Trigger>
23+ <Button type="button" icon={<PlayIcon />}>
24+ Run workflow
25+ <span aria-hidden className="-mr-1 inline-grid opacity-70">
26+ <ChevronDownIcon width={12} height={12} />
27+ </span>
28+ </Button>
29+ </Popover.Trigger>
30+ <Popover.Content side="bottom" align="end" className="w-[300px]">
31+ <Popover.Title>Run this workflow</Popover.Title>
32+ <Popover.Body>
33+ <form action={formAction} className="flex flex-col gap-3">
34+ <label className="flex flex-col gap-1.5">
35+ <span className="type-ui text-ink">Branch</span>
36+ <Select
37+ name="branch"
38+ aria-label="Branch"
39+ defaultValue={defaultBranch}
40+ options={branches.map((b) => ({ value: b, label: b, lead: <BranchIcon /> }))}
41+ />
42+ </label>
43+ {state?.error && <Alert>{state.error}</Alert>}
44+ <Button variant="primary" icon={<PlayIcon />} disabled={pending}>
45+ {pending ? "Starting…" : "Run Workflow"}
46+ </Button>
47+ </form>
48+ </Popover.Body>
49+ </Popover.Content>
50+ </Popover>
51+ );
52+ }
+78−0components/ci/job-steps.tsxaddedView File
@@ -0,0 +1,78 @@
1+ "use client";
2+
3+ import { useState } from "react";
4+ import { ChevronRightIcon } from "@/components/icons";
5+ import type { StepStatus } from "@/lib/runs";
6+ import { RunStatusIcon } from "./run-status";
7+
8+ export type StepView = {
9+ index: number;
10+ name: string;
11+ status: StepStatus;
12+ exitCode: number | null;
13+ duration: string | null;
14+ /** The output's last lines, already cleaned of terminal escapes. */
15+ lines: string[];
16+ /** How many lines before `lines` were left out. */
17+ omitted: number;
18+ };
19+
20+ /**
21+ * A job's steps, each a row that opens onto its log. Running and failed steps open by themselves (and
22+ * follow along as the page refreshes) until the reader opens or closes them by hand.
23+ */
24+ export function JobSteps({ steps, rawHref }: { steps: StepView[]; rawHref: string }) {
25+ const [toggled, setToggled] = useState<Map<number, boolean>>(new Map());
26+ return (
27+ <ol className="flex flex-col">
28+ {steps.map((step) => {
29+ const auto = step.status === "in_progress" || step.status === "failure";
30+ const open = toggled.get(step.index) ?? auto;
31+ const hasLog = step.lines.length > 0 || step.omitted > 0;
32+ return (
33+ <li key={step.index}>
34+ <button
35+ type="button"
36+ aria-expanded={open}
37+ onClick={() => setToggled((t) => new Map(t).set(step.index, !open))}
38+ className="flex h-10 w-full items-center gap-2.5 rounded-row px-3 text-left transition-[background,box-shadow] duration-150 outline-none hover:recipe-row-list-hover focus-visible:focus-ring"
39+ >
40+ <ChevronRightIcon width={14} height={14} className={`shrink-0 text-ink3 transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
41+ <RunStatusIcon status={step.status} size={15} />
42+ <span className={`min-w-0 flex-1 truncate type-ui ${step.status === "skipped" || step.status === "pending" ? "text-ink3" : "text-ink"}`}>
43+ {step.name}
44+ </span>
45+ {step.duration && <span className="shrink-0 type-readout text-ink3">{step.duration}</span>}
46+ </button>
47+ {open && (
48+ <div className="mx-3 mt-0.5 mb-2 overflow-hidden rounded-row well">
49+ {hasLog ? (
50+ <pre className="max-h-[560px] overflow-auto py-2 font-mono text-[12px] leading-5 text-ink">
51+ {step.omitted > 0 && (
52+ <div className="px-3 pb-1 text-ink3">
53+ {step.omitted.toLocaleString("en")} earlier lines not shown ·{" "}
54+ <a href={rawHref} className="underline underline-offset-2 hover:text-ink">
55+ view the full log
56+ </a>
57+ </div>
58+ )}
59+ {step.lines.map((line, i) => (
60+ <div key={i} className="flex hover:bg-[color-mix(in_srgb,var(--mu-ink)_4%,transparent)]">
61+ <span className="w-12 shrink-0 pr-3 text-right text-ink3 select-none">{step.omitted + i + 1}</span>
62+ <span className="min-w-0 pr-3 break-all whitespace-pre-wrap">{line || " "}</span>
63+ </div>
64+ ))}
65+ </pre>
66+ ) : (
67+ <p className="px-3 py-2.5 type-meta text-ink3">
68+ {step.status === "pending" ? "Not started yet." : step.status === "skipped" ? "This step was skipped." : "No output."}
69+ </p>
70+ )}
71+ </div>
72+ )}
73+ </li>
74+ );
75+ })}
76+ </ol>
77+ );
78+ }
+17−0components/ci/live-refresh.tsxaddedView File
@@ -0,0 +1,17 @@
1+ "use client";
2+
3+ import { useRouter } from "next/navigation";
4+ import { useEffect } from "react";
5+
6+ /** Re-renders the page every few seconds while something on it is still running (and the tab is visible). */
7+ export function LiveRefresh({ active, interval = 2000 }: { active: boolean; interval?: number }) {
8+ const router = useRouter();
9+ useEffect(() => {
10+ if (!active) return;
11+ const timer = setInterval(() => {
12+ if (document.visibilityState === "visible") router.refresh();
13+ }, interval);
14+ return () => clearInterval(timer);
15+ }, [active, interval, router]);
16+ return null;
17+ }
+91−0components/ci/new-runner-form.tsxaddedView File
@@ -0,0 +1,91 @@
1+ "use client";
2+
3+ import { useActionState } from "react";
4+ import type { FormState } from "@/app/actions/types";
5+ import { PlusIcon, ServerIcon, TagIcon } from "@/components/icons";
6+ import { CopyIconButton, TextField } from "@/components/settings-fields";
7+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
8+ import { Alert, Button } from "@/components/ui";
9+
10+ function Command({ children }: { children: string }) {
11+ return (
12+ <div className="flex items-start gap-2 rounded-row well py-1.5 pr-1.5 pl-3.5">
13+ <pre className="min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-[12px] leading-5 text-ink">{children}</pre>
14+ <CopyIconButton text={children} label="Copy command" toast="Copied command" />
15+ </div>
16+ );
17+ }
18+
19+ /** Registers a runner, then shows its token once with the commands that start it. */
20+ export function NewRunnerForm({
21+ action,
22+ origin,
23+ scopeText,
24+ }: {
25+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
26+ origin: string;
27+ scopeText: string;
28+ }) {
29+ const [state, formAction, pending] = useActionState(action, undefined);
30+
31+ if (state?.token) {
32+ return (
33+ <>
34+ <PlateBody className="max-w-3xl">
35+ <Alert kind="success">Runner registered. Copy its token now — you won’t be able to see it again.</Alert>
36+ <div className="flex items-center gap-2 rounded-row recipe-well-field py-1.5 pr-1.5 pl-3.5">
37+ <code className="flex-1 font-mono text-[12.5px] leading-5 break-all text-ink">{state.token}</code>
38+ <CopyIconButton text={state.token} label="Copy token" toast="Copied token" />
39+ </div>
40+ <div className="flex flex-col gap-2">
41+ <p className="type-title text-ink">Start it on your machine</p>
42+ <p className="type-body text-ink2">
43+ The runner is one file and needs Node.js 18+ and git. Download it, then start it with the token. Jobs run as the user
44+ that starts it, so use an account (or a VM or container) set aside for it.
45+ </p>
46+ <Command>{`curl -fsSLO ${origin}/vectrum-runner.mjs`}</Command>
47+ <Command>{`node vectrum-runner.mjs --url ${origin} --token ${state.token}`}</Command>
48+ <p className="type-body text-ink2">
49+ It shows up as online here within a few seconds. Jobs with <code className="font-mono text-[12px]">runs-on: self-hosted</code>{" "}
50+ run on any runner; use labels to pick particular ones.
51+ </p>
52+ </div>
53+ </PlateBody>
54+ <PlateFooter>
55+ <Button type="button" onClick={() => window.location.reload()}>
56+ Done
57+ </Button>
58+ </PlateFooter>
59+ </>
60+ );
61+ }
62+
63+ return (
64+ <form action={formAction}>
65+ <PlateBody className="max-w-2xl gap-6">
66+ <p className="type-body text-ink2">{scopeText}</p>
67+ <TextField label="Name" icon={<ServerIcon />} name="name" required maxLength={100} description="e.g. the machine it runs on." />
68+ <TextField
69+ label="Labels"
70+ icon={<TagIcon />}
71+ name="labels"
72+ optional
73+ placeholder="docker, gpu"
74+ description={
75+ <>
76+ Jobs pick runners by label with <code className="font-mono text-[12px]">runs-on</code>. Every runner also has{" "}
77+ <code className="font-mono text-[12px]">self-hosted</code> and its OS and architecture (e.g.{" "}
78+ <code className="font-mono text-[12px]">linux</code>, <code className="font-mono text-[12px]">x64</code>).
79+ </>
80+ }
81+ />
82+ {state?.error && <Alert>{state.error}</Alert>}
83+ </PlateBody>
84+ <PlateFooter>
85+ <Button variant="primary" icon={<PlusIcon />} disabled={pending}>
86+ {pending ? "Registering…" : "Register Runner"}
87+ </Button>
88+ </PlateFooter>
89+ </form>
90+ );
91+ }
+102−0components/ci/run-status.tsxaddedView File
@@ -0,0 +1,102 @@
1+ import type { LedKind } from "@unlocalhosted/metalui";
2+ import type { CommitStatus, JobStatus, RunStatus, StepStatus } from "@/lib/runs";
3+
4+ // Run, job and step states, drawn like the issue status glyphs (MetalUI's 24 grid, 1.7 stroke) and
5+ // coloured like its LEDs: green passed, red failed, amber working or waiting, unlit (ink3) the rest.
6+
7+ export type AnyStatus = RunStatus | JobStatus | StepStatus | CommitStatus;
8+
9+ const AMBER = "text-[#c98a18]";
10+
11+ const COLORS: Record<AnyStatus, string> = {
12+ success: "text-green-deep",
13+ failure: "text-red",
14+ in_progress: AMBER,
15+ queued: AMBER,
16+ pending: "text-ink3",
17+ waiting: "text-ink3",
18+ cancelled: "text-ink3",
19+ skipped: "text-ink3",
20+ };
21+
22+ const LABELS: Record<AnyStatus, string> = {
23+ success: "Success",
24+ failure: "Failure",
25+ in_progress: "In progress",
26+ queued: "Queued",
27+ pending: "Pending",
28+ waiting: "Waiting",
29+ cancelled: "Cancelled",
30+ skipped: "Skipped",
31+ };
32+
33+ export const statusText = (status: AnyStatus) => LABELS[status];
34+
35+ /** The LED for a status beside its words (StatusBadge). */
36+ export function statusLed(status: AnyStatus): LedKind {
37+ if (status === "success") return "live";
38+ if (status === "failure") return "failed";
39+ if (status === "in_progress" || status === "queued") return "waiting";
40+ return "off";
41+ }
42+
43+ const R = 9.15;
44+
45+ export function RunStatusIcon({ status, size = 16, className = "" }: { status: AnyStatus; size?: number; className?: string }) {
46+ return (
47+ <svg
48+ viewBox="0 0 24 24"
49+ width={size}
50+ height={size}
51+ className={`shrink-0 ${COLORS[status]} ${className}`}
52+ role="img"
53+ aria-label={LABELS[status]}
54+ >
55+ <title>{LABELS[status]}</title>
56+ {status === "success" && (
57+ <>
58+ <circle cx="12" cy="12" r="10" fill="currentColor" />
59+ <path d="m7.6 12.3 3.1 3.1 5.7-6.2" fill="none" stroke="var(--canvas)" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
60+ </>
61+ )}
62+ {status === "failure" && (
63+ <>
64+ <circle cx="12" cy="12" r="10" fill="currentColor" />
65+ <path d="m8.7 8.7 6.6 6.6m0-6.6-6.6 6.6" fill="none" stroke="var(--canvas)" strokeWidth="1.9" strokeLinecap="round" />
66+ </>
67+ )}
68+ {status === "in_progress" && (
69+ <>
70+ <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" opacity="0.3" />
71+ <path
72+ d={`M12 ${12 - R}a${R} ${R} 0 0 1 ${R} ${R}`}
73+ fill="none"
74+ stroke="currentColor"
75+ strokeWidth="1.9"
76+ strokeLinecap="round"
77+ className="origin-center animate-spin"
78+ />
79+ <circle cx="12" cy="12" r="3" fill="currentColor" />
80+ </>
81+ )}
82+ {(status === "queued" || status === "waiting" || status === "pending") && (
83+ <>
84+ <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" strokeDasharray="2.6 2.19" />
85+ {status === "queued" && <circle cx="12" cy="12" r="3" fill="currentColor" />}
86+ </>
87+ )}
88+ {status === "cancelled" && (
89+ <>
90+ <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" />
91+ <path d="m5.6 18.4 12.8-12.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
92+ </>
93+ )}
94+ {status === "skipped" && (
95+ <>
96+ <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" opacity="0.6" />
97+ <path d="M8 12h8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
98+ </>
99+ )}
100+ </svg>
101+ );
102+ }
+16−0components/ci/run-text.tsaddedView File
@@ -0,0 +1,16 @@
1+ import { formatDuration, shortSha } from "@/lib/format";
2+ import type { Run } from "@/lib/runs";
3+
4+ /** How long something ran (or has been running), or null if it hasn't started. */
5+ export function runDuration(item: { startedAt: number | null; finishedAt: number | null }) {
6+ if (!item.startedAt) return null;
7+ return formatDuration((item.finishedAt ?? Date.now()) - item.startedAt);
8+ }
9+
10+ /** What started a run: "Commit 1a2b3c4 pushed by ana", "Manually run by ana". */
11+ export function triggerText(run: Run) {
12+ const by = run.actor ? ` by ${run.actor}` : "";
13+ return run.event === "workflow_dispatch" ? `Manually run${by}` : `Commit ${shortSha(run.sha)} pushed${by}`;
14+ }
15+
16+ export const refName = (ref: string) => ref.replace(/^refs\/(heads|tags)\//, "");
+88−0components/ci/runner-list.tsxaddedView File
@@ -0,0 +1,88 @@
1+ import { Label as MuLabel, StatusBadge } from "@unlocalhosted/metalui";
2+ import { ConfirmButton } from "@/components/confirm-button";
3+ import { ServerIcon } from "@/components/icons";
4+ import { SettingsSection } from "@/components/metal";
5+ import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate";
6+ import { Blankslate, RefChip } from "@/components/ui";
7+ import { timeAgo } from "@/lib/format";
8+ import { RUNNER_TOKEN_PREFIX, type Runner } from "@/lib/runners";
9+
10+ /** Registered runners: online, busy or offline, with their labels and machine, each removable. */
11+ export function RunnerList({ runners, deleteAction }: { runners: Runner[]; deleteAction: (runnerId: string) => Promise<void> }) {
12+ if (runners.length === 0) {
13+ return (
14+ <PlateSection title="Runners">
15+ <Blankslate icon={<ServerIcon />} title="No runners yet">
16+ Register a runner below, then start it on the machine that should run your jobs.
17+ </Blankslate>
18+ </PlateSection>
19+ );
20+ }
21+ return (
22+ <SettingsSection title={`Runners · ${runners.length}`}>
23+ {runners.map((r) => (
24+ <ListRow
25+ key={r.id}
26+ lead={
27+ <RowGlyph>
28+ <ServerIcon />
29+ </RowGlyph>
30+ }
31+ title={
32+ <>
33+ <span className="truncate">{r.name}</span>
34+ {r.busy ? (
35+ <StatusBadge led="waiting">Busy</StatusBadge>
36+ ) : r.online ? (
37+ <StatusBadge led="live">Idle</StatusBadge>
38+ ) : (
39+ <StatusBadge led="off">Offline</StatusBadge>
40+ )}
41+ <RefChip>
42+ {RUNNER_TOKEN_PREFIX}…{r.hint}
43+ </RefChip>
44+ </>
45+ }
46+ meta={
47+ <>
48+ <span className="flex flex-wrap gap-1">
49+ {[...r.systemLabels, ...r.labels].map((l) => (
50+ <RefChip key={l} className="text-ink2">
51+ {l}
52+ </RefChip>
53+ ))}
54+ </span>
55+ <MuLabel variant="engraved">
56+ {r.busy ? (
57+ <>
58+ Running <b>{r.busy.jobName}</b> in <b>{r.busy.repo}</b> #{r.busy.runNumber}
59+ </>
60+ ) : r.lastSeenAt ? (
61+ <>
62+ {r.hostname && (
63+ <>
64+ <b>{r.hostname}</b> ·{" "}
65+ </>
66+ )}
67+ Last seen <b>{timeAgo(r.lastSeenAt)}</b>
68+ {r.version && <> · v{r.version}</>}
69+ </>
70+ ) : (
71+ "Never connected"
72+ )}
73+ </MuLabel>
74+ </>
75+ }
76+ >
77+ <ConfirmButton
78+ action={deleteAction.bind(null, r.id)}
79+ message={`Remove the runner “${r.name}”? It stops picking up jobs, and its token stops working.`}
80+ variant="default"
81+ >
82+ Remove
83+ </ConfirmButton>
84+ </ListRow>
85+ ))}
86+ </SettingsSection>
87+ );
88+ }
+30−0components/ci/workflow-nav.tsxaddedView File
@@ -0,0 +1,30 @@
1+ "use client";
2+
3+ import Link from "next/link";
4+ import { Sidebar } from "@unlocalhosted/metalui";
5+ import { PlayIcon } from "@/components/icons";
6+
7+ export type WorkflowLink = { path: string; name: string };
8+
9+ /** The workflows to filter runs by, on MetalUI's sidebar section; "All workflows" first. */
10+ export function WorkflowNav({ base, workflows, current }: { base: string; workflows: WorkflowLink[]; current: string | null }) {
11+ return (
12+ <Sidebar aria-label="Workflows" className="!h-auto !w-auto !p-0">
13+ <Sidebar.Section title="Workflows">
14+ <Sidebar.Item icon={<PlayIcon />} active={current === null} render={<Link href={`${base}/actions`} />}>
15+ All workflows
16+ </Sidebar.Item>
17+ {workflows.map((w) => (
18+ <Sidebar.Item
19+ key={w.path}
20+ icon={<PlayIcon />}
21+ active={current === w.path}
22+ render={<Link href={`${base}/actions?workflow=${encodeURIComponent(w.path)}`} />}
23+ >
24+ {w.name}
25+ </Sidebar.Item>
26+ ))}
27+ </Sidebar.Section>
28+ </Sidebar>
29+ );
30+ }
+15−9components/header.tsxView File
@@ -1,5 +1,7 @@
1+ import { cache } from "react";
12 import Link from "next/link";
23 import { listReposForUser } from "@/lib/repos";
4+ import { SIGNUPS_ENABLED } from "@/lib/signups";
35 import type { Theme } from "@/lib/theme";
46 import type { User } from "@/lib/users";
57 import { BRAND, Logo, LogoMark } from "./brand";
@@ -9,18 +11,20 @@ import { SearchBox } from "./search-box";
911 import { AppSidebar, MobileNav } from "./sidebar-nav";
1012 import { ButtonLink } from "./ui";
1113
12- const sidebarRepos = (viewer: User) =>
13- listReposForUser(viewer.id).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion }));
14+ // The sidebar and the search box both list them; one query per request.
15+ const sidebarRepos = cache(async (viewerId: string) =>
16+ (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })),
17+ );
1418
1519 const viewerOf = (viewer: User) => ({ username: viewer.username, name: viewer.name });
1620
1721 /** Left rail for signed-in users: account menu, places and repository shortcuts. */
18- export function SiteSidebar({ viewer, theme }: { viewer: User; theme: Theme }) {
19- return <AppSidebar viewer={viewerOf(viewer)} repos={sidebarRepos(viewer)} theme={theme} />;
22+ export async function SiteSidebar({ viewer, theme }: { viewer: User; theme: Theme }) {
23+ return <AppSidebar viewer={viewerOf(viewer)} repos={await sidebarRepos(viewer.id)} theme={theme} />;
2024 }
2125
2226 /** Thin bar above the sheet for signed-in users. On small screens it also carries the navigation. */
23- export function TopBar({ viewer }: { viewer: User }) {
27+ export async function TopBar({ viewer }: { viewer: User }) {
2428 return (
2529 <header className="flex h-14 shrink-0 items-center gap-2 px-3 lg:pl-1">
2630 <div className="flex items-center gap-2 lg:hidden">
@@ -31,7 +35,7 @@ export function TopBar({ viewer }: { viewer: User }) {
3135 </div>
3236
3337 <div className="ml-auto flex items-center gap-2.5">
34- <SearchBox repos={sidebarRepos(viewer)} signedIn />
38+ <SearchBox repos={await sidebarRepos(viewer.id)} signedIn />
3539 <ButtonLink href="/new" variant="primary" icon={<PlusIcon />}>
3640 New
3741 </ButtonLink>
@@ -53,9 +57,11 @@ export function SiteHeader() {
5357 <ButtonLink href="/login" variant="invisible">
5458 Sign In
5559 </ButtonLink>
56- <ButtonLink href="/signup" variant="primary">
57- Create Account
58- </ButtonLink>
60+ {SIGNUPS_ENABLED && (
61+ <ButtonLink href="/signup" variant="primary">
62+ Create Account
63+ </ButtonLink>
64+ )}
5965 </div>
6066 </header>
6167 );
+14−0components/icons.tsxView File
@@ -274,3 +274,17 @@ export const BoardIcon = icon(
274274 </>,
275275 );
276276 export const SlidersIcon = icon(<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />);
277+ export const PlayIcon = icon(
278+ <>
279+ <circle cx="12" cy="12" r="9.15" />
280+ <path d="m10 8.5 5.5 3.5-5.5 3.5Z" />
281+ </>,
282+ );
283+ export const ServerIcon = icon(
284+ <>
285+ <rect x="3" y="3.5" width="18" height="7" rx="2" />
286+ <rect x="3" y="13.5" width="18" height="7" rx="2" />
287+ <path d="M7 7h.01M7 17h.01M11 7h6M11 17h6" />
288+ </>,
289+ );
290+ export const DownloadIcon = icon(<path d="M12 4v11m-4.5-4.5L12 15l4.5-4.5M5 20h14" />);
+2−2components/issues/issue-composer.tsxView File
@@ -28,8 +28,8 @@ export function IssueComposer({
2828 const [body, setBody] = useState("");
2929 const [status, setStatus] = useState<Status>(defaults?.status ?? "todo");
3030 const [priority, setPriority] = useState(defaults?.priority ?? 0);
31- const [assigneeId, setAssigneeId] = useState<number | null>(defaults?.assigneeId ?? null);
32- const [labelIds, setLabelIds] = useState<number[]>(defaults?.labelIds ?? []);
31+ const [assigneeId, setAssigneeId] = useState<string | null>(defaults?.assigneeId ?? null);
32+ const [labelIds, setLabelIds] = useState<string[]>(defaults?.labelIds ?? []);
3333 const [estimate, setEstimate] = useState<number | null>(defaults?.estimate ?? null);
3434 const [dueDate, setDueDate] = useState<string | null>(defaults?.dueDate ?? null);
3535 const [createMore, setCreateMore] = useState(false);
+8−8components/issues/issue-tracker.tsxView File
@@ -107,7 +107,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[];
107107 (!q || i.title.toLowerCase().includes(q) || `#${i.number}` === q || String(i.number) === q) &&
108108 (view.assignee.length === 0 || view.assignee.includes(i.assigneeId === null ? "none" : String(i.assigneeId))) &&
109109 (view.priority.length === 0 || view.priority.includes(String(i.priority))) &&
110- (view.label.length === 0 || i.labels.some((l) => view.label.includes(String(l.id)))),
110+ (view.label.length === 0 || i.labels.some((l) => view.label.includes(l.id))),
111111 )
112112 .sort(COMPARATORS[view.order]);
113113
@@ -393,7 +393,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[];
393393 </Tooltip>
394394 );
395395
396- const mine = view.assignee.length === 1 && view.assignee[0] === String(viewerId);
396+ const mine = view.assignee.length === 1 && view.assignee[0] === viewerId;
397397
398398 return (
399399 <div>
@@ -494,7 +494,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[];
494494 options={[
495495 { value: "none", label: "No assignee", icon: <PersonIcon width={14} height={14} /> },
496496 ...members.map((m) => ({
497- value: String(m.id),
497+ value: m.id,
498498 label: m.id === viewerId ? `${m.username} (you)` : m.username,
499499 icon: <Avatar name={m.username} size={16} />,
500500 })),
@@ -512,10 +512,10 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[];
512512 icon={<TagIcon width={14} height={14} />}
513513 value={view.label}
514514 onChange={(v) => set("label", v)}
515- options={labels.map((l) => ({ value: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))}
515+ options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))}
516516 />
517517 {viewerId !== null && members.some((m) => m.id === viewerId) && (
518- <Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [String(viewerId)])}>
518+ <Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [viewerId])}>
519519 My issues
520520 </Toggle>
521521 )}
@@ -704,7 +704,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member
704704 label: m.username,
705705 icon: <Avatar name={m.username} size={16} />,
706706 patch: { assigneeId: m.id },
707- issues: by((i) => i.assigneeId, m.id as number | null),
707+ issues: by((i) => i.assigneeId, m.id as string | null),
708708 })),
709709 ];
710710 // People who were assigned before losing access still get a (read-only) column.
@@ -719,7 +719,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member
719719 label: username,
720720 icon: <Avatar name={username} size={16} />,
721721 patch: null,
722- issues: by((i) => i.assigneeId, id as number | null),
722+ issues: by((i) => i.assigneeId, id as string | null),
723723 });
724724 }
725725 groups.push({
@@ -731,7 +731,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member
731731 </span>
732732 ),
733733 patch: { assigneeId: null },
734- issues: by((i) => i.assigneeId, null as number | null),
734+ issues: by((i) => i.assigneeId, null as string | null),
735735 });
736736 return groups;
737737 }
+8−8components/issues/properties.tsxView File
@@ -76,7 +76,7 @@ function Unassigned() {
7676 );
7777 }
7878
79- export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: Control<number | null>) {
79+ export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: Control<string | null>) {
8080 const { members, viewerId } = useTracker();
8181 const assignee = members.find((m) => m.id === value);
8282 // The viewer first, like Linear's "assign to me".
@@ -88,13 +88,13 @@ export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: C
8888 options={[
8989 { value: "", label: "No assignee", icon: <PersonIcon width={14} height={14} /> },
9090 ...sorted.map((m) => ({
91- value: String(m.id),
91+ value: m.id,
9292 label: m.id === viewerId ? `${m.username} (you)` : m.username,
9393 icon: <Avatar name={m.username} size={16} />,
9494 })),
9595 ]}
96- value={value === null ? "" : String(value)}
97- onSelect={(v) => onChange(v ? Number(v) : null)}
96+ value={value ?? ""}
97+ onSelect={(v) => onChange(v || null)}
9898 triggerClassName={TRIGGER[variant]}
9999 {...rest}
100100 >
@@ -108,7 +108,7 @@ export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: C
108108 );
109109 }
110110
111- export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Control<number[]>) {
111+ export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Control<string[]>) {
112112 const { labels, createLabel, canTriage } = useTracker();
113113 const chosen = labels.filter((l) => value.includes(l.id));
114114 return (
@@ -116,9 +116,9 @@ export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Con
116116 multiple
117117 label="Labels"
118118 placeholder="Add labels…"
119- options={labels.map((l) => ({ value: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))}
120- value={value.map(String)}
121- onSelect={(v) => onChange(v.map(Number))}
119+ options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))}
120+ value={value}
121+ onSelect={(v) => onChange(v)}
122122 onCreate={
123123 canTriage
124124 ? async (name) => {
+1−1components/issues/tracker-context.tsxView File
@@ -13,7 +13,7 @@ type Tracker = {
1313 base: string;
1414 members: Member[];
1515 labels: IssueLabel[];
16- viewerId: number | null;
16+ viewerId: string | null;
1717 /** Push access: may set priority, assignee, labels, estimates and reorder. */
1818 canTriage: boolean;
1919 /** Today as YYYY-MM-DD, from the server, for overdue highlighting. */
+3−2components/repo-tabs.tsxView File
@@ -3,7 +3,7 @@
33 import Link from "next/link";
44 import { usePathname } from "next/navigation";
55 import { useState } from "react";
6- import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon } from "./icons";
6+ import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon, PlayIcon } from "./icons";
77 import { SlidingIndicator, thumbClass } from "./sliding-indicator";
88
99 /**
@@ -24,8 +24,9 @@ export function RepoTabs({
2424 const pathname = usePathname();
2525 const section = pathname.slice(base.length).split("/")[1] ?? "";
2626 const tabs = [
27- { href: base, label: "Code", icon: CodeIcon, active: !["issues", "branches", "commits", "commit", "packages", "settings"].includes(section) },
27+ { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "branches", "commits", "commit", "packages", "settings"].includes(section) },
2828 { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" },
29+ { href: `${base}/actions`, label: "Actions", icon: PlayIcon, active: section === "actions" },
2930 { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" },
3031 { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" },
3132 { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" },
+21−0docker-compose.ymladdedView File
@@ -0,0 +1,21 @@
1+ services:
2+ vectrum:
3+ build: .
4+ image: vectrum
5+ restart: unless-stopped
6+ ports:
7+ - "3000:3000" # web UI and git over HTTPS (put a TLS reverse proxy in front)
8+ - "2222:2222" # git over SSH; clone URLs show SSH_PORT (2222), so keep both sides the same
9+ environment:
10+ # Close registration: the sign-up page and button go away, existing users can still sign in.
11+ # Create accounts with: docker compose exec -it vectrum node scripts/create-user.mjs <username> <email>
12+ SIGNUPS_DISABLED: "0"
13+ # Host name shown in SSH clone URLs (defaults to the host the web UI was reached on).
14+ # SSH_HOST: vectrum.example.com
15+ # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above).
16+ # SSH_DISABLED: "1"
17+ volumes:
18+ - vectrum-data:/data # repositories, the PocketBase database and the SSH host key
19+
20+ volumes:
21+ vectrum-data:
+38−28lib/auth.tsView File
@@ -1,52 +1,62 @@
11 import "server-only";
2- import { createHash, randomBytes } from "node:crypto";
32 import { cache } from "react";
43 import { cookies, headers } from "next/headers";
54 import { redirect } from "next/navigation";
6- import { db } from "./db";
7- import { getUserById } from "./users";
5+ import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase";
6+ import { client, db } from "./db";
7+ import { authenticateUser, toUser } from "./users";
8+
9+ // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the
10+ // users collection's token duration) and stop working when the password or email changes.
811
912 const SESSION_COOKIE = "session";
10- const SESSION_DAYS = 30;
11-
12- const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
13-
14- export async function startSession(userId: number) {
15- const token = randomBytes(32).toString("base64url");
16- const now = Date.now();
17- const expires = now + SESSION_DAYS * 86400e3;
18- db.prepare("INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
19- sha256(token),
20- userId,
21- now,
22- expires,
23- );
13+ const SESSION_SECONDS = 30 * 86400;
14+
15+ async function setSessionCookie(token: string) {
2416 const h = await headers();
2517 (await cookies()).set(SESSION_COOKIE, token, {
2618 httpOnly: true,
2719 sameSite: "lax",
2820 secure: h.get("x-forwarded-proto") === "https",
2921 path: "/",
30- expires: new Date(expires),
22+ expires: new Date(getTokenPayload(token).exp * 1000),
3123 });
3224 }
3325
26+ /** Checks a username or email and password and starts a session. Returns the user, or null. */
27+ export async function signIn(login: string, password: string) {
28+ const auth = await authenticateUser(login, password);
29+ if (!auth) return null;
30+ await setSessionCookie(auth.token);
31+ return auth.user;
32+ }
33+
34+ /** A new session for a signed-in user whose old token stopped working (after an email change). */
35+ export async function renewSession(userId: string) {
36+ const impersonated = await (await db()).collection("users").impersonate(userId, SESSION_SECONDS);
37+ await setSessionCookie(impersonated.authStore.token);
38+ }
39+
3440 export async function endSession() {
35- const jar = await cookies();
36- const token = jar.get(SESSION_COOKIE)?.value;
37- if (token) db.prepare("DELETE FROM sessions WHERE id = ?").run(sha256(token));
38- jar.delete(SESSION_COOKIE);
41+ (await cookies()).delete(SESSION_COOKIE);
3942 }
4043
4144 /** The signed-in user for this request, or null. */
4245 export const getViewer = cache(async () => {
4346 const token = (await cookies()).get(SESSION_COOKIE)?.value;
44- if (!token) return null;
45- const session = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?").get(sha256(token)) as
46- | { user_id: number; expires_at: number }
47- | undefined;
48- if (!session || session.expires_at < Date.now()) return null;
49- return getUserById(session.user_id);
47+ if (!token || isTokenExpired(token)) return null;
48+ const { id, type } = getTokenPayload(token);
49+ if (type !== "auth" || typeof id !== "string") return null;
50+ await db(); // makes sure PocketBase is running
51+ // Reading the record with the user's own token is what checks the token.
52+ const pb = client();
53+ pb.authStore.save(token);
54+ try {
55+ return toUser(await pb.collection("users").getOne(id));
56+ } catch (e) {
57+ if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null;
58+ throw e;
59+ }
5060 });
5161
5262 /** Returns the signed-in user, or redirects to the login page. */
+106−0lib/ci.tsaddedView File
@@ -0,0 +1,106 @@
1+ import { getCommit, git, listTree, readBlob, revParse } from "./git";
2+ import type { RepoRecord } from "./repos";
3+ import { createRun, type Run, type RunEvent } from "./runs";
4+ import { isWorkflowFile, matchesFilter, matchesPaths, parseWorkflow, WORKFLOW_DIR, WorkflowError, type Workflow } from "./workflows";
5+
6+ // Starting workflow runs: on pushes (over SSH, HTTPS or from the web editor), by hand, and again.
7+ // Like lib/git.ts this is used by the SSH server, so it must not import anything from next/*.
8+
9+ export class CiError extends Error {}
10+
11+ type WorkflowFile = { path: string; workflow: Workflow } | { path: string; error: string; name: string };
12+
13+ /** The workflow files at a commit, parsed; files that don't parse carry their error instead. */
14+ export async function readWorkflows(dir: string, sha: string): Promise<WorkflowFile[]> {
15+ const entries = await listTree(dir, sha, WORKFLOW_DIR).catch(() => []);
16+ const files: WorkflowFile[] = [];
17+ for (const entry of entries) {
18+ if (entry.type !== "blob" || !isWorkflowFile(entry.name)) continue;
19+ files.push(await readWorkflow(dir, sha, entry.path));
20+ }
21+ return files;
22+ }
23+
24+ async function readWorkflow(dir: string, sha: string, path: string): Promise<WorkflowFile> {
25+ const name = path.split("/").pop()!.replace(/\.ya?ml$/i, "");
26+ const blob = await readBlob(dir, sha, path).catch(() => null);
27+ if (!blob) return { path, name, error: `${path} doesn't exist at this commit.` };
28+ if (blob.tooLarge || blob.binary) return { path, name, error: "The workflow file is too large or not text." };
29+ try {
30+ return { path, workflow: parseWorkflow(blob.content.toString("utf8"), path) };
31+ } catch (e) {
32+ if (e instanceof WorkflowError) return { path, name, error: e.message };
33+ throw e;
34+ }
35+ }
36+
37+ async function start(repo: RepoRecord, file: WorkflowFile, run: { event: RunEvent; ref: string; sha: string; actorId: string | null }) {
38+ const commit = await getCommit(repo.dir, run.sha);
39+ return createRun({
40+ repoId: repo.id,
41+ workflowPath: file.path,
42+ workflowName: "workflow" in file ? file.workflow.name : file.name,
43+ ...run,
44+ title: commit?.subject || run.sha.slice(0, 7),
45+ jobs: "workflow" in file ? file.workflow.jobs : { error: file.error },
46+ });
47+ }
48+
49+ /** Files changed between two commits, or null when that isn't known (a new branch). */
50+ async function changedFiles(dir: string, before: string | null, after: string) {
51+ if (!before) return null;
52+ const out = await git(dir, ["diff", "--name-only", "-z", before, after]).catch(() => null);
53+ return out === null ? null : out.split("\0").filter(Boolean);
54+ }
55+
56+ /** Starts the workflows that run on a push of `ref` (refs/heads/… or refs/tags/…). */
57+ async function onPush(repo: RepoRecord, ref: string, before: string | null, after: string, actorId: string | null) {
58+ const sha = await revParse(repo.dir, after);
59+ if (!sha) return;
60+ const tag = ref.startsWith("refs/tags/");
61+ const name = ref.replace(/^refs\/(heads|tags)\//, "");
62+ let changed: string[] | null | undefined;
63+
64+ for (const file of await readWorkflows(repo.dir, sha)) {
65+ // A broken file can't say when it runs, so it fails on every push, where its author will see it.
66+ if ("workflow" in file) {
67+ const push = file.workflow.push;
68+ if (!push || !matchesFilter(tag ? push.tags : push.branches, name)) continue;
69+ if (!tag) {
70+ changed ??= await changedFiles(repo.dir, before, sha);
71+ if (!matchesPaths(push.paths, changed)) continue;
72+ }
73+ }
74+ await start(repo, file, { event: "push", ref, sha, actorId });
75+ }
76+ }
77+
78+ /** Starts workflows for whatever a push changed, given the refs before and after it. */
79+ export async function onRefsUpdated(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actorId: string | null) {
80+ for (const [ref, sha] of after) {
81+ const old = before.get(ref) ?? null;
82+ if (old === sha) continue;
83+ await onPush(repo, ref, old, sha, actorId);
84+ }
85+ }
86+
87+ /** For callers that shouldn't wait on (or fail because of) workflows starting. */
88+ export function startWorkflowsInBackground(work: Promise<unknown>) {
89+ work.catch((e) => console.error("[ci] could not start workflows:", e));
90+ }
91+
92+ /** Starts a workflow by hand on a branch, if it has `on: workflow_dispatch`. Returns the run number. */
93+ export async function dispatchWorkflow(repo: RepoRecord, path: string, branch: string, actorId: string) {
94+ const sha = await revParse(repo.dir, `refs/heads/${branch}`);
95+ if (!sha) throw new CiError(`There is no branch named ${branch}.`);
96+ const file = await readWorkflow(repo.dir, sha, path);
97+ if ("error" in file) throw new CiError(file.error);
98+ if (!file.workflow.dispatch) throw new CiError("This workflow can't be run by hand. Add workflow_dispatch to its on: triggers.");
99+ return start(repo, file, { event: "workflow_dispatch", ref: `refs/heads/${branch}`, sha, actorId });
100+ }
101+
102+ /** Runs a run's workflow again, as it was at the run's commit. Returns the new run's number. */
103+ export async function rerunWorkflow(repo: RepoRecord, run: Run, actorId: string) {
104+ const file = await readWorkflow(repo.dir, run.sha, run.workflowPath);
105+ return start(repo, file, { event: run.event, ref: run.ref, sha: run.sha, actorId });
106+ }
+152−225lib/db.tsView File
@@ -1,238 +1,165 @@
11 import "server-only";
2- import { mkdirSync } from "node:fs";
2+ import { execFile, spawn } from "node:child_process";
3+ import { randomBytes } from "node:crypto";
4+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
35 import path from "node:path";
4- import { DatabaseSync } from "node:sqlite";
6+ import { promisify } from "node:util";
7+ import PocketBase, { ClientResponseError, isTokenExpired, type RecordModel } from "pocketbase";
58 import { DATA_ROOT } from "./paths";
69
7- const SCHEMA = `
8- CREATE TABLE IF NOT EXISTS users (
9- id INTEGER PRIMARY KEY,
10- username TEXT NOT NULL UNIQUE COLLATE NOCASE,
11- name TEXT NOT NULL DEFAULT '',
12- email TEXT NOT NULL UNIQUE COLLATE NOCASE,
13- password_hash TEXT NOT NULL,
14- bio TEXT NOT NULL DEFAULT '',
15- created_at INTEGER NOT NULL
16- );
17-
18- CREATE TABLE IF NOT EXISTS sessions (
19- id TEXT PRIMARY KEY, -- sha256 of the cookie token
20- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
21- created_at INTEGER NOT NULL,
22- expires_at INTEGER NOT NULL
23- );
24-
25- CREATE TABLE IF NOT EXISTS ssh_keys (
26- id INTEGER PRIMARY KEY,
27- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
28- title TEXT NOT NULL,
29- key_type TEXT NOT NULL,
30- public_key TEXT NOT NULL, -- "ssh-ed25519 AAAA..." without comment
31- fingerprint TEXT NOT NULL UNIQUE, -- SHA256:...
32- created_at INTEGER NOT NULL,
33- last_used_at INTEGER
34- );
35-
36- CREATE TABLE IF NOT EXISTS api_tokens (
37- id INTEGER PRIMARY KEY,
38- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
39- name TEXT NOT NULL,
40- token_hash TEXT NOT NULL UNIQUE, -- sha256 of the token
41- hint TEXT NOT NULL, -- last 4 chars, shown in the UI
42- permission TEXT NOT NULL CHECK (permission IN ('read', 'write')),
43- all_repos INTEGER NOT NULL DEFAULT 1,
44- expires_at INTEGER,
45- created_at INTEGER NOT NULL,
46- last_used_at INTEGER
47- );
48-
49- CREATE TABLE IF NOT EXISTS repos (
50- id INTEGER PRIMARY KEY,
51- owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
52- name TEXT NOT NULL COLLATE NOCASE,
53- description TEXT NOT NULL DEFAULT '',
54- visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
55- created_at INTEGER NOT NULL,
56- pushed_at INTEGER,
57- UNIQUE (owner_id, name)
58- );
59-
60- -- An uploaded repository image. Without a row, the owner's avatar is shown.
61- CREATE TABLE IF NOT EXISTS repo_images (
62- repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
63- content_type TEXT NOT NULL,
64- data BLOB NOT NULL,
65- updated_at INTEGER NOT NULL
66- );
67-
68- CREATE TABLE IF NOT EXISTS api_token_repos (
69- token_id INTEGER NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE,
70- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
71- PRIMARY KEY (token_id, repo_id)
72- );
73-
74- CREATE TABLE IF NOT EXISTS collaborators (
75- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
76- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
77- created_at INTEGER NOT NULL,
78- PRIMARY KEY (repo_id, user_id)
79- );
80-
81- CREATE TABLE IF NOT EXISTS issues (
82- id INTEGER PRIMARY KEY,
83- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
84- number INTEGER NOT NULL,
85- title TEXT NOT NULL,
86- body TEXT NOT NULL DEFAULT '',
87- author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
88- state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
89- created_at INTEGER NOT NULL,
90- updated_at INTEGER NOT NULL,
91- UNIQUE (repo_id, number)
92- );
93-
94- CREATE TABLE IF NOT EXISTS labels (
95- id INTEGER PRIMARY KEY,
96- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
97- name TEXT NOT NULL COLLATE NOCASE,
98- color TEXT NOT NULL, -- #rrggbb
99- UNIQUE (repo_id, name)
100- );
101-
102- CREATE TABLE IF NOT EXISTS issue_labels (
103- issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
104- label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
105- PRIMARY KEY (issue_id, label_id)
106- );
107-
108- -- Property changes, shown in an issue's activity feed. Values are stored as
109- -- they read at the time (status ids, priority numbers, usernames, label names).
110- CREATE TABLE IF NOT EXISTS issue_events (
111- id INTEGER PRIMARY KEY,
112- issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
113- actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
114- field TEXT NOT NULL,
115- old_value TEXT,
116- new_value TEXT,
117- created_at INTEGER NOT NULL
118- );
119-
120- CREATE TABLE IF NOT EXISTS issue_comments (
121- id INTEGER PRIMARY KEY,
122- issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
123- author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
124- body TEXT NOT NULL,
125- created_at INTEGER NOT NULL
126- );
127-
128- -- Container registry. Blob contents live under registry/blobs, shared by all repositories.
129- CREATE TABLE IF NOT EXISTS registry_blobs (
130- digest TEXT PRIMARY KEY, -- sha256:…
131- size INTEGER NOT NULL,
132- created_at INTEGER NOT NULL
133- );
134-
135- -- The blobs a repository may serve: pushed to it or mounted from another repository.
136- -- created_at is refreshed on use; unreferenced links are pruned once it's old.
137- CREATE TABLE IF NOT EXISTS registry_repo_blobs (
138- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
139- digest TEXT NOT NULL REFERENCES registry_blobs(digest) ON DELETE CASCADE,
140- created_at INTEGER NOT NULL,
141- PRIMARY KEY (repo_id, digest)
142- );
143-
144- CREATE TABLE IF NOT EXISTS registry_uploads (
145- id TEXT PRIMARY KEY, -- file name under registry/uploads
146- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
147- size INTEGER NOT NULL,
148- created_at INTEGER NOT NULL,
149- updated_at INTEGER NOT NULL
150- );
151-
152- -- image is the name below owner/repo ('' for the repository's own image).
153- CREATE TABLE IF NOT EXISTS registry_manifests (
154- repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
155- image TEXT NOT NULL,
156- digest TEXT NOT NULL,
157- media_type TEXT NOT NULL,
158- content BLOB NOT NULL,
159- size INTEGER NOT NULL, -- config and layers, compressed
160- subject TEXT, -- digest of the manifest this one is attached to
161- artifact_type TEXT,
162- created_at INTEGER NOT NULL,
163- PRIMARY KEY (repo_id, image, digest)
164- );
165-
166- -- What a manifest references: its config and layer blobs, or an index's manifests.
167- CREATE TABLE IF NOT EXISTS registry_manifest_refs (
168- repo_id INTEGER NOT NULL,
169- image TEXT NOT NULL,
170- manifest_digest TEXT NOT NULL,
171- digest TEXT NOT NULL,
172- PRIMARY KEY (repo_id, image, manifest_digest, digest),
173- FOREIGN KEY (repo_id, image, manifest_digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE
174- );
175- CREATE INDEX IF NOT EXISTS registry_manifest_refs_digest ON registry_manifest_refs (repo_id, digest);
176-
177- CREATE TABLE IF NOT EXISTS registry_tags (
178- repo_id INTEGER NOT NULL,
179- image TEXT NOT NULL,
180- tag TEXT NOT NULL,
181- digest TEXT NOT NULL,
182- updated_at INTEGER NOT NULL,
183- PRIMARY KEY (repo_id, image, tag),
184- FOREIGN KEY (repo_id, image, digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE
185- );
186- `;
187-
188- function open() {
189- mkdirSync(DATA_ROOT, { recursive: true });
190- const db = new DatabaseSync(path.join(DATA_ROOT, "app.db"));
191- db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
192- return db;
10+ export type { RecordModel };
11+
12+ /*
13+ * The data lives in PocketBase. Unless PB_URL points at a server of your own, Vectrum runs one
14+ * itself: bin/pocketbase (installed by scripts/install-pocketbase.mjs) with its data in
15+ * data/pb_data and the schema from pb_migrations/. The server talks to it as a superuser.
16+ */
17+
18+ const MANAGED = !process.env.PB_URL;
19+ export const PB_URL = (process.env.PB_URL ?? `http://127.0.0.1:${process.env.PB_PORT ?? 8090}`).replace(/\/+$/, "");
20+ const BINARY = process.env.PB_BINARY ?? path.join(process.cwd(), "bin", process.platform === "win32" ? "pocketbase.exe" : "pocketbase");
21+ const PB_DATA = path.join(DATA_ROOT, "pb_data");
22+ const MIGRATIONS = path.join(process.cwd(), "pb_migrations");
23+
24+ type Credentials = { email: string; password: string };
25+
26+ /** From the environment, or (for the server Vectrum runs) generated once and kept in the data directory. */
27+ function superuserCredentials(): Credentials {
28+ const { PB_SUPERUSER_EMAIL: email, PB_SUPERUSER_PASSWORD: password } = process.env;
29+ if (email && password) return { email, password };
30+ if (!MANAGED) throw new Error("Set PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD to use the PocketBase server at PB_URL.");
31+ const file = path.join(DATA_ROOT, "pocketbase-superuser.json");
32+ if (!existsSync(file)) {
33+ mkdirSync(DATA_ROOT, { recursive: true });
34+ const generated = { email: "superuser@vectrum.local", password: randomBytes(24).toString("base64url") };
35+ writeFileSync(file, JSON.stringify(generated, null, 2), { mode: 0o600 });
36+ }
37+ return JSON.parse(readFileSync(file, "utf8")) as Credentials;
38+ }
39+
40+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
41+
42+ async function healthy() {
43+ try {
44+ return (await fetch(`${PB_URL}/api/health`, { signal: AbortSignal.timeout(1000) })).ok;
45+ } catch {
46+ return false;
47+ }
48+ }
49+
50+ /** Starts PocketBase unless it already runs (e.g. left over from before a restart). */
51+ async function ensureServer(credentials: Credentials) {
52+ if (await healthy()) return;
53+ if (!MANAGED) throw new Error(`PocketBase isn't reachable at ${PB_URL}.`);
54+ if (!existsSync(BINARY)) throw new Error(`PocketBase isn't installed at ${BINARY}. Run \`bun run pocketbase:install\`.`);
55+
56+ const dirs = [`--dir=${PB_DATA}`, `--migrationsDir=${MIGRATIONS}`];
57+ await promisify(execFile)(BINARY, ["superuser", "upsert", credentials.email, credentials.password, ...dirs]);
58+ const child = spawn(BINARY, ["serve", `--http=${new URL(PB_URL).host}`, ...dirs], { stdio: ["ignore", "ignore", "inherit"] });
59+ process.once("exit", () => child.kill());
60+ child.once("exit", (code) => console.error(`[pocketbase] exited with code ${code}`));
61+
62+ for (const deadline = Date.now() + 20_000; Date.now() < deadline; ) {
63+ if (await healthy()) return console.log(`[pocketbase] listening on ${PB_URL}`);
64+ if (child.exitCode !== null) break;
65+ await sleep(100);
66+ }
67+ throw new Error("PocketBase didn't start. See its output above.");
68+ }
69+
70+ type Connection = { pb: PocketBase; credentials: Credentials; reauth?: Promise<unknown> };
71+
72+ async function connect(): Promise<Connection> {
73+ const credentials = superuserCredentials();
74+ await ensureServer(credentials);
75+ const pb = client();
76+ await pb.collection("_superusers").authWithPassword(credentials.email, credentials.password);
77+ return { pb, credentials };
78+ }
79+
80+ /** A client without credentials. Requests run concurrently, so the SDK mustn't cancel duplicates. */
81+ export function client() {
82+ const pb = new PocketBase(PB_URL);
83+ pb.autoCancellation(false);
84+ return pb;
85+ }
86+
87+ // One connection and one set of locks for the whole process: Next.js may load this module more than
88+ // once (route handlers, pages, instrumentation), and reloads it in development.
89+ const g = globalThis as unknown as { __pocketbase?: Promise<Connection>; __dbLocks?: Map<string, Promise<unknown>> };
90+
91+ /** The superuser client. */
92+ export async function db() {
93+ const conn = await (g.__pocketbase ??= connect().catch((e) => {
94+ g.__pocketbase = undefined;
95+ throw e;
96+ }));
97+ if (isTokenExpired(conn.pb.authStore.token, 60)) {
98+ await (conn.reauth ??= conn.pb
99+ .collection("_superusers")
100+ .authWithPassword(conn.credentials.email, conn.credentials.password)
101+ .finally(() => (conn.reauth = undefined)));
102+ }
103+ return conn.pb;
193104 }
194105
195- // Reuse one connection across hot reloads in development. The schema is
196- // re-applied on every load (it's idempotent) so new tables reach a reused connection.
197- const g = globalThis as unknown as { __db?: DatabaseSync };
198- export const db = (g.__db ??= open());
199- db.exec(SCHEMA);
200-
201- // Columns added to existing tables after their first release. CREATE TABLE IF NOT
202- // EXISTS leaves old tables alone, so missing columns are added here.
203- const ADDED_COLUMNS: { table: string; column: string; definition: string; backfill?: string }[] = [
204- {
205- table: "issues",
206- column: "status",
207- definition: `TEXT NOT NULL DEFAULT 'todo'
208- CHECK (status IN ('backlog', 'todo', 'in_progress', 'in_review', 'done', 'canceled'))`,
209- // `state` stays in sync with `status`: done and canceled issues are closed.
210- backfill: "UPDATE issues SET status = CASE state WHEN 'closed' THEN 'done' ELSE 'todo' END",
211- },
212- // 0 = no priority, 1 = urgent … 4 = low
213- { table: "issues", column: "priority", definition: "INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4)" },
214- { table: "issues", column: "assignee_id", definition: "INTEGER REFERENCES users(id) ON DELETE SET NULL" },
215- { table: "issues", column: "estimate", definition: "INTEGER" },
216- { table: "issues", column: "due_date", definition: "TEXT" }, // YYYY-MM-DD
217- // Manual order on the board and in lists; new issues go to the top.
218- { table: "issues", column: "sort_order", definition: "REAL NOT NULL DEFAULT 0", backfill: "UPDATE issues SET sort_order = -created_at" },
219- ];
220-
221- for (const { table, column, definition, backfill } of ADDED_COLUMNS) {
222- const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
223- if (columns.some((c) => c.name === column)) continue;
224- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
225- if (backfill) db.exec(backfill);
106+ /** A record id, for records created in batches that reference each other. */
107+ export function newId() {
108+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
109+ return Array.from(randomBytes(15), (b) => alphabet[b % alphabet.length]).join("");
226110 }
227111
228- export function transaction<T>(fn: () => T): T {
229- db.exec("BEGIN IMMEDIATE");
112+ /** The first record matching a filter, or null. */
113+ export async function findFirst<T = RecordModel>(collection: string, filter: string, options: { sort?: string; expand?: string; fields?: string } = {}) {
114+ const { items } = await (await db()).collection(collection).getList<T>(1, 1, { filter, skipTotal: true, ...options });
115+ return items[0] ?? null;
116+ }
117+
118+ /** A record by id, or null. */
119+ export async function findById<T = RecordModel>(collection: string, id: string, options: { expand?: string } = {}) {
120+ if (!id) return null;
230121 try {
231- const result = fn();
232- db.exec("COMMIT");
233- return result;
122+ return await (await db()).collection(collection).getOne<T>(id, options);
234123 } catch (e) {
235- db.exec("ROLLBACK");
124+ if (e instanceof ClientResponseError && e.status === 404) return null;
236125 throw e;
237126 }
238127 }
128+
129+ /** How many records match a filter. */
130+ export async function count(collection: string, filter: string) {
131+ return (await (await db()).collection(collection).getList(1, 1, { filter, fields: "id" })).totalItems;
132+ }
133+
134+ /** Builds a filter expression with safely quoted parameters. */
135+ export function filter(expression: string, params: Record<string, unknown> = {}) {
136+ return client().filter(expression, params);
137+ }
138+
139+ /** Whether a request failed because a value broke a unique index. */
140+ export function isUniqueViolation(e: unknown) {
141+ if (!(e instanceof ClientResponseError) || e.status !== 400) return false;
142+ const data = (e.response?.data ?? {}) as Record<string, { code?: string }>;
143+ return Object.values(data).some((v) => v?.code === "validation_not_unique");
144+ }
145+
146+ const locks = (g.__dbLocks ??= new Map());
147+
148+ /**
149+ * Runs `fn` after every earlier call with the same key has finished. PocketBase has no
150+ * transactions over its API, so read-modify-write sequences (numbering issues, the job queue)
151+ * are serialized here. That holds because one Node.js process serves all requests.
152+ */
153+ export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
154+ const previous = locks.get(key) ?? Promise.resolve();
155+ const result = previous.catch(() => {}).then(fn);
156+ const settled = result.catch(() => {});
157+ locks.set(key, settled);
158+ settled.then(() => {
159+ if (locks.get(key) === settled) locks.delete(key);
160+ });
161+ return result;
162+ }
163+
164+ /** 0 and "" stand for "not set" in PocketBase number, text and relation fields. */
165+ export const orNull = <T extends string | number>(value: T | null | undefined): T | null => (value ? value : null);
+9−0lib/format.tsView File
@@ -36,3 +36,12 @@ export function shortSha(sha: string) {
3636 export function encodePath(p: string) {
3737 return p.split("/").map(encodeURIComponent).join("/");
3838 }
39+
40+ /** A run's length: "45s", "3m 12s", "1h 4m". */
41+ export function formatDuration(ms: number) {
42+ const s = Math.max(0, Math.round(ms / 1000));
43+ if (s < 60) return `${s}s`;
44+ const m = Math.floor(s / 60);
45+ if (m < 60) return `${m}m ${s % 60}s`;
46+ return `${Math.floor(m / 60)}h ${m % 60}m`;
47+ }
+46−15lib/git-http.tsView File

Large diffs are not rendered by default.

+11−0lib/git.tsView File

Large diffs are not rendered by default.

+9−9lib/issue-meta.tsView File

Large diffs are not rendered by default.

+185−215lib/issues.tsView File

Large diffs are not rendered by default.

+30−31lib/keys.tsView File

Large diffs are not rendered by default.

+17−0lib/logs.tsaddedView File

Large diffs are not rendered by default.

+7−7lib/registry-http.tsView File

Large diffs are not rendered by default.

+117−18lib/registry.tsView File

Large diffs are not rendered by default.

+2−2lib/repo.tsView File

Large diffs are not rendered by default.

+120−106lib/repos.tsView File

Large diffs are not rendered by default.

+8−0lib/request-origin.tsaddedView File

Large diffs are not rendered by default.

+36−0lib/runner-api.tsaddedView File

Large diffs are not rendered by default.

+165−0lib/runners.tsaddedView File

Large diffs are not rendered by default.

+555−0lib/runs.tsaddedView File

Large diffs are not rendered by default.

+2−0lib/signups.tsaddedView File

Large diffs are not rendered by default.

+18−10lib/ssh-server.tsView File

Large diffs are not rendered by default.

+47−71lib/tokens.tsView File

Large diffs are not rendered by default.

+4−3lib/tracker.tsView File

Large diffs are not rendered by default.

+72−74lib/users.tsView File

Large diffs are not rendered by default.

+298−0lib/workflows.tsaddedView File

Large diffs are not rendered by default.

+2−0next.config.tsView File

Large diffs are not rendered by default.

+6−2package.jsonView File

Large diffs are not rendered by default.

+345−0pb_migrations/1759320000_init.jsaddedView File

Large diffs are not rendered by default.

+390−0public/vectrum-runner.mjsaddedView File

Large diffs are not rendered by default.

+93−0scripts/create-user.mjsaddedView File

Large diffs are not rendered by default.

+60−0scripts/install-pocketbase.mjsaddedView File

Large diffs are not rendered by default.