pnpm, verify, cleanup (#6)

This commit is contained in:
Hari 2026-03-31 23:59:52 -04:00 committed by GitHub
parent b68151035a
commit e2f03f47af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4276 additions and 5133 deletions

15
.editorconfig Normal file
View file

@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[Makefile]
indent_style = tab

7
.prettierignore Normal file
View file

@ -0,0 +1,7 @@
.git
.next
.turbo
coverage
dist
node_modules
apps/web/.next

View file

@ -30,8 +30,15 @@ Read these in order:
- `apps/node-agent`: Go NAS runtime / WebDAV node - `apps/node-agent`: Go NAS runtime / WebDAV node
- `apps/nextcloud-app`: optional Nextcloud adapter - `apps/nextcloud-app`: optional Nextcloud adapter
- `packages/contracts`: canonical shared contracts - `packages/contracts`: canonical shared contracts
- `packages/sdk-ts`: TypeScript SDK surface for the web app
- `packages/ui`: shared React UI - `packages/ui`: shared React UI
- `infra/docker`: local Docker runtime - `infra/docker`: local Docker runtime
The root planning and delegation guide lives in [skeleton.md](/home/rathi/Documents/GitHub/betterNAS/skeleton.md). The root planning and delegation guide lives in [skeleton.md](/home/rathi/Documents/GitHub/betterNAS/skeleton.md).
## Verify
Run the repo acceptance loop with:
```bash
pnpm verify
```

7
TODO.md Normal file
View file

@ -0,0 +1,7 @@
# TODO
- [x] Remove the temporary TypeScript SDK layer so shared interfaces only come from `packages/contracts`.
- [x] Switch the monorepo from `npm` workspaces to `pnpm`.
- [x] Add root formatting, verification, and Go formatting rails.
- [x] Add hard boundary checks so apps and packages cannot drift across lanes with private imports.
- [ ] Make the first contract-backed mount loop real: node registration, export inventory, mount profile issuance, and a Finder-mountable WebDAV export.

View file

@ -60,12 +60,12 @@ func main() {
}) })
mux.HandleFunc("/api/v1/nodes/register", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/v1/nodes/register", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, jsonObject{ writeJSON(w, http.StatusOK, jsonObject{
"id": "dev-node", "id": "dev-node",
"machineId": "dev-machine", "machineId": "dev-machine",
"displayName": "Development NAS", "displayName": "Development NAS",
"agentVersion": "0.1.0-dev", "agentVersion": "0.1.0-dev",
"status": "online", "status": "online",
"lastSeenAt": time.Now().UTC().Format(time.RFC3339), "lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"directAddress": env("BETTERNAS_NODE_DIRECT_ADDRESS", "http://localhost:8090"), "directAddress": env("BETTERNAS_NODE_DIRECT_ADDRESS", "http://localhost:8090"),
"relayAddress": nil, "relayAddress": nil,
}) })

View file

@ -5,6 +5,8 @@
"scripts": { "scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/control-plane", "dev": "CGO_ENABLED=0 go run ./cmd/control-plane",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/control-plane ./cmd/control-plane", "build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/control-plane ./cmd/control-plane",
"format": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then gofmt -w $files; fi",
"format:check": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then test -z \"$(gofmt -l $files)\"; fi",
"lint": "CGO_ENABLED=0 go vet ./...", "lint": "CGO_ENABLED=0 go vet ./...",
"check-types": "CGO_ENABLED=0 go test ./...", "check-types": "CGO_ENABLED=0 go test ./...",
"test": "CGO_ENABLED=0 go test ./..." "test": "CGO_ENABLED=0 go test ./..."

View file

@ -14,4 +14,3 @@
"nextcloud/ocp": "dev-stable31" "nextcloud/ocp": "dev-stable31"
} }
} }

View file

