@nickmeriano/task 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -1
- package/dist/board/github.d.ts +68 -0
- package/dist/board/github.d.ts.map +1 -0
- package/dist/board/github.js +112 -0
- package/dist/board/github.js.map +1 -0
- package/dist/board/handler.d.ts +50 -0
- package/dist/board/handler.d.ts.map +1 -0
- package/dist/board/handler.js +183 -0
- package/dist/board/handler.js.map +1 -0
- package/dist/board/handler.test.d.ts +11 -0
- package/dist/board/handler.test.d.ts.map +1 -0
- package/dist/board/handler.test.js +229 -0
- package/dist/board/handler.test.js.map +1 -0
- package/dist/board/pages-function.d.ts +23 -0
- package/dist/board/pages-function.d.ts.map +1 -0
- package/dist/board/pages-function.js +34 -0
- package/dist/board/pages-function.js.map +1 -0
- package/dist/board/source.d.ts +118 -0
- package/dist/board/source.d.ts.map +1 -0
- package/dist/board/source.js +333 -0
- package/dist/board/source.js.map +1 -0
- package/dist/board/source.test.d.ts +12 -0
- package/dist/board/source.test.d.ts.map +1 -0
- package/dist/board/source.test.js +165 -0
- package/dist/board/source.test.js.map +1 -0
- package/dist/board/tar.d.ts +28 -0
- package/dist/board/tar.d.ts.map +1 -0
- package/dist/board/tar.js +188 -0
- package/dist/board/tar.js.map +1 -0
- package/dist/board/tar.test.d.ts +9 -0
- package/dist/board/tar.test.d.ts.map +1 -0
- package/dist/board/tar.test.js +110 -0
- package/dist/board/tar.test.js.map +1 -0
- package/dist/cli.js +37 -2
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +31 -0
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +61 -1
- package/dist/export.js.map +1 -1
- package/dist/export.test.js +80 -1
- package/dist/export.test.js.map +1 -1
- package/dist/functions/board.js +709 -0
- package/dist/git-serve.d.ts +14 -1
- package/dist/git-serve.d.ts.map +1 -1
- package/dist/git-serve.js +30 -2
- package/dist/git-serve.js.map +1 -1
- package/dist/git-serve.test.d.ts +1 -0
- package/dist/git-serve.test.d.ts.map +1 -1
- package/dist/git-serve.test.js +36 -1
- package/dist/git-serve.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skill/SKILL.md +10 -3
- package/src/board/github.ts +151 -0
- package/src/board/handler.test.ts +276 -0
- package/src/board/handler.ts +228 -0
- package/src/board/pages-function.ts +42 -0
- package/src/board/source.test.ts +203 -0
- package/src/board/source.ts +422 -0
- package/src/board/tar.test.ts +128 -0
- package/src/board/tar.ts +199 -0
- package/src/cli.ts +36 -2
- package/src/export.test.ts +108 -1
- package/src/export.ts +79 -1
- package/src/git-serve.test.ts +41 -1
- package/src/git-serve.ts +30 -2
- package/src/index.ts +10 -0
- package/ui/dist/assets/index-B8M_DaOt.js +229 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-mJmm4sWq.js +0 -229
package/README.md
CHANGED
|
@@ -376,7 +376,9 @@ jobs:
|
|
|
376
376
|
`--force`). Hosted anywhere but a domain root, pass the mount point —
|
|
377
377
|
`task export --base /board/` for `example.com/board/` — so the page knows
|
|
378
378
|
where its assets and snapshot live. Point the host's SPA fallback at
|
|
379
|
-
`index.html` so deep links resolve.
|
|
379
|
+
`index.html` so deep links resolve. It also writes a live upgrade for
|
|
380
|
+
Cloudflare Pages that stays dormant until you add a secret — see "Live data
|
|
381
|
+
for an exported board" below; `--no-functions` leaves it out.
|
|
380
382
|
- `task serve` is the one command that also looks *down*: run it at a monorepo
|
|
381
383
|
root and it serves every nested board from one server, with a board switcher
|
|
382
384
|
in the header. Run it inside a package and you get just that board. Every
|
|
@@ -386,6 +388,178 @@ jobs:
|
|
|
386
388
|
files (the database stays on disk as an ignored backup) — newer versions
|
|
387
389
|
read only the text format.
|
|
388
390
|
|
|
391
|
+
## Deploy the board
|
|
392
|
+
|
|
393
|
+
`task export` writes the board out as plain files, so putting it on the web is
|
|
394
|
+
a CI job and nothing else — no server, no database, no runtime. Cloudflare
|
|
395
|
+
Pages is the path this section walks (per-branch preview URLs come free, and
|
|
396
|
+
the later live upgrade has somewhere to land), but the output is an ordinary
|
|
397
|
+
directory: see "Any static host" below.
|
|
398
|
+
|
|
399
|
+
```yaml
|
|
400
|
+
# .github/workflows/board.yml
|
|
401
|
+
name: Board
|
|
402
|
+
on: push
|
|
403
|
+
permissions:
|
|
404
|
+
contents: read
|
|
405
|
+
jobs:
|
|
406
|
+
deploy:
|
|
407
|
+
runs-on: ubuntu-latest
|
|
408
|
+
steps:
|
|
409
|
+
- uses: actions/checkout@v4
|
|
410
|
+
- uses: actions/setup-node@v4
|
|
411
|
+
with:
|
|
412
|
+
node-version: 22
|
|
413
|
+
- run: npx @nickmeriano/task export --out board
|
|
414
|
+
# The board's routes are real paths (/task/x7k4m), so the host has to
|
|
415
|
+
# answer them with index.html instead of a 404.
|
|
416
|
+
- run: echo '/* /index.html 200' > board/_redirects
|
|
417
|
+
- uses: cloudflare/wrangler-action@v4
|
|
418
|
+
with:
|
|
419
|
+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
420
|
+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
421
|
+
command: pages deploy board --project-name=<your-project> --branch=${{ github.ref_name }}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Three things the workflow can't do for you, once:
|
|
425
|
+
|
|
426
|
+
1. Create the Pages project (Direct Upload) and use its name for
|
|
427
|
+
`--project-name`.
|
|
428
|
+
2. Add `CLOUDFLARE_API_TOKEN` as a repository secret — a token with
|
|
429
|
+
**Pages: Edit** on that account, nothing more.
|
|
430
|
+
3. Add `CLOUDFLARE_ACCOUNT_ID` as a repository secret.
|
|
431
|
+
|
|
432
|
+
Notes on the shape:
|
|
433
|
+
|
|
434
|
+
- **`on: push`, every branch, is the point.** A claim is a push, so the
|
|
435
|
+
deployed board tracks claims at CI latency with no extra machinery — and
|
|
436
|
+
`--branch=${{ github.ref_name }}` hands each branch its own Pages preview
|
|
437
|
+
URL, so "the board as this PR would leave it" is a link rather than a
|
|
438
|
+
checkout.
|
|
439
|
+
- **The `_redirects` line is belt and braces here, and required elsewhere.**
|
|
440
|
+
`task export` writes `index.html`, `snapshot.json` and `assets/` — it does
|
|
441
|
+
not write an SPA fallback, and the board's routes are real paths
|
|
442
|
+
(`/task/<key>`, `/goal/<slug>`). Pages already serves `index.html` for
|
|
443
|
+
unmatched paths when the output has no `404.html`, so the line changes
|
|
444
|
+
nothing there; on a host without that default, leaving it out means every
|
|
445
|
+
deep link 404s while the home page looks fine.
|
|
446
|
+
- **`setup-node` is not decoration.** The package needs Node >= 22.13; without
|
|
447
|
+
the step you get the runner's default and a confusing failure.
|
|
448
|
+
- **No `--base`.** Pages serves at a domain root, where the default base `/` is
|
|
449
|
+
right.
|
|
450
|
+
- **The live upgrade is already in the output.** `task export` also writes a
|
|
451
|
+
`functions/` directory holding a Pages Function that stays dormant until
|
|
452
|
+
the project has a token — see "Live data for an exported board" below.
|
|
453
|
+
Turning it on is a secret in the Pages project, never a change to this
|
|
454
|
+
file.
|
|
455
|
+
|
|
456
|
+
### Any static host
|
|
457
|
+
|
|
458
|
+
The output is a directory of static files, so anything that serves those
|
|
459
|
+
works. Two host-side requirements, and they're the whole list:
|
|
460
|
+
|
|
461
|
+
- **An SPA fallback to `index.html`**, however that host spells it
|
|
462
|
+
(`_redirects` on Pages and Netlify, a rewrite rule elsewhere). The routes are
|
|
463
|
+
real paths.
|
|
464
|
+
- **`task export --base /board/`** if you're not serving from a domain root, so
|
|
465
|
+
the page knows where its assets and snapshot live.
|
|
466
|
+
|
|
467
|
+
No platform matrix beyond that — there isn't one to write.
|
|
468
|
+
|
|
469
|
+
### An exported board is as public as wherever you put it
|
|
470
|
+
|
|
471
|
+
Worth saying plainly, because the export itself has no opinion about it: a
|
|
472
|
+
Pages URL is public even when the repository is private. Anyone with the link
|
|
473
|
+
reads your board.
|
|
474
|
+
|
|
475
|
+
If that's not what you want, you have two answers. Put the deploy behind your
|
|
476
|
+
own perimeter (Cloudflare Access and the like) — the export doesn't care what's
|
|
477
|
+
in front of it. Or use `task publish`, whose entire point is the other trade:
|
|
478
|
+
GitHub-synced private access, where who can see the board is whoever can see
|
|
479
|
+
the repo.
|
|
480
|
+
|
|
481
|
+
Which is the shape of the whole picture — same board, three deployments, and
|
|
482
|
+
you pick by how much setup you want:
|
|
483
|
+
|
|
484
|
+
| | Where it runs | Who can see it | Setup |
|
|
485
|
+
| --- | --- | --- | --- |
|
|
486
|
+
| `task serve` | your machine | you | none |
|
|
487
|
+
| `task export` | any static host | whoever you let | a CI job and a host |
|
|
488
|
+
| `task export` + a token | Cloudflare Pages | whoever you let, live | the same, plus one secret |
|
|
489
|
+
| `task publish` | hosted | whoever can see the repo | one command |
|
|
490
|
+
|
|
491
|
+
## Live data for an exported board
|
|
492
|
+
|
|
493
|
+
An export is a snapshot: as fresh as the last CI run, and only of the branch
|
|
494
|
+
it ran on. When that's not enough — you want the board to read the repository
|
|
495
|
+
*now*, or to browse a branch that nobody exported — the same deploy can
|
|
496
|
+
upgrade itself, and the export has already packed what that takes.
|
|
497
|
+
|
|
498
|
+
Every `task export` writes `functions/api/[[path]].js` beside the static
|
|
499
|
+
files: a Cloudflare Pages Function with the repository baked in (worked out
|
|
500
|
+
from your git remotes; `--repo owner/name` sets it when there isn't one, and
|
|
501
|
+
`--no-functions` leaves the file out). Pages deploys anything under
|
|
502
|
+
`functions/` with the rest of the directory, and other hosts ignore it. It
|
|
503
|
+
does nothing until the host has a token — so the workflow above has already
|
|
504
|
+
deployed it, dormant. To wake it, once:
|
|
505
|
+
|
|
506
|
+
1. On GitHub, create a **fine-grained personal access token**: *Only select
|
|
507
|
+
repositories* → this one; *Repository permissions* → **Contents:
|
|
508
|
+
Read-only**, nothing else. That is the whole permission set the function
|
|
509
|
+
needs, and the only one you should give it.
|
|
510
|
+
2. In the Pages project's settings, add it as a secret named
|
|
511
|
+
**`TASK_GITHUB_TOKEN`**.
|
|
512
|
+
|
|
513
|
+
That's the upgrade. The board's boot probe finds an API answering next to it
|
|
514
|
+
and reads from there instead of `snapshot.json`: the staleness banner is gone,
|
|
515
|
+
every view is the repository's current `.task/`, and the header grows the
|
|
516
|
+
branch switcher — `?ref=<branch>` reads any ref, so a PR's board is a link
|
|
517
|
+
again, the way it is on `task publish`. Until the secret is set (or if it
|
|
518
|
+
expires), the function answers 503, the probe reads that as "no API", and the
|
|
519
|
+
same files serve the snapshot as before. Nobody chooses an architecture up
|
|
520
|
+
front; the deploy is whichever tier it's configured for.
|
|
521
|
+
|
|
522
|
+
Still read-only, still by construction: the token can only read, and the
|
|
523
|
+
function refuses anything but a GET before it even looks for one.
|
|
524
|
+
|
|
525
|
+
Two things to know before you turn it on:
|
|
526
|
+
|
|
527
|
+
- **The token is a person's.** Fine-grained PATs expire (a year at most) and
|
|
528
|
+
belong to whoever minted them — when they leave, the board goes back to
|
|
529
|
+
being a snapshot until someone mints a new one. That's the trade for
|
|
530
|
+
needing no GitHub App of your own.
|
|
531
|
+
- **There is no viewer auth in this tier, and a token makes that matter
|
|
532
|
+
more.** An exported snapshot is as public as wherever you put it; a live
|
|
533
|
+
function is *the repository's current `.task/`*, served to anyone who can
|
|
534
|
+
reach the URL, for as long as the secret is set — including after you stop
|
|
535
|
+
deploying. `task export` says so in one line every time it writes the file,
|
|
536
|
+
because the reminder belongs next to the setup. Put the deploy behind your
|
|
537
|
+
own perimeter (Cloudflare Access and the like), or use `task publish`, whose
|
|
538
|
+
whole point is GitHub-synced access without a token of your own.
|
|
539
|
+
|
|
540
|
+
### Any other host
|
|
541
|
+
|
|
542
|
+
The function is a three-line adapter around an exported handler. Anything
|
|
543
|
+
that can run a `(Request) => Promise<Response>` — a Netlify function, a Deno
|
|
544
|
+
or Bun server, a Worker of your own — gets the same live board:
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
import { createBoardHandler } from "@nickmeriano/task"
|
|
548
|
+
|
|
549
|
+
const board = createBoardHandler({
|
|
550
|
+
token: process.env.TASK_GITHUB_TOKEN!, // or an async getter that mints one
|
|
551
|
+
repo: "owner/name",
|
|
552
|
+
basePath: "/api", // where you mount it: `<base>api`
|
|
553
|
+
})
|
|
554
|
+
export default { fetch: (request: Request) => board(request) }
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Mount it at `<base>api` — `/api` at a domain root, `/board/api` under
|
|
558
|
+
`task export --base /board/` — and the SPA finds it the same way. `token` may
|
|
559
|
+
be a function returning a promise, so an adapter that would rather mint an
|
|
560
|
+
installation token from its own GitHub App can, without this package knowing
|
|
561
|
+
anything about App auth.
|
|
562
|
+
|
|
389
563
|
## How it's built
|
|
390
564
|
|
|
391
565
|
- **Zero dependencies.** Storage is markdown files with a tiny frontmatter
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a repository from GitHub, with a bearer token and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* This is the read half of the hosted board's `worker/github.ts`, moved here
|
|
5
|
+
* so an exported board's live proxy (`handler.ts`) and the hosted worker read
|
|
6
|
+
* a repository through the same four calls. What deliberately did *not* move
|
|
7
|
+
* with it: everything that mints or exchanges a credential — App JWTs,
|
|
8
|
+
* installation tokens, the viewer's OAuth flow. Those are the hosted
|
|
9
|
+
* product's auth surface and stay in the worker; here a token is a string
|
|
10
|
+
* somebody already holds (a fine-grained PAT, an installation token the
|
|
11
|
+
* worker minted), and this module only ever spends it on reads.
|
|
12
|
+
*
|
|
13
|
+
* Web-standard only — `fetch`, `Response`, `DecompressionStream` — so the same
|
|
14
|
+
* source runs under Node 22, a Cloudflare Worker, and a Pages Function.
|
|
15
|
+
*/
|
|
16
|
+
import { type TarContents } from "./tar.ts";
|
|
17
|
+
export declare class GitHubError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
constructor(status: number, message: string);
|
|
20
|
+
}
|
|
21
|
+
export interface RepoInfo {
|
|
22
|
+
defaultBranch: string;
|
|
23
|
+
private: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function repoInfo(token: string, owner: string, repo: string): Promise<RepoInfo>;
|
|
26
|
+
/**
|
|
27
|
+
* Branch names, for the board's branch switcher. One page of 100: a selector
|
|
28
|
+
* over more branches than that stops being a selector, and the switcher's
|
|
29
|
+
* search box only filters what's listed. Sorting is the caller's job — it
|
|
30
|
+
* knows the default branch; this endpoint doesn't.
|
|
31
|
+
*/
|
|
32
|
+
export declare function listBranches(token: string, owner: string, repo: string): Promise<string[]>;
|
|
33
|
+
/** A commit's identity, so the board can say how fresh it is. */
|
|
34
|
+
export interface CommitInfo {
|
|
35
|
+
sha: string;
|
|
36
|
+
committedAt: string;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function headCommit(token: string, owner: string, repo: string, ref: string): Promise<CommitInfo>;
|
|
40
|
+
/**
|
|
41
|
+
* Did GitHub just say "that ref doesn't exist"?
|
|
42
|
+
*
|
|
43
|
+
* It says it two different ways, and the obvious one is the rarer one. Asking
|
|
44
|
+
* `/repos/{o}/{r}/commits/{ref}` for a branch that isn't there answers **422**
|
|
45
|
+
* ("No commit found for SHA: …"), not 404 — 404 is what you get when the
|
|
46
|
+
* repository itself is out of reach. Checking only for 404 let every dead
|
|
47
|
+
* `?ref=` link fall through to a generic "GitHub is unhappy — try again"
|
|
48
|
+
* screen: a retry prompt for something no amount of retrying will fix.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isMissingRef(error: unknown): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The whole repository at `sha`, in one request: every file path, plus the
|
|
53
|
+
* bytes of the paths `keep` selects.
|
|
54
|
+
*
|
|
55
|
+
* This replaced two earlier shapes, each broken by arithmetic. Per-file
|
|
56
|
+
* contents reads were one subrequest per ticket, against the 50-subrequest
|
|
57
|
+
* ceiling Workers get on the free plan — every cold board view of this repo
|
|
58
|
+
* 500'd. Batched GraphQL blob reads fixed that, but still cost one tree
|
|
59
|
+
* listing plus one request per 80 files, scaling with board size. A tarball
|
|
60
|
+
* is a single request whatever the repo holds, and it carries the path
|
|
61
|
+
* listing too. Streamed through gzip and a tar walk, keeping bytes only for
|
|
62
|
+
* `keep`-selected paths, so memory is bounded by the boards rather than the
|
|
63
|
+
* repository.
|
|
64
|
+
*
|
|
65
|
+
* The tarball endpoint answers with a redirect to codeload; fetch follows it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function fetchRepoArchive(token: string, owner: string, repo: string, sha: string, keep: (path: string) => boolean): Promise<TarContents>;
|
|
68
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../src/board/github.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAqB,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AAO9D,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;gBACX,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK5C;AAcD,MAAM,WAAW,QAAQ;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,wBAAsB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAM5F;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAMhG;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wBAAsB,UAAU,CAC9B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,UAAU,CAAC,CAUrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,GAC9B,OAAO,CAAC,WAAW,CAAC,CAmBtB"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a repository from GitHub, with a bearer token and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* This is the read half of the hosted board's `worker/github.ts`, moved here
|
|
5
|
+
* so an exported board's live proxy (`handler.ts`) and the hosted worker read
|
|
6
|
+
* a repository through the same four calls. What deliberately did *not* move
|
|
7
|
+
* with it: everything that mints or exchanges a credential — App JWTs,
|
|
8
|
+
* installation tokens, the viewer's OAuth flow. Those are the hosted
|
|
9
|
+
* product's auth surface and stay in the worker; here a token is a string
|
|
10
|
+
* somebody already holds (a fine-grained PAT, an installation token the
|
|
11
|
+
* worker minted), and this module only ever spends it on reads.
|
|
12
|
+
*
|
|
13
|
+
* Web-standard only — `fetch`, `Response`, `DecompressionStream` — so the same
|
|
14
|
+
* source runs under Node 22, a Cloudflare Worker, and a Pages Function.
|
|
15
|
+
*/
|
|
16
|
+
import { readTar, TarError } from "./tar.js";
|
|
17
|
+
const API = "https://api.github.com";
|
|
18
|
+
/** Sent on every call; GitHub rejects requests without a User-Agent. */
|
|
19
|
+
const UA = "@nickmeriano/task";
|
|
20
|
+
export class GitHubError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
constructor(status, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "GitHubError";
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function apiGet(token, path) {
|
|
29
|
+
const res = await fetch(`${API}${path}`, {
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${token}`,
|
|
32
|
+
Accept: "application/vnd.github+json",
|
|
33
|
+
"User-Agent": UA,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new GitHubError(res.status, `GET ${path} failed (${res.status})`);
|
|
38
|
+
return (await res.json());
|
|
39
|
+
}
|
|
40
|
+
export async function repoInfo(token, owner, repo) {
|
|
41
|
+
const data = await apiGet(token, `/repos/${owner}/${repo}`);
|
|
42
|
+
return { defaultBranch: data.default_branch, private: data.private };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Branch names, for the board's branch switcher. One page of 100: a selector
|
|
46
|
+
* over more branches than that stops being a selector, and the switcher's
|
|
47
|
+
* search box only filters what's listed. Sorting is the caller's job — it
|
|
48
|
+
* knows the default branch; this endpoint doesn't.
|
|
49
|
+
*/
|
|
50
|
+
export async function listBranches(token, owner, repo) {
|
|
51
|
+
const data = await apiGet(token, `/repos/${owner}/${repo}/branches?per_page=100`);
|
|
52
|
+
return data.map((branch) => branch.name);
|
|
53
|
+
}
|
|
54
|
+
export async function headCommit(token, owner, repo, ref) {
|
|
55
|
+
const data = await apiGet(token, `/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`);
|
|
56
|
+
return {
|
|
57
|
+
sha: data.sha,
|
|
58
|
+
committedAt: data.commit.committer.date,
|
|
59
|
+
message: data.commit.message.split("\n")[0],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Did GitHub just say "that ref doesn't exist"?
|
|
64
|
+
*
|
|
65
|
+
* It says it two different ways, and the obvious one is the rarer one. Asking
|
|
66
|
+
* `/repos/{o}/{r}/commits/{ref}` for a branch that isn't there answers **422**
|
|
67
|
+
* ("No commit found for SHA: …"), not 404 — 404 is what you get when the
|
|
68
|
+
* repository itself is out of reach. Checking only for 404 let every dead
|
|
69
|
+
* `?ref=` link fall through to a generic "GitHub is unhappy — try again"
|
|
70
|
+
* screen: a retry prompt for something no amount of retrying will fix.
|
|
71
|
+
*/
|
|
72
|
+
export function isMissingRef(error) {
|
|
73
|
+
return error instanceof GitHubError && (error.status === 404 || error.status === 422);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The whole repository at `sha`, in one request: every file path, plus the
|
|
77
|
+
* bytes of the paths `keep` selects.
|
|
78
|
+
*
|
|
79
|
+
* This replaced two earlier shapes, each broken by arithmetic. Per-file
|
|
80
|
+
* contents reads were one subrequest per ticket, against the 50-subrequest
|
|
81
|
+
* ceiling Workers get on the free plan — every cold board view of this repo
|
|
82
|
+
* 500'd. Batched GraphQL blob reads fixed that, but still cost one tree
|
|
83
|
+
* listing plus one request per 80 files, scaling with board size. A tarball
|
|
84
|
+
* is a single request whatever the repo holds, and it carries the path
|
|
85
|
+
* listing too. Streamed through gzip and a tar walk, keeping bytes only for
|
|
86
|
+
* `keep`-selected paths, so memory is bounded by the boards rather than the
|
|
87
|
+
* repository.
|
|
88
|
+
*
|
|
89
|
+
* The tarball endpoint answers with a redirect to codeload; fetch follows it.
|
|
90
|
+
*/
|
|
91
|
+
export async function fetchRepoArchive(token, owner, repo, sha, keep) {
|
|
92
|
+
const res = await fetch(`${API}/repos/${owner}/${repo}/tarball/${encodeURIComponent(sha)}`, {
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${token}`,
|
|
95
|
+
Accept: "application/vnd.github+json",
|
|
96
|
+
"User-Agent": UA,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok || !res.body) {
|
|
100
|
+
throw new GitHubError(res.status, `could not read the repository archive (${res.status})`);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await readTar(res.body.pipeThrough(new DecompressionStream("gzip")), keep);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof TarError) {
|
|
107
|
+
throw new GitHubError(502, `could not read the repository archive (${error.message})`);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=github.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github.js","sourceRoot":"","sources":["../../src/board/github.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live proxy: an exported board's API, as one fetch handler.
|
|
3
|
+
*
|
|
4
|
+
* `task export` ships the board as static files plus a baked `snapshot.json`.
|
|
5
|
+
* Drop this next to them — as a Cloudflare Pages Function, or behind any host
|
|
6
|
+
* that can run a `(Request) => Response` — hand it a token, and the same
|
|
7
|
+
* files become a live board: the SPA probes for an API before it mounts,
|
|
8
|
+
* finds this one answering, and never reads the snapshot at all. No token,
|
|
9
|
+
* no function, and the probe fails and the snapshot is what you get. The
|
|
10
|
+
* upgrade is the deploy, not a rebuild.
|
|
11
|
+
*
|
|
12
|
+
* The routes are `task serve`'s read routes — the same ones the snapshot
|
|
13
|
+
* answers (`ui/src/snapshot.ts`), plus the two things a live proxy is *for*:
|
|
14
|
+
* `?ref=` to read any branch, and `/branches` so the switcher has something to
|
|
15
|
+
* list. Payloads come from `source.ts`, which is what the hosted board serves,
|
|
16
|
+
* so a board rendered from this differs from the hosted one in one thing:
|
|
17
|
+
* who may look. This tier has no viewer auth — the perimeter is wherever you
|
|
18
|
+
* deploy it — and it says so in `/project` the only way the SPA reads:
|
|
19
|
+
* `readOnly: true`, no `git` capability.
|
|
20
|
+
*
|
|
21
|
+
* Read-only by construction: anything but GET is a 405 before a token is
|
|
22
|
+
* even resolved.
|
|
23
|
+
*/
|
|
24
|
+
export interface BoardHandlerOptions {
|
|
25
|
+
/**
|
|
26
|
+
* A bearer token GitHub will accept for reading the repository — a
|
|
27
|
+
* fine-grained PAT with `Contents: Read-only` on this one repo is the
|
|
28
|
+
* shape the README walks through — or a function that produces one, for
|
|
29
|
+
* an adapter that mints its own (an installation token, say). Called per
|
|
30
|
+
* request; cache in the getter if minting is expensive.
|
|
31
|
+
*/
|
|
32
|
+
token: string | (() => string | Promise<string>);
|
|
33
|
+
/** `owner/name`. */
|
|
34
|
+
repo: string;
|
|
35
|
+
/** What a request with no `?ref=` reads. Default `HEAD`: the default branch. */
|
|
36
|
+
ref?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Where the handler is mounted, so it can strip the prefix off incoming
|
|
39
|
+
* paths. `task export` writes `<base>api` here — `/api` at a domain root,
|
|
40
|
+
* `/board/api` under `--base /board/` — matching where the SPA looks.
|
|
41
|
+
*/
|
|
42
|
+
basePath?: string;
|
|
43
|
+
/** Snapshots kept in memory, by commit. Default 24. */
|
|
44
|
+
maxCachedSnapshots?: number;
|
|
45
|
+
}
|
|
46
|
+
export type BoardHandler = (request: Request) => Promise<Response>;
|
|
47
|
+
/** `owner/name`, both halves as GitHub spells them. */
|
|
48
|
+
export declare const REPO_PATTERN: RegExp;
|
|
49
|
+
export declare function createBoardHandler(options: BoardHandlerOptions): BoardHandler;
|
|
50
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/board/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAYH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAA;IAChD,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uDAAuD;IACvD,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;AAIlE,uDAAuD;AACvD,eAAO,MAAM,YAAY,QAAuB,CAAA;AAoBhD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAiJ7E"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live proxy: an exported board's API, as one fetch handler.
|
|
3
|
+
*
|
|
4
|
+
* `task export` ships the board as static files plus a baked `snapshot.json`.
|
|
5
|
+
* Drop this next to them — as a Cloudflare Pages Function, or behind any host
|
|
6
|
+
* that can run a `(Request) => Response` — hand it a token, and the same
|
|
7
|
+
* files become a live board: the SPA probes for an API before it mounts,
|
|
8
|
+
* finds this one answering, and never reads the snapshot at all. No token,
|
|
9
|
+
* no function, and the probe fails and the snapshot is what you get. The
|
|
10
|
+
* upgrade is the deploy, not a rebuild.
|
|
11
|
+
*
|
|
12
|
+
* The routes are `task serve`'s read routes — the same ones the snapshot
|
|
13
|
+
* answers (`ui/src/snapshot.ts`), plus the two things a live proxy is *for*:
|
|
14
|
+
* `?ref=` to read any branch, and `/branches` so the switcher has something to
|
|
15
|
+
* list. Payloads come from `source.ts`, which is what the hosted board serves,
|
|
16
|
+
* so a board rendered from this differs from the hosted one in one thing:
|
|
17
|
+
* who may look. This tier has no viewer auth — the perimeter is wherever you
|
|
18
|
+
* deploy it — and it says so in `/project` the only way the SPA reads:
|
|
19
|
+
* `readOnly: true`, no `git` capability.
|
|
20
|
+
*
|
|
21
|
+
* Read-only by construction: anything but GET is a 405 before a token is
|
|
22
|
+
* even resolved.
|
|
23
|
+
*/
|
|
24
|
+
import { GitHubError, headCommit, isMissingRef, listBranches, repoInfo } from "./github.js";
|
|
25
|
+
import { BoardSourceError, SnapshotMemory, STATUSES, snapshotAt, } from "./source.js";
|
|
26
|
+
const REF_PATTERN = /^[\w.\-/]{1,255}$/;
|
|
27
|
+
/** `owner/name`, both halves as GitHub spells them. */
|
|
28
|
+
export const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/;
|
|
29
|
+
function json(body, status = 200) {
|
|
30
|
+
return new Response(JSON.stringify(body), {
|
|
31
|
+
status,
|
|
32
|
+
headers: {
|
|
33
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
34
|
+
// Every answer is "the repository right now"; nothing between this
|
|
35
|
+
// function and the browser should hold on to one.
|
|
36
|
+
"Cache-Control": "no-store",
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** The board the request asked for, or the repo's default — `pickBoard`'s rule. */
|
|
41
|
+
function pickBoard(snapshot, requested) {
|
|
42
|
+
const id = requested && snapshot.data.has(requested) ? requested : snapshot.boards[0]?.id;
|
|
43
|
+
return id === undefined ? null : (snapshot.data.get(id) ?? null);
|
|
44
|
+
}
|
|
45
|
+
export function createBoardHandler(options) {
|
|
46
|
+
const { repo: slug, ref: defaultRef = "HEAD" } = options;
|
|
47
|
+
if (!REPO_PATTERN.test(slug))
|
|
48
|
+
throw new Error(`repo must be owner/name — got ${JSON.stringify(slug)}`);
|
|
49
|
+
const [owner, repo] = slug.split("/");
|
|
50
|
+
const basePath = (options.basePath ?? "/api").replace(/\/+$/, "");
|
|
51
|
+
const memory = new SnapshotMemory(options.maxCachedSnapshots);
|
|
52
|
+
// A cold start sees the SPA's four boot requests at once, all for the same
|
|
53
|
+
// commit. One tarball, not four: the first caller's load is shared with
|
|
54
|
+
// everyone who asks for that sha while it is still in flight.
|
|
55
|
+
const inFlight = new Map();
|
|
56
|
+
async function resolveToken() {
|
|
57
|
+
const token = typeof options.token === "function" ? await options.token() : options.token;
|
|
58
|
+
if (!token) {
|
|
59
|
+
// Unconfigured is not an error the board can act on; it is "no API",
|
|
60
|
+
// and a 5xx is exactly what makes the SPA's probe fall back to the
|
|
61
|
+
// snapshot sitting next to this function.
|
|
62
|
+
throw new BoardSourceError(503, "no GitHub token configured for the live board", "no-token");
|
|
63
|
+
}
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
async function load(token, ref, explicit) {
|
|
67
|
+
let commit;
|
|
68
|
+
try {
|
|
69
|
+
commit = await headCommit(token, owner, repo, ref);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// With an explicit ref, a miss almost always means the branch — a
|
|
73
|
+
// deleted preview, a typo — not the repository.
|
|
74
|
+
if (explicit && isMissingRef(error)) {
|
|
75
|
+
throw new BoardSourceError(404, `no such branch or ref: ${ref}`, "no-such-ref");
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const key = `${owner}/${repo}@${commit.sha}`;
|
|
80
|
+
const cached = memory.get(key);
|
|
81
|
+
if (cached)
|
|
82
|
+
return cached;
|
|
83
|
+
let pending = inFlight.get(key);
|
|
84
|
+
if (!pending) {
|
|
85
|
+
pending = snapshotAt(token, owner, repo, commit, ref)
|
|
86
|
+
.then((snapshot) => memory.remember(key, snapshot))
|
|
87
|
+
.finally(() => inFlight.delete(key));
|
|
88
|
+
inFlight.set(key, pending);
|
|
89
|
+
}
|
|
90
|
+
return pending;
|
|
91
|
+
}
|
|
92
|
+
async function handle(request) {
|
|
93
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
94
|
+
// Every editing affordance is already hidden by `readOnly`; this is the
|
|
95
|
+
// backstop, in the shape `ApiError` reads.
|
|
96
|
+
return json({ error: "this board is read-only — commit and push to change it", reason: "read-only" }, 405);
|
|
97
|
+
}
|
|
98
|
+
const url = new URL(request.url);
|
|
99
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
100
|
+
return json({ error: `not an API path: ${url.pathname}` }, 404);
|
|
101
|
+
}
|
|
102
|
+
const route = url.pathname.slice(basePath.length) || "/";
|
|
103
|
+
const token = await resolveToken();
|
|
104
|
+
if (route === "/branches") {
|
|
105
|
+
// Default branch first (it's the way back), then alphabetical — the
|
|
106
|
+
// hosted board's order, which is the order the switcher expects.
|
|
107
|
+
const [names, info] = await Promise.all([
|
|
108
|
+
listBranches(token, owner, repo),
|
|
109
|
+
repoInfo(token, owner, repo),
|
|
110
|
+
]);
|
|
111
|
+
const branches = names
|
|
112
|
+
.map((name) => ({ name, isDefault: name === info.defaultBranch }))
|
|
113
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name));
|
|
114
|
+
return json({ branches });
|
|
115
|
+
}
|
|
116
|
+
// A ref is a viewer-supplied string that lands in a URL path; anything
|
|
117
|
+
// outside git's own naming rules is ignored rather than escaped.
|
|
118
|
+
const requestedRef = url.searchParams.get("ref");
|
|
119
|
+
const ref = requestedRef && REF_PATTERN.test(requestedRef) && !requestedRef.includes("..")
|
|
120
|
+
? requestedRef
|
|
121
|
+
: null;
|
|
122
|
+
const snapshot = await load(token, ref ?? defaultRef, ref !== null);
|
|
123
|
+
if (route === "/boards")
|
|
124
|
+
return json({ boards: snapshot.boards });
|
|
125
|
+
const board = pickBoard(snapshot, url.searchParams.get("board"));
|
|
126
|
+
if (!board)
|
|
127
|
+
return json({ error: "no such board" }, 404);
|
|
128
|
+
if (route === "/project") {
|
|
129
|
+
return json({
|
|
130
|
+
name: board.info.name,
|
|
131
|
+
prefix: board.info.prefix,
|
|
132
|
+
statuses: STATUSES,
|
|
133
|
+
// The payload the SPA reads to decide whether to offer editing at all.
|
|
134
|
+
// The data is a commit; the only way to change one is to push.
|
|
135
|
+
readOnly: true,
|
|
136
|
+
// Locally this is who would author a comment. Nothing here can
|
|
137
|
+
// comment, and nothing here knows who is looking.
|
|
138
|
+
author: "",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (route === "/tasks")
|
|
142
|
+
return json({ tasks: board.tasks });
|
|
143
|
+
if (route === "/goals")
|
|
144
|
+
return json({ goals: board.goals });
|
|
145
|
+
const detail = /^\/tasks\/([^/]+)$/.exec(route);
|
|
146
|
+
if (detail) {
|
|
147
|
+
// The CLI accepts "PREFIX-x7k4m" or "x7k4m"; so does this.
|
|
148
|
+
const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(decodeURIComponent(detail[1]));
|
|
149
|
+
if (!match)
|
|
150
|
+
return json({ error: "invalid task id" }, 400);
|
|
151
|
+
const key = match[1].toLowerCase();
|
|
152
|
+
const task = board.tasks.find((candidate) => candidate.key === key);
|
|
153
|
+
if (!task)
|
|
154
|
+
return json({ error: `no such task: ${key}` }, 404);
|
|
155
|
+
return json({ task, comments: board.comments.get(key) ?? [] });
|
|
156
|
+
}
|
|
157
|
+
// `/inbox` and the git surface are deliberately absent: the SPA degrades
|
|
158
|
+
// to no inbox and no git chrome when they 404, same as the hosted board.
|
|
159
|
+
return json({ error: `not here: ${route}` }, 404);
|
|
160
|
+
}
|
|
161
|
+
return async (request) => {
|
|
162
|
+
try {
|
|
163
|
+
return await handle(request);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error instanceof BoardSourceError) {
|
|
167
|
+
return json({ error: error.message, reason: error.reason }, error.status);
|
|
168
|
+
}
|
|
169
|
+
if (error instanceof GitHubError) {
|
|
170
|
+
// A bad or expired token, a repo the token can't see, GitHub itself
|
|
171
|
+
// down: all of them are "the live board can't answer right now". A
|
|
172
|
+
// 5xx is deliberate — on the boot probe it is what sends the SPA to
|
|
173
|
+
// the snapshot instead of an error screen.
|
|
174
|
+
return json({
|
|
175
|
+
error: `GitHub answered ${error.status} — check the token and the repository name`,
|
|
176
|
+
reason: "github",
|
|
177
|
+
}, 502);
|
|
178
|
+
}
|
|
179
|
+
return json({ error: error instanceof Error ? error.message : "internal error" }, 500);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/board/handler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live proxy, against a fake GitHub.
|
|
3
|
+
*
|
|
4
|
+
* `globalThis.fetch` is replaced with a tiny router that answers the four
|
|
5
|
+
* calls the handler makes — head commit, tarball, branches, repo info — from
|
|
6
|
+
* an in-memory repository, so what's under test is the handler's routing,
|
|
7
|
+
* caching and error shapes, not the network. The payloads themselves are
|
|
8
|
+
* pinned elsewhere (`source.test.ts` diffs them against the CLI's store).
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=handler.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.test.d.ts","sourceRoot":"","sources":["../../src/board/handler.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
|