forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
1import { parseArgs } from "@std/cli/parse-args";
2import { join, dirname } from "@std/path";
3import { ensureDir } from "@std/fs";
4import type { AtProtoClient } from "../../generated_client.ts";
5import { ConfigManager } from "../../auth/config.ts";
6import { createAuthenticatedClient } from "../../utils/client.ts";
7import { logger } from "../../utils/logger.ts";
8import { SlicesConfigLoader, mergeConfig } from "../../utils/config.ts";
9
10function showPullHelp() {
11 console.log(`
12slices lexicon pull - Pull lexicon files from your slice
13
14USAGE:
15 slices lexicon pull [OPTIONS]
16
17OPTIONS:
18 --path <PATH> Directory to save lexicon files (default: ./lexicons or from slices.json)
19 --slice <SLICE_URI> Source slice URI (required, or from slices.json)
20 --nsid <PATTERN> Filter lexicons by NSID pattern (supports wildcards with *)
21 --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json)
22 -h, --help Show this help message
23
24EXAMPLES:
25 slices lexicon pull --slice at://did:plc:example/slice
26 slices lexicon pull --path ./my-lexicons --slice at://did:plc:example/slice
27 slices lexicon pull --nsid "app.bsky.*" --slice at://did:plc:example/slice
28 slices lexicon pull --nsid "app.bsky.actor.*" --slice at://did:plc:example/slice
29 slices lexicon pull # Uses config from slices.json
30
31NOTE:
32 When using wildcards (*), wrap the pattern in quotes to prevent shell expansion
33`);
34}
35
36interface PullStats {
37 fetched: number;
38 written: number;
39 failed: number;
40 errors: Array<{ nsid: string; error: string }>;
41}
42
43function nsidToPath(nsid: string, basePath: string): string {
44 const parts = nsid.split(".");
45 const dirParts = parts.slice(0, -1);
46 const fileName = parts[parts.length - 1] + ".json";
47
48 return join(basePath, ...dirParts, fileName);
49}
50
51function matchesNsidPattern(nsid: string, pattern: string): boolean {
52 const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
53 const regex = new RegExp(`^${regexPattern}$`);
54 return regex.test(nsid);
55}
56
57async function pullLexicons(
58 sliceUri: string,
59 lexiconPath: string,
60 client: AtProtoClient,
61 nsidPattern?: string
62): Promise<PullStats> {
63 const stats: PullStats = {
64 fetched: 0,
65 written: 0,
66 failed: 0,
67 errors: [],
68 };
69
70 try {
71 const response = await client.network.slices.lexicon.getRecords({
72 where: { slice: { eq: sliceUri } },
73 limit: 100,
74 });
75
76 stats.fetched = response.records.length;
77
78 for (const record of response.records) {
79 try {
80 const nsid = record.value.nsid;
81
82 if (nsidPattern && !matchesNsidPattern(nsid, nsidPattern)) {
83 continue;
84 }
85 const definitions = JSON.parse(record.value.definitions);
86
87 const lexiconDoc = {
88 lexicon: 1,
89 id: nsid,
90 defs: definitions,
91 };
92
93 const filePath = nsidToPath(nsid, lexiconPath);
94
95 await ensureDir(dirname(filePath));
96
97 await Deno.writeTextFile(
98 filePath,
99 JSON.stringify(lexiconDoc, null, 2) + "\n"
100 );
101
102 logger.info(`Wrote: ${filePath}`);
103 stats.written++;
104 } catch (error) {
105 const err = error as Error;
106 stats.failed++;
107 stats.errors.push({
108 nsid: record.value.nsid,
109 error: err.message,
110 });
111 }
112 }
113 } catch (error) {
114 const err = error as Error;
115 logger.error(`Failed to fetch lexicons: ${err.message}`);
116 throw error;
117 }
118
119 return stats;
120}
121
122export async function pullCommand(
123 commandArgs: unknown[],
124 _globalArgs: Record<string, unknown>
125): Promise<void> {
126 const args = parseArgs(commandArgs as string[], {
127 boolean: ["help"],
128 string: ["path", "slice", "api-url", "nsid"],
129 alias: {
130 h: "help",
131 },
132 });
133
134 if (args.help) {
135 showPullHelp();
136 return;
137 }
138
139 const configLoader = new SlicesConfigLoader();
140 const slicesConfig = await configLoader.load();
141 const mergedConfig = mergeConfig(slicesConfig, args);
142
143 if (!mergedConfig.slice) {
144 logger.error("--slice is required");
145 if (!slicesConfig.slice) {
146 logger.info(
147 "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"
148 );
149 }
150 console.log("\nRun 'slices lexicon pull --help' for usage information.");
151 Deno.exit(1);
152 }
153
154 const lexiconPath = mergedConfig.lexiconPath!;
155 const sliceUri = mergedConfig.slice!;
156 const apiUrl = mergedConfig.apiUrl!;
157 const nsidPattern = args.nsid as string | undefined;
158
159 const config = new ConfigManager();
160 await config.load();
161
162 if (!config.isAuthenticated()) {
163 logger.error("Not authenticated. Run 'slices login' first.");
164 Deno.exit(1);
165 }
166
167 const client = await createAuthenticatedClient(sliceUri, apiUrl);
168
169 const pullStats = await pullLexicons(
170 sliceUri,
171 lexiconPath,
172 client,
173 nsidPattern
174 );
175
176 if (pullStats.failed > 0) {
177 logger.warn(`${pullStats.failed} lexicons failed to write`);
178 for (const error of pullStats.errors) {
179 logger.error(`${error.nsid}: ${error.error}`);
180 }
181 }
182
183 if (pullStats.written > 0) {
184 const filterMsg = nsidPattern ? ` matching '${nsidPattern}'` : "";
185 logger.success(
186 `Pulled ${pullStats.written} lexicons${filterMsg} to ${lexiconPath}`
187 );
188 } else {
189 const filterMsg = nsidPattern ? ` matching '${nsidPattern}'` : "";
190 logger.info(`No lexicons found${filterMsg}`);
191 }
192}