feat: add pure NixOS flake with bun2nix
Adds a complete Nix flake for NixOS integration: Package: - Pure reproducible builds using bun2nix (no network during build) - Bundles restic, rclone, shoutrrr, and other runtime dependencies - Supports x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin - Version read from package.json to avoid drift NixOS Module: - Systemd service with security hardening - FUSE support for remote mounts (NFS, SMB, WebDAV) - Configurable options: port, dataDir, user/group, timezone, etc. - protectHome option for backing up home directories - CAP_DAC_READ_SEARCH capability when protectHome=false - PORT env var properly passed to server Development: - devShell with bun, node, biome, typescript, and runtime tools - bun2nix CLI for regenerating bun.nix after dependency changes Also includes: - Overlay exposing zerobyte and shoutrrr packages - NixOS VM integration tests - Experimental darwin module (requires TCC permissions for home dirs) - Configurable MIGRATIONS_PATH and PORT for Nix store compatibility Note: package.json includes additional peer dependencies (@standard-community/*, @standard-schema/spec, openapi-types, quansync, react-is) required by bun2nix for pure builds - bun requires all transitive peer deps to be satisfied.
This commit is contained in:
parent
54068d5269
commit
5cb092f130
10 changed files with 3024 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.direnv/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
result
|
||||||
|
|
||||||
# React Router
|
# React Router
|
||||||
/.react-router/
|
/.react-router/
|
||||||
|
|
@ -13,3 +15,4 @@ CLAUDE.md
|
||||||
mutagen.yml.lock
|
mutagen.yml.lock
|
||||||
notes.md
|
notes.md
|
||||||
smb-password.txt
|
smb-password.txt
|
||||||
|
.envrc
|
||||||
|
|
|
||||||
61
README.md
61
README.md
|
|
@ -98,6 +98,67 @@ services:
|
||||||
|
|
||||||
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
||||||
|
|
||||||
|
### Nix Flake
|
||||||
|
|
||||||
|
Zerobyte provides a Nix flake for NixOS users and Nix-based development environments.
|
||||||
|
|
||||||
|
**Development shell:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
**NixOS module:**
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.zerobyte.url = "github:nicotsx/zerobyte";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, zerobyte }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
|
modules = [
|
||||||
|
zerobyte.nixosModules.default
|
||||||
|
{
|
||||||
|
services.zerobyte = {
|
||||||
|
enable = true;
|
||||||
|
port = 4096;
|
||||||
|
openFirewall = true;
|
||||||
|
# fuse.enable = true; # Enabled by default for remote mounts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available module options (NixOS):**
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `enable` | `false` | Enable Zerobyte service |
|
||||||
|
| `port` | `4096` | HTTP port |
|
||||||
|
| `dataDir` | `/var/lib/zerobyte` | Data directory |
|
||||||
|
| `user` | `"zerobyte"` | User to run service as |
|
||||||
|
| `group` | `"zerobyte"` | Group to run service as |
|
||||||
|
| `createUser` | `true` | Create user/group automatically |
|
||||||
|
| `serverIp` | `"0.0.0.0"` | Bind address |
|
||||||
|
| `openFirewall` | `false` | Open firewall port (NixOS only) |
|
||||||
|
| `fuse.enable` | `true` | Enable FUSE/remote mounts (NixOS only) |
|
||||||
|
| `protectHome` | `true` | Block /home access (NixOS only, set false to backup home dirs) |
|
||||||
|
| `extraReadWritePaths` | `[]` | Additional writable paths (e.g., `["/mnt/storage"]` for custom repos) |
|
||||||
|
| `timezone` | `"UTC"` | Timezone for scheduling |
|
||||||
|
| `resticHostname` | `"zerobyte"` | Hostname for restic |
|
||||||
|
|
||||||
|
**Updating dependencies (for contributors):**
|
||||||
|
|
||||||
|
After modifying `package.json` or `bun.lock`, regenerate the Nix dependency file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
bun2nix -o bun.nix
|
||||||
|
```
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.
|
See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly examples.
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,19 @@ import "dotenv/config";
|
||||||
|
|
||||||
const envSchema = type({
|
const envSchema = type({
|
||||||
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
||||||
|
PORT: 'string.integer.parse = "4096"',
|
||||||
SERVER_IP: 'string = "localhost"',
|
SERVER_IP: 'string = "localhost"',
|
||||||
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
|
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
|
||||||
RESTIC_HOSTNAME: "string = 'zerobyte'",
|
RESTIC_HOSTNAME: "string = 'zerobyte'",
|
||||||
|
MIGRATIONS_PATH: "string?",
|
||||||
}).pipe((s) => ({
|
}).pipe((s) => ({
|
||||||
__prod__: s.NODE_ENV === "production",
|
__prod__: s.NODE_ENV === "production",
|
||||||
environment: s.NODE_ENV,
|
environment: s.NODE_ENV,
|
||||||
|
port: s.PORT,
|
||||||
serverIp: s.SERVER_IP,
|
serverIp: s.SERVER_IP,
|
||||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||||
resticHostname: s.RESTIC_HOSTNAME,
|
resticHostname: s.RESTIC_HOSTNAME,
|
||||||
|
migrationsPath: s.MIGRATIONS_PATH,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,17 @@ const sqlite = new Database(DATABASE_URL);
|
||||||
export const db = drizzle({ client: sqlite, schema });
|
export const db = drizzle({ client: sqlite, schema });
|
||||||
|
|
||||||
export const runDbMigrations = () => {
|
export const runDbMigrations = () => {
|
||||||
let migrationsFolder = path.join("/app", "assets", "migrations");
|
let migrationsFolder: string;
|
||||||
|
|
||||||
if (!config.__prod__) {
|
// Migration path priority:
|
||||||
|
// 1. MIGRATIONS_PATH env var (Nix, custom deployments)
|
||||||
|
// 2. /app/assets/migrations (Docker production)
|
||||||
|
// 3. /app/app/drizzle (Docker development)
|
||||||
|
if (config.migrationsPath) {
|
||||||
|
migrationsFolder = config.migrationsPath;
|
||||||
|
} else if (config.__prod__) {
|
||||||
|
migrationsFolder = path.join("/app", "assets", "migrations");
|
||||||
|
} else {
|
||||||
migrationsFolder = path.join("/app", "app", "drizzle");
|
migrationsFolder = path.join("/app", "app", "drizzle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
||||||
|
|
||||||
startup();
|
startup();
|
||||||
|
|
||||||
logger.info(`Server is running at http://localhost:4096`);
|
logger.info(`Server is running at http://localhost:${config.port}`);
|
||||||
|
|
||||||
export type AppType = typeof app;
|
export type AppType = typeof app;
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ process.on("SIGINT", async () => {
|
||||||
|
|
||||||
export default await createHonoServer({
|
export default await createHonoServer({
|
||||||
app,
|
app,
|
||||||
port: 4096,
|
port: config.port,
|
||||||
customBunServer: {
|
customBunServer: {
|
||||||
idleTimeout: config.serverIdleTimeout,
|
idleTimeout: config.serverIdleTimeout,
|
||||||
error(err) {
|
error(err) {
|
||||||
|
|
|
||||||
9
bun.lock
9
bun.lock
|
|
@ -26,7 +26,11 @@
|
||||||
"@react-router/node": "^7.10.0",
|
"@react-router/node": "^7.10.0",
|
||||||
"@react-router/serve": "^7.10.0",
|
"@react-router/serve": "^7.10.0",
|
||||||
"@scalar/hono-api-reference": "^0.9.25",
|
"@scalar/hono-api-reference": "^0.9.25",
|
||||||
|
"@standard-community/standard-json": "^0.3.5",
|
||||||
|
"@standard-community/standard-openapi": "^0.2.9",
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"arktype": "^2.1.28",
|
"arktype": "^2.1.28",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -45,9 +49,12 @@
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"openapi-types": "^12.1.3",
|
||||||
|
"quansync": "^1.0.0",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-is": "^19.2.3",
|
||||||
"react-router": "^7.10.0",
|
"react-router": "^7.10.0",
|
||||||
"react-router-hono-server": "^2.22.0",
|
"react-router-hono-server": "^2.22.0",
|
||||||
"recharts": "3.5.1",
|
"recharts": "3.5.1",
|
||||||
|
|
@ -952,7 +959,7 @@
|
||||||
|
|
||||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||||
|
|
||||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||||
|
|
||||||
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
|
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
|
||||||
|
|
||||||
|
|
|
||||||
170
flake.lock
Normal file
170
flake.lock
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"bun2nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"import-tree": "import-tree",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"systems": "systems",
|
||||||
|
"treefmt-nix": "treefmt-nix"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1765198123,
|
||||||
|
"narHash": "sha256-pkahE6wwIszQ8e107qa95wyNr4Qj94hEdL9fA/IE288=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "bun2nix",
|
||||||
|
"rev": "29d2c26c269b2bc7d8885c6a2fc90ec0fc86ec40",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "bun2nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763759067,
|
||||||
|
"narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"import-tree": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763762820,
|
||||||
|
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
|
||||||
|
"owner": "vic",
|
||||||
|
"repo": "import-tree",
|
||||||
|
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "vic",
|
||||||
|
"repo": "import-tree",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1766651565,
|
||||||
|
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761765539,
|
||||||
|
"narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "719359f4562934ae99f5443f20aa06c2ffff91fc",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"bun2nix": "bun2nix",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"bun2nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1762938485,
|
||||||
|
"narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
535
flake.nix
Normal file
535
flake.nix
Normal file
|
|
@ -0,0 +1,535 @@
|
||||||
|
{
|
||||||
|
description = "Zerobyte - Self-hosted backup automation and management";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
bun2nix.url = "github:nix-community/bun2nix";
|
||||||
|
bun2nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils, bun2nix }:
|
||||||
|
let
|
||||||
|
# Systems for packages and devShells
|
||||||
|
allSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Linux-only systems for NixOS module and tests
|
||||||
|
linuxSystems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
|
||||||
|
# shoutrrr version and hashes (SRI format)
|
||||||
|
shoutrrrVersion = "0.13.1";
|
||||||
|
shoutrrrHashes = {
|
||||||
|
x86_64-linux = "sha256-TZrDstm5InQOalYf9da5rhnsJm7qTnmG18jLJtvsD8A=";
|
||||||
|
aarch64-linux = "sha256-IHgZhsykJbmW/uYUsd6o7Wh3EIsUldduIKFZ0GkjrwI=";
|
||||||
|
x86_64-darwin = "sha256-pzmAGRzbWYVHoZqvx6tsuxpuKcfIXMVNPXbKHeeAyxs=";
|
||||||
|
aarch64-darwin = "sha256-DKQRzdDd1xccNqetscEKKzgyT1IatOlwPBwa4E8fbDc=";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Map Nix system to shoutrrr release naming
|
||||||
|
shoutrrrArch = {
|
||||||
|
x86_64-linux = "linux_amd64";
|
||||||
|
aarch64-linux = "linux_arm64v8";
|
||||||
|
x86_64-darwin = "macOS_amd64";
|
||||||
|
aarch64-darwin = "macOS_arm64v8";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Check if system is Linux
|
||||||
|
isLinux = system: builtins.elem system linuxSystems;
|
||||||
|
|
||||||
|
in
|
||||||
|
flake-utils.lib.eachSystem allSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
config.allowUnfree = true;
|
||||||
|
overlays = [ bun2nix.overlays.default ];
|
||||||
|
};
|
||||||
|
|
||||||
|
shoutrrr = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "shoutrrr";
|
||||||
|
version = shoutrrrVersion;
|
||||||
|
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${shoutrrrVersion}/shoutrrr_${shoutrrrArch.${system}}_${shoutrrrVersion}.tar.gz";
|
||||||
|
hash = shoutrrrHashes.${system};
|
||||||
|
};
|
||||||
|
|
||||||
|
sourceRoot = ".";
|
||||||
|
|
||||||
|
nativeBuildInputs = pkgs.lib.optionals (isLinux system) [ pkgs.autoPatchelfHook ];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
install -Dm755 shoutrrr $out/bin/shoutrrr
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Notification library and CLI for various services";
|
||||||
|
homepage = "https://github.com/nicholas-fedor/shoutrrr";
|
||||||
|
license = licenses.mit;
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Read version from package.json to avoid drift
|
||||||
|
packageJson = builtins.fromJSON (builtins.readFile ./package.json);
|
||||||
|
|
||||||
|
zerobyte = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "zerobyte";
|
||||||
|
version = packageJson.version or "0.0.0";
|
||||||
|
|
||||||
|
src = pkgs.lib.cleanSource ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.bun2nix.hook
|
||||||
|
pkgs.makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
# Fetch bun dependencies using bun2nix
|
||||||
|
bunDeps = pkgs.bun2nix.fetchBunDeps {
|
||||||
|
bunNix = ./bun.nix;
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
|
||||||
|
# Build the application (react-router build)
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
# Create output directories matching the expected structure
|
||||||
|
mkdir -p $out/lib/zerobyte/dist
|
||||||
|
mkdir -p $out/lib/zerobyte/drizzle
|
||||||
|
mkdir -p $out/bin
|
||||||
|
|
||||||
|
# Copy built assets (server expects dist/server and dist/client)
|
||||||
|
cp -r dist/server $out/lib/zerobyte/dist/server
|
||||||
|
cp -r dist/client $out/lib/zerobyte/dist/client
|
||||||
|
cp -r app/drizzle/* $out/lib/zerobyte/drizzle/
|
||||||
|
cp package.json $out/lib/zerobyte/
|
||||||
|
|
||||||
|
# Copy node_modules for runtime dependencies
|
||||||
|
cp -r node_modules $out/lib/zerobyte/
|
||||||
|
|
||||||
|
# Create wrapper script with runtime dependencies
|
||||||
|
# --chdir ensures server finds dist/client relative to package dir
|
||||||
|
makeWrapper ${pkgs.bun}/bin/bun $out/bin/zerobyte \
|
||||||
|
--chdir $out/lib/zerobyte \
|
||||||
|
--add-flags "dist/server/index.js" \
|
||||||
|
--prefix PATH : ${pkgs.lib.makeBinPath ([
|
||||||
|
pkgs.restic
|
||||||
|
pkgs.rclone
|
||||||
|
shoutrrr
|
||||||
|
pkgs.openssh
|
||||||
|
] ++ pkgs.lib.optionals (isLinux system) [
|
||||||
|
pkgs.fuse3
|
||||||
|
pkgs.davfs2
|
||||||
|
])} \
|
||||||
|
--set NODE_ENV "production"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with pkgs.lib; {
|
||||||
|
description = "Self-hosted backup automation and management";
|
||||||
|
homepage = "https://github.com/nicotsx/zerobyte";
|
||||||
|
license = licenses.mit;
|
||||||
|
platforms = platforms.unix;
|
||||||
|
mainProgram = "zerobyte";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = {
|
||||||
|
inherit zerobyte shoutrrr;
|
||||||
|
default = zerobyte;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = [
|
||||||
|
# JavaScript runtime and package manager
|
||||||
|
pkgs.bun
|
||||||
|
pkgs.nodejs
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
pkgs.biome
|
||||||
|
pkgs.typescript
|
||||||
|
|
||||||
|
# bun2nix CLI for regenerating bun.nix
|
||||||
|
bun2nix.packages.${system}.bun2nix
|
||||||
|
|
||||||
|
# External tools (for local testing)
|
||||||
|
pkgs.restic
|
||||||
|
pkgs.rclone
|
||||||
|
shoutrrr
|
||||||
|
|
||||||
|
# Database tools
|
||||||
|
pkgs.sqlite
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
pkgs.git
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.jq
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "Zerobyte development environment"
|
||||||
|
echo " bun: $(bun --version)"
|
||||||
|
echo " node: $(node --version)"
|
||||||
|
echo " restic: $(restic version | head -1)"
|
||||||
|
echo " rclone: $(rclone version | head -1)"
|
||||||
|
echo ""
|
||||||
|
echo "To update bun.nix after changing dependencies:"
|
||||||
|
echo " bun2nix -o bun.nix"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) // {
|
||||||
|
# Overlay
|
||||||
|
overlays.default = final: prev: {
|
||||||
|
zerobyte = self.packages.${final.system}.zerobyte;
|
||||||
|
shoutrrr = self.packages.${final.system}.shoutrrr;
|
||||||
|
};
|
||||||
|
|
||||||
|
# NixOS Module
|
||||||
|
nixosModules.default = { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.zerobyte;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.zerobyte = {
|
||||||
|
enable = lib.mkEnableOption "Zerobyte backup management service";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.system}.zerobyte;
|
||||||
|
defaultText = lib.literalExpression "pkgs.zerobyte";
|
||||||
|
description = "The Zerobyte package to use.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "zerobyte";
|
||||||
|
description = "User account under which Zerobyte runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "zerobyte";
|
||||||
|
description = "Group under which Zerobyte runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
createUser = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Whether to create the user and group automatically.
|
||||||
|
Set to false if using an existing user account.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/zerobyte";
|
||||||
|
description = "Directory to store Zerobyte data.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4096;
|
||||||
|
description = "Port on which Zerobyte listens.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for Zerobyte.";
|
||||||
|
};
|
||||||
|
|
||||||
|
serverIp = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "0.0.0.0";
|
||||||
|
description = "IP address to bind the server to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
timezone = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "UTC";
|
||||||
|
description = "Timezone for scheduling backups.";
|
||||||
|
};
|
||||||
|
|
||||||
|
resticHostname = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "zerobyte";
|
||||||
|
description = "Hostname used for restic operations.";
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = {};
|
||||||
|
description = "Additional environment variables for Zerobyte.";
|
||||||
|
};
|
||||||
|
|
||||||
|
fuse = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Enable FUSE mounting capabilities.
|
||||||
|
Requires CAP_SYS_ADMIN and access to /dev/fuse.
|
||||||
|
Enables NFS, SMB, and WebDAV volume mounts.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
protectHome = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Enable ProtectHome systemd security hardening.
|
||||||
|
When true, /home, /root, and /run/user are inaccessible.
|
||||||
|
Set to false if you need to backup home directories.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
extraReadWritePaths = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [];
|
||||||
|
example = [ "/mnt/storage" "/backup" ];
|
||||||
|
description = ''
|
||||||
|
Additional paths the service can write to.
|
||||||
|
Use this for custom repository locations outside of dataDir.
|
||||||
|
Required because ProtectSystem=strict makes the filesystem read-only.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = lib.mkIf cfg.createUser {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
createHome = true;
|
||||||
|
description = "Zerobyte service user";
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf cfg.createUser {};
|
||||||
|
|
||||||
|
systemd.services.zerobyte = {
|
||||||
|
description = "Zerobyte backup management service";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
SERVER_IP = cfg.serverIp;
|
||||||
|
RESTIC_HOSTNAME = cfg.resticHostname;
|
||||||
|
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
|
||||||
|
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
|
||||||
|
TZ = cfg.timezone;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
ExecStart = "${cfg.package}/bin/zerobyte";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# State directory
|
||||||
|
StateDirectory = "zerobyte";
|
||||||
|
StateDirectoryMode = "0750";
|
||||||
|
WorkingDirectory = cfg.dataDir;
|
||||||
|
|
||||||
|
# Capabilities
|
||||||
|
# - CAP_SYS_ADMIN: Required for FUSE mounts
|
||||||
|
# - CAP_DAC_READ_SEARCH: Required to read restricted directories (e.g., 700 home dirs)
|
||||||
|
# - CAP_DAC_OVERRIDE: Required to write to directories not owned by service user
|
||||||
|
AmbientCapabilities =
|
||||||
|
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
|
||||||
|
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
|
||||||
|
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
|
||||||
|
CapabilityBoundingSet =
|
||||||
|
lib.optional cfg.fuse.enable "CAP_SYS_ADMIN"
|
||||||
|
++ lib.optional (!cfg.protectHome) "CAP_DAC_READ_SEARCH"
|
||||||
|
++ lib.optional (cfg.extraReadWritePaths != []) "CAP_DAC_OVERRIDE";
|
||||||
|
DeviceAllow = lib.mkIf cfg.fuse.enable [ "/dev/fuse rw" ];
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = cfg.protectHome;
|
||||||
|
# Disable when capabilities are needed (FUSE, home access, or extra write paths)
|
||||||
|
NoNewPrivileges = !cfg.fuse.enable && cfg.protectHome && cfg.extraReadWritePaths == [];
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
|
RestrictNamespaces = !cfg.fuse.enable;
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = false; # Required for bun/V8
|
||||||
|
RestrictRealtime = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
RemoveIPC = true;
|
||||||
|
PrivateMounts = !cfg.fuse.enable;
|
||||||
|
|
||||||
|
# Allow write access to data directory
|
||||||
|
ReadWritePaths = [ cfg.dataDir ] ++ cfg.extraReadWritePaths;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# nix-darwin Module (macOS) - EXPERIMENTAL/FUTURE USE
|
||||||
|
# macOS lacks Linux capabilities (CAP_DAC_READ_SEARCH, etc.) and uses TCC
|
||||||
|
# (Transparency, Consent, and Control) which blocks access to ~/Desktop,
|
||||||
|
# ~/Documents, etc. even for root. Full support requires significant code
|
||||||
|
# changes to handle TCC permission grants via System Preferences.
|
||||||
|
darwinModules.default = { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.zerobyte;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.zerobyte = {
|
||||||
|
enable = lib.mkEnableOption "Zerobyte backup management service";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.system}.zerobyte;
|
||||||
|
defaultText = lib.literalExpression "pkgs.zerobyte";
|
||||||
|
description = "The Zerobyte package to use.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/zerobyte";
|
||||||
|
description = "Directory to store Zerobyte data.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4096;
|
||||||
|
description = "Port on which Zerobyte listens.";
|
||||||
|
};
|
||||||
|
|
||||||
|
serverIp = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "0.0.0.0";
|
||||||
|
description = "IP address to bind the server to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
timezone = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "UTC";
|
||||||
|
description = "Timezone for scheduling backups.";
|
||||||
|
};
|
||||||
|
|
||||||
|
resticHostname = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "zerobyte";
|
||||||
|
description = "Hostname used for restic operations.";
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf lib.types.str;
|
||||||
|
default = {};
|
||||||
|
description = "Additional environment variables for Zerobyte.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Create data directory
|
||||||
|
system.activationScripts.zerobyte.text = ''
|
||||||
|
mkdir -p ${cfg.dataDir}/data
|
||||||
|
chmod 750 ${cfg.dataDir}
|
||||||
|
'';
|
||||||
|
|
||||||
|
launchd.daemons.zerobyte = {
|
||||||
|
serviceConfig = {
|
||||||
|
Label = "org.zerobyte.daemon";
|
||||||
|
ProgramArguments = [ "${cfg.package}/bin/zerobyte" ];
|
||||||
|
RunAtLoad = true;
|
||||||
|
KeepAlive = true;
|
||||||
|
WorkingDirectory = "${cfg.dataDir}";
|
||||||
|
|
||||||
|
EnvironmentVariables = {
|
||||||
|
NODE_ENV = "production";
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
SERVER_IP = cfg.serverIp;
|
||||||
|
RESTIC_HOSTNAME = cfg.resticHostname;
|
||||||
|
DATABASE_URL = "${cfg.dataDir}/data/zerobyte.db";
|
||||||
|
MIGRATIONS_PATH = "${cfg.package}/lib/zerobyte/drizzle";
|
||||||
|
TZ = cfg.timezone;
|
||||||
|
} // cfg.environment;
|
||||||
|
|
||||||
|
StandardOutPath = "/var/log/zerobyte.log";
|
||||||
|
StandardErrorPath = "/var/log/zerobyte.error.log";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# NixOS VM Tests (Linux only)
|
||||||
|
checks = builtins.listToAttrs (map (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
name = system;
|
||||||
|
value = {
|
||||||
|
integration = pkgs.testers.nixosTest {
|
||||||
|
name = "zerobyte-integration";
|
||||||
|
|
||||||
|
nodes.machine = { config, pkgs, ... }: {
|
||||||
|
imports = [ self.nixosModules.default ];
|
||||||
|
|
||||||
|
services.zerobyte = {
|
||||||
|
enable = true;
|
||||||
|
openFirewall = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Ensure the test VM has enough resources
|
||||||
|
virtualisation = {
|
||||||
|
memorySize = 1024;
|
||||||
|
diskSize = 2048;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
testScript = ''
|
||||||
|
machine.start()
|
||||||
|
machine.wait_for_unit("zerobyte.service")
|
||||||
|
machine.wait_for_open_port(4096)
|
||||||
|
|
||||||
|
# Test healthcheck endpoint (returns {"status":"ok"})
|
||||||
|
result = machine.succeed("curl -s http://localhost:4096/healthcheck")
|
||||||
|
assert '"status":"ok"' in result or '"ok"' in result, f"Healthcheck failed: {result}"
|
||||||
|
|
||||||
|
machine.log("Zerobyte integration test passed!")
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) linuxSystems);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,11 @@
|
||||||
"@react-router/node": "^7.10.0",
|
"@react-router/node": "^7.10.0",
|
||||||
"@react-router/serve": "^7.10.0",
|
"@react-router/serve": "^7.10.0",
|
||||||
"@scalar/hono-api-reference": "^0.9.25",
|
"@scalar/hono-api-reference": "^0.9.25",
|
||||||
|
"@standard-community/standard-json": "^0.3.5",
|
||||||
|
"@standard-community/standard-openapi": "^0.2.9",
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"arktype": "^2.1.28",
|
"arktype": "^2.1.28",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -62,9 +66,12 @@
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"openapi-types": "^12.1.3",
|
||||||
|
"quansync": "^1.0.0",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-is": "^19.2.3",
|
||||||
"react-router": "^7.10.0",
|
"react-router": "^7.10.0",
|
||||||
"react-router-hono-server": "^2.22.0",
|
"react-router-hono-server": "^2.22.0",
|
||||||
"recharts": "3.5.1",
|
"recharts": "3.5.1",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue