From 75003b277163584f981eadbf501d929c014cac34 Mon Sep 17 00:00:00 2001 From: Seth Etter Date: Wed, 28 Jan 2026 22:09:02 -0600 Subject: [PATCH] CLI auth and `pub view` command Was curious to see if I ran into any oauth snags but this seems to work well! Neat. --- DEVELOPMENT.md | 136 ++++++++++++++++++++ bun.lock | 74 +++++++++-- install-cli.sh | 4 + packages/cli/package.json | 4 +- packages/cli/src/auth.ts | 221 ++++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 83 +++++++++++- packages/cli/src/publication.ts | 16 +++ 7 files changed, 521 insertions(+), 17 deletions(-) create mode 100644 DEVELOPMENT.md create mode 100755 install-cli.sh create mode 100644 packages/cli/src/auth.ts create mode 100644 packages/cli/src/publication.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..cc4f9af --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,136 @@ +# Development Guide + +## Prerequisites + +- [Bun](https://bun.sh/) v1.3+ + +## Setup + +```bash +bun install +``` + +## Project Structure + +``` +sitebase/ +├── packages/ +│ ├── core/ # Export library (@sitebase/core) +│ ├── cli/ # CLI tool (@sitebase/cli) +│ └── web/ # Web management UI (@sitebase/web) +├── scripts/ # Utility scripts +├── biome.json # Linter/formatter config +└── tsconfig.json # TypeScript config +``` + +## Commands + +### Verification + +Run these before committing: + +```bash +# Run all checks (typecheck + lint) +bun run check + +# Individual checks +bun run typecheck # TypeScript type checking +bun run lint:check # Biome lint (no auto-fix) +bun run format:check # Biome format (no auto-fix) +``` + +### Fixing Issues + +```bash +bun run lint # Auto-fix lint issues +bun run format # Auto-fix formatting +``` + +## Code Style + +Enforced by [Biome](https://biomejs.dev/): + +- **Indentation**: Tabs +- **Quotes**: Double quotes +- **Imports**: Auto-organized on save (via `biome.json` assist) + +TypeScript is configured with strict mode and additional safety flags: +- `noUncheckedIndexedAccess` - Array/object index access may be undefined +- `noFallthroughCasesInSwitch` - Require break/return in switch cases +- `noImplicitOverride` - Require `override` keyword + +## Packages + +### @sitebase/core + +Core export functionality. No runtime dependencies except Handlebars. + +Key files: +- `src/types.ts` - Type definitions +- `src/export.ts` - Export logic +- `src/config.ts` - Config file loading +- `src/templates.ts` - Handlebars helpers, slugify +- `src/atproto.ts` - ATProto fetch utilities + +### @sitebase/cli + +CLI wrapper around core. Uses Commander for argument parsing. + +```bash +cd packages/cli +bun run src/index.ts export --config ../path/to/config.ts +``` + +### @sitebase/web + +Hono-based web server with ATProto OAuth. + +```bash +cd packages/web +bun run dev +``` + +See `packages/web/CLAUDE.md` for web-specific details. + +## ATProto Integration + +The project uses ATProto lexicons: +- `site.standard.publication` - Blog/publication metadata +- `site.standard.document` - Individual documents/posts + +Documents are fetched from any PDS via: +1. Parse AT URI to extract DID +2. Resolve DID to PDS endpoint via `plc.directory` +3. Fetch records via `com.atproto.repo.getRecord` / `listRecords` + +## Adding Features + +### New Handlebars Helper + +Add to `packages/core/src/templates.ts` in `registerHelpers()`: + +```typescript +hbs.registerHelper("myHelper", (arg1: unknown) => { + // Return transformed value + return String(arg1).toUpperCase(); +}); +``` + +### New Export Option + +1. Add type to `packages/core/src/types.ts` in `ExportOptions` and `ExportTarget` +2. Handle in `packages/core/src/export.ts` in `exportPublication()` +3. Update `packages/core/src/config.ts` if it affects config loading +4. Document in README.md + +## Workspace Dependencies + +Use workspace protocol for internal deps: + +```json +{ + "dependencies": { + "@sitebase/core": "workspace:*" + } +} +``` diff --git a/bun.lock b/bun.lock index 81ca428..f91e2a7 100644 --- a/bun.lock +++ b/bun.lock @@ -18,9 +18,11 @@ "name": "@sitebase/cli", "version": "0.0.1", "bin": { - "sitebase": "./src/index.ts", + "sitebase": "./bin/sitebase", }, "dependencies": { + "@atproto/api": "^0.18.16", + "@atproto/oauth-client-node": "^0.2.1", "@sitebase/core": "workspace:*", "commander": "^12.1.0", }, @@ -50,35 +52,35 @@ }, }, "packages": { - "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.5", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ=="], + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.1.13", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-DG3YNaCKc6PAIv1Gsz3E1Kufw2t14OBxe4LdKK7KKLCNoex51hm+A5yMevShe3BSll+QosqWYIEgkPSc5xBoGQ=="], "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], - "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.1.9", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-8sHDDXZEzQptLu8ddUU/8U+THS6dumgPynVX0/1PjUYd4S/FWyPcz6yMIiVChTfzKnZvYRRz47+qvOKhydrHQw=="], - "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA=="], + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.1.8", "", { "dependencies": { "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-Y0ckccoCGDo/3g4thPkgp9QcORmc+qqEaCBCYCZYtfLIQp4775u22wd+4fyEyJP4DqoReKacninkICgRGfs3dQ=="], - "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.24", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.5", "@atproto/did": "0.2.4" } }, "sha512-w/zvktigmRQpOLQQclp48tbb2K/2XW8j1szoIpT8T8v6P5dZ8GGVDIEF142xQMX9vWToFqMTu1P2yOuz8e3Ilg=="], + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.16", "", { "dependencies": { "@atproto-labs/fetch-node": "0.1.9", "@atproto-labs/handle-resolver": "0.1.8", "@atproto/did": "0.1.5" } }, "sha512-i2F989zjyC7b/odrV3/tOpIT1IDIxR3F0khPG4REfOWcmJ89QcP8BiejJ6KFJk3hbTJHq6X9/pTG1vesCvyIKA=="], - "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver": "0.3.5" } }, "sha512-kSxnreUSPhKL77doUbSl/9I6Y9qpkpD7MMJoYFQVU/WG0PB90tzfIb6DNuWsjbU2I5Q91Nzc4Tm4VJMV+OPKGQ=="], + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.1.18", "", { "dependencies": { "@atproto-labs/did-resolver": "0.1.13", "@atproto-labs/handle-resolver": "0.1.8", "@atproto/syntax": "0.4.0" } }, "sha512-DArYXP1hzZJIBcojun0CWEF+TjAhlGKcVq/RwLiGfY1mKq2yPjCiXyHj+5L0+z9jBSZiAB7L65JgcjI2+MFiRg=="], "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], - "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.2.0", "", {}, "sha512-0bRbAlI8Ayh03wRwncAMEAyUKtZ+AuTS1jgPrfym1WVOAOiottI/ZmgccqLl6w5MbxVcClNQF7WYGKvGwGoIhA=="], - "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.3", "", { "dependencies": { "@atproto-labs/simple-store": "0.2.0", "lru-cache": "^10.2.0" } }, "sha512-jkitT9+AtU+0b28DoN92iURLaCt/q/q4yX8q6V+9LSwYlUTqKoj/5NFKvF7x6EBuG+gpUdlcycbH7e60gjOhRQ=="], "@atproto/api": ["@atproto/api@0.18.16", "", { "dependencies": { "@atproto/common-web": "^0.4.12", "@atproto/lexicon": "^0.6.0", "@atproto/syntax": "^0.4.2", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-tRGKSWr83pP5CQpSboePU21pE+GqLDYy1XHae4HH4hjaT0pr5V8wNgu70kbKB0B02GVUumeDRpJnlHKD+eMzLg=="], "@atproto/common-web": ["@atproto/common-web@0.4.12", "", { "dependencies": { "@atproto/lex-data": "0.0.8", "@atproto/lex-json": "0.0.8", "zod": "^3.23.8" } }, "sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw=="], - "@atproto/did": ["@atproto/did@0.2.4", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g=="], + "@atproto/did": ["@atproto/did@0.1.5", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ=="], - "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], + "@atproto/jwk": ["@atproto/jwk@0.3.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-MIAXyNMGu1tCNHjqW/8jqfE/wgWCIoK2cJ0mR6UxwhNPvkbe35TcpRYJdtQu/E6MUd7TziyDBa/GO4dKAiePhQ=="], "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], - "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.1.8", "", { "dependencies": { "@atproto/jwk": "0.3.0", "@atproto/jwk-jose": "0.1.8", "zod": "^3.23.8" } }, "sha512-oOW/G40f6M0NbTOo8uZgiSsFtcvlfNFldyxm+V+fVo5yKe6cvgvPNqckpqMsoBe6JYfImdc/zdVak9fCSSh41A=="], "@atproto/lex-data": ["@atproto/lex-data@0.0.8", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA=="], @@ -86,11 +88,11 @@ "@atproto/lexicon": ["@atproto/lexicon@0.6.0", "", { "dependencies": { "@atproto/common-web": "^0.4.7", "@atproto/syntax": "^0.4.2", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ=="], - "@atproto/oauth-client": ["@atproto/oauth-client@0.5.13", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.5", "@atproto-labs/identity-resolver": "0.3.5", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.1", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-FLbqHkC7BAVZ90LHVzSxQf+s8ZNIQI4TsDuhYDyzi7lYtktFHDbgd88KuM2ClJFOtGCsSS17yR1Joy925tDSaA=="], + "@atproto/oauth-client": ["@atproto/oauth-client@0.4.0", "", { "dependencies": { "@atproto-labs/did-resolver": "0.1.13", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.1.8", "@atproto-labs/identity-resolver": "0.1.18", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "@atproto/jwk": "0.3.0", "@atproto/oauth-types": "0.3.0", "@atproto/xrpc": "0.7.0", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-uWVnlhennWkgvzqP0l53sFaw6DM6B4zmq0fv1xs05vt56Sjly8YirAj0GVDXlb37/BQRJQ1WOBzJVYDI3bH9uw=="], - "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.15", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver-node": "0.1.24", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.13", "@atproto/oauth-types": "0.6.1" } }, "sha512-iuT7QrLli7IyB4px1+lHvm/YoIRfNRpbNG9seJRtu5eX4N5aLsBP6vpXs9rCygd1+/15LcLRAAGKVEcrLT9tXA=="], + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.2.24", "", { "dependencies": { "@atproto-labs/did-resolver": "0.1.13", "@atproto-labs/handle-resolver-node": "0.1.16", "@atproto-labs/simple-store": "0.2.0", "@atproto/did": "0.1.5", "@atproto/jwk": "0.3.0", "@atproto/jwk-jose": "0.1.8", "@atproto/jwk-webcrypto": "0.1.8", "@atproto/oauth-client": "0.4.0", "@atproto/oauth-types": "0.3.0" } }, "sha512-WsUiFkHjlm80J2d4czP7msYZoxvKB4hDpZGw34RgMD4VLA2jt03GMH4wTQPIZxfV3/9yrgMoOW/BDC9Iv4MavA=="], - "@atproto/oauth-types": ["@atproto/oauth-types@0.6.1", "", { "dependencies": { "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA=="], + "@atproto/oauth-types": ["@atproto/oauth-types@0.3.0", "", { "dependencies": { "@atproto/jwk": "0.3.0", "zod": "^3.23.8" } }, "sha512-ptfsJARKODXfuOoDQag4a6PpEkDEj4Urz3jOmnQZy2YspPc/TNm1o0HglU0YehELv1vfhh9gEz40BJztPPhiLA=="], "@atproto/syntax": ["@atproto/syntax@0.4.2", "", {}, "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA=="], @@ -174,8 +176,52 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@atproto-labs/identity-resolver/@atproto/syntax": ["@atproto/syntax@0.4.0", "", {}, "sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA=="], + + "@atproto/jwk-jose/@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], + + "@atproto/jwk-webcrypto/@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.8", "", { "dependencies": { "@atproto/jwk": "0.3.0", "jose": "^5.2.0" } }, "sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA=="], + + "@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.0", "", { "dependencies": { "@atproto/lexicon": "^0.4.11", "zod": "^3.23.8" } }, "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw=="], + + "@atproto/oauth-client-node/@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.8", "", { "dependencies": { "@atproto/jwk": "0.3.0", "jose": "^5.2.0" } }, "sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA=="], + "@sitebase/web/@atproto/api": ["@atproto/api@0.18.14", "", { "dependencies": { "@atproto/common-web": "^0.4.12", "@atproto/lexicon": "^0.6.0", "@atproto/syntax": "^0.4.2", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-1pWAPbuG3RA1o8uOAwYWZOddvNjuweYOxwTvys1q/r9NCjoGkZY0uJUy1dr6LKFaDk8bjikd2O1cgsRwFfv6Fw=="], + "@sitebase/web/@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.15", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver-node": "0.1.24", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.13", "@atproto/oauth-types": "0.6.1" } }, "sha512-iuT7QrLli7IyB4px1+lHvm/YoIRfNRpbNG9seJRtu5eX4N5aLsBP6vpXs9rCygd1+/15LcLRAAGKVEcrLT9tXA=="], + "bun-types/@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="], + + "@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.5", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.24", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.5", "@atproto/did": "0.2.4" } }, "sha512-w/zvktigmRQpOLQQclp48tbb2K/2XW8j1szoIpT8T8v6P5dZ8GGVDIEF142xQMX9vWToFqMTu1P2yOuz8e3Ilg=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/did": ["@atproto/did@0.2.4", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.13", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.5", "@atproto-labs/identity-resolver": "0.3.5", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.1", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-FLbqHkC7BAVZ90LHVzSxQf+s8ZNIQI4TsDuhYDyzi7lYtktFHDbgd88KuM2ClJFOtGCsSS17yR1Joy925tDSaA=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.1", "", { "dependencies": { "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/did-resolver/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/handle-resolver-node/@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver": "0.3.5" } }, "sha512-kSxnreUSPhKL77doUbSl/9I6Y9qpkpD7MMJoYFQVU/WG0PB90tzfIb6DNuWsjbU2I5Q91Nzc4Tm4VJMV+OPKGQ=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto/oauth-client/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], + + "@sitebase/web/@atproto/oauth-client-node/@atproto-labs/handle-resolver-node/@atproto-labs/handle-resolver/@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], } } diff --git a/install-cli.sh b/install-cli.sh new file mode 100755 index 0000000..7874b42 --- /dev/null +++ b/install-cli.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +bun run build:cli +cp packages/cli/bin/sitebase ~/.local/bin/sitebase diff --git a/packages/cli/package.json b/packages/cli/package.json index d3f4367..bb863e9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,9 +7,11 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "build": "bun build --target node --outfile ./bin/sitebase ./src/index.ts" + "build": "bun build --target bun --outfile ./bin/sitebase ./src/index.ts" }, "dependencies": { + "@atproto/api": "^0.18.16", + "@atproto/oauth-client-node": "^0.2.1", "@sitebase/core": "workspace:*", "commander": "^12.1.0" } diff --git a/packages/cli/src/auth.ts b/packages/cli/src/auth.ts new file mode 100644 index 0000000..9307b90 --- /dev/null +++ b/packages/cli/src/auth.ts @@ -0,0 +1,221 @@ +import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { Agent } from "@atproto/api"; +import { + NodeOAuthClient, + type NodeSavedSession, + type NodeSavedState, +} from "@atproto/oauth-client-node"; + +const SESSION_DIR = join(homedir(), ".sitebase"); +const SESSION_FILE = join(SESSION_DIR, "session.json"); + +const CLIENT_ID = + "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+include%3Asite.standard.authFull"; + +interface StoredSession { + did: string; + session: NodeSavedSession; +} + +let verbose = false; + +export function setVerbose(v: boolean): void { + verbose = v; +} + +function debug(...args: unknown[]): void { + if (verbose) { + console.error("[debug]", ...args); + } +} + +async function readStoredSession(): Promise { + try { + const data = await readFile(SESSION_FILE, "utf-8"); + debug("Read session from", SESSION_FILE); + return JSON.parse(data) as StoredSession; + } catch { + debug("No stored session found at", SESSION_FILE); + return null; + } +} + +async function writeStoredSession(stored: StoredSession): Promise { + await mkdir(SESSION_DIR, { recursive: true }); + await writeFile(SESSION_FILE, JSON.stringify(stored, null, 2), "utf-8"); + debug("Wrote session to", SESSION_FILE); +} + +function createClient( + sessionStore: { + get: (key: string) => Promise; + set: (key: string, value: NodeSavedSession) => Promise; + del: (key: string) => Promise; + }, + redirectUri?: string, +): NodeOAuthClient { + const stateStore = new Map(); + + const redirect_uris = redirectUri + ? [redirectUri] + : ["http://127.0.0.1/callback"]; + + debug("Creating OAuth client with client_id:", CLIENT_ID); + debug("redirect_uris:", redirect_uris); + + return new NodeOAuthClient({ + clientMetadata: { + client_id: CLIENT_ID, + redirect_uris: redirect_uris as [string], + application_type: "native", + token_endpoint_auth_method: "none", + dpop_bound_access_tokens: true, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + scope: "atproto include:site.standard.authFull", + }, + stateStore: { + get: async (key: string) => { + debug("stateStore.get:", key); + return stateStore.get(key); + }, + set: async (key: string, value: NodeSavedState) => { + debug("stateStore.set:", key); + stateStore.set(key, value); + }, + del: async (key: string) => { + debug("stateStore.del:", key); + stateStore.delete(key); + }, + }, + sessionStore: { + get: async (key: string) => { + debug("sessionStore.get:", key); + return sessionStore.get(key); + }, + set: async (key: string, value: NodeSavedSession) => { + debug("sessionStore.set:", key); + return sessionStore.set(key, value); + }, + del: async (key: string) => { + debug("sessionStore.del:", key); + return sessionStore.del(key); + }, + }, + }); +} + +function openBrowser(url: string): void { + const cmd = process.platform === "darwin" ? "open" : "xdg-open"; + debug("Opening browser with command:", cmd, url); + spawn(cmd, [url], { stdio: "ignore", detached: true }).unref(); +} + +export async function login(handle: string): Promise { + let stored: StoredSession | null = null; + + const sessionStore = { + get: async (_key: string) => stored?.session, + set: async (key: string, value: NodeSavedSession) => { + stored = { did: key, session: value }; + await writeStoredSession(stored); + }, + del: async (_key: string) => { + stored = null; + }, + }; + + // Start callback server first to get the ephemeral port + const { promise: callbackPromise, resolve: resolveCallback } = + Promise.withResolvers(); + + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + debug("Received request:", url.pathname, url.search); + if (url.pathname === "/callback") { + resolveCallback(url.searchParams); + return new Response( + "