@ -1,85 +1,84 @@
.betternas-shell { .betternas-shell {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 32px; padding: 32px;
} }
.betternas-shell__hero { .betternas-shell__hero {
margin-bottom: 28px; margin-bottom: 28px;
padding: 28px; padding: 28px;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(135deg, #10212d 0%, #184152 100%); background: linear-gradient(135deg, #10212d 0%, #184152 100%);
color: #f6fafc; color: #f6fafc;
} }
.betternas-shell__eyebrow { .betternas-shell__eyebrow {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 12px; font-size: 12px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
opacity: 0.8; opacity: 0.8;
} }
.betternas-shell__title { .betternas-shell__title {
margin: 0 0 12px; margin: 0 0 12px;
font-size: 32px; font-size: 32px;
line-height: 1.1; line-height: 1.1;
} }
.betternas-shell__copy { .betternas-shell__copy {
margin: 0; margin: 0;
max-width: 70ch; max-width: 70ch;
font-size: 15px; font-size: 15px;
line-height: 1.6; line-height: 1.6;
} }
.betternas-shell__grid { .betternas-shell__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px; gap: 20px;
} }
.betternas-shell__card { .betternas-shell__card {
padding: 24px; padding: 24px;
border: 1px solid rgba(16, 33, 45, 0.12); border: 1px solid rgba(16, 33, 45, 0.12);
border-radius: 20px; border-radius: 20px;
background: #ffffff; background: #ffffff;
box-shadow: 0 20px 40px rgba(16, 33, 45, 0.06); box-shadow: 0 20px 40px rgba(16, 33, 45, 0.06);
} }
.betternas-shell__card h2 { .betternas-shell__card h2 {
margin-top: 0; margin-top: 0;
} }
.betternas-shell__card dl { .betternas-shell__card dl {
display: grid; display: grid;
grid-template-columns: minmax(120px, 160px) 1fr; grid-template-columns: minmax(120px, 160px) 1fr;
gap: 8px 16px; gap: 8px 16px;
margin: 0; margin: 0;
} }
.betternas-shell__card dt { .betternas-shell__card dt {
font-weight: 600; font-weight: 600;
} }
.betternas-shell__card dd { .betternas-shell__card dd {
margin: 0; margin: 0;
} }
.betternas-shell__card code { .betternas-shell__card code {
display: inline-block; display: inline-block;
padding: 4px 8px; padding: 4px 8px;
border-radius: 999px; border-radius: 999px;
background: #eef4f7; background: #eef4f7;
} }
.betternas-shell__card ul { .betternas-shell__card ul {
margin: 0; margin: 0;
padding-left: 20px; padding-left: 20px;
} }
.betternas-shell__error { .betternas-shell__error {
margin-top: 16px; margin-top: 16px;
color: #b42318; color: #b42318;
} }

View file

