···6bun run index.ts --help
7```
89-Deploying a site
000000000000000000000000010```bash
11bun run index.ts deploy alice.bsky.social --path . --site my-blog
12bun run index.ts alice.bsky.social --path . --site my-blog
13```
1415-List domains for an account:
001617```bash
18-bun run index.ts list domains alice.bsky.social
000019```
2021-List sites for an account:
002223```bash
24-bun run index.ts list sites alice.bsky.social
000000000025```
2627-Use an alternate proxy service DID:
2829```bash
30-bun run index.ts list domains alice.bsky.social --service did:web:regents-macbook-air.west-major.ts.net
031```
3233-Domain CRUD examples:
3435```bash
36bun run index.ts domain claim alice.bsky.social --domain example.com
···41bun run index.ts site delete alice.bsky.social --site mysite
42```
4344-OAuth note:
00000000045- CLI requests `rpc:<nsid>?aud=*` scopes for Wisp XRPC methods.
46- `--service did:...` controls proxy target (`atproto-proxy`), not scope audience (scoping audience couldnt work for me idk why).
···6bun run index.ts --help
7```
89+## Install (pre-built binary)
10+11+```bash
12+# macOS (Apple Silicon)
13+curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin
14+chmod +x wisp-cli-aarch64-darwin
15+16+# macOS (Intel)
17+curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-darwin
18+chmod +x wisp-cli-x86_64-darwin
19+20+# macOS (Universal)
21+curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-darwin-universal
22+chmod +x wisp-cli-darwin-universal
23+24+# Linux (x86_64)
25+curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux
26+chmod +x wisp-cli-x86_64-linux
27+28+# Linux (ARM64)
29+curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux
30+chmod +x wisp-cli-aarch64-linux
31+```
32+33+## Deploy a site
34+35```bash
36bun run index.ts deploy alice.bsky.social --path . --site my-blog
37bun run index.ts alice.bsky.social --path . --site my-blog
38```
3940+## Pull a site from PDS
41+42+Download a site from the PDS to your local machine (uses OAuth authentication):
4344```bash
45+# Pull to a specific directory
46+bun run index.ts pull alice.bsky.social --site my-blog --output ./my-blog
47+48+# Pull to current directory
49+bun run index.ts pull alice.bsky.social --site my-blog
50```
5152+## Serve a site locally
53+54+Run a local server that monitors the firehose for real-time updates (uses OAuth authentication):
5556```bash
57+# Serve on http://localhost:8080 (default)
58+bun run index.ts serve alice.bsky.social --site my-blog
59+60+# Serve on a custom port
61+bun run index.ts serve alice.bsky.social --site my-blog --port 3000
62+63+# Enable SPA mode (serve index.html for all routes)
64+bun run index.ts serve alice.bsky.social --site my-blog --spa
65+66+# Enable directory listing for paths without index files
67+bun run index.ts serve alice.bsky.social --site my-blog --directory
68```
6970+## List domains / sites
7172```bash
73+bun run index.ts list domains alice.bsky.social
74+bun run index.ts list sites alice.bsky.social
75```
7677+## Domain CRUD
7879```bash
80bun run index.ts domain claim alice.bsky.social --domain example.com
···85bun run index.ts site delete alice.bsky.social --site mysite
86```
8788+## Options
89+90+Use an alternate proxy service DID:
91+92+```bash
93+bun run index.ts list domains alice.bsky.social --service did:web:regents-macbook-air.west-major.ts.net
94+```
95+96+## OAuth note
97+98- CLI requests `rpc:<nsid>?aud=*` scopes for Wisp XRPC methods.
99- `--service did:...` controls proxy target (`atproto-proxy`), not scope audience (scoping audience couldnt work for me idk why).
+26-49
cli/commands/serve.ts
···5import type { Record as SettingsRecord } from '@wispplace/lexicons/types/place/wisp/settings';
6import { resolveDid, getPdsForDid } from '@wispplace/atproto-utils';
7import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
8-import { join, extname } from 'path';
09import { lookup } from 'mime-types';
10import { pull } from './pull.ts';
11import { createSpinner, pc } from '../lib/progress.ts';
···16 site: string;
17 path: string;
18 port: number;
0019}
2021interface SiteState {
···25 siteDir: string;
26 settings: SettingsRecord | null;
27 redirectRules: RedirectRule[];
00028}
2930async function fetchSettings(pdsEndpoint: string, did: string, rkey: string): Promise<SettingsRecord | null> {
···56 return settings?.indexFiles || ['index.html', 'index.htm'];
57}
5859-function generateDirectoryListing(dirPath: string, urlPath: string): string {
60 const entries = readdirSync(dirPath, { withFileTypes: true });
61-62- const items = entries
63 .filter(e => !e.name.startsWith('.'))
64- .sort((a, b) => {
65- if (a.isDirectory() && !b.isDirectory()) return -1;
66- if (!a.isDirectory() && b.isDirectory()) return 1;
67- return a.name.localeCompare(b.name);
68- })
69- .map(entry => {
70- const isDir = entry.isDirectory();
71- const name = isDir ? `${entry.name}/` : entry.name;
72- const href = urlPath === '/' ? `/${entry.name}` : `${urlPath}/${entry.name}`;
73- return `<li><a href="${href}">${name}</a></li>`;
74- });
75-76- const parentLink = urlPath !== '/'
77- ? `<li><a href="${urlPath.split('/').slice(0, -1).join('/') || '/'}">..</a></li>`
78- : '';
79-80- return `<!DOCTYPE html>
81-<html>
82-<head><title>Index of ${urlPath}</title>
83-<style>body{font-family:system-ui;padding:2rem}ul{list-style:none;padding:0}li{padding:0.25rem 0}a{color:#0066cc}</style>
84-</head>
85-<body>
86-<h1>Index of ${urlPath}</h1>
87-<ul>${parentLink}${items.join('')}</ul>
88-</body>
89-</html>`;
90-}
91-92-function generate404Page(): string {
93- return `<!DOCTYPE html>
94-<html>
95-<head><title>404 Not Found</title>
96-<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
97-.container{text-align:center}h1{font-size:4rem;margin:0;color:#666}p{color:#999}</style>
98-</head>
99-<body>
100-<div class="container"><h1>404</h1><p>Page not found</p></div>
101-</body>
102-</html>`;
103}
104105function serveFile(filePath: string): Response {
···153 // Resolve file path
154 let filePath = join(state.siteDir, urlPath);
155000000156 // Check if it's a directory
157 if (existsSync(filePath) && statSync(filePath).isDirectory()) {
158 // Try index files
···165 }
166167 // Directory listing if enabled
168- if (state.settings?.directoryListing) {
169- const html = generateDirectoryListing(filePath, urlPath);
170 return new Response(html, {
171 headers: { 'Content-Type': 'text/html' }
172 });
···192 }
193 }
194195- // SPA mode - serve index.html for all routes
196- if (state.settings?.spaMode) {
197- const spaPath = join(state.siteDir, state.settings.spaMode);
198 if (existsSync(spaPath)) {
199 return serveFile(spaPath);
200 }
···275 pdsEndpoint,
276 siteDir: outputPath,
277 settings,
278- redirectRules
00279 };
280281 // 5. Start HTTP server with Hono (works on both Bun and Node)
···5import type { Record as SettingsRecord } from '@wispplace/lexicons/types/place/wisp/settings';
6import { resolveDid, getPdsForDid } from '@wispplace/atproto-utils';
7import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
8+import { join } from 'path';
9+import { generate404Page, generateDirectoryListing } from '@wispplace/page-generators';
10import { lookup } from 'mime-types';
11import { pull } from './pull.ts';
12import { createSpinner, pc } from '../lib/progress.ts';
···17 site: string;
18 path: string;
19 port: number;
20+ spa?: string | boolean;
21+ directoryListing?: boolean;
22}
2324interface SiteState {
···28 siteDir: string;
29 settings: SettingsRecord | null;
30 redirectRules: RedirectRule[];
31+ // CLI flag overrides (take precedence over settings record)
32+ spaOverride?: string | boolean;
33+ directoryListingOverride?: boolean;
34}
3536async function fetchSettings(pdsEndpoint: string, did: string, rkey: string): Promise<SettingsRecord | null> {
···62 return settings?.indexFiles || ['index.html', 'index.htm'];
63}
6465+function buildDirectoryListing(dirPath: string, urlPath: string): string {
66 const entries = readdirSync(dirPath, { withFileTypes: true });
67+ const normalized = urlPath.replace(/^\//, '').replace(/\/$/, '');
68+ return generateDirectoryListing(normalized, entries
69 .filter(e => !e.name.startsWith('.'))
70+ .map(e => ({ name: e.name, isDirectory: e.isDirectory() }))
71+ );
000000000000000000000000000000000000072}
7374function serveFile(filePath: string): Response {
···122 // Resolve file path
123 let filePath = join(state.siteDir, urlPath);
124125+ // Resolve effective settings (CLI flags take precedence over settings record)
126+ const directoryListingEnabled = state.directoryListingOverride ?? state.settings?.directoryListing ?? false;
127+ const spaFile = state.spaOverride !== undefined
128+ ? (state.spaOverride === true ? 'index.html' : state.spaOverride || undefined)
129+ : state.settings?.spaMode;
130+131 // Check if it's a directory
132 if (existsSync(filePath) && statSync(filePath).isDirectory()) {
133 // Try index files
···140 }
141142 // Directory listing if enabled
143+ if (directoryListingEnabled) {
144+ const html = buildDirectoryListing(filePath, urlPath);
145 return new Response(html, {
146 headers: { 'Content-Type': 'text/html' }
147 });
···167 }
168 }
169170+ // SPA mode - serve the SPA file for all unmatched routes
171+ if (spaFile) {
172+ const spaPath = join(state.siteDir, spaFile);
173 if (existsSync(spaPath)) {
174 return serveFile(spaPath);
175 }
···250 pdsEndpoint,
251 siteDir: outputPath,
252 settings,
253+ redirectRules,
254+ spaOverride: options.spa,
255+ directoryListingOverride: options.directoryListing,
256 };
257258 // 5. Start HTTP server with Hono (works on both Bun and Node)