Highly ambitious ATProtocol AppView service and sdks
1import { parseArgs } from "@std/cli/parse-args";
2import { resolve } from "@std/path";
3import type {
4 AtProtoClient,
5 NetworkSlicesLexicon,
6} from "../../generated_client.ts";
7import { ConfigManager } from "../../auth/config.ts";
8import { createAuthenticatedClient } from "../../utils/client.ts";
9import { logger } from "../../utils/logger.ts";
10import { SlicesConfigLoader, mergeConfig } from "../../utils/config.ts";
11import {
12 findLexiconFiles,
13 validateLexiconFiles,
14 printValidationSummary,
15 type LexiconValidationResult,
16} from "../../utils/lexicon.ts";
17import type { LexiconDoc } from "@slices/lexicon";
18
19function showPushHelp() {
20 console.log(`
21slices lexicon push - Push lexicon files to your slice
22
23USAGE:
24 slices lexicon push [OPTIONS]
25
26OPTIONS:
27 --path <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json)
28 --slice <SLICE_URI> Target slice URI (required, or from slices.json)
29 --exclude-from-sync Exclude these lexicons from sync (sets excludedFromSync: true)
30 --validate-only Only validate files, don't upload
31 --dry-run Show what would be imported without uploading
32 --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json)
33 -h, --help Show this help message
34
35EXAMPLES:
36 slices lexicon push --slice at://did:plc:example/slice
37 slices lexicon push --path ./my-lexicons --slice at://did:plc:example/slice
38 slices lexicon push --exclude-from-sync --slice at://did:plc:example/slice
39 slices lexicon push --validate-only --path ./lexicons
40 slices lexicon push --dry-run --slice at://did:plc:example/slice
41 slices lexicon push # Uses config from slices.json
42`);
43}
44
45interface ImportStats {
46 attempted: number;
47 created: number;
48 updated: number;
49 skipped: number;
50 failed: number;
51 errors: Array<{ file: string; error: string }>;
52}
53
54async function uploadLexicons(
55 validationResult: LexiconValidationResult,
56 sliceUri: string,
57 client: AtProtoClient,
58 dryRun = false,
59 excludeFromSync = false
60): Promise<ImportStats> {
61 const stats: ImportStats = {
62 attempted: 0,
63 created: 0,
64 updated: 0,
65 skipped: 0,
66 failed: 0,
67 errors: [],
68 };
69
70 const validFiles = validationResult.files.filter((f) => f.valid);
71
72 for (let i = 0; i < validFiles.length; i++) {
73 const file = validFiles[i];
74 stats.attempted++;
75
76 try {
77 const lexicon = file.content as LexiconDoc & { definitions?: unknown };
78 const nsid = lexicon.id;
79
80 if (dryRun) {
81 // Even in dry run, check for existing records to show accurate results
82 let existingRecord = null;
83 try {
84 const response = await client.network.slices.lexicon.getRecords({
85 where: { nsid: { eq: nsid } },
86 limit: 1,
87 });
88 existingRecord =
89 response.records.length > 0 ? response.records[0] : null;
90 } catch (_error) {
91 // Ignore error - assume lexicon doesn't exist
92 }
93
94 if (existingRecord) {
95 // Parse existing definitions and compare with new definitions
96 const existingDefs = JSON.parse(existingRecord.value.definitions);
97 const newDefs = lexicon.defs || lexicon.definitions;
98 const existingDescription = existingRecord.value.description;
99 const newDescription = lexicon.description;
100
101 // Deep comparison of definitions and description
102 const defsEqual =
103 JSON.stringify(existingDefs) === JSON.stringify(newDefs);
104 const descriptionEqual = existingDescription === newDescription;
105
106 // Debug logging
107 if (!defsEqual) {
108 logger.info(`[DRY RUN] Definitions changed for ${nsid}`);
109 }
110
111 if (defsEqual && descriptionEqual) {
112 logger.info(
113 `[DRY RUN] Would skip (unchanged): ${file.path} (${nsid})`
114 );
115 stats.skipped++;
116 } else {
117 logger.info(`[DRY RUN] Would update: ${file.path} (${nsid})`);
118 stats.updated++;
119 }
120 } else {
121 logger.info(`[DRY RUN] Would create: ${file.path} (${nsid})`);
122 stats.created++;
123 }
124 continue;
125 }
126
127 // Check if lexicon already exists by NSID
128 let existingRecord = null;
129 try {
130 const response = await client.network.slices.lexicon.getRecords({
131 where: { nsid: { eq: nsid } },
132 limit: 1,
133 });
134 existingRecord =
135 response.records.length > 0 ? response.records[0] : null;
136 } catch (_error) {
137 // If getRecords fails, assume it doesn't exist and continue with create
138 }
139
140 const lexiconRecord: Omit<NetworkSlicesLexicon, "createdAt" | "updatedAt"> = {
141 nsid: nsid,
142 description: lexicon.description,
143 definitions: JSON.stringify(lexicon.defs || lexicon.definitions),
144 slice: sliceUri,
145 excludedFromSync: excludeFromSync,
146 };
147
148 if (existingRecord) {
149 // Parse and compare the actual definition objects
150 const existingDefs = JSON.parse(existingRecord.value.definitions);
151 const newDefs = lexicon.defs || lexicon.definitions;
152 const existingDescription = existingRecord.value.description;
153 const newDescription = lexicon.description;
154
155 // Deep comparison of definitions and description
156 const defsEqual =
157 JSON.stringify(existingDefs) === JSON.stringify(newDefs);
158 const descriptionEqual = existingDescription === newDescription;
159
160 // Debug logging
161 if (!defsEqual) {
162 logger.info(`Definitions changed for ${nsid}`);
163 }
164
165 if (defsEqual && descriptionEqual) {
166 stats.skipped++;
167 } else {
168 // Update existing record
169 const updateRecord = {
170 ...lexiconRecord,
171 createdAt: existingRecord.value.createdAt, // Preserve original creation time
172 updatedAt: new Date().toISOString(),
173 };
174
175 await client.network.slices.lexicon.updateRecord(
176 existingRecord.uri.split("/").pop()!, // Extract record ID from URI
177 updateRecord
178 );
179
180 stats.updated++;
181 }
182 } else {
183 // Create new record
184 const createRecord = {
185 ...lexiconRecord,
186 createdAt: new Date().toISOString(),
187 };
188
189 await client.network.slices.lexicon.createRecord(createRecord);
190
191 stats.created++;
192 }
193 } catch (error) {
194 const err = error as Error;
195 stats.failed++;
196 stats.errors.push({
197 file: file.path,
198 error: err.message,
199 });
200 }
201 }
202
203 return stats;
204}
205
206export async function pushCommand(
207 commandArgs: unknown[],
208 _globalArgs: Record<string, unknown>
209): Promise<void> {
210 const args = parseArgs(commandArgs as string[], {
211 boolean: ["help", "validate-only", "dry-run", "exclude-from-sync"],
212 string: ["path", "slice", "api-url"],
213 alias: {
214 h: "help",
215 },
216 });
217
218 if (args.help) {
219 showPushHelp();
220 return;
221 }
222
223 // Load config file
224 const configLoader = new SlicesConfigLoader();
225 const slicesConfig = await configLoader.load();
226 const mergedConfig = mergeConfig(slicesConfig, args);
227
228 // Validate required arguments
229 if (!args["validate-only"] && !mergedConfig.slice) {
230 logger.error("--slice is required unless using --validate-only");
231 if (!slicesConfig.slice) {
232 logger.info(
233 "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"
234 );
235 }
236 console.log("\nRun 'slices lexicon push --help' for usage information.");
237 Deno.exit(1);
238 }
239
240 const lexiconPath = resolve(mergedConfig.lexiconPath!);
241 const sliceUri = mergedConfig.slice!;
242 const apiUrl = mergedConfig.apiUrl!;
243 const validateOnly = args["validate-only"] as boolean;
244 const dryRun = args["dry-run"] as boolean;
245 const excludeFromSync = args["exclude-from-sync"] as boolean;
246
247 const lexiconFiles = await findLexiconFiles(lexiconPath);
248
249 if (lexiconFiles.length === 0) {
250 logger.warn(`No .json files found in ${lexiconPath}`);
251 return;
252 }
253
254 const validationResult = await validateLexiconFiles(lexiconFiles, false);
255
256 if (validationResult.invalidFiles > 0) {
257 printValidationSummary(validationResult);
258 logger.error("Please fix validation errors before pushing");
259 Deno.exit(1);
260 }
261
262 if (validateOnly) {
263 logger.success("Validation complete");
264 return;
265 }
266
267 if (validationResult.validFiles === 0) {
268 logger.error("No valid lexicon files to push");
269 Deno.exit(1);
270 }
271
272 const config = new ConfigManager();
273 await config.load();
274
275 if (!config.isAuthenticated()) {
276 logger.error("Not authenticated. Run 'slices login' first.");
277 Deno.exit(1);
278 }
279
280 const client = await createAuthenticatedClient(sliceUri, apiUrl);
281
282 if (dryRun) {
283 logger.info("DRY RUN - No actual uploads will be performed");
284 }
285
286 const importStats = await uploadLexicons(
287 validationResult,
288 sliceUri,
289 client,
290 dryRun,
291 excludeFromSync
292 );
293
294 if (importStats.failed > 0) {
295 logger.error(`${importStats.failed} uploads failed`);
296 Deno.exit(1);
297 }
298
299 if (dryRun) {
300 logger.success(
301 `DRY RUN complete - ${
302 importStats.created + importStats.updated
303 } files would be processed`
304 );
305 } else {
306 const total = importStats.created + importStats.updated;
307 if (total > 0) {
308 logger.success(
309 `Pushed ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`
310 );
311 } else {
312 logger.success("All lexicons up to date");
313 }
314 }
315}