import { parseArgs } from "@std/cli/parse-args"; import { resolve } from "@std/path"; import type { AtProtoClient, NetworkSlicesLexicon, } from "../../generated_client.ts"; import { ConfigManager } from "../../auth/config.ts"; import { createAuthenticatedClient } from "../../utils/client.ts"; import { logger } from "../../utils/logger.ts"; import { SlicesConfigLoader, mergeConfig } from "../../utils/config.ts"; import { findLexiconFiles, validateLexiconFiles, printValidationSummary, type LexiconValidationResult, } from "../../utils/lexicon.ts"; import type { LexiconDoc } from "@slices/lexicon"; function showPushHelp() { console.log(` slices lexicon push - Push lexicon files to your slice USAGE: slices lexicon push [OPTIONS] OPTIONS: --path Directory containing lexicon files (default: ./lexicons or from slices.json) --slice Target slice URI (required, or from slices.json) --exclude-from-sync Exclude these lexicons from sync (sets excludedFromSync: true) --validate-only Only validate files, don't upload --dry-run Show what would be imported without uploading --api-url Slices API base URL (default: https://api.slices.network or from slices.json) -h, --help Show this help message EXAMPLES: slices lexicon push --slice at://did:plc:example/slice slices lexicon push --path ./my-lexicons --slice at://did:plc:example/slice slices lexicon push --exclude-from-sync --slice at://did:plc:example/slice slices lexicon push --validate-only --path ./lexicons slices lexicon push --dry-run --slice at://did:plc:example/slice slices lexicon push # Uses config from slices.json `); } interface ImportStats { attempted: number; created: number; updated: number; skipped: number; failed: number; errors: Array<{ file: string; error: string }>; } async function uploadLexicons( validationResult: LexiconValidationResult, sliceUri: string, client: AtProtoClient, dryRun = false, excludeFromSync = false ): Promise { const stats: ImportStats = { attempted: 0, created: 0, updated: 0, skipped: 0, failed: 0, errors: [], }; const validFiles = validationResult.files.filter((f) => f.valid); for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; stats.attempted++; try { const lexicon = file.content as LexiconDoc & { definitions?: unknown }; const nsid = lexicon.id; if (dryRun) { // Even in dry run, check for existing records to show accurate results let existingRecord = null; try { const response = await client.network.slices.lexicon.getRecords({ where: { nsid: { eq: nsid } }, limit: 1, }); existingRecord = response.records.length > 0 ? response.records[0] : null; } catch (_error) { // Ignore error - assume lexicon doesn't exist } if (existingRecord) { // Parse existing definitions and compare with new definitions const existingDefs = JSON.parse(existingRecord.value.definitions); const newDefs = lexicon.defs || lexicon.definitions; const existingDescription = existingRecord.value.description; const newDescription = lexicon.description; // Deep comparison of definitions and description const defsEqual = JSON.stringify(existingDefs) === JSON.stringify(newDefs); const descriptionEqual = existingDescription === newDescription; // Debug logging if (!defsEqual) { logger.info(`[DRY RUN] Definitions changed for ${nsid}`); } if (defsEqual && descriptionEqual) { logger.info( `[DRY RUN] Would skip (unchanged): ${file.path} (${nsid})` ); stats.skipped++; } else { logger.info(`[DRY RUN] Would update: ${file.path} (${nsid})`); stats.updated++; } } else { logger.info(`[DRY RUN] Would create: ${file.path} (${nsid})`); stats.created++; } continue; } // Check if lexicon already exists by NSID let existingRecord = null; try { const response = await client.network.slices.lexicon.getRecords({ where: { nsid: { eq: nsid } }, limit: 1, }); existingRecord = response.records.length > 0 ? response.records[0] : null; } catch (_error) { // If getRecords fails, assume it doesn't exist and continue with create } const lexiconRecord: Omit = { nsid: nsid, description: lexicon.description, definitions: JSON.stringify(lexicon.defs || lexicon.definitions), slice: sliceUri, excludedFromSync: excludeFromSync, }; if (existingRecord) { // Parse and compare the actual definition objects const existingDefs = JSON.parse(existingRecord.value.definitions); const newDefs = lexicon.defs || lexicon.definitions; const existingDescription = existingRecord.value.description; const newDescription = lexicon.description; // Deep comparison of definitions and description const defsEqual = JSON.stringify(existingDefs) === JSON.stringify(newDefs); const descriptionEqual = existingDescription === newDescription; // Debug logging if (!defsEqual) { logger.info(`Definitions changed for ${nsid}`); } if (defsEqual && descriptionEqual) { stats.skipped++; } else { // Update existing record const updateRecord = { ...lexiconRecord, createdAt: existingRecord.value.createdAt, // Preserve original creation time updatedAt: new Date().toISOString(), }; await client.network.slices.lexicon.updateRecord( existingRecord.uri.split("/").pop()!, // Extract record ID from URI updateRecord ); stats.updated++; } } else { // Create new record const createRecord = { ...lexiconRecord, createdAt: new Date().toISOString(), }; await client.network.slices.lexicon.createRecord(createRecord); stats.created++; } } catch (error) { const err = error as Error; stats.failed++; stats.errors.push({ file: file.path, error: err.message, }); } } return stats; } export async function pushCommand( commandArgs: unknown[], _globalArgs: Record ): Promise { const args = parseArgs(commandArgs as string[], { boolean: ["help", "validate-only", "dry-run", "exclude-from-sync"], string: ["path", "slice", "api-url"], alias: { h: "help", }, }); if (args.help) { showPushHelp(); return; } // Load config file const configLoader = new SlicesConfigLoader(); const slicesConfig = await configLoader.load(); const mergedConfig = mergeConfig(slicesConfig, args); // Validate required arguments if (!args["validate-only"] && !mergedConfig.slice) { logger.error("--slice is required unless using --validate-only"); if (!slicesConfig.slice) { logger.info( "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" ); } console.log("\nRun 'slices lexicon push --help' for usage information."); Deno.exit(1); } const lexiconPath = resolve(mergedConfig.lexiconPath!); const sliceUri = mergedConfig.slice!; const apiUrl = mergedConfig.apiUrl!; const validateOnly = args["validate-only"] as boolean; const dryRun = args["dry-run"] as boolean; const excludeFromSync = args["exclude-from-sync"] as boolean; const lexiconFiles = await findLexiconFiles(lexiconPath); if (lexiconFiles.length === 0) { logger.warn(`No .json files found in ${lexiconPath}`); return; } const validationResult = await validateLexiconFiles(lexiconFiles, false); if (validationResult.invalidFiles > 0) { printValidationSummary(validationResult); logger.error("Please fix validation errors before pushing"); Deno.exit(1); } if (validateOnly) { logger.success("Validation complete"); return; } if (validationResult.validFiles === 0) { logger.error("No valid lexicon files to push"); Deno.exit(1); } const config = new ConfigManager(); await config.load(); if (!config.isAuthenticated()) { logger.error("Not authenticated. Run 'slices login' first."); Deno.exit(1); } const client = await createAuthenticatedClient(sliceUri, apiUrl); if (dryRun) { logger.info("DRY RUN - No actual uploads will be performed"); } const importStats = await uploadLexicons( validationResult, sliceUri, client, dryRun, excludeFromSync ); if (importStats.failed > 0) { logger.error(`${importStats.failed} uploads failed`); Deno.exit(1); } if (dryRun) { logger.success( `DRY RUN complete - ${ importStats.created + importStats.updated } files would be processed` ); } else { const total = importStats.created + importStats.updated; if (total > 0) { logger.success( `Pushed ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)` ); } else { logger.success("All lexicons up to date"); } } }