A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { command, flag } from "cmd-ts";
2import { select, spinner, log } from "@clack/prompts";
3import * as path from "node:path";
4import { loadConfig, findConfig } from "../lib/config";
5import {
6 loadCredentials,
7 listAllCredentials,
8 getCredentials,
9} from "../lib/credentials";
10import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
11import { createAgent } from "../lib/atproto";
12import { syncStateFromPDS } from "../lib/sync";
13import { exitOnCancel } from "../lib/prompts";
14
15export const syncCommand = command({
16 name: "sync",
17 description: "Sync state from ATProto to restore .sequoia-state.json",
18 args: {
19 updateFrontmatter: flag({
20 long: "update-frontmatter",
21 short: "u",
22 description: "Update frontmatter atUri fields in local markdown files",
23 }),
24 dryRun: flag({
25 long: "dry-run",
26 short: "n",
27 description: "Preview what would be synced without making changes",
28 }),
29 },
30 handler: async ({ updateFrontmatter, dryRun }) => {
31 // Load config
32 const configPath = await findConfig();
33 if (!configPath) {
34 log.error("No sequoia.json found. Run 'sequoia init' first.");
35 process.exit(1);
36 }
37
38 const config = await loadConfig(configPath);
39 const configDir = path.dirname(configPath);
40
41 log.info(`Site: ${config.siteUrl}`);
42 log.info(`Publication: ${config.publicationUri}`);
43
44 // Load credentials
45 let credentials = await loadCredentials(config.identity);
46
47 if (!credentials) {
48 const identities = await listAllCredentials();
49 if (identities.length === 0) {
50 log.error(
51 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
52 );
53 process.exit(1);
54 }
55
56 // Build labels with handles for OAuth sessions
57 const options = await Promise.all(
58 identities.map(async (cred) => {
59 if (cred.type === "oauth") {
60 const handle = await getOAuthHandle(cred.id);
61 return {
62 value: cred.id,
63 label: `${handle || cred.id} (OAuth)`,
64 };
65 }
66 return {
67 value: cred.id,
68 label: `${cred.id} (App Password)`,
69 };
70 }),
71 );
72
73 log.info("Multiple identities found. Select one to use:");
74 const selected = exitOnCancel(
75 await select({
76 message: "Identity:",
77 options,
78 }),
79 );
80
81 // Load the selected credentials
82 const selectedCred = identities.find((c) => c.id === selected);
83 if (selectedCred?.type === "oauth") {
84 const session = await getOAuthSession(selected);
85 if (session) {
86 const handle = await getOAuthHandle(selected);
87 credentials = {
88 type: "oauth",
89 did: selected,
90 handle: handle || selected,
91 };
92 }
93 } else {
94 credentials = await getCredentials(selected);
95 }
96
97 if (!credentials) {
98 log.error("Failed to load selected credentials.");
99 process.exit(1);
100 }
101 }
102
103 // Create agent
104 const s = spinner();
105 const connectingTo =
106 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
107 s.start(`Connecting as ${connectingTo}...`);
108 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
109 try {
110 agent = await createAgent(credentials);
111 s.stop(`Logged in as ${agent.did}`);
112 } catch (error) {
113 s.stop("Failed to login");
114 log.error(`Failed to login: ${error}`);
115 process.exit(1);
116 }
117
118 // Sync state from PDS
119 s.start("Fetching documents from PDS...");
120 const result = await syncStateFromPDS(agent, config, configDir, {
121 updateFrontmatter,
122 dryRun,
123 quiet: false,
124 });
125 s.stop(`Found documents on PDS`);
126
127 if (!dryRun) {
128 const stateCount = Object.keys(result.state.posts).length;
129 log.success(`\nSaved .sequoia-state.json (${stateCount} entries)`);
130
131 if (result.frontmatterUpdatesApplied > 0) {
132 log.success(
133 `Updated frontmatter in ${result.frontmatterUpdatesApplied} files`,
134 );
135 }
136 }
137
138 log.success("\nSync complete!");
139 },
140});