@ -5,6 +5,8 @@
"scripts": { "scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/node-agent", "dev": "CGO_ENABLED=0 go run ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent", "build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent",
"format": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then gofmt -w $files; fi",
"format:check": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then test -z \"$(gofmt -l $files)\"; fi",
"lint": "CGO_ENABLED=0 go vet ./...", "lint": "CGO_ENABLED=0 go vet ./...",
"check-types": "CGO_ENABLED=0 go test ./...", "check-types": "CGO_ENABLED=0 go test ./...",
"test": "CGO_ENABLED=0 go test ./..." "test": "CGO_ENABLED=0 go test ./..."

View file

@ -13,7 +13,8 @@ const geistMono = localFont({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "betterNAS", title: "betterNAS",
description: "Contract-first monorepo for NAS mounts and optional cloud access", description:
"Contract-first monorepo for NAS mounts and optional cloud access",
}; };
export default function RootLayout({ export default function RootLayout({

View file

@ -2,7 +2,11 @@
min-height: 100svh; min-height: 100svh;
padding: 48px 24px 80px; padding: 48px 24px 80px;
background: background:
radial-gradient(circle at top left, rgba(91, 186, 166, 0.18), transparent 28%), radial-gradient(
circle at top left,
rgba(91, 186, 166, 0.18),
transparent 28%
),
linear-gradient(180deg, #f5fbfa 0%, #edf5f3 100%); linear-gradient(180deg, #f5fbfa 0%, #edf5f3 100%);
color: #10212d; color: #10212d;
} }

View file

@ -25,7 +25,9 @@ export default function Home() {
<main className={styles.page}> <main className={styles.page}>
<section className={styles.hero}> <section className={styles.hero}>
<p className={styles.eyebrow}>betterNAS monorepo</p> <p className={styles.eyebrow}>betterNAS monorepo</p>
<h1 className={styles.title}>Contract-first scaffold for NAS mounts and cloud mode.</h1> <h1 className={styles.title}>
Contract-first scaffold for NAS mounts and cloud mode.
</h1>
<p className={styles.copy}> <p className={styles.copy}>
The repo is organized so each system part can be built in parallel The repo is organized so each system part can be built in parallel
without inventing new interfaces. The source of truth is the root without inventing new interfaces. The source of truth is the root
@ -35,7 +37,12 @@ export default function Home() {
<section className={styles.grid}> <section className={styles.grid}>
{lanes.map((lane) => ( {lanes.map((lane) => (
<Card key={lane.title} className={styles.card} title={lane.title} href="/#"> <Card
key={lane.title}
className={styles.card}
title={lane.title}
href="/#"
>
{lane.body} {lane.body}
</Card> </Card>
))} ))}

View file

@ -11,15 +11,14 @@
"check-types": "next typegen && tsc --noEmit" "check-types": "next typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@betternas/sdk-ts": "*", "@betternas/ui": "workspace:*",
"@betternas/ui": "*",
"next": "16.2.0", "next": "16.2.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@betternas/eslint-config": "*", "@betternas/eslint-config": "workspace:*",
"@betternas/typescript-config": "*", "@betternas/typescript-config": "workspace:*",
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",

View file

@ -8,11 +8,6 @@
], ],
"strictNullChecks": true "strictNullChecks": true
}, },
"include": [ "include": ["**/*.ts", "**/*.tsx", "next-env.d.ts", "next.config.js"],
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
"next.config.js"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View file

@ -7,12 +7,14 @@ This document describes the software that runs on the actual NAS machine, VM, or
The NAS node is the machine that actually has the storage. The NAS node is the machine that actually has the storage.
It should run: It should run:
- a WebDAV server - a WebDAV server
- a small betterNAS node agent - a small betterNAS node agent
- declarative config via Nix - declarative config via Nix
- optional tunnel or relay connection if the machine is not directly reachable - optional tunnel or relay connection if the machine is not directly reachable
It should expose one or more storage exports such as: It should expose one or more storage exports such as:
- `/data` - `/data`
- `/media` - `/media`
- `/backups` - `/backups`

View file

@ -7,6 +7,7 @@ This document describes the main backend that owns product semantics and coordin
The control plane is the source of truth for betterNAS. The control plane is the source of truth for betterNAS.
It should own: It should own:
- users - users
- devices - devices
- NAS nodes - NAS nodes

View file

@ -7,10 +7,12 @@ This document describes the software and user experience on the user's Mac or ot
The local device layer is how a user actually mounts and uses their NAS. The local device layer is how a user actually mounts and uses their NAS.
It can start simple: It can start simple:
- Finder + WebDAV mount - Finder + WebDAV mount
- manual `Connect to Server` - manual `Connect to Server`
It can later grow into: It can later grow into:
- a small desktop helper - a small desktop helper
- one-click mount flows - one-click mount flows
- auto-mount at login - auto-mount at login

View file

@ -7,6 +7,7 @@ This document describes the optional browser, mobile, and cloud-drive style acce
The cloud/web layer is the part of betterNAS that makes storage accessible beyond local mounts. The cloud/web layer is the part of betterNAS that makes storage accessible beyond local mounts.
This is where we can reuse Nextcloud heavily for: This is where we can reuse Nextcloud heavily for:
- browser file UI - browser file UI
- uploads and downloads - uploads and downloads
- sharing links - sharing links

View file

@ -3,6 +3,7 @@
This document ties the other four parts together. This document ties the other four parts together.
It answers four questions: It answers four questions:
- how the full system fits together - how the full system fits together
- where each part starts - where each part starts
- what we should steal from existing tools - what we should steal from existing tools
@ -40,17 +41,18 @@ It answers four questions:
The control plane owns product semantics. The control plane owns product semantics.
The other three parts are execution surfaces: The other three parts are execution surfaces:
- the NAS node serves storage - the NAS node serves storage
- the local device mounts and uses storage - the local device mounts and uses storage
- the cloud/web layer exposes storage through browser and mobile-friendly flows - the cloud/web layer exposes storage through browser and mobile-friendly flows
## What we steal vs write ## What we steal vs write
| Part | Steal first | Write ourselves | | Part | Steal first | Write ourselves |
|---|---|---| | --------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| NAS node | NixOS/Nix module patterns, existing WebDAV servers | node agent, export model, node registration flow | | NAS node | NixOS/Nix module patterns, existing WebDAV servers | node agent, export model, node registration flow |
| Control plane | Go stdlib routing, pgx/sqlc, go-redis/asynq, OpenAPI codegen | product domain model, policy engine, mount/cloud APIs, registry | | Control plane | Go stdlib routing, pgx/sqlc, go-redis/asynq, OpenAPI codegen | product domain model, policy engine, mount/cloud APIs, registry |
| Local device | Finder WebDAV mount, macOS Keychain, later maybe launch agent patterns | helper app, mount profile handling, auto-mount UX | | Local device | Finder WebDAV mount, macOS Keychain, later maybe launch agent patterns | helper app, mount profile handling, auto-mount UX |
| Cloud/web layer | Nextcloud server, Nextcloud shell app, Nextcloud share/file UI, Nextcloud mobile references | betterNAS integration layer, mapping between product model and Nextcloud, later branded UI | | Cloud/web layer | Nextcloud server, Nextcloud shell app, Nextcloud share/file UI, Nextcloud mobile references | betterNAS integration layer, mapping between product model and Nextcloud, later branded UI |
## Where each part should start ## Where each part should start
@ -58,11 +60,13 @@ The other three parts are execution surfaces:
## 1. NAS node ## 1. NAS node
Start from: Start from:
- Nix flake / module - Nix flake / module
- a standard WebDAV server - a standard WebDAV server
- a very small agent process - a very small agent process
Do not start by writing: Do not start by writing:
- custom storage protocol - custom storage protocol
- custom file server - custom file server
- custom sync engine - custom sync engine
@ -72,6 +76,7 @@ The NAS node should be boring and reproducible.
## 2. Control plane ## 2. Control plane
Start from: Start from:
- Go - Go
- standard library routing first - standard library routing first
- Postgres via `pgx` and `sqlc` - Postgres via `pgx` and `sqlc`
@ -80,6 +85,7 @@ Start from:
- standalone API mindset - standalone API mindset
Do not start by writing: Do not start by writing:
- microservices - microservices
- custom file transport - custom file transport
- a proxy that sits in the middle of every file transfer - a proxy that sits in the middle of every file transfer
@ -89,15 +95,18 @@ This is the first real thing we should build.
## 3. Local device ## 3. Local device
Start from: Start from:
- native Finder `Connect to Server` - native Finder `Connect to Server`
- WebDAV mount URLs issued by the control plane - WebDAV mount URLs issued by the control plane
Then later add: Then later add:
- a lightweight helper app - a lightweight helper app
- Keychain integration - Keychain integration
- auto-mount at login - auto-mount at login
Do not start by writing: Do not start by writing:
- a full custom desktop sync client - a full custom desktop sync client
- a Finder extension - a Finder extension
- a new filesystem driver - a new filesystem driver
@ -105,16 +114,19 @@ Do not start by writing:
## 4. Cloud / web layer ## 4. Cloud / web layer
Start from: Start from:
- stock Nextcloud - stock Nextcloud
- current shell app - current shell app
- Nextcloud browser/share/mobile primitives - Nextcloud browser/share/mobile primitives
Then later add: Then later add:
- betterNAS-specific integration pages - betterNAS-specific integration pages
- standalone control-plane web UI - standalone control-plane web UI
- custom branding or replacement UI where justified - custom branding or replacement UI where justified
Do not start by writing: Do not start by writing:
- a full custom browser file manager - a full custom browser file manager
- a custom mobile client - a custom mobile client
- a custom sharing stack - a custom sharing stack
@ -148,19 +160,23 @@ This is high leverage, but should not block Phase A.
## External parts we should deliberately reuse ## External parts we should deliberately reuse
### NAS node ### NAS node
- WebDAV server implementation - WebDAV server implementation
- Nix module patterns - Nix module patterns
### Control plane ### Control plane
- Go API service scaffold - Go API service scaffold
- Postgres - Postgres
- Redis - Redis
### Local device ### Local device
- Finder's native WebDAV mounting - Finder's native WebDAV mounting
- macOS credential storage - macOS credential storage
### Cloud/web layer ### Cloud/web layer
- Nextcloud server - Nextcloud server
- Nextcloud app shell - Nextcloud app shell
- Nextcloud share/browser behavior - Nextcloud share/browser behavior
@ -169,11 +185,13 @@ This is high leverage, but should not block Phase A.
## From-scratch parts we should deliberately own ## From-scratch parts we should deliberately own
### NAS node ### NAS node
- node enrollment - node enrollment
- export registration - export registration
- machine identity and health reporting - machine identity and health reporting
### Control plane ### Control plane
- full backend domain model - full backend domain model
- access and policy model - access and policy model
- mount profile generation - mount profile generation
@ -181,22 +199,24 @@ This is high leverage, but should not block Phase A.
- audit and registry - audit and registry
### Local device ### Local device
- user-friendly mounting workflow - user-friendly mounting workflow
- helper app if needed - helper app if needed
- local mount orchestration - local mount orchestration
### Cloud/web layer ### Cloud/web layer
- betterNAS-to-Nextcloud mapping layer - betterNAS-to-Nextcloud mapping layer
- standalone betterNAS product UI over time - standalone betterNAS product UI over time
## First scaffolds to use ## First scaffolds to use
| Part | First scaffold | | Part | First scaffold |
|---|---| | --------------- | ------------------------------------------------------------- |
| NAS node | Nix flake/module + WebDAV server service config | | NAS node | Nix flake/module + WebDAV server service config |
| Control plane | Go service + OpenAPI contract + Postgres/Redis adapters later | | Control plane | Go service + OpenAPI contract + Postgres/Redis adapters later |
| Local device | documented Finder mount flow, then lightweight helper app | | Local device | documented Finder mount flow, then lightweight helper app |
| Cloud/web layer | current Nextcloud scaffold and shell app | | Cloud/web layer | current Nextcloud scaffold and shell app |
## What not to overbuild early ## What not to overbuild early

View file

@ -123,12 +123,12 @@ parts exist at once.
Each area gets an owner and a narrow write surface. Each area gets an owner and a narrow write surface.
| Part | Owns | May read | Must not own | | Part | Owns | May read | Must not own |
|---|---|---|---| | --------------- | ------------------------------------------------ | ----------------------------- | ------------------------------ |
| NAS node | node runtime, export reporting, WebDAV config | contracts, control-plane docs | product policy | | NAS node | node runtime, export reporting, WebDAV config | contracts, control-plane docs | product policy |
| Control plane | domain model, grants, profile issuance, registry | everything | direct file serving by default | | Control plane | domain model, grants, profile issuance, registry | everything | direct file serving by default |
| Local device | mount UX, helper flows, credential handling | contracts, control-plane docs | access policy | | Local device | mount UX, helper flows, credential handling | contracts, control-plane docs | access policy |
| Cloud/web layer | Nextcloud adapter, browser/mobile integration | contracts, control-plane docs | source of truth | | Cloud/web layer | Nextcloud adapter, browser/mobile integration | contracts, control-plane docs | source of truth |
The only shared write surface across teams should be: The only shared write surface across teams should be:

View file

@ -38,7 +38,7 @@ services:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"node -e \"fetch('http://127.0.0.1:3000/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))\"" 'node -e "fetch(''http://127.0.0.1:3000/health'').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"',
] ]
interval: 5s interval: 5s
timeout: 5s timeout: 5s

4928
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,10 +4,13 @@
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev", "dev": "turbo run dev",
"guardrails": "node ./scripts/check-boundaries.mjs",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --ignore-unknown --write . && turbo run format",
"format:check": "prettier --ignore-unknown --check . && turbo run format:check",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"test": "turbo run test" "test": "turbo run test",
"verify": "pnpm run guardrails && pnpm run format:check && turbo run lint check-types test build"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.7.4", "prettier": "^3.7.4",
@ -17,9 +20,5 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"packageManager": "npm@10.9.7", "packageManager": "pnpm@10.33.0"
"workspaces": [
"apps/*",
"packages/*"
]
} }

