forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
1#!/usr/bin/env bun
2import { Command } from 'commander';
3import { text, isCancel, cancel, intro, outro } from '@clack/prompts';
4import { authenticate, clearSessions } from './lib/auth.ts';
5import { deploy } from './commands/deploy.ts';
6import { pull } from './commands/pull.ts';
7import { serve } from './commands/serve.ts';
8import { pc } from './lib/progress.ts';
9
10const program = new Command();
11
12program
13 .name('wisp-cli')
14 .description('CLI for wisp.place - deploy static sites to the AT Protocol')
15 .version('1.0.0');
16
17// Deploy command (default)
18program
19 .command('deploy [handle]', { isDefault: true })
20 .description('Deploy a static site to wisp.place')
21 .option('-p, --path <path>', 'Directory to deploy')
22 .option('-s, --site <name>', 'Site name (defaults to directory name)')
23 .option('--directory', 'Enable directory listing')
24 .option('--spa', 'Enable SPA mode (serve index.html for all routes)')
25 .option('-c, --concurrency <n>', 'Number of concurrent uploads (backs off to 2 on rate limit)', '3')
26 .option('--password <password>', 'App password for headless authentication')
27 .option('--store <path>', 'OAuth session store path')
28 .option('-y, --yes', 'Skip confirmation prompts')
29 .action(async (handle: string | undefined, options) => {
30 try {
31 let resolvedHandle = handle;
32 let resolvedPath = options.path;
33 let resolvedSite = options.site;
34
35 // If any required values are missing, show prompts
36 const needsPrompts = !resolvedHandle || !resolvedPath || !resolvedSite;
37
38 if (needsPrompts) {
39 intro(pc.cyan('wisp.place deploy'));
40
41 // Prompt for handle if not provided
42 if (!resolvedHandle) {
43 const handleResult = await text({
44 message: 'AT Protocol handle',
45 placeholder: 'alice.bsky.social',
46 validate: (value) => {
47 if (!value) return 'Handle is required';
48 if (!value.includes('.')) return 'Handle must include a domain (e.g., alice.bsky.social)';
49 }
50 });
51
52 if (isCancel(handleResult)) {
53 cancel('Deploy cancelled');
54 process.exit(0);
55 }
56 resolvedHandle = handleResult;
57 }
58
59 // Prompt for path if not provided
60 if (!resolvedPath) {
61 const pathResult = await text({
62 message: 'Directory to deploy',
63 placeholder: '.',
64 defaultValue: '.'
65 });
66
67 if (isCancel(pathResult)) {
68 cancel('Deploy cancelled');
69 process.exit(0);
70 }
71 resolvedPath = pathResult || '.';
72 }
73
74 // Prompt for site name if not provided
75 if (!resolvedSite) {
76 const siteResult = await text({
77 message: 'Site name',
78 placeholder: 'my-website',
79 validate: (value) => {
80 if (!value) return 'Site name is required';
81 if (!/^[a-zA-Z0-9._~:-]{1,512}$/.test(value)) {
82 return 'Site name must be 1-512 characters of [a-zA-Z0-9._~:-]';
83 }
84 }
85 });
86
87 if (isCancel(siteResult)) {
88 cancel('Deploy cancelled');
89 process.exit(0);
90 }
91 resolvedSite = siteResult;
92 }
93 }
94
95 const { agent, did } = await authenticate(resolvedHandle!, {
96 appPassword: options.password,
97 storePath: options.store
98 });
99
100 const result = await deploy(agent, did, {
101 path: resolvedPath,
102 site: resolvedSite,
103 directory: options.directory,
104 spa: options.spa,
105 yes: options.yes,
106 concurrency: parseInt(options.concurrency, 10)
107 });
108
109 console.log();
110 console.log(pc.dim(` URI: ${result.uri}`));
111 console.log(pc.cyan(` URL: ${result.url}`));
112
113 if (needsPrompts) {
114 outro(pc.green('Deployed successfully!'));
115 } else {
116 console.log();
117 console.log(pc.green('✓ Deployed successfully!'));
118 }
119 process.exit(0);
120 } catch (err: any) {
121 console.error(pc.red(`\nError: ${err.message}\n`));
122 process.exit(1);
123 }
124 });
125
126// Pull command
127program
128 .command('pull <handle>')
129 .description('Download a site from wisp.place to a local directory')
130 .requiredOption('-s, --site <name>', 'Site name to pull')
131 .option('-p, --path <path>', 'Output directory', '.')
132 .action(async (handle: string, options) => {
133 try {
134 await pull(handle, {
135 site: options.site,
136 path: options.path
137 });
138 } catch (err: any) {
139 console.error(pc.red(`\nError: ${err.message}\n`));
140 process.exit(1);
141 }
142 });
143
144// Serve command
145program
146 .command('serve <handle>')
147 .description('Serve a site locally with live updates from firehose')
148 .requiredOption('-s, --site <name>', 'Site name to serve')
149 .option('-p, --path <path>', 'Local directory to cache site', '.wisp-serve')
150 .option('-P, --port <port>', 'Port to serve on', '8080')
151 .action(async (handle: string, options) => {
152 try {
153 await serve(handle, {
154 site: options.site,
155 path: options.path,
156 port: parseInt(options.port, 10)
157 });
158 } catch (err: any) {
159 console.error(pc.red(`\nError: ${err.message}\n`));
160 process.exit(1);
161 }
162 });
163
164// Logout command
165program
166 .command('logout')
167 .description('Clear stored OAuth sessions')
168 .option('--store <path>', 'OAuth session store path')
169 .action((options) => {
170 clearSessions(options.store);
171 });
172
173program.parse();