Highly ambitious ATProtocol AppView service and sdks
at main 315 lines 9.7 kB view raw
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}