View file

@ -12,6 +12,6 @@
], ],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json" "check-types": "tsc --noEmit -p tsconfig.json"
} }
} }

View file

@ -3,13 +3,7 @@
"$id": "https://betternas.local/schemas/cloud-profile.schema.json", "$id": "https://betternas.local/schemas/cloud-profile.schema.json",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["id", "exportId", "provider", "baseUrl", "path"],
"id",
"exportId",
"provider",
"baseUrl",
"path"
],
"properties": { "properties": {
"id": { "id": {
"type": "string" "type": "string"

View file

@ -3,13 +3,7 @@
"$id": "https://betternas.local/schemas/control-plane-health.schema.json", "$id": "https://betternas.local/schemas/control-plane-health.schema.json",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["service", "status", "timestamp", "uptimeSeconds", "nextcloud"],
"service",
"status",
"timestamp",
"uptimeSeconds",
"nextcloud"
],
"properties": { "properties": {
"service": { "service": {
"const": "control-plane" "const": "control-plane"
@ -26,11 +20,7 @@
"nextcloud": { "nextcloud": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["configured", "baseUrl", "provider"],
"configured",
"baseUrl",
"provider"
],
"properties": { "properties": {
"configured": { "configured": {
"type": "boolean" "type": "boolean"
@ -45,4 +35,3 @@
} }
} }
} }

View file

@ -3,11 +3,7 @@
"$id": "https://betternas.local/schemas/control-plane-version.schema.json", "$id": "https://betternas.local/schemas/control-plane-version.schema.json",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["service", "version", "apiVersion"],
"service",
"version",
"apiVersion"
],
"properties": { "properties": {
"service": { "service": {
"const": "control-plane" "const": "control-plane"
@ -20,4 +16,3 @@
} }
} }
} }

View file

@ -32,10 +32,7 @@
"type": "boolean" "type": "boolean"
}, },
"credentialMode": { "credentialMode": {
"enum": [ "enum": ["session-token", "app-password"]
"session-token",
"app-password"
]
} }
} }
} }

