packages/cli/src/commands/sync.ts 6.1 K raw
1
import { command, flag } from "cmd-ts";
2
import { select, spinner, log } from "@clack/prompts";
3
import * as path from "path";
4
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
5
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
6
import { createAgent, listDocuments } from "../lib/atproto";
7
import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown";
8
import { exitOnCancel } from "../lib/prompts";
9
10
export const syncCommand = command({
11
  name: "sync",
12
  description: "Sync state from ATProto to restore .sequoia-state.json",
13
  args: {
14
    updateFrontmatter: flag({
15
      long: "update-frontmatter",
16
      short: "u",
17
      description: "Update frontmatter atUri fields in local markdown files",
18
    }),
19
    dryRun: flag({
20
      long: "dry-run",
21
      short: "n",
22
      description: "Preview what would be synced without making changes",
23
    }),
24
  },
25
  handler: async ({ updateFrontmatter, dryRun }) => {
26
    // Load config
27
    const configPath = await findConfig();
28
    if (!configPath) {
29
      log.error("No sequoia.json found. Run 'sequoia init' first.");
30
      process.exit(1);
31
    }
32
33
    const config = await loadConfig(configPath);
34
    const configDir = path.dirname(configPath);
35
36
    log.info(`Site: ${config.siteUrl}`);
37
    log.info(`Publication: ${config.publicationUri}`);
38
39
    // Load credentials
40
    let credentials = await loadCredentials(config.identity);
41
42
    if (!credentials) {
43
      const identities = await listCredentials();
44
      if (identities.length === 0) {
45
        log.error("No credentials found. Run 'sequoia auth' first.");
46
        process.exit(1);
47
      }
48
49
      log.info("Multiple identities found. Select one to use:");
50
      const selected = exitOnCancel(await select({
51
        message: "Identity:",
52
        options: identities.map(id => ({ value: id, label: id })),
53
      }));
54
55
      credentials = await getCredentials(selected);
56
      if (!credentials) {
57
        log.error("Failed to load selected credentials.");
58
        process.exit(1);
59
      }
60
    }
61
62
    // Create agent
63
    const s = spinner();
64
    s.start(`Connecting to ${credentials.pdsUrl}...`);
65
    let agent;
66
    try {
67
      agent = await createAgent(credentials);
68
      s.stop(`Logged in as ${agent.session?.handle}`);
69
    } catch (error) {
70
      s.stop("Failed to login");
71
      log.error(`Failed to login: ${error}`);
72
      process.exit(1);
73
    }
74
75
    // Fetch documents from PDS
76
    s.start("Fetching documents from PDS...");
77
    const documents = await listDocuments(agent, config.publicationUri);
78
    s.stop(`Found ${documents.length} documents on PDS`);
79
80
    if (documents.length === 0) {
81
      log.info("No documents found for this publication.");
82
      return;
83
    }
84
85
    // Resolve content directory
86
    const contentDir = path.isAbsolute(config.contentDir)
87
      ? config.contentDir
88
      : path.join(configDir, config.contentDir);
89
90
    // Scan local posts
91
    s.start("Scanning local content...");
92
    const localPosts = await scanContentDirectory(contentDir, config.frontmatter);
93
    s.stop(`Found ${localPosts.length} local posts`);
94
95
    // Build a map of path -> local post for matching
96
    // Document path is like /posts/my-post-slug
97
    const postsByPath = new Map<string, typeof localPosts[0]>();
98
    for (const post of localPosts) {
99
      const postPath = `/posts/${post.slug}`;
100
      postsByPath.set(postPath, post);
101
    }
102
103
    // Load existing state
104
    const state = await loadState(configDir);
105
    const originalPostCount = Object.keys(state.posts).length;
106
107
    // Track changes
108
    let matchedCount = 0;
109
    let unmatchedCount = 0;
110
    let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
111
112
    log.message("\nMatching documents to local files:\n");
113
114
    for (const doc of documents) {
115
      const docPath = doc.value.path;
116
      const localPost = postsByPath.get(docPath);
117
118
      if (localPost) {
119
        matchedCount++;
120
        log.message(`  ✓ ${doc.value.title}`);
121
        log.message(`    Path: ${docPath}`);
122
        log.message(`    URI: ${doc.uri}`);
123
        log.message(`    File: ${path.basename(localPost.filePath)}`);
124
125
        // Update state (use relative path from config directory)
126
        const contentHash = await getContentHash(localPost.rawContent);
127
        const relativeFilePath = path.relative(configDir, localPost.filePath);
128
        state.posts[relativeFilePath] = {
129
          contentHash,
130
          atUri: doc.uri,
131
          lastPublished: doc.value.publishedAt,
132
        };
133
134
        // Check if frontmatter needs updating
135
        if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) {
136
          frontmatterUpdates.push({
137
            filePath: localPost.filePath,
138
            atUri: doc.uri,
139
          });
140
          log.message(`    → Will update frontmatter`);
141
        }
142
      } else {
143
        unmatchedCount++;
144
        log.message(`  ✗ ${doc.value.title} (no matching local file)`);
145
        log.message(`    Path: ${docPath}`);
146
        log.message(`    URI: ${doc.uri}`);
147
      }
148
      log.message("");
149
    }
150
151
    // Summary
152
    log.message("---");
153
    log.info(`Matched: ${matchedCount} documents`);
154
    if (unmatchedCount > 0) {
155
      log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
156
    }
157
158
    if (dryRun) {
159
      log.info("\nDry run complete. No changes made.");
160
      return;
161
    }
162
163
    // Save updated state
164
    await saveState(configDir, state);
165
    const newPostCount = Object.keys(state.posts).length;
166
    log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
167
168
    // Update frontmatter if requested
169
    if (frontmatterUpdates.length > 0) {
170
      s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
171
      for (const { filePath, atUri } of frontmatterUpdates) {
172
        const file = Bun.file(filePath);
173
        const content = await file.text();
174
        const updated = updateFrontmatterWithAtUri(content, atUri);
175
        await Bun.write(filePath, updated);
176
        log.message(`  Updated: ${path.basename(filePath)}`);
177
      }
178
      s.stop("Frontmatter updated");
179
    }
180
181
    log.success("\nSync complete!");
182
  },
183
});