Login successful!

You can close this tab.

", + { headers: { "Content-Type": "text/html" } }, + ); + } + return new Response("Not found", { status: 404 }); + }, + }); + + const port = server.port; + const redirectUri = `http://127.0.0.1:${port}/callback`; + debug("Callback server listening on port", port); + debug("Redirect URI:", redirectUri); + + // Create client with port-specific redirect_uri in metadata + const client = createClient(sessionStore, redirectUri); + + try { + debug("Calling client.authorize with handle:", handle); + const authUrl = await client.authorize(handle); + debug("Got auth URL:", authUrl.toString()); + + console.log("Opening browser for authentication..."); + openBrowser(authUrl.toString()); + console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`); + + // Wait for callback + debug("Waiting for OAuth callback..."); + const params = await callbackPromise; + debug("Received callback params:", Object.fromEntries(params.entries())); + + debug("Calling client.callback..."); + await client.callback(params); + + console.log(`\nLogged in as ${stored!.did}`); + } finally { + server.stop(); + debug("Callback server stopped"); + } +} + +export async function logout(): Promise { + try { + await unlink(SESSION_FILE); + console.log("Logged out successfully."); + } catch { + console.log("No active session."); + } +} + +export async function getAuthenticatedAgent(): Promise<{ + agent: Agent; + did: string; +}> { + const stored = await readStoredSession(); + if (!stored) { + throw new Error( + "Not logged in. Run `sitebase auth login ` first.", + ); + } + + const sessionStore = { + get: async (_key: string) => stored.session, + set: async (key: string, value: NodeSavedSession) => { + stored.session = value; + await writeStoredSession(stored); + }, + del: async (_key: string) => { + await unlink(SESSION_FILE).catch(() => {}); + }, + }; + + debug("Restoring session for DID:", stored.did); + const client = createClient(sessionStore); + const session = await client.restore(stored.did); + debug("Session restored successfully"); + const agent = new Agent(session); + + return { agent, did: stored.did }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4cca600..e90eab0 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,19 +1,27 @@ #!/usr/bin/env bun import { access, mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { Command } from "commander"; import { exportFromConfig, findConfigFile, loadExportConfig, } from "@sitebase/core"; +import { Command } from "commander"; +import { getAuthenticatedAgent, login, logout, setVerbose } from "./auth.ts"; +import { formatPublication } from "./publication.ts"; const program = new Command(); program .name("sitebase") .description("CLI tools for standard.site publications") - .version("0.0.1"); + .version("0.0.1") + .option("-v, --verbose", "Enable verbose debug output") + .hook("preAction", () => { + if (program.opts().verbose) { + setVerbose(true); + } + }); program .command("export") @@ -201,4 +209,75 @@ tags: } }); +const auth = program.command("auth").description("Manage authentication"); + +auth + .command("login") + .description("Authenticate with ATProto via OAuth") + .argument("", "Your ATProto handle (e.g. user.bsky.social)") + .action(async (handle: string) => { + try { + await login(handle); + } catch (error) { + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + }); + +auth + .command("logout") + .description("Clear stored session") + .action(async () => { + await logout(); + }); + +const pub = program.command("pub").description("Manage publications"); + +pub + .command("list") + .description("List all of your publications") + .action(async () => { + try { + const { agent, did } = await getAuthenticatedAgent(); + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "site.standard.publication", + }); + } catch (e) {} + }); + +pub + .command("view") + .description("View your publication") + .action(async () => { + try { + const { agent, did } = await getAuthenticatedAgent(); + const response = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: "site.standard.publication", + limit: 1, + }); + + if (!response.data.records.length) { + console.log("No publication found."); + return; + } + + const record = response.data.records[0]!; + console.log( + formatPublication({ + uri: record.uri, + value: record.value as Record, + }), + ); + } catch (error) { + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + }); + program.parse(); diff --git a/packages/cli/src/publication.ts b/packages/cli/src/publication.ts new file mode 100644 index 0000000..116471c --- /dev/null +++ b/packages/cli/src/publication.ts @@ -0,0 +1,16 @@ +export interface PublicationRecord { + uri: string; + value: Record; +} + +export function formatPublication(record: PublicationRecord): string { + const value = record.value; + const lines: string[] = []; + + lines.push(`Name: ${value.name ?? "(untitled)"}`); + if (value.url) lines.push(`URL: ${value.url}`); + if (value.description) lines.push(`Description: ${value.description}`); + lines.push(`AT URI: ${record.uri}`); + + return lines.join("\n"); +} -- 2.51.2