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