A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { AtpAgent } from "@atproto/api";
2import {
3 confirm,
4 log,
5 note,
6 password,
7 select,
8 spinner,
9 text,
10} from "@clack/prompts";
11import { command, flag, option, optional, string } from "cmd-ts";
12import { resolveHandleToPDS } from "../lib/atproto";
13import {
14 deleteCredentials,
15 getCredentials,
16 getCredentialsPath,
17 listCredentials,
18 saveCredentials,
19} from "../lib/credentials";
20import { exitOnCancel } from "../lib/prompts";
21
22export const authCommand = command({
23 name: "auth",
24 description: "Authenticate with your ATProto PDS",
25 args: {
26 logout: option({
27 long: "logout",
28 description:
29 "Remove credentials for a specific identity (or all if only one exists)",
30 type: optional(string),
31 }),
32 list: flag({
33 long: "list",
34 description: "List all stored identities",
35 }),
36 },
37 handler: async ({ logout, list }) => {
38 // List identities
39 if (list) {
40 const identities = await listCredentials();
41 if (identities.length === 0) {
42 log.info("No stored identities");
43 } else {
44 log.info("Stored identities:");
45 for (const id of identities) {
46 console.log(` - ${id}`);
47 }
48 }
49 return;
50 }
51
52 // Logout
53 if (logout !== undefined) {
54 // If --logout was passed without a value, it will be an empty string
55 const identifier = logout || undefined;
56
57 if (!identifier) {
58 // No identifier provided - show available and prompt
59 const identities = await listCredentials();
60 if (identities.length === 0) {
61 log.info("No saved credentials found");
62 return;
63 }
64 if (identities.length === 1) {
65 const deleted = await deleteCredentials(identities[0]);
66 if (deleted) {
67 log.success(`Removed credentials for ${identities[0]}`);
68 }
69 return;
70 }
71 // Multiple identities - prompt
72 const selected = exitOnCancel(
73 await select({
74 message: "Select identity to remove:",
75 options: identities.map((id) => ({ value: id, label: id })),
76 }),
77 );
78 const deleted = await deleteCredentials(selected);
79 if (deleted) {
80 log.success(`Removed credentials for ${selected}`);
81 }
82 return;
83 }
84
85 const deleted = await deleteCredentials(identifier);
86 if (deleted) {
87 log.success(`Removed credentials for ${identifier}`);
88 } else {
89 log.info(`No credentials found for ${identifier}`);
90 }
91 return;
92 }
93
94 note(
95 "To authenticate, you'll need an App Password.\n\n" +
96 "Create one at: https://bsky.app/settings/app-passwords\n\n" +
97 "App Passwords are safer than your main password and can be revoked.",
98 "Authentication",
99 );
100
101 const identifier = exitOnCancel(
102 await text({
103 message: "Handle or DID:",
104 placeholder: "yourhandle.bsky.social",
105 }),
106 );
107
108 const appPassword = exitOnCancel(
109 await password({
110 message: "App Password:",
111 }),
112 );
113
114 if (!identifier || !appPassword) {
115 log.error("Handle and password are required");
116 process.exit(1);
117 }
118
119 // Check if this identity already exists
120 const existing = await getCredentials(identifier);
121 if (existing) {
122 const overwrite = exitOnCancel(
123 await confirm({
124 message: `Credentials for ${identifier} already exist. Update?`,
125 initialValue: false,
126 }),
127 );
128 if (!overwrite) {
129 log.info("Keeping existing credentials");
130 return;
131 }
132 }
133
134 // Resolve PDS from handle
135 const s = spinner();
136 s.start("Resolving PDS...");
137 let pdsUrl: string;
138 try {
139 pdsUrl = await resolveHandleToPDS(identifier);
140 s.stop(`Found PDS: ${pdsUrl}`);
141 } catch (error) {
142 s.stop("Failed to resolve PDS");
143 log.error(`Failed to resolve PDS from handle: ${error}`);
144 process.exit(1);
145 }
146
147 // Verify credentials
148 s.start("Verifying credentials...");
149
150 try {
151 const agent = new AtpAgent({ service: pdsUrl });
152 await agent.login({
153 identifier: identifier,
154 password: appPassword,
155 });
156
157 s.stop(`Logged in as ${agent.session?.handle}`);
158
159 // Save credentials
160 await saveCredentials({
161 pdsUrl,
162 identifier: identifier,
163 password: appPassword,
164 });
165
166 log.success(`Credentials saved to ${getCredentialsPath()}`);
167 } catch (error) {
168 s.stop("Failed to login");
169 log.error(`Failed to login: ${error}`);
170 process.exit(1);
171 }
172 },
173});