View file

@ -27,26 +27,16 @@
"type": "string" "type": "string"
}, },
"status": { "status": {
"enum": [ "enum": ["online", "offline", "degraded"]
"online",
"offline",
"degraded"
]
}, },
"lastSeenAt": { "lastSeenAt": {
"type": "string" "type": "string"
}, },
"directAddress": { "directAddress": {
"type": [ "type": ["string", "null"]
"string",
"null"
]
}, },
"relayAddress": { "relayAddress": {
"type": [ "type": ["string", "null"]
"string",
"null"
]
} }
} }
} }

View file

@ -32,10 +32,7 @@
} }
}, },
"capacityBytes": { "capacityBytes": {
"type": [ "type": ["number", "null"]
"number",
"null"
]
}, },
"tags": { "tags": {
"type": "array", "type": "array",

View file

@ -1,6 +1,6 @@
export const CONTROL_PLANE_ROUTES = { export const CONTROL_PLANE_ROUTES = {
health: "/health", health: "/health",
version: "/version" version: "/version",
} as const; } as const;
export interface NextcloudBackendStatus { export interface NextcloudBackendStatus {
@ -22,4 +22,3 @@ export interface ControlPlaneVersionResponse {
version: string; version: string;
apiVersion: "v1"; apiVersion: "v1";
} }

View file

@ -3,7 +3,7 @@ export const FOUNDATION_API_ROUTES = {
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat", nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
listExports: "/api/v1/exports", listExports: "/api/v1/exports",
issueMountProfile: "/api/v1/mount-profiles/issue", issueMountProfile: "/api/v1/mount-profiles/issue",
issueCloudProfile: "/api/v1/cloud-profiles/issue" issueCloudProfile: "/api/v1/cloud-profiles/issue",
} as const; } as const;
export type NasNodeStatus = "online" | "offline" | "degraded"; export type NasNodeStatus = "online" | "offline" | "degraded";

View file

@ -4,8 +4,5 @@
"rootDir": "src", "rootDir": "src",
"outDir": "dist" "outDir": "dist"
}, },
"include": [ "include": ["src/**/*.ts"]
"src/**/*.ts"
]
} }

