GitHub
Open any repository from GitHub in the workbench, nothing cloned and nothing installed
github puts any GitHub repository in the workbench. Type owner/repo, or hand it an address
you already have. The tarball is fetched, gunzipped by the browser, and unpacked straight into
the tree — no git, no clone, no server holding a checkout.
import { Workbench, FileSystem } from "codelet/workbench";
import { github } from "codelet/extensions/github";
const workbench = new Workbench({
parent: document.getElementById("app")!,
fs: new FileSystem({}),
extensions: [github({ url: "/api/gh/" })],
});#What a repository may be written as
The field and repo() both accept the short form and every shape GitHub itself hands out:
| Written | What opens |
|---|---|
h3js/h3 | the default branch |
h3js/h3@v2 | a branch, tag, or sha |
nitrojs/nitro@main:examples/hello-world | that directory, opened as the whole tree |
https://github.com/h3js/h3/tree/main/src | the same, pasted |
https://github.com/h3js/h3/blob/main/src/h3.ts | the repository, with that file opened |
https://github.com/h3js/h3/pull/1516 | the pull request's tree |
A #L12 or ?plain=1 is dropped. A route this doesn't know — issues, actions, wiki —
still opens the repository at its default branch, rather than refusing an address GitHub gave
you.
A directory opens as the tree, not as a folder inside one: …/tree/main/examples/x roots
the workspace at examples/x, which is what lets a container run npm install straight in it.
Whatever actually opened is handed back as owner/repo@ref:dir — the same short form, so a link
built from it reopens without asking GitHub the same question twice.
#Proxy required
GitHub only serves tarballs to render.githubusercontent.com, so a browser on your own origin
is refused before it reads a byte. url is where you name an origin that's allowed to ask —
either a prefix string, fetched as ${url}<owner>/<name>?ref=<ref>, or a function for an
address of another shape.
// Any server — Nitro shown, express/hono/a lambda are the same four lines.
export default defineHandler(async (event) => {
const url = event.url;
const [owner, name] = url.pathname.split("/").slice(3);
const ref = url.searchParams.get("ref") || "HEAD";
if (!NAME.test(owner) || !NAME.test(name) || !REF.test(ref)) {
return new Response("Not a repository.\n", { status: 400 });
}
const upstream = await fetch(`https://codeload.github.com/${owner}/${name}/tar.gz/${ref}`);
if (!upstream.ok) return new Response(null, { status: upstream.status === 404 ? 404 : 502 });
return new Response(upstream.body, { headers: { "content-type": "application/gzip" } });
});Stream the body through and don't set content-encoding — the archive is gzip as content, not
as transport. Leave url unset and the extension calls codeload.github.com directly, which a
browser refuses; the error message says so.
Note
Leave it there and every fetch is anonymous, and GitHub rates anonymous callers by address —
sixty an hour on its API. Signing in is what raises that, and it needs two more
routes on the same server. A private repository is out of reach either way: url reaches only
what an anonymous request can, unless your own proxy authenticates the upstream fetch itself.
#Signing in
codelet/extensions/github/auth is a second entry and a second extension: it signs the reader in
to GitHub and registers an authentication provider under the id github. This one then asks it
for a session and puts the token on the tarball fetch — GitHub rates an authenticated caller at
five thousand an hour against an anonymous sixty.
import { github } from "codelet/extensions/github";
import { githubAuth } from "codelet/extensions/github/auth";
extensions: [githubAuth({ relay: "/api/gh/auth" }), github({ url: "/api/gh/" })];relay is two routes you serve, and without it nothing is registered at all — the workbench
is exactly what it was, fetching anonymously. It exists because github.com/login/device/code
and github.com/login/oauth/access_token send no CORS headers, so a page cannot call either; and
because your OAuth app's client id belongs on a server rather than in a bundle where it would be
a second thing to configure.
| Route | Sent | Answered |
|---|---|---|
POST <relay>/device/code | { scopes } | { device_code, user_code, verification_uri, interval, expires_in } |
POST <relay>/access_token | { device_code } | { access_token }, or { error } — authorization_pending, slow_down, expired_token, access_denied |
Which is GitHub's own device flow with client_id added on the way through:
// Both routes, and the whole of the server side. Nitro shown; any framework is the same.
export default defineHandler(async (event) => {
const client_id = process.env.GITHUB_CLIENT_ID;
if (!client_id) return Response.json({ error: "no_client_id" }, { status: 501 });
const body = await event.req.json();
const device = event.url.pathname.endsWith("/device/code");
const answer = await fetch(
device ? "https://github.com/login/device/code" : "https://github.com/login/oauth/access_token",
{
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(
device
? { client_id, scope: (body.scopes ?? []).join(" ") }
: {
client_id,
device_code: body.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
},
),
},
);
return Response.json(await answer.json(), { headers: { "cache-control": "no-store" } });
});Make the OAuth app at Settings → Developer settings → OAuth Apps, and turn Enable Device
Flow on — the flow answers device_flow_disabled until you do. There is no client secret
anywhere: the device flow doesn't use one.
What the reader sees is a code and a dialog: Copy and Continue puts it on the clipboard and
opens github.com/login/device in a second tab, and the footer keeps the code in view while it
waits.
Nothing opens on its own — opening a public repository asks quietly, which puts a row in the
Accounts menu at the foot of the activity bar; the reader clicks that row when they want it.
Signing out is a row in the same menu.
Warning
The token reaches your proxy. It has to: the page cannot fetch its own archive, so the tarball
request goes through url, and that is the request the token is on. Forward the authorization
header to codeload, don't write it down, and answer private rather than public in
cache-control when one was used. A reader who would rather not is a reader who doesn't sign in.
A token GitHub has since revoked doesn't leave you stuck: codeload reads the header and answers 404 rather than ignoring it, so a 404 on a request that carried one is retried without it — the repository opens anonymously and a notice says the sign-in was refused.
One caveat worth having in writing: codeload.github.com isn't GitHub's documented API and its
own limits aren't published. The extension sends the header and your hop forwards it; what that
is worth on the archive endpoint is between you and GitHub.
The token itself lives in this extension's own context.secrets, which is what makes a session
survive a reload (Settings, secrets and stored state). Signing out forgets it — revoking one needs the
client secret, so a token you want dead is one to remove under Authorized OAuth Apps in your
GitHub settings.
#Options
| Option | Type | Default | Description |
|---|---|---|---|
url | string \| (tarball) => string | codeload direct | Where the tarball comes from — see above. |
api | Ungh | ungh() | Where the pane's questions go. Opening a repository never depends on it. |
repo | () => string | — | Which repository to open without being asked, called on activation. |
opened | (name: string) => void | — | A repository opened, named the way it would be typed back. |
command | () => string | — | A shell command to run once the first repository has landed. |
group | string | ungrouped | Where its line sits in the explorer's welcome, group@order. |
githubAuth takes one option, relay: string | (() => string) — the prefix the two routes above
sit under. A function where you read it off the page's own address, since it's called at
activation and the same extension list goes to renderWorkbench(). Nothing named is no provider
registered.
repo and opened are how a host reads and writes its own address bar — the extension never
touches location itself:
github({
url: "/api/gh/",
repo: () => new URL(location.href).searchParams.get("gh") ?? "",
opened: (name) => {
const url = new URL(location.href);
url.searchParams.set("gh", name);
history.replaceState(null, "", url);
},
});Pass command alongside terminal and bash, and ?gh=h3js/h3&run=npm install is a
repository, in a machine, installing — one address, nothing typed. It runs in the last shell
profile the workbench has, once the repository has actually landed.
#What it adds
- A GitHub pane in the activity bar: an Open a repository… field, the repository's description, stars, forks and default branch, and folded lists of branches, releases and contributors.
- GitHub: Open Repository… and GitHub: Browse Repositories in the palette, and the same two as links in the explorer's empty state.
- A status bar item naming whatever is open — click it to open another.
api points the pane's own questions (description, branches, releases, contributors) at
ungh.cc, a public mirror of GitHub's API with no rate limit for you and no
proxy to mount. It answers public repositories only, which is the only kind a tarball can be
fetched for anyway. If ungh is unreachable the pane just says less — opening a repository through
url isn't affected.
#Where the files go, and what's left out
Every repository lands at the root of your tree, beside anything you seeded — not in a workspace
of its own, because codelet/extensions/bash and codelet/extensions/remote both mirror
the tree you're looking at, and a repository opened somewhere else would be invisible to both.
Opening a second repository removes what the first put there and nothing else.
Files over 4MB are skipped and named in the GitHub output channel. A repository over 96MB
unpacked is abandoned partway. Nothing is pushed anywhere — .git isn't in a tarball, and a save
writes into the page and no further.
#Changes since you opened it
The tarball is a base revision, so mounting codelet/extensions/scm beside
this one gets you a real, if read-only, source control: the Source Control pane fills with what
you've changed against the ref you opened, a Discard button puts a file back, and the status
bar shows the ref. Renamed files are recognised as one row rather than two, where the answer
isn't a guess.
import { github } from "codelet/extensions/github";
import { scm } from "codelet/extensions/scm";
extensions: [scm(), github({ url: "/api/gh/" })];Without scm mounted, none of that draws — the registry it reads from is the workbench's own,
so nothing here costs you for not using it.