@voidbase-cloud/voidbase 0.1.0 → 0.2.1
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/CHANGELOG.md +15 -2
- package/README.md +10 -2
- package/bin/voidbase.ts +17 -0
- package/docs/releasing.md +76 -27
- package/package.json +7 -2
- package/scripts/build-exe.ts +72 -0
- package/src/node/embedded.ts +16 -0
- package/src/node/panel.ts +16 -2
- package/src/node/serve.ts +15 -7
- package/src/node/update.ts +91 -0
- package/src/server/oauth2/providers.ts +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Entries after 0.1.0 are compiled by release-please from the Conventional Commits merged since the previous release
|
|
4
|
+
(docs/releasing.md); 0.1.0 was written by hand.
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## [0.2.1](https://github.com/voidbase-cloud/voidbase/compare/v0.2.0...v0.2.1) (2026-09-06)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **oauth2:** Cloudflare sign-in without the openid scope ([16d660d](https://github.com/voidbase-cloud/voidbase/commit/16d660de61728b705e1aa385b71b2fdf9397a216))
|
|
12
|
+
|
|
13
|
+
## [0.2.0](https://github.com/voidbase-cloud/voidbase/compare/v0.1.0...v0.2.0) (2026-09-06)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **cli:** prebuilt executables with voidbase update, PocketBase-style release archives ([48eef41](https://github.com/voidbase-cloud/voidbase/commit/48eef41f080092cd4c3bdb533e58fa83bfec1026))
|
|
6
19
|
|
|
7
20
|
## 0.1.0
|
|
8
21
|
|
package/README.md
CHANGED
|
@@ -28,8 +28,16 @@ bun add @voidbase-cloud/voidbase # the package: library, CLI (`voidbase`)
|
|
|
28
28
|
bunx @voidbase-cloud/voidbase serve # or run the CLI without installing
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
Or, like PocketBase, a single prebuilt executable from the
|
|
32
|
+
[releases](https://github.com/voidbase-cloud/voidbase/releases): `voidbase_<version>_<os>_<arch>.zip` for Linux,
|
|
33
|
+
macOS and Windows (amd64 and arm64; musl builds for Alpine), with the admin panel, the system migrations and the hooks
|
|
34
|
+
typings inside, so `./voidbase serve` needs nothing else. `./voidbase update` fetches the latest release for the
|
|
35
|
+
platform, verifies its checksum and replaces the executable (`--backup` zips `pb_data` first). The Cloudflare
|
|
36
|
+
toolchain (`deploy`, `bundle`, `dev`) stays with the npm package.
|
|
37
|
+
|
|
38
|
+
Commits follow Conventional Commits (enforced by husky and CI); release-please turns them into a release PR,
|
|
39
|
+
and merging it publishes to npm (with provenance once the repository is public) and GitHub Packages with the
|
|
40
|
+
compiled notes; see [docs/releasing.md](docs/releasing.md).
|
|
33
41
|
|
|
34
42
|
## Run it like PocketBase
|
|
35
43
|
|
package/bin/voidbase.ts
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync, cpSync } from "node:fs";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { exportAll } from "../scripts/export";
|
|
8
|
+
import { embedded, isExecutable } from "../src/node/embedded";
|
|
9
|
+
|
|
10
|
+
// the version: the executable carries it, a checkout reads package.json
|
|
11
|
+
async function currentVersion(): Promise<string> { return (await embedded())?.version ?? (JSON.parse(await Bun.file(resolve(import.meta.dir, "../package.json")).text()) as { version: string }).version; }
|
|
12
|
+
// the prebuilt executable serves; the Cloudflare toolchain (Void, Vite, wrangler) comes with the npm package
|
|
13
|
+
const TOOLCHAIN = new Set(["dev", "build", "preview", "deploy", "bundle", "release", "cloud", "panel", "app", "init", "seed-user"]);
|
|
8
14
|
|
|
9
15
|
const ROOT = resolve(`${import.meta.dir}/..`);
|
|
10
16
|
const argv = process.argv.slice(2);
|
|
@@ -32,6 +38,9 @@ const HELP = `voidbase - PocketBase-compatible backend: a single Bun process loc
|
|
|
32
38
|
stores the superuser as worker secrets and runs void deploy --backend cloudflare
|
|
33
39
|
deploy --void deploy to the Void platform instead (void auth login first)
|
|
34
40
|
token print the Cloudflare dashboard link that creates VOIDBASE_DEPLOY_CF_API_KEY
|
|
41
|
+
update [--dir pb_data] [--backup] prebuilt executable only: fetch the latest GitHub release for this platform, verify
|
|
42
|
+
its checksum and replace the executable (--backup zips pb_data first)
|
|
43
|
+
version print the version
|
|
35
44
|
bundle [--out dir] [--version v] build the generic Worker + panel as a release directory (default .cloud/releases/<v>)
|
|
36
45
|
[--push http://vb --token t] and optionally push it into a voidbase control plane (POST /api/vbcloud/releases)
|
|
37
46
|
release push <dir> --url http://vb --token <superuser token> push a built release ( --no-activate keeps the current one)
|
|
@@ -59,8 +68,16 @@ async function login(): Promise<string> {
|
|
|
59
68
|
return String(r.json.token);
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
if (cmd && TOOLCHAIN.has(cmd) && isExecutable()) { console.error(`"${cmd}" needs the Cloudflare toolchain, which comes with the npm package, not the prebuilt executable:\n bunx @voidbase-cloud/voidbase ${argv.join(" ")}`); process.exit(1); }
|
|
62
72
|
switch (cmd) {
|
|
63
73
|
case undefined: case "help": case "--help": console.log(HELP); break;
|
|
74
|
+
case "version": case "--version": console.log(await currentVersion()); break;
|
|
75
|
+
case "update": {
|
|
76
|
+
if (!isExecutable()) { console.error("voidbase update replaces the prebuilt executable; this is a checkout or an npm install: update the package instead (bun update @voidbase-cloud/voidbase)."); process.exit(1); }
|
|
77
|
+
const { update } = await import("../src/node/update");
|
|
78
|
+
await update({ currentVersion: await currentVersion(), dataDir: resolve(flags.dir ?? "pb_data"), backup: !!flags.backup });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
64
81
|
case "token": { const { tokenHelp } = await import("../src/node/deploy-cf"); console.log(tokenHelp()); break; }
|
|
65
82
|
case "bundle": {
|
|
66
83
|
const { buildRelease, pushRelease } = await import("../src/node/bundle");
|
package/docs/releasing.md
CHANGED
|
@@ -1,38 +1,87 @@
|
|
|
1
1
|
# Releasing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Every release has release notes because every commit message is a release note in waiting: commits follow
|
|
4
|
+
[Conventional Commits](https://www.conventionalcommits.org), husky and CI enforce it, and
|
|
5
|
+
[release-please](https://github.com/googleapis/release-please) compiles the commits merged since the previous
|
|
6
|
+
release into `CHANGELOG.md`, the version bump and the GitHub release notes.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
# on master, tree clean, CHANGELOG.md has a "## X.Y.Z" section
|
|
8
|
-
npm version minor # or patch / major / 0.2.0: bumps package.json, commits "vX.Y.Z", tags it
|
|
9
|
-
git push --follow-tags # the tag triggers the release workflow
|
|
10
|
-
```
|
|
8
|
+
## Commit messages
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
```
|
|
11
|
+
type(scope): subject # scope optional; subject in the imperative, no trailing period, header <= 100 chars
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
2. Refuses to continue if the tag and `package.json` disagree, or if that version is already on npm.
|
|
16
|
-
3. `npm pack`, then installs the tarball into a temporary project and runs the CLI from it.
|
|
17
|
-
4. `npm publish` to npm with `--access public`, and with `--provenance` when the repository is public (npm's
|
|
18
|
-
provenance needs a public repository; the workflow turns it off while the repository is private).
|
|
19
|
-
5. The same tarball to GitHub Packages (`npm.pkg.github.com`, the `@voidbase-cloud` scope matches the organization).
|
|
20
|
-
6. A GitHub release for the tag with the `CHANGELOG.md` section for that version as notes and the tarball attached.
|
|
13
|
+
body (optional): what and why, wrapped at 120
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
workflow's own `GITHUB_TOKEN` (`packages: write`, `contents: write`, `id-token: write` for provenance).
|
|
15
|
+
BREAKING CHANGE: description # footer; or `feat(scope)!: subject`
|
|
16
|
+
```
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
| type | in the release notes as | bumps |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `feat` | Features | minor (0.x: minor) |
|
|
21
|
+
| `fix` | Bug Fixes | patch |
|
|
22
|
+
| `perf` | Performance | patch |
|
|
23
|
+
| `revert` | Reverts | patch |
|
|
24
|
+
| `docs`, `refactor` | Documentation, Refactoring | nothing on their own |
|
|
25
|
+
| `build`, `ci`, `chore`, `test`, `style` | hidden | nothing |
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
A breaking change (`!` or the footer) bumps the minor while the version is below 1.0, the major after.
|
|
28
|
+
Scopes are the areas of the code base (`commitlint.config.js` lists them; an unknown scope is a warning, a
|
|
29
|
+
wrong type or a long header is an error). Examples:
|
|
31
30
|
|
|
32
|
-
```bash
|
|
33
|
-
bun run check && bun test
|
|
34
|
-
NPM_CONFIG_//registry.npmjs.org/:_authToken=$VOIDBASE_NPM_TOKEN npm publish --access public
|
|
35
31
|
```
|
|
32
|
+
feat(realtime): push changes through the hub Durable Object
|
|
33
|
+
fix(mail): keep stored SMTP passwords when the settings PATCH sends a blank
|
|
34
|
+
docs(deploy): custom domains through the Workers API
|
|
35
|
+
ci(release): compile release notes with release-please
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`.husky/commit-msg` runs commitlint on every commit; `.husky/pre-commit` runs `bun run check` and `bun test`.
|
|
39
|
+
`bun install` installs the hooks (`prepare`); `git commit --no-verify` skips them, and CI (`ci.yml`, job
|
|
40
|
+
`commitlint`) checks the pushed or proposed commits regardless.
|
|
41
|
+
|
|
42
|
+
## The release
|
|
43
|
+
|
|
44
|
+
1. Push or merge conventional commits to `master`. `release.yml` runs release-please, which opens or updates the
|
|
45
|
+
pull request "chore(master): release X.Y.Z": the next version from the commit types, the `CHANGELOG.md`
|
|
46
|
+
section compiled from the commits, the `package.json` bump. Keep merging work; the PR follows.
|
|
47
|
+
2. Merge the PR. release-please tags `vX.Y.Z` and creates the GitHub release with that section as notes.
|
|
48
|
+
3. The `publish` job of the same run then installs, typechecks, runs the unit and cloud-rest tests, packs, smoke-
|
|
49
|
+
installs the tarball and runs the CLI from it, publishes to npm (`--provenance` when the repository is public)
|
|
50
|
+
and to GitHub Packages, and attaches the tarball to the release.
|
|
51
|
+
4. The `executables` job builds the prebuilt executables for every platform (`scripts/build-exe.ts`: Bun
|
|
52
|
+
cross-compiles from one runner; the panel, the system migrations and the hooks typings are embedded), smokes the
|
|
53
|
+
runner's own build (`test/exe-smoke.ts`: serve with pb_hooks, the panel from the embedded zip, a thumbnail through
|
|
54
|
+
the wasm, then `voidbase update` against a mock GitHub API), attaches `voidbase_<version>_<os>_<arch>.zip` for
|
|
55
|
+
linux/darwin/windows × amd64/arm64 (plus musl builds) and `checksums.txt` to the release, attests each archive
|
|
56
|
+
with `actions/attest-build-provenance`, and puts the release notes in PocketBase's shape: the
|
|
57
|
+
`./voidbase update` hint first, then the compiled notes.
|
|
58
|
+
|
|
59
|
+
The layout mirrors PocketBase's releases: the zip holds the executable, `CHANGELOG.md` and `LICENSE`;
|
|
60
|
+
`checksums.txt` is goreleaser's format (`<sha256> <file>`), which `voidbase update` checks before replacing the
|
|
61
|
+
executable. Verify an archive's provenance with `gh attestation verify voidbase_X.Y.Z_linux_amd64.zip --owner
|
|
62
|
+
voidbase-cloud`. For the "Immutable" badge and the release attestation GitHub adds itself, enable immutable releases
|
|
63
|
+
once in the repository settings (Settings > General > Releases); it is a setting, not something the workflow can
|
|
64
|
+
turn on.
|
|
65
|
+
|
|
66
|
+
`.release-please-manifest.json` holds the released version (0.1.0 was cut by hand and its notes written by hand;
|
|
67
|
+
everything after it is compiled). `release-please-config.json` maps commit types to changelog sections.
|
|
68
|
+
|
|
69
|
+
## Rehearsals and manual paths
|
|
70
|
+
|
|
71
|
+
- Actions > release > Run workflow with `dry_run` on (or `gh workflow run release.yml -f dry_run=true`): the
|
|
72
|
+
publish job with `npm publish --dry-run`, nothing published, no release touched.
|
|
73
|
+
- A release cut by hand also publishes: `gh release create vX.Y.Z --notes-file notes.md` after bumping
|
|
74
|
+
`package.json` to X.Y.Z on `master`; the `release: published` event runs the publish job against that tag.
|
|
75
|
+
- Publishing from a machine: `bun run check && bun test`, then
|
|
76
|
+
`NPM_CONFIG_//registry.npmjs.org/:_authToken=$VOIDBASE_NPM_TOKEN npm publish --access public`.
|
|
77
|
+
|
|
78
|
+
Secrets and permissions: `NPM_TOKEN` (repository secret, an npm granular token with publish rights on the
|
|
79
|
+
`@voidbase-cloud` scope). release-please opens the release PR with the workflow's own token only if the
|
|
80
|
+
organization allows it (voidbase-cloud > Settings > Actions > General > "Allow GitHub Actions to create and
|
|
81
|
+
approve pull requests", then the same switch on the repository); otherwise add `RELEASE_PLEASE_TOKEN`, a
|
|
82
|
+
fine-grained PAT with contents and pull requests write on this repository, which the workflow prefers when
|
|
83
|
+
present and which also makes CI run on the release PR (the workflow's own token cannot trigger other workflows).
|
|
84
|
+
GitHub Packages and the release assets use the workflow's `GITHUB_TOKEN`.
|
|
36
85
|
|
|
37
|
-
Consumers
|
|
38
|
-
`@voidbase-cloud:registry=https://npm.pkg.github.com`
|
|
86
|
+
Consumers: `bun add @voidbase-cloud/voidbase`; from GitHub Packages instead, `.npmrc` with
|
|
87
|
+
`@voidbase-cloud:registry=https://npm.pkg.github.com` and a token with `read:packages`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voidbase-cloud/voidbase",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "PocketBase-compatible backend on Cloudflare Workers (D1, R2, Queues, Durable Objects) via Void, or a single Bun process",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -112,7 +112,9 @@
|
|
|
112
112
|
"app:sync": "bun scripts/sync-app.ts",
|
|
113
113
|
"check": "void prepare && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
|
|
114
114
|
"pack:check": "npm pack --dry-run",
|
|
115
|
-
"prepublishOnly": "bun run check && bun test"
|
|
115
|
+
"prepublishOnly": "bun run check && bun test",
|
|
116
|
+
"prepare": "husky || true",
|
|
117
|
+
"build:exe": "bun scripts/build-exe.ts"
|
|
116
118
|
},
|
|
117
119
|
"dependencies": {
|
|
118
120
|
"@cf-wasm/photon": "^0.4.0",
|
|
@@ -127,7 +129,10 @@
|
|
|
127
129
|
"@cloudflare/workers-types": "^4.20250903.0"
|
|
128
130
|
},
|
|
129
131
|
"devDependencies": {
|
|
132
|
+
"@commitlint/cli": "^21.2.2",
|
|
133
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
130
134
|
"@types/bun": "^1.4.1",
|
|
135
|
+
"husky": "^9.1.7",
|
|
131
136
|
"playwright": "^1.63.0",
|
|
132
137
|
"pocketbase": "0.28.0"
|
|
133
138
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Prebuilt executables laid out like PocketBase's release assets: voidbase_<version>_<os>_<arch>.zip holding the
|
|
2
|
+
// executable, CHANGELOG.md and LICENSE, plus checksums.txt (sha256, goreleaser's format). Bun cross-compiles every
|
|
3
|
+
// target from one machine; the admin panel, the system migrations and the hooks typings are embedded
|
|
4
|
+
// (src/node/embedded.ts), so `voidbase serve` works offline from the single file.
|
|
5
|
+
// bun scripts/build-exe.ts [--targets host|all|linux-x64,darwin-arm64,...] [--out dist/release] [--version X.Y.Z] [--keep-embedded]
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join, relative, resolve } from "node:path";
|
|
8
|
+
import { zipSync, type Zippable } from "fflate";
|
|
9
|
+
|
|
10
|
+
const PKG = resolve(import.meta.dir, "..");
|
|
11
|
+
export const TARGETS: Record<string, { bun: string; os: string; arch: string; exe: string }> = {
|
|
12
|
+
"linux-x64": { bun: "bun-linux-x64", os: "linux", arch: "amd64", exe: "voidbase" },
|
|
13
|
+
"linux-arm64": { bun: "bun-linux-arm64", os: "linux", arch: "arm64", exe: "voidbase" },
|
|
14
|
+
"linux-x64-musl": { bun: "bun-linux-x64-musl", os: "linux", arch: "amd64_musl", exe: "voidbase" },
|
|
15
|
+
"linux-arm64-musl": { bun: "bun-linux-arm64-musl", os: "linux", arch: "arm64_musl", exe: "voidbase" },
|
|
16
|
+
"darwin-x64": { bun: "bun-darwin-x64", os: "darwin", arch: "amd64", exe: "voidbase" },
|
|
17
|
+
"darwin-arm64": { bun: "bun-darwin-arm64", os: "darwin", arch: "arm64", exe: "voidbase" },
|
|
18
|
+
"windows-x64": { bun: "bun-windows-x64", os: "windows", arch: "amd64", exe: "voidbase.exe" },
|
|
19
|
+
};
|
|
20
|
+
export const hostTarget = () => `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
|
21
|
+
|
|
22
|
+
const walk = (dir: string, base = dir): string[] => readdirSync(dir).flatMap((n) => { const f = join(dir, n); return statSync(f).isDirectory() ? walk(f, base) : [relative(base, f).replace(/\\/g, "/")]; });
|
|
23
|
+
const b64 = (bytes: Uint8Array) => { let s = ""; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return btoa(s); };
|
|
24
|
+
const sha256 = async (bytes: Uint8Array) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as BufferSource))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
const UNIX_EXEC = { os: 3, attrs: 0o100755 << 16 }; // zip external attributes: a regular file, mode 0755
|
|
26
|
+
|
|
27
|
+
export interface BuildOptions { targets?: string[]; out?: string; version?: string; keepEmbedded?: boolean; log?: (line: string) => void }
|
|
28
|
+
export async function buildExecutables(o: BuildOptions = {}): Promise<{ version: string; out: string; archives: { target: string; file: string; bytes: number; sha256: string }[] }> {
|
|
29
|
+
const log = o.log ?? ((l: string) => console.log(l));
|
|
30
|
+
const version = o.version ?? (JSON.parse(readFileSync(`${PKG}/package.json`, "utf8")) as { version: string }).version;
|
|
31
|
+
const targets = (o.targets ?? ["host"]).flatMap((t) => (t === "all" ? Object.keys(TARGETS) : t === "host" ? [hostTarget()] : [t]));
|
|
32
|
+
for (const t of targets) if (!TARGETS[t]) throw new Error(`unknown target ${t} (known: ${Object.keys(TARGETS).join(", ")})`);
|
|
33
|
+
const out = resolve(o.out ?? `${PKG}/dist/release`); mkdirSync(out, { recursive: true });
|
|
34
|
+
|
|
35
|
+
// what the executable must carry: the panel (zipped), the system migrations, the typings, the version
|
|
36
|
+
const panelDir = `${PKG}/public/_`;
|
|
37
|
+
if (!existsSync(`${panelDir}/index.html`)) { log("syncing the admin panel into public/_"); const r = Bun.spawnSync(["bun", `${PKG}/scripts/sync-panel.ts`], { cwd: PKG, stdout: "inherit", stderr: "inherit" }); if (r.exitCode !== 0) throw new Error("panel sync failed"); }
|
|
38
|
+
const panelFiles: Zippable = {}; for (const f of walk(panelDir)) panelFiles[f] = [new Uint8Array(readFileSync(join(panelDir, f))), { level: 6 }];
|
|
39
|
+
const panelVersion = (() => { try { return (JSON.parse(readFileSync(`${panelDir}/../../pocketbase-panel.json`, "utf8")) as { version: string }).version; } catch { return process.env.POCKETBASE_PANEL_VERSION ?? "0.40.2"; } })();
|
|
40
|
+
const migrations: Record<string, string> = {}; for (const f of readdirSync(`${PKG}/db/migrations`).filter((f) => f.endsWith(".sql")).sort()) migrations[f] = readFileSync(`${PKG}/db/migrations/${f}`, "utf8");
|
|
41
|
+
const embeddedPath = `${PKG}/src/node/embedded.generated.json`;
|
|
42
|
+
writeFileSync(embeddedPath, JSON.stringify({ version, migrations, typesDts: readFileSync(`${PKG}/types/pb_data.d.ts`, "utf8"), panel: { version: panelVersion, zipBase64: b64(zipSync(panelFiles)) } }));
|
|
43
|
+
log(`embedded: ${Object.keys(migrations).length} migrations, the typings, the ${panelVersion} panel (${Object.keys(panelFiles).length} files)`);
|
|
44
|
+
|
|
45
|
+
const archives: { target: string; file: string; bytes: number; sha256: string }[] = [];
|
|
46
|
+
const changelog = new Uint8Array(readFileSync(`${PKG}/CHANGELOG.md`)), license = new Uint8Array(readFileSync(`${PKG}/LICENSE`));
|
|
47
|
+
try {
|
|
48
|
+
for (const t of targets) {
|
|
49
|
+
const T = TARGETS[t]!; const exeDir = `${PKG}/dist/exe/${t}`; mkdirSync(exeDir, { recursive: true });
|
|
50
|
+
const exe = `${exeDir}/${T.exe}`;
|
|
51
|
+
log(`compiling ${t} (${T.bun})`);
|
|
52
|
+
const r = Bun.spawnSync(["bun", "build", "--compile", `--target=${T.bun}`, `${PKG}/bin/voidbase.ts`, "--outfile", exe], { cwd: PKG, stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
if (r.exitCode !== 0) throw new Error(`bun build --compile failed for ${t}:\n${new TextDecoder().decode(r.stderr)}`);
|
|
54
|
+
const name = `voidbase_${version}_${T.os}_${T.arch}.zip`;
|
|
55
|
+
const entries: Zippable = { [T.exe]: [new Uint8Array(readFileSync(exe)), { level: 6, ...(T.os === "windows" ? {} : UNIX_EXEC) }], "CHANGELOG.md": [changelog, { level: 6 }], LICENSE: [license, { level: 6 }] };
|
|
56
|
+
const zip = zipSync(entries);
|
|
57
|
+
writeFileSync(`${out}/${name}`, zip);
|
|
58
|
+
archives.push({ target: t, file: name, bytes: zip.length, sha256: await sha256(zip) });
|
|
59
|
+
log(` ${name}: ${(zip.length / 1024 / 1024).toFixed(1)} MB`);
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(`${out}/checksums.txt`, archives.map((a) => `${a.sha256} ${a.file}`).join("\n") + "\n");
|
|
62
|
+
log(`checksums.txt: ${archives.length} archives in ${out}`);
|
|
63
|
+
} finally { if (!o.keepEmbedded) rmSync(embeddedPath, { force: true }); }
|
|
64
|
+
return { version, out, archives };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (import.meta.main) {
|
|
68
|
+
const flags: Record<string, string> = {};
|
|
69
|
+
const argv = process.argv.slice(2);
|
|
70
|
+
for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (a.startsWith("--")) { const [k, v] = a.slice(2).split("="); flags[k!] = v ?? (argv[i + 1] && !argv[i + 1]!.startsWith("--") ? argv[++i]! : "1"); } }
|
|
71
|
+
await buildExecutables({ targets: (flags.targets ?? "host").split(","), out: flags.out, version: flags.version, keepEmbedded: !!flags["keep-embedded"] });
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// What a standalone executable carries that a checkout reads from disk: the version, the system migrations, the
|
|
2
|
+
// hooks typings and the admin panel (zipped). scripts/build-exe.ts writes src/node/embedded.generated.json right
|
|
3
|
+
// before compiling; in a checkout the file does not exist and every reader falls back to the package directory.
|
|
4
|
+
export interface Embedded { version: string; migrations: Record<string, string>; typesDts: string; panel: { version: string; zipBase64: string } | null }
|
|
5
|
+
|
|
6
|
+
let cached: Embedded | null | undefined;
|
|
7
|
+
export async function embedded(): Promise<Embedded | null> {
|
|
8
|
+
if (cached !== undefined) return cached;
|
|
9
|
+
// the file exists only while scripts/build-exe.ts compiles; consumers typecheck this module without it
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
try { cached = ((await import("./embedded.generated.json", { with: { type: "json" } })) as { default: Embedded }).default; } catch { cached = null; }
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Bun mounts a compiled executable's modules under a virtual root ("/$bunfs/root", "B:\~BUN\root" on Windows)
|
|
16
|
+
export const isExecutable = (): boolean => /\$bunfs|~BUN/.test(import.meta.dir);
|
package/src/node/panel.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
// Where the unmodified PocketBase admin panel comes from: a synced public/_ in this checkout, else the pinned
|
|
2
2
|
// release's committed ui/dist downloaded once into ~/.cache/voidbase.
|
|
3
3
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { unzipSync } from "fflate";
|
|
6
|
+
import { embedded } from "./embedded";
|
|
5
7
|
export const PANEL_VERSION = process.env.POCKETBASE_PANEL_VERSION ?? "0.40.2";
|
|
8
|
+
const cacheDir = (version: string) => resolve(`${process.env.XDG_CACHE_HOME ?? `${process.env.HOME ?? process.env.USERPROFILE ?? "."}/.cache`}/voidbase/panel-${version}`);
|
|
6
9
|
export async function ensurePanelDir(): Promise<string> {
|
|
7
10
|
const local = [process.env.POCKETBASE_UI_DIST, resolve(import.meta.dir, "../../public/_"), resolve(import.meta.dir, "../../../pocketbase/ui/dist")].filter((p): p is string => !!p);
|
|
8
11
|
for (const p of local) if (existsSync(`${p}/index.html`)) return p;
|
|
9
|
-
|
|
12
|
+
// a standalone executable carries the panel: unpacked once into the cache
|
|
13
|
+
const emb = await embedded();
|
|
14
|
+
if (emb?.panel) {
|
|
15
|
+
const dir = cacheDir(emb.panel.version);
|
|
16
|
+
if (!existsSync(`${dir}/index.html`)) {
|
|
17
|
+
const files = unzipSync(Uint8Array.from(atob(emb.panel.zipBase64), (c) => c.charCodeAt(0)));
|
|
18
|
+
for (const [name, bytes] of Object.entries(files)) { if (name.endsWith("/")) continue; mkdirSync(dirname(`${dir}/${name}`), { recursive: true }); writeFileSync(`${dir}/${name}`, bytes); }
|
|
19
|
+
if (!existsSync(`${dir}/extensions.js`)) writeFileSync(`${dir}/extensions.js`, "// voidbase: no UI extensions configured\n");
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
const cache = cacheDir(PANEL_VERSION);
|
|
10
24
|
if (existsSync(`${cache}/index.html`)) return cache;
|
|
11
25
|
console.log(`voidbase: downloading the PocketBase ${PANEL_VERSION} admin panel (ui/dist) into ${cache}`);
|
|
12
26
|
const res = await fetch(`https://codeload.github.com/pocketbase/pocketbase/tar.gz/refs/tags/v${PANEL_VERSION}`);
|
package/src/node/serve.ts
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
// `voidbase serve`: the PocketBase-shaped single process. The same Hono app that runs on Cloudflare, with D1 on
|
|
2
2
|
// bun:sqlite, R2 on the filesystem, SMTP on node sockets and the cron scheduler on a timer.
|
|
3
3
|
// import { serve } from "@voidbase-cloud/voidbase"; serve({ http: "127.0.0.1:8090", dir: "pb_data", publicDir: "../sk/build" });
|
|
4
|
-
import {
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { d1, openDatabase } from "./d1";
|
|
7
7
|
import { fsBucket } from "./storage";
|
|
8
8
|
import { assetsFetcher } from "./assets";
|
|
9
9
|
import { ensurePanelDir } from "./panel";
|
|
10
|
+
import { embedded } from "./embedded";
|
|
10
11
|
|
|
11
12
|
export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string; publicDir?: string; quiet?: boolean }
|
|
12
13
|
const PKG = resolve(import.meta.dir, "../..");
|
|
13
14
|
|
|
14
15
|
// system tables: the same SQL migrations Void applies on Cloudflare
|
|
15
|
-
export function
|
|
16
|
+
export function readSystemMigrations(): Record<string, string> {
|
|
17
|
+
const out: Record<string, string> = {};
|
|
18
|
+
for (const f of readdirSync(`${PKG}/db/migrations`).filter((f) => f.endsWith(".sql")).sort()) out[f] = readFileSync(`${PKG}/db/migrations/${f}`, "utf8");
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export function applySystemMigrations(db: ReturnType<typeof openDatabase>, migrations: Record<string, string> = readSystemMigrations()): number {
|
|
16
22
|
db.exec("CREATE TABLE IF NOT EXISTS `_vb_migrations` (name TEXT PRIMARY KEY, applied TEXT NOT NULL)");
|
|
17
23
|
const done = new Set((db.query("SELECT name FROM `_vb_migrations`").all() as { name: string }[]).map((r) => r.name));
|
|
18
24
|
let applied = 0;
|
|
19
|
-
for (const f of
|
|
25
|
+
for (const f of Object.keys(migrations).sort()) {
|
|
20
26
|
if (done.has(f)) continue;
|
|
21
27
|
db.transaction(() => {
|
|
22
|
-
for (const statement of
|
|
28
|
+
for (const statement of migrations[f]!.split("--> statement-breakpoint")) if (statement.trim()) db.exec(statement);
|
|
23
29
|
db.query("INSERT INTO `_vb_migrations` (name, applied) VALUES (?, ?)").run(f, new Date().toISOString());
|
|
24
30
|
})();
|
|
25
31
|
applied++;
|
|
@@ -49,10 +55,12 @@ export async function openLocal(opts: ServeOptions) {
|
|
|
49
55
|
mkdirSync(dir, { recursive: true });
|
|
50
56
|
process.env.VOIDBASE_HOOKS_DIR = resolve(opts.hooksDir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
|
|
51
57
|
process.env.VOIDBASE_MIGRATIONS_DIR = resolve(opts.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
|
|
52
|
-
// pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings)
|
|
53
|
-
|
|
58
|
+
// pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings); a standalone executable carries
|
|
59
|
+
// the typings and the system migrations itself (src/node/embedded.ts)
|
|
60
|
+
const emb = await embedded();
|
|
61
|
+
try { if (!existsSync(`${dir}/types.d.ts`)) writeFileSync(`${dir}/types.d.ts`, emb?.typesDts ?? readFileSync(`${PKG}/types/pb_data.d.ts`, "utf8")); } catch { /* optional */ }
|
|
54
62
|
const sqlite = openDatabase(`${dir}/data.db`);
|
|
55
|
-
applySystemMigrations(sqlite);
|
|
63
|
+
applySystemMigrations(sqlite, emb?.migrations ?? readSystemMigrations());
|
|
56
64
|
const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir: opts.publicDir ? resolve(opts.publicDir) : undefined }) };
|
|
57
65
|
return { dir, sqlite, env };
|
|
58
66
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `voidbase update`: PocketBase's `pocketbase update` for the prebuilt executable. The latest GitHub release, the
|
|
2
|
+
// asset for this platform (voidbase_<version>_<os>_<arch>.zip), its sha256 against checksums.txt, then the running
|
|
3
|
+
// executable replaced in place (the old one kept as `.old` until the end), optionally a pb_data backup first.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { unzipSync } from "fflate";
|
|
7
|
+
|
|
8
|
+
export const REPO = "voidbase-cloud/voidbase";
|
|
9
|
+
export const EXECUTABLE = "voidbase";
|
|
10
|
+
|
|
11
|
+
// the archive name PocketBase uses, per Go's GOOS/GOARCH names, for the platforms Bun can build for
|
|
12
|
+
export function archiveSuffix(platform: string = process.platform, arch: string = process.arch): string | null {
|
|
13
|
+
const os = platform === "win32" ? "windows" : platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : null;
|
|
14
|
+
const cpu = arch === "x64" ? "amd64" : arch === "arm64" ? "arm64" : null;
|
|
15
|
+
return os && cpu ? `_${os}_${cpu}.zip` : null;
|
|
16
|
+
}
|
|
17
|
+
// "0.1.0" vs "0.2.0-rc.1": numeric parts first, a pre-release tag sorts before the plain version
|
|
18
|
+
export function compareVersions(a: string, b: string): number {
|
|
19
|
+
const split = (v: string) => { const [core, pre] = v.replace(/^v/, "").split("-", 2); return { nums: core!.split(".").map((n) => Number(n) || 0), pre: pre ?? "" }; };
|
|
20
|
+
const x = split(a), y = split(b);
|
|
21
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) { const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
|
|
22
|
+
if (x.pre === y.pre) return 0;
|
|
23
|
+
if (!x.pre) return 1; if (!y.pre) return -1;
|
|
24
|
+
return x.pre < y.pre ? -1 : 1;
|
|
25
|
+
}
|
|
26
|
+
// goreleaser's checksums.txt: "<sha256> <file>" per line
|
|
27
|
+
export function parseChecksums(text: string): Map<string, string> {
|
|
28
|
+
const out = new Map<string, string>();
|
|
29
|
+
for (const line of text.split("\n")) { const m = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim()); if (m) out.set(m[2]!.trim(), m[1]!); }
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export async function sha256(bytes: Uint8Array): Promise<string> { return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as BufferSource))].map((b) => b.toString(16).padStart(2, "0")).join(""); }
|
|
33
|
+
|
|
34
|
+
export interface Release { tag: string; body: string; assets: { name: string; url: string }[] }
|
|
35
|
+
export async function fetchLatestRelease(api = process.env.VOIDBASE_UPDATE_API || "https://api.github.com"): Promise<Release> {
|
|
36
|
+
const res = await fetch(`${api.replace(/\/$/, "")}/repos/${REPO}/releases/latest`, { headers: { accept: "application/vnd.github+json", "user-agent": "voidbase-update" } });
|
|
37
|
+
if (!res.ok) throw new Error(`fetching the latest release: HTTP ${res.status}`);
|
|
38
|
+
const r = (await res.json()) as { tag_name: string; body?: string; assets?: { name: string; browser_download_url: string }[] };
|
|
39
|
+
return { tag: r.tag_name, body: r.body ?? "", assets: (r.assets ?? []).map((a) => ({ name: a.name, url: a.browser_download_url })) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UpdateOptions { currentVersion: string; dataDir: string; backup?: boolean; api?: string; execPath?: string; log?: (line: string) => void }
|
|
43
|
+
export async function update(o: UpdateOptions): Promise<{ updated: boolean; version: string }> {
|
|
44
|
+
const log = o.log ?? ((l: string) => console.log(l));
|
|
45
|
+
log("Fetching release information...");
|
|
46
|
+
const latest = await fetchLatestRelease(o.api);
|
|
47
|
+
if (compareVersions(o.currentVersion, latest.tag) >= 0) { log(`You already have the latest version ${o.currentVersion}.`); return { updated: false, version: o.currentVersion }; }
|
|
48
|
+
const suffix = archiveSuffix();
|
|
49
|
+
if (!suffix) throw new Error(`unsupported platform ${process.platform}/${process.arch}`);
|
|
50
|
+
const asset = latest.assets.find((a) => a.name.startsWith(`${EXECUTABLE}_`) && a.name.endsWith(suffix));
|
|
51
|
+
if (!asset) throw new Error(`release ${latest.tag} has no asset for this platform (${EXECUTABLE}_*${suffix})`);
|
|
52
|
+
const tmp = resolve(o.dataDir, ".tmp", `update-${latest.tag}`); rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { recursive: true });
|
|
53
|
+
try {
|
|
54
|
+
log(`Downloading ${asset.name}...`);
|
|
55
|
+
const zip = new Uint8Array(await (await fetchOk(asset.url)).arrayBuffer());
|
|
56
|
+
const sums = latest.assets.find((a) => a.name === "checksums.txt");
|
|
57
|
+
if (sums) {
|
|
58
|
+
const expected = parseChecksums(await (await fetchOk(sums.url)).text()).get(asset.name);
|
|
59
|
+
if (!expected) throw new Error(`checksums.txt has no entry for ${asset.name}`);
|
|
60
|
+
const actual = await sha256(zip);
|
|
61
|
+
if (actual !== expected) throw new Error(`checksum mismatch for ${asset.name}: expected ${expected}, got ${actual}`);
|
|
62
|
+
log("Checksum verified.");
|
|
63
|
+
} else log("No checksums.txt in the release; skipping the checksum check.");
|
|
64
|
+
log(`Extracting ${asset.name}...`);
|
|
65
|
+
const files = unzipSync(zip);
|
|
66
|
+
const name = [EXECUTABLE, `${EXECUTABLE}.exe`].find((n) => files[n]);
|
|
67
|
+
if (!name) throw new Error("the archive has no executable in it");
|
|
68
|
+
const extracted = join(tmp, name); writeFileSync(extracted, files[name]!); if (process.platform !== "win32") chmodSync(extracted, 0o755);
|
|
69
|
+
if (o.backup) {
|
|
70
|
+
log("Creating pb_data backup...");
|
|
71
|
+
const { openLocal } = await import("./serve"); const { createBackup } = await import("../server/backups");
|
|
72
|
+
const { env } = await openLocal({ dir: o.dataDir });
|
|
73
|
+
await createBackup(env as never, `@update_${latest.tag}.zip`);
|
|
74
|
+
}
|
|
75
|
+
log("Replacing the executable...");
|
|
76
|
+
const oldExec = o.execPath ?? process.execPath;
|
|
77
|
+
const renamedOld = `${oldExec}.old`;
|
|
78
|
+
renameSync(oldExec, renamedOld);
|
|
79
|
+
try { renameSync(extracted, oldExec); } catch (err) { renameSync(renamedOld, oldExec); throw new Error(`failed replacing the executable: ${err instanceof Error ? err.message : err}`); }
|
|
80
|
+
try { rmSync(renamedOld, { force: true }); } catch { /* Windows keeps a running executable's file; it goes on the next update */ }
|
|
81
|
+
log("---\nUpdate completed successfully! You can start the executable as usual.");
|
|
82
|
+
const notes = latest.body.replace(/^> _To update the prebuilt executable you can run `\.\/voidbase update`\._\s*/m, "").trim();
|
|
83
|
+
if (notes) log(`\nHere is a list with some of the ${latest.tag} changes:\n${notes}`);
|
|
84
|
+
return { updated: true, version: latest.tag.replace(/^v/, "") };
|
|
85
|
+
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
86
|
+
}
|
|
87
|
+
async function fetchOk(url: string): Promise<Response> {
|
|
88
|
+
const res = await fetch(url, { headers: { "user-agent": "voidbase-update" }, redirect: "follow" });
|
|
89
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
@@ -7,11 +7,14 @@ export interface ProviderContext { name: string; clientId: string; clientSecret:
|
|
|
7
7
|
type Raw = Record<string, unknown>;
|
|
8
8
|
|
|
9
9
|
const oidc: ProviderDefaults = { displayName: "OIDC", pkce: true, scopes: ["openid", "email", "profile"] };
|
|
10
|
-
// Cloudflare OAuth (developers.cloudflare.com/fundamentals/oauth):
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// Cloudflare OAuth (developers.cloudflare.com/fundamentals/oauth): OAuth 2.0 on dash.cloudflare.com, not OIDC: `openid`
|
|
11
|
+
// is not a Cloudflare scope and a request carrying it is refused (invalid_scope). Identity comes from the API's GET
|
|
12
|
+
// /user (scope user-details.read); the userinfo endpoint, when it answers, only adds `sub`. Resource scopes (API token
|
|
13
|
+
// permission ids such as workers-scripts.write, plus offline_access for a refresh token) must be registered on the
|
|
14
|
+
// client and are added per provider with extra.scopes; extra.apiBase overrides the API for tests. The client
|
|
15
|
+
// authenticates with HTTP Basic at the token endpoint (the token exchange tries that first).
|
|
13
16
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
14
|
-
const cloudflare: ProviderDefaults = { displayName: "Cloudflare", pkce: true, scopes: ["
|
|
17
|
+
const cloudflare: ProviderDefaults = { displayName: "Cloudflare", pkce: true, scopes: ["user-details.read"], authURL: "https://dash.cloudflare.com/oauth2/auth", tokenURL: "https://dash.cloudflare.com/oauth2/token", userInfoURL: "https://dash.cloudflare.com/oauth2/userinfo" };
|
|
15
18
|
export const PROVIDER_DEFAULTS: Record<string, ProviderDefaults> = {
|
|
16
19
|
oidc, oidc2: oidc, oidc3: oidc, cloudflare,
|
|
17
20
|
apple: { displayName: "Apple", pkce: true, scopes: ["name", "email"], authURL: "https://appleid.apple.com/auth/authorize", tokenURL: "https://appleid.apple.com/auth/token" },
|
|
@@ -72,7 +75,9 @@ export async function fetchRawUser(p: ProviderContext, token: Token): Promise<Ra
|
|
|
72
75
|
if (!token.id_token) throw new Error("empty id_token");
|
|
73
76
|
return jwtClaims(String(token.id_token));
|
|
74
77
|
case "cloudflare": {
|
|
75
|
-
|
|
78
|
+
let info: Raw = {};
|
|
79
|
+
if (p.userInfoURL) { try { info = await getJSON(p.userInfoURL, token); } catch { /* no openid: identity comes from GET /user */ } }
|
|
80
|
+
else if (token.id_token) info = jwtClaims(String(token.id_token));
|
|
76
81
|
const base = String(p.extra.apiBase || CF_API_BASE).replace(/\/$/, "");
|
|
77
82
|
const me = await getJSON(`${base}/user`, token);
|
|
78
83
|
return { ...info, cf_user: (me.result ?? me) as Raw };
|