View file

@ -1,10 +0,0 @@
# `@betternas/sdk-ts`
Temporary TypeScript-facing SDK surface for the Next.js app.
The source of truth remains:
- [`packages/contracts/openapi`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/openapi)
- [`packages/contracts/src`](/home/rathi/Documents/GitHub/betterNAS/packages/contracts/src)
Later this package should become generated code from the OpenAPI contracts.

View file

@ -1,3 +0,0 @@
import { config } from "@betternas/eslint-config/base";
export default config;

View file

@ -1,24 +0,0 @@
{
"name": "@betternas/sdk-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint . --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@betternas/contracts": "*"
},
"devDependencies": {
"@betternas/eslint-config": "*",
"@betternas/typescript-config": "*",
"@types/node": "^22.15.3",
"eslint": "^9.39.1",
"typescript": "5.9.2"
}
}

View file

@ -1 +0,0 @@
export * from "@betternas/contracts";

View file

@ -1,8 +0,0 @@
{
"extends": "@betternas/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -12,8 +12,8 @@
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@betternas/eslint-config": "*", "@betternas/eslint-config": "workspace:*",
"@betternas/typescript-config": "*", "@betternas/typescript-config": "workspace:*",
"@types/node": "^22.15.3", "@types/node": "^22.15.3",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",

3967
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

View file

@ -0,0 +1,117 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const sourceExtensions = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx"]);
const ignoredDirectories = new Set([
".git",
".next",
".turbo",
"coverage",
"dist",
"node_modules",
]);
const laneRoots = [];
for (const baseDir of ["apps", "packages"]) {
const absoluteBaseDir = path.join(repoRoot, baseDir);
for (const entry of readdirSync(absoluteBaseDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
laneRoots.push(path.join(absoluteBaseDir, entry.name));
}
}
}
const disallowedWorkspacePackages = new Set([
"@betternas/sdk-ts",
"@betternas/web",
"@betternas/control-plane",
"@betternas/node-agent",
"@betternas/nextcloud-app",
]);
const importPattern =
/\b(?:import|export)\b[\s\S]*?\bfrom\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
const errors = [];
walk(path.join(repoRoot, "apps"));
walk(path.join(repoRoot, "packages"));
if (errors.length > 0) {
console.error("Boundary check failed:\n");
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log("Boundary check passed.");
function walk(currentPath) {
const stat = statSync(currentPath);
if (stat.isDirectory()) {
if (ignoredDirectories.has(path.basename(currentPath))) {
return;
}
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
walk(path.join(currentPath, entry.name));
}
return;
}
if (!sourceExtensions.has(path.extname(currentPath))) {
return;
}
const fileContent = readFileSync(currentPath, "utf8");
const fileRoot = findLaneRoot(currentPath);
if (fileRoot === null) {
return;
}
for (const match of fileContent.matchAll(importPattern)) {
const specifier = match[1] ?? match[2];
if (!specifier) {
continue;
}
if (disallowedWorkspacePackages.has(specifier)) {
errors.push(
`${relativeToRepo(currentPath)} imports forbidden workspace package ${specifier}`,
);
continue;
}
if (!specifier.startsWith(".")) {
continue;
}
const resolvedImport = path.resolve(path.dirname(currentPath), specifier);
const targetRoot = findLaneRoot(resolvedImport);
if (targetRoot !== null && targetRoot !== fileRoot) {
errors.push(
`${relativeToRepo(currentPath)} crosses lane boundary with relative import ${specifier}`,
);
}
}
}
function findLaneRoot(targetPath) {
const normalizedTargetPath = path.normalize(targetPath);
for (const laneRoot of laneRoots) {
const normalizedLaneRoot = path.normalize(laneRoot);
if (
normalizedTargetPath === normalizedLaneRoot ||
normalizedTargetPath.startsWith(`${normalizedLaneRoot}${path.sep}`)
) {
return normalizedLaneRoot;
}
}
return null;
}
function relativeToRepo(targetPath) {
return path.relative(repoRoot, targetPath) || ".";
}

View file

@ -21,7 +21,6 @@ betterNAS/
│ └── nextcloud-app/ # optional Nextcloud adapter │ └── nextcloud-app/ # optional Nextcloud adapter
├── packages/ ├── packages/
│ ├── contracts/ # canonical OpenAPI, schemas, TS types │ ├── contracts/ # canonical OpenAPI, schemas, TS types
│ ├── sdk-ts/ # TS SDK facade for the web app
│ ├── ui/ # shared React UI │ ├── ui/ # shared React UI
│ ├── eslint-config/ # shared lint config │ ├── eslint-config/ # shared lint config
│ └── typescript-config/ # shared TS config │ └── typescript-config/ # shared TS config
@ -36,14 +35,13 @@ betterNAS/
## Runtime and language choices ## Runtime and language choices
| Part | Language | Why | | Part | Language | Why |
|---|---|---| | -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
| `apps/web` | TypeScript + Next.js | best UI velocity, best admin/control-plane UX | | `apps/web` | TypeScript + Next.js | best UI velocity, best admin/control-plane UX |
| `apps/control-plane` | Go | strong concurrency, static binaries, operationally simple | | `apps/control-plane` | Go | strong concurrency, static binaries, operationally simple |
| `apps/node-agent` | Go | best fit for host runtime, WebDAV service, and future Nix deployment | | `apps/node-agent` | Go | best fit for host runtime, WebDAV service, and future Nix deployment |
| `apps/nextcloud-app` | PHP | native language for the Nextcloud adapter surface | | `apps/nextcloud-app` | PHP | native language for the Nextcloud adapter surface |
| `packages/contracts` | OpenAPI + JSON Schema + TypeScript | language-neutral source of truth with practical TS ergonomics | | `packages/contracts` | OpenAPI + JSON Schema + TypeScript | language-neutral source of truth with practical TS ergonomics |
| `packages/sdk-ts` | TypeScript | ergonomic consumption from the Next.js app |
## Canonical contract rule ## Canonical contract rule
@ -89,7 +87,6 @@ Allowed ownership:
- Nextcloud mapping logic - Nextcloud mapping logic
- shared contract lane - shared contract lane
- `packages/contracts` - `packages/contracts`
- `packages/sdk-ts`
- `docs/architecture.md` - `docs/architecture.md`
## The first verification loop ## The first verification loop
@ -253,6 +250,7 @@ If a task does not make one of those steps more real, it is probably too early.
- the mount profile model - the mount profile model
- the mapping between cloud mode and mount mode - the mapping between cloud mode and mount mode
- the repo contract and shared schemas - the repo contract and shared schemas
- the root `pnpm verify` loop
## The first implementation slices after this scaffold ## The first implementation slices after this scaffold

View file

@ -11,4 +11,3 @@
"resolveJsonModule": true "resolveJsonModule": true
} }
} }

View file

@ -10,6 +10,12 @@
"lint": { "lint": {
"dependsOn": ["^lint"] "dependsOn": ["^lint"]
}, },
"format": {
"cache": false
},
"format:check": {
"dependsOn": ["^format:check"]
},
"check-types": { "check-types": {
"dependsOn": ["^check-types"] "dependsOn": ["^check-types"]
}, },