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