@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 CHANGED
@@ -1,8 +1,21 @@
1
1
  # Changelog
2
2
 
3
- Releases are tagged `vX.Y.Z`; the section for the tagged version becomes the GitHub release notes.
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
- ## Unreleased
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
- Releases are tagged `vX.Y.Z` and published from GitHub Actions to npm (with provenance once the repository is
32
- public) and to GitHub Packages; see [docs/releasing.md](docs/releasing.md).
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
- The package is `@voidbase-cloud/voidbase`. A release is a git tag `vX.Y.Z` that matches `package.json`; pushing
4
- the tag runs `.github/workflows/release.yml`, which checks, packs, smoke-installs and publishes.
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
- ```bash
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
- What the workflow does, in order:
10
+ ```
11
+ type(scope): subject # scope optional; subject in the imperative, no trailing period, header <= 100 chars
13
12
 
14
- 1. `bun install --frozen-lockfile`, the two typechecks, `bun test`, `test/cloud-rest.ts` (self-contained mocks).
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
- Secrets and permissions: `NPM_TOKEN` (repository secret: an npm granular token with publish rights on the
23
- `@voidbase-cloud` scope, created at npmjs.com > Access Tokens); GitHub Packages and the release use the
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
- Try the pipeline without publishing: Actions > release > Run workflow with `dry_run` on (or
27
- `gh workflow run release.yml -f dry_run=true`). It runs every step with `npm publish --dry-run` and creates no
28
- release.
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
- Publishing by hand, when Actions is not an option:
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 install with `bun add @voidbase-cloud/voidbase`. From GitHub Packages instead, add to `.npmrc`:
38
- `@voidbase-cloud:registry=https://npm.pkg.github.com` plus a token with `read:packages`.
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.0",
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
- const cache = resolve(`${process.env.XDG_CACHE_HOME ?? `${process.env.HOME}/.cache`}/voidbase/panel-${PANEL_VERSION}`);
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 { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
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 applySystemMigrations(db: ReturnType<typeof openDatabase>): number {
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 readdirSync(`${PKG}/db/migrations`).filter((f) => f.endsWith(".sql")).sort()) {
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 readFileSync(`${PKG}/db/migrations/${f}`, "utf8").split("--> statement-breakpoint")) if (statement.trim()) db.exec(statement);
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
- try { if (!existsSync(`${dir}/types.d.ts`)) copyFileSync(`${PKG}/types/pb_data.d.ts`, `${dir}/types.d.ts`); } catch { /* optional */ }
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): plain OIDC on dash.cloudflare.com whose userinfo
11
- // carries only `sub`, so identity comes from the API's GET /user. Resource scopes (API token permission names such
12
- // as workers-platform.write) are added per provider with extra.scopes; extra.apiBase overrides the API for tests.
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: ["openid", "offline_access"], authURL: "https://dash.cloudflare.com/oauth2/auth", tokenURL: "https://dash.cloudflare.com/oauth2/token", userInfoURL: "https://dash.cloudflare.com/oauth2/userinfo" };
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
- const info = p.userInfoURL ? await getJSON(p.userInfoURL, token) : (token.id_token ? jwtClaims(String(token.id_token)) : {});
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 };