Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

mvp on cli using xrpc routes

+1128 -135
+2 -1
apps/main-app/src/lib/oauth-client.ts
··· 1 - import { NodeOAuthClient, type ClientMetadata } from "@atproto/oauth-client-node"; 1 + import { NodeOAuthClient, requestLocalLock, type ClientMetadata } from "@atproto/oauth-client-node"; 2 2 import { JoseKey } from "@atproto/jwk-jose"; 3 3 import { db } from "./db"; 4 4 import { logger } from "./logger"; ··· 250 250 keyset: keys, 251 251 stateStore, 252 252 sessionStore, 253 + requestLock: requestLocalLock, 253 254 handleResolver: new SlingshotHandleResolver() 254 255 }); 255 256 };
+31 -6
cli/README.md
··· 1 - # cli 1 + # wispctl CLI 2 + 3 + Run from the `cli/` directory: 4 + 5 + ```bash 6 + bun run index.ts --help 7 + ``` 8 + 9 + List domains for an account: 10 + 11 + ```bash 12 + bun run index.ts list domains alice.bsky.social 13 + ``` 14 + 15 + List sites for an account: 16 + 17 + ```bash 18 + bun run index.ts list sites alice.bsky.social 19 + ``` 2 20 3 - To install dependencies: 21 + Use an alternate proxy service DID: 4 22 5 23 ```bash 6 - bun install 24 + bun run index.ts list domains alice.bsky.social --service did:web:regents-macbook-air.west-major.ts.net 7 25 ``` 8 26 9 - To run: 27 + Domain CRUD examples: 10 28 11 29 ```bash 12 - bun run index.ts 30 + bun run index.ts domain claim alice.bsky.social --domain example.com 31 + bun run index.ts domain claim-subdomain alice.bsky.social --subdomain alice 32 + bun run index.ts domain status alice.bsky.social --domain example.com 33 + bun run index.ts domain add-site alice.bsky.social --domain example.com --site mysite 34 + bun run index.ts domain delete alice.bsky.social --domain example.com 35 + bun run index.ts site delete alice.bsky.social --site mysite 13 36 ``` 14 37 15 - This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 38 + OAuth note: 39 + - CLI requests `rpc:<nsid>?aud=*` scopes for Wisp XRPC methods. 40 + - `--service did:...` controls proxy target (`atproto-proxy`), not scope audience (scoping audience couldnt work for me idk why).
+185
cli/commands/domain.ts
··· 1 + import type { OutputSchema as DomainAddSiteOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/addSite'; 2 + import type { OutputSchema as DomainClaimOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/claim'; 3 + import type { OutputSchema as DomainClaimSubdomainOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/claimSubdomain'; 4 + import type { OutputSchema as DomainDeleteOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/delete'; 5 + import type { OutputSchema as DomainGetStatusOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/getStatus'; 6 + import type { OutputSchema as SiteDeleteOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/delete'; 7 + import { createSpinner, pc } from '../lib/progress.ts'; 8 + import { authenticateForXrpc, type XrpcCommandOptions } from '../lib/command-utils.ts'; 9 + import { callWispXrpc } from '../lib/xrpc.ts'; 10 + 11 + export type DomainCommandOptions = XrpcCommandOptions; 12 + 13 + export async function claimCustomDomain( 14 + identifier: string | undefined, 15 + domain: string, 16 + siteRkey: string | undefined, 17 + options: DomainCommandOptions, 18 + ): Promise<void> { 19 + const nsid = 'place.wisp.v2.domain.claim'; 20 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 21 + 22 + const spinner = createSpinner(`Claiming ${domain}...`).start(); 23 + const data = await callWispXrpc<DomainClaimOutput>(agent, nsid, { 24 + serviceDid, 25 + data: siteRkey ? { domain, siteRkey } : { domain }, 26 + }); 27 + spinner.succeed(`Claimed ${data.domain}`); 28 + 29 + if (options.json) { 30 + console.log(JSON.stringify(data, null, 2)); 31 + return; 32 + } 33 + 34 + console.log(`${pc.bold(data.domain)} -> ${data.status}`); 35 + if (data.txtName && data.txtValue) { 36 + console.log(`TXT: ${data.txtName} = ${data.txtValue}`); 37 + } 38 + if (data.cnameTarget) { 39 + console.log(`CNAME: ${data.cnameTarget}`); 40 + } 41 + if (data.siteRkey) { 42 + console.log(`Mapped site: ${data.siteRkey}`); 43 + } 44 + } 45 + 46 + export async function claimWispSubdomain( 47 + identifier: string | undefined, 48 + subdomain: string, 49 + siteRkey: string | undefined, 50 + options: DomainCommandOptions, 51 + ): Promise<void> { 52 + const nsid = 'place.wisp.v2.domain.claimSubdomain'; 53 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 54 + 55 + const spinner = createSpinner(`Claiming subdomain ${subdomain}...`).start(); 56 + const data = await callWispXrpc<DomainClaimSubdomainOutput>(agent, nsid, { 57 + serviceDid, 58 + data: siteRkey ? { handle: subdomain, siteRkey } : { handle: subdomain }, 59 + }); 60 + spinner.succeed(`Claimed ${data.domain}`); 61 + 62 + if (options.json) { 63 + console.log(JSON.stringify(data, null, 2)); 64 + return; 65 + } 66 + 67 + console.log(`${pc.bold(data.domain)} -> ${data.status}`); 68 + if (data.siteRkey) { 69 + console.log(`Mapped site: ${data.siteRkey}`); 70 + } 71 + } 72 + 73 + export async function getDomainStatus( 74 + identifier: string | undefined, 75 + domain: string, 76 + options: DomainCommandOptions, 77 + ): Promise<void> { 78 + const nsid = 'place.wisp.v2.domain.getStatus'; 79 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 80 + 81 + const spinner = createSpinner(`Fetching status for ${domain}...`).start(); 82 + const data = await callWispXrpc<DomainGetStatusOutput>(agent, nsid, { 83 + serviceDid, 84 + params: { domain }, 85 + }); 86 + spinner.succeed(`Fetched status for ${data.domain}`); 87 + 88 + if (options.json) { 89 + console.log(JSON.stringify(data, null, 2)); 90 + return; 91 + } 92 + 93 + console.log(`${pc.bold(data.domain)} -> ${data.status}`); 94 + if (data.kind) { 95 + console.log(`Kind: ${data.kind}`); 96 + } 97 + if (typeof data.verified === 'boolean') { 98 + console.log(`Verified: ${data.verified}`); 99 + } 100 + if (data.siteRkey) { 101 + console.log(`Mapped site: ${data.siteRkey}`); 102 + } 103 + if (data.lastCheckedAt) { 104 + console.log(`Last checked: ${data.lastCheckedAt}`); 105 + } 106 + if (data.lastError) { 107 + console.log(`Last error: ${data.lastError}`); 108 + } 109 + } 110 + 111 + export async function mapDomainToSite( 112 + identifier: string | undefined, 113 + domain: string, 114 + siteRkey: string, 115 + options: DomainCommandOptions, 116 + ): Promise<void> { 117 + const nsid = 'place.wisp.v2.domain.addSite'; 118 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 119 + 120 + const spinner = createSpinner(`Mapping ${domain} -> ${siteRkey}...`).start(); 121 + const data = await callWispXrpc<DomainAddSiteOutput>(agent, nsid, { 122 + serviceDid, 123 + data: { domain, siteRkey }, 124 + }); 125 + spinner.succeed(`Mapped ${data.domain}`); 126 + 127 + if (options.json) { 128 + console.log(JSON.stringify(data, null, 2)); 129 + return; 130 + } 131 + 132 + console.log(`${pc.bold(data.domain)} -> ${data.siteRkey} (${data.status})`); 133 + } 134 + 135 + export async function deleteDomain( 136 + identifier: string | undefined, 137 + domain: string, 138 + options: DomainCommandOptions, 139 + ): Promise<void> { 140 + const nsid = 'place.wisp.v2.domain.delete'; 141 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 142 + 143 + const spinner = createSpinner(`Deleting ${domain}...`).start(); 144 + const data = await callWispXrpc<DomainDeleteOutput>(agent, nsid, { 145 + serviceDid, 146 + params: { domain }, 147 + }); 148 + spinner.succeed(`Deleted ${data.domain}`); 149 + 150 + if (options.json) { 151 + console.log(JSON.stringify(data, null, 2)); 152 + return; 153 + } 154 + 155 + console.log(`${pc.bold(data.domain)} deleted`); 156 + } 157 + 158 + export async function deleteSite( 159 + identifier: string | undefined, 160 + siteRkey: string, 161 + options: DomainCommandOptions, 162 + ): Promise<void> { 163 + const nsid = 'place.wisp.v2.site.delete'; 164 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsid, options); 165 + 166 + const spinner = createSpinner(`Deleting site ${siteRkey}...`).start(); 167 + const data = await callWispXrpc<SiteDeleteOutput>(agent, nsid, { 168 + serviceDid, 169 + data: { siteRkey }, 170 + }); 171 + spinner.succeed(`Deleted site ${data.siteRkey}`); 172 + 173 + if (options.json) { 174 + console.log(JSON.stringify(data, null, 2)); 175 + return; 176 + } 177 + 178 + console.log(`${pc.bold(data.siteRkey)} deleted`); 179 + if (data.unmappedDomains.length > 0) { 180 + console.log('Unmapped domains:'); 181 + for (const domain of data.unmappedDomains) { 182 + console.log(`- ${domain.domain} [${domain.kind}] ${domain.status}`); 183 + } 184 + } 185 + }
+102
cli/commands/list.ts
··· 1 + import type { OutputSchema as DomainListOutput } from '@wispplace/lexicons/types/place/wisp/v2/domain/getList'; 2 + import type { OutputSchema as SiteListOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/getList'; 3 + import { createSpinner, pc } from '../lib/progress.ts'; 4 + import { authenticateForXrpc, type XrpcCommandOptions } from '../lib/command-utils.ts'; 5 + import { callWispXrpc } from '../lib/xrpc.ts'; 6 + 7 + export type ListCommandOptions = XrpcCommandOptions; 8 + 9 + function renderDomainList(data: DomainListOutput): void { 10 + const { domains } = data; 11 + 12 + if (domains.length === 0) { 13 + console.log(pc.dim('No domains found.')); 14 + return; 15 + } 16 + 17 + console.log(pc.bold(`Domains (${domains.length})`)); 18 + for (const domain of domains) { 19 + const statusColor = domain.status === 'verified' ? pc.green : pc.yellow; 20 + const mappedSite = domain.siteRkey ? ` -> ${domain.siteRkey}` : ''; 21 + console.log( 22 + `- ${pc.bold(domain.domain)} [${domain.kind}] ${statusColor(domain.status)}${mappedSite}`, 23 + ); 24 + } 25 + } 26 + 27 + function renderSiteList(data: SiteListOutput): void { 28 + const { sites } = data; 29 + 30 + if (sites.length === 0) { 31 + console.log(pc.dim('No sites found.')); 32 + return; 33 + } 34 + 35 + console.log(pc.bold(`Sites (${sites.length})`)); 36 + for (const site of sites) { 37 + const title = site.displayName 38 + ? `${site.siteRkey} (${site.displayName})` 39 + : site.siteRkey; 40 + console.log(`- ${pc.bold(title)}`); 41 + 42 + if (site.domains.length === 0) { 43 + console.log(pc.dim(' (no mapped domains)')); 44 + continue; 45 + } 46 + 47 + for (const domain of site.domains) { 48 + const statusColor = domain.status === 'verified' ? pc.green : pc.yellow; 49 + console.log( 50 + ` ${domain.domain} [${domain.kind}] ${statusColor(domain.status)}`, 51 + ); 52 + } 53 + } 54 + } 55 + 56 + export async function listDomains( 57 + identifier: string | undefined, 58 + options: ListCommandOptions, 59 + ): Promise<void> { 60 + const { agent, serviceDid } = await authenticateForXrpc( 61 + identifier, 62 + 'place.wisp.v2.domain.getList', 63 + options, 64 + ); 65 + 66 + const fetchSpinner = createSpinner('Fetching domains...').start(); 67 + const data = await callWispXrpc<DomainListOutput>(agent, 'place.wisp.v2.domain.getList', { 68 + serviceDid, 69 + }); 70 + fetchSpinner.succeed('Fetched domains'); 71 + 72 + if (options.json) { 73 + console.log(JSON.stringify(data, null, 2)); 74 + return; 75 + } 76 + 77 + renderDomainList(data); 78 + } 79 + 80 + export async function listSites( 81 + identifier: string | undefined, 82 + options: ListCommandOptions, 83 + ): Promise<void> { 84 + const { agent, serviceDid } = await authenticateForXrpc( 85 + identifier, 86 + 'place.wisp.v2.site.getList', 87 + options, 88 + ); 89 + 90 + const fetchSpinner = createSpinner('Fetching sites...').start(); 91 + const data = await callWispXrpc<SiteListOutput>(agent, 'place.wisp.v2.site.getList', { 92 + serviceDid, 93 + }); 94 + fetchSpinner.succeed('Fetched sites'); 95 + 96 + if (options.json) { 97 + console.log(JSON.stringify(data, null, 2)); 98 + return; 99 + } 100 + 101 + renderSiteList(data); 102 + }
+438 -108
cli/index.ts
··· 1 1 #!/usr/bin/env bun 2 2 import { Command } from 'commander'; 3 - import { text, isCancel, cancel, intro, outro } from '@clack/prompts'; 3 + import { text, select, confirm, isCancel, cancel, intro, outro, type Option } from '@clack/prompts'; 4 4 import { authenticate, clearSessions } from './lib/auth.ts'; 5 5 import { deploy } from './commands/deploy.ts'; 6 6 import { pull } from './commands/pull.ts'; 7 7 import { serve } from './commands/serve.ts'; 8 - import { pc } from './lib/progress.ts'; 8 + import { listDomains, listSites } from './commands/list.ts'; 9 + import type { OutputSchema as SiteGetListOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/getList'; 10 + import type { OutputSchema as SiteDeleteOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/delete'; 11 + import { 12 + claimCustomDomain, 13 + claimWispSubdomain, 14 + getDomainStatus, 15 + mapDomainToSite, 16 + deleteDomain, 17 + } from './commands/domain.ts'; 18 + import { 19 + addXrpcAuthOptions, 20 + authenticateForXrpc, 21 + bindAuthStatusToSpinner, 22 + type XrpcCommandOptions, 23 + withExit, 24 + } from './lib/command-utils.ts'; 25 + import { createSpinner, pc } from './lib/progress.ts'; 26 + import { callWispXrpc } from './lib/xrpc.ts'; 9 27 10 28 const program = new Command(); 11 29 30 + async function promptRequiredText( 31 + message: string, 32 + options: { 33 + placeholder?: string; 34 + defaultValue?: string; 35 + validate?: (value: string) => string | Error | undefined; 36 + cancelMessage: string; 37 + }, 38 + ): Promise<string> { 39 + const result = await text({ 40 + message, 41 + placeholder: options.placeholder, 42 + defaultValue: options.defaultValue, 43 + validate: options.validate, 44 + }); 45 + 46 + if (isCancel(result)) { 47 + cancel(options.cancelMessage); 48 + process.exit(0); 49 + } 50 + 51 + return result; 52 + } 53 + 54 + async function promptSelect<T extends string>( 55 + message: string, 56 + choices: Option<T>[], 57 + cancelMessage: string, 58 + ): Promise<T> { 59 + const result = await select({ 60 + message, 61 + options: choices, 62 + }); 63 + 64 + if (isCancel(result)) { 65 + cancel(cancelMessage); 66 + process.exit(0); 67 + } 68 + 69 + return result as T; 70 + } 71 + 72 + async function promptConfirm( 73 + message: string, 74 + cancelMessage: string, 75 + ): Promise<boolean> { 76 + const result = await confirm({ message }); 77 + 78 + if (isCancel(result)) { 79 + cancel(cancelMessage); 80 + process.exit(0); 81 + } 82 + 83 + return result; 84 + } 85 + 86 + async function deleteSiteWithSelection( 87 + identifier: string | undefined, 88 + options: XrpcCommandOptions & { site?: string; yes?: boolean }, 89 + ): Promise<void> { 90 + const nsids = ['place.wisp.v2.site.getList', 'place.wisp.v2.site.delete'] as const; 91 + const { agent, serviceDid } = await authenticateForXrpc(identifier, nsids, options); 92 + 93 + let siteRkey = options.site; 94 + if (!siteRkey) { 95 + const fetchSpinner = createSpinner('Fetching sites...').start(); 96 + const listData = await callWispXrpc<SiteGetListOutput>(agent, 'place.wisp.v2.site.getList', { 97 + serviceDid, 98 + }); 99 + fetchSpinner.succeed('Fetched sites'); 100 + 101 + if (listData.sites.length === 0) { 102 + throw new Error('No sites found for this account'); 103 + } 104 + 105 + siteRkey = await promptSelect( 106 + 'Select site to delete', 107 + listData.sites.map((site) => ({ 108 + value: site.siteRkey, 109 + label: site.displayName ? `${site.siteRkey} (${site.displayName})` : site.siteRkey, 110 + hint: site.domains.length > 0 ? `${site.domains.length} mapped domain(s)` : undefined, 111 + })), 112 + 'Site deletion cancelled', 113 + ); 114 + } 115 + 116 + if (!options.yes) { 117 + const shouldDelete = await promptConfirm( 118 + `Delete site "${siteRkey}" and unmap its domains?`, 119 + 'Site deletion cancelled', 120 + ); 121 + if (!shouldDelete) { 122 + cancel('Site deletion cancelled'); 123 + process.exit(0); 124 + } 125 + } 126 + 127 + const deleteSpinner = createSpinner(`Deleting site ${siteRkey}...`).start(); 128 + const data = await callWispXrpc<SiteDeleteOutput>(agent, 'place.wisp.v2.site.delete', { 129 + serviceDid, 130 + data: { siteRkey }, 131 + }); 132 + deleteSpinner.succeed(`Deleted site ${data.siteRkey}`); 133 + 134 + if (options.json) { 135 + console.log(JSON.stringify(data, null, 2)); 136 + return; 137 + } 138 + 139 + console.log(`${pc.bold(data.siteRkey)} deleted`); 140 + if (data.unmappedDomains.length > 0) { 141 + console.log('Unmapped domains:'); 142 + for (const domain of data.unmappedDomains) { 143 + console.log(`- ${domain.domain} [${domain.kind}] ${domain.status}`); 144 + } 145 + } 146 + } 147 + 12 148 program 13 149 .name('wisp-cli') 14 150 .description('CLI for wisp.place - deploy static sites to the AT Protocol') ··· 26 162 .option('--password <password>', 'App password for headless authentication') 27 163 .option('--store <path>', 'OAuth session store path') 28 164 .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')); 165 + .action(withExit(async (handle: string | undefined, options) => { 166 + let resolvedHandle = handle; 167 + let resolvedPath = options.path; 168 + let resolvedSite = options.site; 40 169 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 - }); 170 + // If any required values are missing, show prompts 171 + const needsPrompts = !resolvedHandle || !resolvedPath || !resolvedSite; 51 172 52 - if (isCancel(handleResult)) { 53 - cancel('Deploy cancelled'); 54 - process.exit(0); 55 - } 56 - resolvedHandle = handleResult; 57 - } 173 + if (needsPrompts) { 174 + intro(pc.cyan('wisp.place deploy')); 58 175 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 - }); 176 + if (!resolvedHandle) { 177 + resolvedHandle = await promptRequiredText('AT Protocol handle', { 178 + placeholder: 'alice.bsky.social', 179 + cancelMessage: 'Deploy cancelled', 180 + validate: (value) => { 181 + if (!value) return 'Handle is required'; 182 + if (!value.includes('.')) return 'Handle must include a domain (e.g., alice.bsky.social)'; 183 + return undefined; 184 + }, 185 + }); 186 + } 66 187 67 - if (isCancel(pathResult)) { 68 - cancel('Deploy cancelled'); 69 - process.exit(0); 70 - } 71 - resolvedPath = pathResult || '.'; 72 - } 188 + if (!resolvedPath) { 189 + resolvedPath = await promptRequiredText('Directory to deploy', { 190 + placeholder: '.', 191 + defaultValue: '.', 192 + cancelMessage: 'Deploy cancelled', 193 + }); 194 + } 73 195 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 - } 196 + if (!resolvedSite) { 197 + resolvedSite = await promptRequiredText('Site name', { 198 + placeholder: 'my-website', 199 + cancelMessage: 'Deploy cancelled', 200 + validate: (value) => { 201 + if (!value) return 'Site name is required'; 202 + if (!/^[a-zA-Z0-9._~:-]{1,512}$/.test(value)) { 203 + return 'Site name must be 1-512 characters of [a-zA-Z0-9._~:-]'; 84 204 } 85 - }); 86 - 87 - if (isCancel(siteResult)) { 88 - cancel('Deploy cancelled'); 89 - process.exit(0); 90 - } 91 - resolvedSite = siteResult; 92 - } 205 + return undefined; 206 + }, 207 + }); 93 208 } 209 + } 94 210 95 - const { agent, did } = await authenticate(resolvedHandle!, { 96 - appPassword: options.password, 97 - storePath: options.store 98 - }); 211 + const authSpinner = createSpinner('Authenticating...').start(); 212 + const { agent, did } = await authenticate(resolvedHandle!, { 213 + appPassword: options.password, 214 + storePath: options.store, 215 + onStatus: bindAuthStatusToSpinner(authSpinner), 216 + }); 217 + authSpinner.succeed(`Authenticated as ${resolvedHandle} (${did})`); 99 218 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 - }); 219 + const result = await deploy(agent, did, { 220 + path: resolvedPath, 221 + site: resolvedSite, 222 + directory: options.directory, 223 + spa: options.spa, 224 + yes: options.yes, 225 + concurrency: parseInt(options.concurrency, 10), 226 + }); 108 227 109 - console.log(); 110 - console.log(pc.dim(` URI: ${result.uri}`)); 111 - console.log(pc.cyan(` URL: ${result.url}`)); 228 + const handleUrl = `https://sites.wisp.place/${resolvedHandle}/${resolvedSite}`; 229 + const didUrl = result.url; 112 230 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); 231 + console.log(); 232 + console.log(pc.dim(` URI: ${result.uri}`)); 233 + console.log(pc.cyan(` URL: ${handleUrl}`)); 234 + console.log(pc.cyan(` URL: ${didUrl}`)); 235 + 236 + if (needsPrompts) { 237 + outro(pc.green('Deployed successfully!')); 238 + } else { 239 + console.log(); 240 + console.log(pc.green('✓ Deployed successfully!')); 123 241 } 124 - }); 242 + })); 125 243 126 244 // Pull command 127 245 program ··· 129 247 .description('Download a site from wisp.place to a local directory') 130 248 .requiredOption('-s, --site <name>', 'Site name to pull') 131 249 .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 - }); 250 + .action(withExit(async (handle: string, options) => { 251 + await pull(handle, { 252 + site: options.site, 253 + path: options.path 254 + }); 255 + })); 143 256 144 257 // Serve command 145 258 program ··· 148 261 .requiredOption('-s, --site <name>', 'Site name to serve') 149 262 .option('-p, --path <path>', 'Local directory to cache site', '.wisp-serve') 150 263 .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 - } 264 + .action(withExit(async (handle: string, options) => { 265 + await serve(handle, { 266 + site: options.site, 267 + path: options.path, 268 + port: parseInt(options.port, 10) 269 + }); 270 + })); 271 + 272 + // Logout command 273 + const listCommand = program 274 + .command('list') 275 + .description('List sites and domains from wisp XRPC routes'); 276 + 277 + addXrpcAuthOptions(listCommand).action(withExit(async (options) => { 278 + intro(pc.cyan('wisp.place list')); 279 + const action = await promptSelect( 280 + 'What do you want to list?', 281 + [ 282 + { value: 'domains', label: 'Domains', hint: 'Claimed, pending, and mapped domains' }, 283 + { value: 'sites', label: 'Sites', hint: 'Sites with mapped domains' }, 284 + ], 285 + 'List cancelled', 286 + ); 287 + 288 + if (action === 'domains') { 289 + await listDomains(undefined, options); 290 + return; 291 + } 292 + 293 + await listSites(undefined, options); 294 + })); 295 + 296 + addXrpcAuthOptions( 297 + listCommand 298 + .command('domains [handle]') 299 + .description('List domains for an account'), 300 + ).action(withExit(async (handle: string | undefined, options) => { 301 + await listDomains(handle, options); 302 + })); 303 + 304 + addXrpcAuthOptions( 305 + listCommand 306 + .command('sites [handle]') 307 + .description('List sites and their mapped domains for an account'), 308 + ).action(withExit(async (handle: string | undefined, options) => { 309 + await listSites(handle, options); 310 + })); 311 + 312 + const domainCommand = program 313 + .command('domain') 314 + .description('Manage domains with wisp XRPC'); 315 + 316 + addXrpcAuthOptions( 317 + domainCommand 318 + .command('claim [handle]') 319 + .description('Claim a custom domain') 320 + .option('-d, --domain <domain>', 'Custom domain') 321 + .option('-s, --site <rkey>', 'Optional site rkey to map'), 322 + ).action(withExit(async (handle: string | undefined, options) => { 323 + const domain = options.domain ?? await promptRequiredText('Custom domain', { 324 + placeholder: 'example.com', 325 + cancelMessage: 'Claim cancelled', 326 + validate: (value) => (!value ? 'Domain is required' : undefined), 327 + }); 328 + await claimCustomDomain(handle, domain, options.site, options); 329 + })); 330 + 331 + addXrpcAuthOptions( 332 + domainCommand 333 + .command('claim-subdomain [handle]') 334 + .description('Claim a wisp subdomain') 335 + .option('-n, --subdomain <name>', 'Subdomain handle') 336 + .option('-s, --site <rkey>', 'Optional site rkey to map'), 337 + ).action(withExit(async (handle: string | undefined, options) => { 338 + const subdomain = options.subdomain ?? await promptRequiredText('Subdomain handle', { 339 + placeholder: 'alice', 340 + cancelMessage: 'Claim cancelled', 341 + validate: (value) => (!value ? 'Subdomain is required' : undefined), 342 + }); 343 + await claimWispSubdomain(handle, subdomain, options.site, options); 344 + })); 345 + 346 + addXrpcAuthOptions( 347 + domainCommand 348 + .command('status [handle]') 349 + .description('Get domain verification/claim status') 350 + .option('-d, --domain <domain>', 'Domain'), 351 + ).action(withExit(async (handle: string | undefined, options) => { 352 + const domain = options.domain ?? await promptRequiredText('Domain', { 353 + placeholder: 'example.com', 354 + cancelMessage: 'Status check cancelled', 355 + validate: (value) => (!value ? 'Domain is required' : undefined), 356 + }); 357 + await getDomainStatus(handle, domain, options); 358 + })); 359 + 360 + addXrpcAuthOptions( 361 + domainCommand 362 + .command('add-site [handle]') 363 + .description('Map a claimed domain to a site rkey') 364 + .option('-d, --domain <domain>', 'Domain') 365 + .option('-s, --site <rkey>', 'Site rkey'), 366 + ).action(withExit(async (handle: string | undefined, options) => { 367 + const domain = options.domain ?? await promptRequiredText('Domain', { 368 + placeholder: 'example.com', 369 + cancelMessage: 'Add-site cancelled', 370 + validate: (value) => (!value ? 'Domain is required' : undefined), 371 + }); 372 + const site = options.site ?? await promptRequiredText('Site rkey', { 373 + placeholder: 'mysite', 374 + cancelMessage: 'Add-site cancelled', 375 + validate: (value) => (!value ? 'Site rkey is required' : undefined), 376 + }); 377 + await mapDomainToSite(handle, domain, site, options); 378 + })); 379 + 380 + addXrpcAuthOptions( 381 + domainCommand 382 + .command('delete [handle]') 383 + .description('Delete a claimed domain') 384 + .option('-d, --domain <domain>', 'Domain'), 385 + ).action(withExit(async (handle: string | undefined, options) => { 386 + const domain = options.domain ?? await promptRequiredText('Domain', { 387 + placeholder: 'example.com', 388 + cancelMessage: 'Delete cancelled', 389 + validate: (value) => (!value ? 'Domain is required' : undefined), 162 390 }); 391 + await deleteDomain(handle, domain, options); 392 + })); 393 + 394 + const siteCommand = program 395 + .command('site') 396 + .description('Manage sites with wisp XRPC'); 397 + 398 + addXrpcAuthOptions(domainCommand).action(withExit(async (options) => { 399 + intro(pc.cyan('wisp.place domain')); 400 + const action = await promptSelect( 401 + 'Choose domain action', 402 + [ 403 + { value: 'claim', label: 'Claim custom domain' }, 404 + { value: 'claim-subdomain', label: 'Claim wisp subdomain' }, 405 + { value: 'status', label: 'Get domain status' }, 406 + { value: 'add-site', label: 'Map domain to site' }, 407 + { value: 'delete', label: 'Delete domain' }, 408 + ], 409 + 'Domain command cancelled', 410 + ); 411 + 412 + if (action === 'claim') { 413 + const domain = await promptRequiredText('Custom domain', { 414 + placeholder: 'example.com', 415 + cancelMessage: 'Claim cancelled', 416 + validate: (value) => (!value ? 'Domain is required' : undefined), 417 + }); 418 + await claimCustomDomain(undefined, domain, undefined, options); 419 + return; 420 + } 421 + 422 + if (action === 'claim-subdomain') { 423 + const subdomain = await promptRequiredText('Subdomain handle', { 424 + placeholder: 'alice', 425 + cancelMessage: 'Claim cancelled', 426 + validate: (value) => (!value ? 'Subdomain is required' : undefined), 427 + }); 428 + await claimWispSubdomain(undefined, subdomain, undefined, options); 429 + return; 430 + } 431 + 432 + if (action === 'status') { 433 + const domain = await promptRequiredText('Domain', { 434 + placeholder: 'example.com', 435 + cancelMessage: 'Status check cancelled', 436 + validate: (value) => (!value ? 'Domain is required' : undefined), 437 + }); 438 + await getDomainStatus(undefined, domain, options); 439 + return; 440 + } 441 + 442 + if (action === 'add-site') { 443 + const domain = await promptRequiredText('Domain', { 444 + placeholder: 'example.com', 445 + cancelMessage: 'Add-site cancelled', 446 + validate: (value) => (!value ? 'Domain is required' : undefined), 447 + }); 448 + const site = await promptRequiredText('Site rkey', { 449 + placeholder: 'mysite', 450 + cancelMessage: 'Add-site cancelled', 451 + validate: (value) => (!value ? 'Site rkey is required' : undefined), 452 + }); 453 + await mapDomainToSite(undefined, domain, site, options); 454 + return; 455 + } 456 + 457 + const domain = await promptRequiredText('Domain', { 458 + placeholder: 'example.com', 459 + cancelMessage: 'Delete cancelled', 460 + validate: (value) => (!value ? 'Domain is required' : undefined), 461 + }); 462 + await deleteDomain(undefined, domain, options); 463 + })); 464 + 465 + addXrpcAuthOptions(siteCommand).action(withExit(async (options) => { 466 + intro(pc.cyan('wisp.place site')); 467 + const action = await promptSelect( 468 + 'Choose site action', 469 + [ 470 + { value: 'list', label: 'List sites', hint: 'Show sites and mapped domains' }, 471 + { value: 'delete', label: 'Delete site', hint: 'Remove site mapping metadata' }, 472 + ], 473 + 'Site command cancelled', 474 + ); 475 + 476 + if (action === 'list') { 477 + await listSites(undefined, options); 478 + return; 479 + } 480 + 481 + await deleteSiteWithSelection(undefined, options); 482 + })); 483 + 484 + addXrpcAuthOptions( 485 + siteCommand 486 + .command('delete [handle]') 487 + .description('Delete a site from wisp metadata and unmap its domains') 488 + .option('-s, --site <rkey>', 'Site rkey') 489 + .option('-y, --yes', 'Skip delete confirmation'), 490 + ).action(withExit(async (handle: string | undefined, options) => { 491 + await deleteSiteWithSelection(handle, options); 492 + })); 163 493 164 494 // Logout command 165 495 program
+162 -20
cli/lib/auth.ts
··· 1 - import { NodeOAuthClient, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node"; 1 + import { NodeOAuthClient, requestLocalLock, type NodeSavedSession, type NodeSavedState, type NodeSavedStateStore, type NodeSavedSessionStore } from "@atproto/oauth-client-node"; 2 2 import { Agent, CredentialSession } from "@atproto/api"; 3 3 import { resolvePdsFromHandle } from "@wispplace/atproto-utils"; 4 4 import { Hono } from "hono"; ··· 8 8 import { dirname, join } from "path"; 9 9 import { homedir } from "os"; 10 10 import { isBun } from "@wispplace/bun-firehose"; 11 + import { parseServiceDid } from "./wisp-service.ts"; 11 12 12 - // OAuth scope for CLI 13 - const OAUTH_SCOPE = 'atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/*'; 13 + const REQUIRED_BASE_SCOPE = 'atproto'; 14 + const REPO_BLOB_SCOPES = [ 15 + 'repo:place.wisp.fs', 16 + 'repo:place.wisp.subfs', 17 + 'repo:place.wisp.settings', 18 + 'blob:*/*', 19 + ] as const; 14 20 15 21 // Default session store path 16 22 const DEFAULT_STORE_PATH = join(homedir(), '.wisp', 'oauth-session.json'); ··· 89 95 export interface AuthOptions { 90 96 storePath?: string; 91 97 appPassword?: string; 98 + serviceDid?: string; 99 + requiredLxms?: readonly string[]; 100 + includeRepoBlobScopes?: boolean; 101 + onStatus?: (message: string) => void; 102 + } 103 + 104 + function buildOAuthScope(options: AuthOptions = {}): string { 105 + const requestedLxms = options.requiredLxms ?? []; 106 + const rpcScopes = requestedLxms.map((lxm) => `rpc:${lxm}?aud=*`); 107 + if (options.serviceDid) { 108 + parseServiceDid(options.serviceDid); 109 + } 110 + 111 + const scopes = [REQUIRED_BASE_SCOPE]; 112 + if (options.includeRepoBlobScopes !== false) { 113 + scopes.push(...REPO_BLOB_SCOPES); 114 + } 115 + scopes.push(...rpcScopes); 116 + 117 + return scopes.join(' '); 118 + } 119 + 120 + function normalizeScopeToken(scope: string): string { 121 + try { 122 + return decodeURIComponent(scope); 123 + } catch { 124 + return scope; 125 + } 126 + } 127 + 128 + function findMissingScopes(grantedScope: string | undefined, requiredScope: string): string[] { 129 + const granted = new Set( 130 + (grantedScope || '') 131 + .split(/\s+/) 132 + .filter(Boolean) 133 + .map(normalizeScopeToken), 134 + ); 135 + const required = requiredScope 136 + .split(/\s+/) 137 + .filter(Boolean) 138 + .map(normalizeScopeToken); 139 + 140 + return required.filter((scope) => !granted.has(scope)); 141 + } 142 + 143 + function emitStatus(options: AuthOptions | undefined, message: string) { 144 + if (options?.onStatus) { 145 + options.onStatus(message); 146 + return; 147 + } 148 + console.log(message); 149 + } 150 + 151 + function emitWarning(options: AuthOptions | undefined, message: string) { 152 + if (options?.onStatus) { 153 + options.onStatus(`Warning: ${message}`); 154 + return; 155 + } 156 + console.warn(`Warning: ${message}`); 92 157 } 93 158 94 159 /** ··· 99 164 options: AuthOptions = {} 100 165 ): Promise<{ agent: Agent; did: string }> { 101 166 const storePath = options.storePath || DEFAULT_STORE_PATH; 167 + const oauthScope = buildOAuthScope(options); 102 168 103 169 // Build loopback client metadata 104 170 const redirectUri = `http://${LOOPBACK_HOST}:${LOOPBACK_PORT}/oauth/callback`; 105 171 const clientIdParams = new URLSearchParams(); 106 172 clientIdParams.append('redirect_uri', redirectUri); 107 - clientIdParams.append('scope', OAUTH_SCOPE); 173 + clientIdParams.append('scope', oauthScope); 108 174 109 175 const client = new NodeOAuthClient({ 110 176 clientMetadata: { ··· 116 182 response_types: ['code'], 117 183 application_type: 'web', 118 184 token_endpoint_auth_method: 'none', 119 - scope: OAUTH_SCOPE, 185 + scope: oauthScope, 120 186 dpop_bound_access_tokens: false, 121 187 }, 122 188 stateStore: createStateStore(storePath), 123 189 sessionStore: createSessionStore(storePath), 190 + requestLock: requestLocalLock, 124 191 }); 125 192 126 193 // Try to restore existing session ··· 138 205 139 206 // Check if this is the handle we want 140 207 if (profile.data.handle === handle || sub === handle) { 141 - console.log(`Restored existing session for ${profile.data.handle}`); 208 + const tokenInfo = await session.getTokenInfo(false); 209 + const missingScopes = findMissingScopes(tokenInfo.scope, oauthScope); 210 + if (missingScopes.length > 0) { 211 + continue; 212 + } 213 + 214 + emitStatus(options, `Restored session for ${profile.data.handle}`); 142 215 return { agent, did: sub }; 143 216 } 144 217 } ··· 148 221 } 149 222 150 223 // Start new OAuth flow 151 - console.log(`Starting OAuth flow for ${handle}...`); 224 + emitStatus(options, `Starting OAuth flow for ${handle}...`); 152 225 153 226 // Create loopback server to receive callback 154 227 const callbackPromise = new Promise<{ params: URLSearchParams }>((resolve, reject) => { 155 228 const app = new Hono(); 156 229 let serverHandle: { close: () => void } | null = null; 230 + let timeoutHandle: ReturnType<typeof setTimeout> | undefined; 231 + let settled = false; 157 232 158 233 const successHtml = ` 159 234 <html> 160 - <head><title>Wisp CLI - Authentication Successful</title></head> 161 - <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;"> 162 - <div style="text-align: center;"> 235 + <head> 236 + <title>Wisp CLI - Authentication Successful</title> 237 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 238 + <style> 239 + :root { 240 + color-scheme: light dark; 241 + } 242 + 243 + body { 244 + margin: 0; 245 + min-height: 100vh; 246 + display: flex; 247 + align-items: center; 248 + justify-content: center; 249 + font-family: system-ui, -apple-system, Segoe UI, sans-serif; 250 + background: #f4f5f7; 251 + color: #111827; 252 + text-align: center; 253 + padding: 24px; 254 + } 255 + 256 + .content { 257 + max-width: 560px; 258 + } 259 + 260 + h1 { 261 + margin: 0 0 10px; 262 + font-size: 22px; 263 + } 264 + 265 + p { 266 + margin: 0; 267 + color: #4b5563; 268 + line-height: 1.5; 269 + } 270 + 271 + @media (prefers-color-scheme: dark) { 272 + body { 273 + background: #1e1e1e; 274 + color: #f3f4f6; 275 + } 276 + 277 + p { 278 + color: #d1d5db; 279 + } 280 + } 281 + </style> 282 + </head> 283 + <body> 284 + <div class="content"> 163 285 <h1>Authentication Successful</h1> 164 286 <p>You can close this window and return to the CLI.</p> 165 287 </div> ··· 169 291 170 292 app.get('/oauth/callback', (c) => { 171 293 const params = new URLSearchParams(c.req.url.split('?')[1] || ''); 294 + if (timeoutHandle) { 295 + clearTimeout(timeoutHandle); 296 + } 297 + settled = true; 172 298 173 299 // Close server after receiving callback 174 300 setTimeout(() => serverHandle?.close(), 100); ··· 199 325 } 200 326 201 327 // Timeout after 5 minutes 202 - setTimeout(() => { 328 + timeoutHandle = setTimeout(() => { 329 + if (settled) { 330 + return; 331 + } 332 + settled = true; 203 333 serverHandle?.close(); 204 334 reject(new Error('OAuth callback timeout')); 205 335 }, 5 * 60 * 1000); 336 + 337 + if (typeof (timeoutHandle as { unref?: () => void }).unref === 'function') { 338 + (timeoutHandle as { unref: () => void }).unref(); 339 + } 206 340 }); 207 341 208 342 // Get authorization URL 209 343 const authUrl = await client.authorize(handle, { 210 - scope: OAUTH_SCOPE, 344 + scope: oauthScope, 211 345 }); 212 346 213 347 // Open browser 214 - console.log(`Opening browser for authentication...`); 215 - console.log(`If browser doesn't open, visit: ${authUrl}`); 348 + emitStatus(options, 'Opening browser for authentication...'); 349 + emitStatus(options, `If browser does not open, visit: ${authUrl}`); 216 350 await open(authUrl.toString()); 217 351 218 352 // Wait for callback ··· 220 354 221 355 // Handle callback 222 356 const { session } = await client.callback(params); 357 + const tokenInfo = await session.getTokenInfo(false); 358 + const missingScopes = findMissingScopes(tokenInfo.scope, oauthScope); 359 + if (missingScopes.length > 0) { 360 + emitWarning(options, 361 + `OAuth token is missing requested scopes (${missingScopes.length}). First missing scope: ${missingScopes[0]}`, 362 + ); 363 + } 223 364 224 365 const agent = new Agent(session); 225 366 const did = session.did; 226 367 227 - console.log(`Successfully authenticated as ${did}`); 368 + emitStatus(options, `Authenticated as ${did}`); 228 369 229 370 return { agent, did }; 230 371 } ··· 235 376 export async function authenticateAppPassword( 236 377 identifier: string, 237 378 password: string, 238 - pdsUrl?: string 379 + pdsUrl?: string, 380 + options: AuthOptions = {}, 239 381 ): Promise<{ agent: Agent; did: string }> { 240 382 let serviceUrl = pdsUrl; 241 383 242 384 if (!serviceUrl) { 243 385 // Resolve the handle to find the correct PDS 244 - console.log(`Resolving PDS for ${identifier}...`); 386 + emitStatus(options, `Resolving PDS for ${identifier}...`); 245 387 serviceUrl = await resolvePdsFromHandle(identifier); 246 - console.log(`Found PDS: ${serviceUrl}`); 388 + emitStatus(options, `Found PDS: ${serviceUrl}`); 247 389 } 248 390 249 391 const credSession = new CredentialSession(new URL(serviceUrl)); ··· 252 394 const agent = new Agent(credSession); 253 395 const did = credSession.did!; 254 396 255 - console.log(`Successfully authenticated as ${did}`); 397 + emitStatus(options, `Authenticated as ${did}`); 256 398 257 399 return { agent, did }; 258 400 } ··· 265 407 options: AuthOptions = {} 266 408 ): Promise<{ agent: Agent; did: string }> { 267 409 if (options.appPassword) { 268 - return authenticateAppPassword(handle, options.appPassword); 410 + return authenticateAppPassword(handle, options.appPassword, undefined, options); 269 411 } 270 412 return authenticateOAuth(handle, options); 271 413 }
+112
cli/lib/command-utils.ts
··· 1 + import { cancel, isCancel, text } from '@clack/prompts'; 2 + import type { Agent } from '@atproto/api'; 3 + import type { Command } from 'commander'; 4 + import { authenticate } from './auth.ts'; 5 + import { createSpinner, pc, type SpinnerLike } from './progress.ts'; 6 + import { parseServiceDid } from './wisp-service.ts'; 7 + 8 + export interface XrpcCommandOptions { 9 + password?: string; 10 + store?: string; 11 + service?: string; 12 + json?: boolean; 13 + } 14 + 15 + export function withExit( 16 + handler: (...args: any[]) => Promise<void>, 17 + ): (...args: any[]) => Promise<never> { 18 + return async (...args: any[]): Promise<never> => { 19 + try { 20 + await handler(...args); 21 + process.exit(0); 22 + } catch (err: any) { 23 + console.error(pc.red(`\nError: ${err.message}\n`)); 24 + process.exit(1); 25 + } 26 + }; 27 + } 28 + 29 + export function addXrpcAuthOptions<T extends Command>(command: T): T { 30 + return command 31 + .option('--password <password>', 'App password for headless authentication') 32 + .option('--store <path>', 'OAuth session store path') 33 + .option('--service <did:...>', 'Service DID to proxy through') 34 + .option('--json', 'Output raw JSON'); 35 + } 36 + 37 + const OAUTH_FALLBACK_PREFIX = 'If browser does not open, visit: '; 38 + const MAX_SPINNER_TEXT_LENGTH = 120; 39 + 40 + function truncateSpinnerText(message: string): string { 41 + const compact = message.replace(/\s+/g, ' ').trim(); 42 + if (compact.length <= MAX_SPINNER_TEXT_LENGTH) { 43 + return compact; 44 + } 45 + return `${compact.slice(0, MAX_SPINNER_TEXT_LENGTH - 1)}...`; 46 + } 47 + 48 + export function bindAuthStatusToSpinner(spinner: SpinnerLike): (message: string) => void { 49 + return (message: string) => { 50 + if (message.startsWith(OAUTH_FALLBACK_PREFIX)) { 51 + spinner.text = 'Waiting for OAuth callback...'; 52 + // Print long OAuth URL once outside the spinner line. 53 + console.log(`\n${message}\n`); 54 + return; 55 + } 56 + 57 + spinner.text = truncateSpinnerText(message); 58 + }; 59 + } 60 + 61 + export async function resolveIdentifier( 62 + identifier: string | undefined, 63 + cancelMessage = 'Command cancelled', 64 + ): Promise<string> { 65 + if (identifier) { 66 + return identifier; 67 + } 68 + 69 + const result = await text({ 70 + message: 'AT Protocol handle', 71 + placeholder: 'alice.bsky.social', 72 + validate: (value) => { 73 + if (!value) { 74 + return 'Handle is required'; 75 + } 76 + 77 + if (!value.includes('.')) { 78 + return 'Handle must include a domain (e.g., alice.bsky.social)'; 79 + } 80 + }, 81 + }); 82 + 83 + if (isCancel(result)) { 84 + cancel(cancelMessage); 85 + process.exit(0); 86 + } 87 + 88 + return result; 89 + } 90 + 91 + export async function authenticateForXrpc( 92 + identifier: string | undefined, 93 + nsid: string | readonly string[], 94 + options: XrpcCommandOptions, 95 + ): Promise<{ agent: Agent; serviceDid: string; did: string }> { 96 + const requiredLxms = Array.isArray(nsid) ? [...nsid] : [nsid]; 97 + const resolvedIdentifier = await resolveIdentifier(identifier); 98 + const serviceDid = parseServiceDid(options.service); 99 + 100 + const spinner = createSpinner('Authenticating...').start(); 101 + const { agent, did } = await authenticate(resolvedIdentifier, { 102 + appPassword: options.password, 103 + storePath: options.store, 104 + serviceDid, 105 + requiredLxms, 106 + includeRepoBlobScopes: false, 107 + onStatus: bindAuthStatusToSpinner(spinner), 108 + }); 109 + spinner.succeed(`Authenticated as ${did}`); 110 + 111 + return { agent, serviceDid, did }; 112 + }
+53
cli/lib/wisp-service.ts
··· 1 + export const DEFAULT_WISP_SERVICE_DID = 'did:web:wisp.place'; 2 + export const WISP_PROXY_SERVICE_ID = 'wisp_xrpc'; 3 + 4 + export const WISP_SERVICE_LXMS = [ 5 + 'place.wisp.v2.domain.addSite', 6 + 'place.wisp.v2.domain.claim', 7 + 'place.wisp.v2.domain.claimSubdomain', 8 + 'place.wisp.v2.domain.delete', 9 + 'place.wisp.v2.domain.getList', 10 + 'place.wisp.v2.domain.getStatus', 11 + 'place.wisp.v2.site.delete', 12 + 'place.wisp.v2.site.getList', 13 + ] as const; 14 + 15 + function isDid(value: string): value is `did:${string}:${string}` { 16 + if (!value.startsWith('did:')) { 17 + return false; 18 + } 19 + 20 + if (value.length < 8) { 21 + return false; 22 + } 23 + 24 + if (value.includes('#') || /\s/.test(value)) { 25 + return false; 26 + } 27 + 28 + const methodSeparator = value.indexOf(':', 4); 29 + if (methodSeparator <= 4 || methodSeparator >= value.length - 1) { 30 + return false; 31 + } 32 + 33 + return true; 34 + } 35 + 36 + export function parseServiceDid(input?: string): `did:${string}:${string}` { 37 + const value = (input?.trim() || DEFAULT_WISP_SERVICE_DID).trim(); 38 + 39 + if (!isDid(value)) { 40 + throw new Error(`Invalid --service value "${value}". Expected did:...`); 41 + } 42 + 43 + return value; 44 + } 45 + 46 + function buildRpcScope(aud: string, lxm: string): string { 47 + return `rpc:${lxm}?aud=${aud}`; 48 + } 49 + 50 + export function buildWispRpcScopes(aud: `did:${string}:${string}`): string[] { 51 + void aud; 52 + return WISP_SERVICE_LXMS.map((lxm) => buildRpcScope('*', lxm)); 53 + }
+31
cli/lib/xrpc.ts
··· 1 + import type { Agent } from '@atproto/api'; 2 + import { schemas } from '@wispplace/lexicons/lexicons'; 3 + import { parseServiceDid, WISP_PROXY_SERVICE_ID } from './wisp-service.ts'; 4 + 5 + function registerWispLexicons(agent: Agent): void { 6 + for (const schema of schemas) { 7 + if (!agent.lex.get(schema.id)) { 8 + agent.lex.add(schema); 9 + } 10 + } 11 + } 12 + 13 + export interface WispXrpcCallOptions { 14 + serviceDid?: string; 15 + params?: Record<string, any>; 16 + data?: unknown; 17 + } 18 + 19 + export async function callWispXrpc<T>( 20 + agent: Agent, 21 + nsid: string, 22 + options: WispXrpcCallOptions = {}, 23 + ): Promise<T> { 24 + const serviceDid = parseServiceDid(options.serviceDid); 25 + const proxiedAgent = agent.withProxy(WISP_PROXY_SERVICE_ID, serviceDid); 26 + 27 + registerWispLexicons(proxiedAgent); 28 + 29 + const response = await proxiedAgent.call(nsid, options.params, options.data); 30 + return response.data as T; 31 + }
+12
packages/@wispplace/lexicons/package.json
··· 34 34 "types": "./src/types/place/wisp/v2/domain/claimSubdomain.ts", 35 35 "default": "./src/types/place/wisp/v2/domain/claimSubdomain.ts" 36 36 }, 37 + "./types/place/wisp/v2/domain/addSite": { 38 + "types": "./src/types/place/wisp/v2/domain/addSite.ts", 39 + "default": "./src/types/place/wisp/v2/domain/addSite.ts" 40 + }, 37 41 "./types/place/wisp/v2/domain/delete": { 38 42 "types": "./src/types/place/wisp/v2/domain/delete.ts", 39 43 "default": "./src/types/place/wisp/v2/domain/delete.ts" ··· 45 49 "./types/place/wisp/v2/domain/getStatus": { 46 50 "types": "./src/types/place/wisp/v2/domain/getStatus.ts", 47 51 "default": "./src/types/place/wisp/v2/domain/getStatus.ts" 52 + }, 53 + "./types/place/wisp/v2/site/delete": { 54 + "types": "./src/types/place/wisp/v2/site/delete.ts", 55 + "default": "./src/types/place/wisp/v2/site/delete.ts" 56 + }, 57 + "./types/place/wisp/v2/site/getList": { 58 + "types": "./src/types/place/wisp/v2/site/getList.ts", 59 + "default": "./src/types/place/wisp/v2/site/getList.ts" 48 60 }, 49 61 "./atcute": { 50 62 "types": "./src/atcute/lexicons/index.ts",