Codelet logoCodelet

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:

WrittenWhat opens
h3js/h3the default branch
h3js/h3@v2a branch, tag, or sha
nitrojs/nitro@main:examples/hello-worldthat directory, opened as the whole tree
https://github.com/h3js/h3/tree/main/srcthe same, pasted
https://github.com/h3js/h3/blob/main/src/h3.tsthe repository, with that file opened
https://github.com/h3js/h3/pull/1516the 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.

RouteSentAnswered
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

OptionTypeDefaultDescription
urlstring \| (tarball) => stringcodeload directWhere the tarball comes from — see above.
apiUnghungh()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.
groupstringungroupedWhere 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.

Read more in Extensions > Scm.