tangled
alpha
login
or
join now
slices.network
/
slices
137
fork
atom
Highly ambitious ATProtocol AppView service and sdks
137
fork
atom
overview
issues
10
pulls
3
pipelines
simplify cli output
chadtmiller.com
5 months ago
ad4cdfde
e6fd36f1
+97
-145
7 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
codegen.ts
lexicon
import.ts
list.ts
utils
client.ts
lexicon.ts
logger.ts
client
src
mod.ts
+27
-38
packages/cli/src/commands/codegen.ts
···
3
3
import { ensureDir } from "@std/fs";
4
4
import { generateTypeScript } from "@slices/codegen";
5
5
import { logger } from "../utils/logger.ts";
6
6
-
import { findLexiconFiles, readAndParseLexicon } from "../utils/lexicon.ts";
6
6
+
import {
7
7
+
findLexiconFiles,
8
8
+
validateLexiconFiles,
9
9
+
printValidationSummary,
10
10
+
} from "../utils/lexicon.ts";
7
11
import { SlicesConfigLoader, mergeConfig } from "../utils/config.ts";
8
12
9
13
function showCodegenHelp() {
···
17
21
--lexicons <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json)
18
22
--output <PATH> Output file path (default: ./generated_client.ts or from slices.json)
19
23
--slice <SLICE_URI> Target slice URI (required, or from slices.json)
20
20
-
--exclude-slices Exclude @slices/client integration
24
24
+
--include-slices Include Slices XRPC methods
21
25
-h, --help Show this help message
22
26
23
27
EXAMPLES:
24
28
slices codegen --slice at://did:plc:example/slice
25
29
slices codegen --lexicons ./my-lexicons --output ./src/client.ts --slice at://did:plc:example/slice
26
26
-
slices codegen --exclude-slices --slice at://did:plc:example/slice
30
30
+
slices codegen --include-slices --slice at://did:plc:example/slice
27
31
slices codegen # Uses config from slices.json
28
32
`);
29
33
}
···
33
37
_globalArgs: Record<string, unknown>
34
38
): Promise<void> {
35
39
const args = parseArgs(commandArgs as string[], {
36
36
-
boolean: ["help", "exclude-slices"],
40
40
+
boolean: ["help", "include-slices"],
37
41
string: ["lexicons", "output", "slice"],
38
42
alias: {
39
43
h: "help",
···
54
58
if (!mergedConfig.slice) {
55
59
logger.error("--slice is required");
56
60
if (!slicesConfig.slice) {
57
57
-
logger.info("💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time");
61
61
+
logger.info(
62
62
+
"💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"
63
63
+
);
58
64
}
59
65
console.log("\nRun 'slices codegen --help' for usage information.");
60
66
Deno.exit(1);
···
63
69
const lexiconsPath = resolve(mergedConfig.lexiconPath!);
64
70
const outputPath = resolve(mergedConfig.clientOutputPath!);
65
71
const sliceUri = mergedConfig.slice!;
66
66
-
const excludeSlices = args["exclude-slices"] as boolean;
67
67
-
68
68
-
logger.step("🔍 Finding lexicon files...");
69
69
-
logger.info(`📁 Scanning directory: ${lexiconsPath}`);
72
72
+
const excludeSlices = !args["include-slices"] as boolean;
70
73
71
74
try {
72
75
const lexiconFiles = await findLexiconFiles(lexiconsPath);
···
76
79
return;
77
80
}
78
81
79
79
-
logger.info(`📄 Found ${lexiconFiles.length} lexicon files`);
80
80
-
81
81
-
logger.step("📖 Reading lexicon files...");
82
82
-
const lexicons: unknown[] = [];
82
82
+
const validationResult = await validateLexiconFiles(lexiconFiles, false);
83
83
84
84
-
for (let i = 0; i < lexiconFiles.length; i++) {
85
85
-
const filePath = lexiconFiles[i];
86
86
-
logger.progress("Reading lexicons", i + 1, lexiconFiles.length);
87
87
-
88
88
-
try {
89
89
-
const content = await readAndParseLexicon(filePath);
90
90
-
lexicons.push(content);
91
91
-
} catch (error) {
92
92
-
const err = error as Error;
93
93
-
logger.warn(`Failed to read ${filePath}: ${err.message}`);
94
94
-
}
84
84
+
if (validationResult.invalidFiles > 0) {
85
85
+
printValidationSummary(validationResult);
86
86
+
logger.error("Cannot generate client with invalid lexicon files");
87
87
+
Deno.exit(1);
95
88
}
96
89
97
97
-
if (lexicons.length === 0) {
90
90
+
if (validationResult.validFiles === 0) {
98
91
logger.error("No valid lexicon files found");
99
92
Deno.exit(1);
100
93
}
101
94
102
102
-
logger.step("⚡ Generating TypeScript client...");
103
103
-
const generatedCode = await generateTypeScript(lexicons, {
95
95
+
const validLexicons = validationResult.files
96
96
+
.filter((f) => f.valid)
97
97
+
.map((f) => f.content);
98
98
+
99
99
+
const generatedCode = await generateTypeScript(validLexicons, {
104
100
sliceUri,
105
101
excludeSlicesClient: excludeSlices,
106
102
});
107
103
108
108
-
logger.step("💾 Writing generated client...");
109
109
-
110
110
-
// Ensure output directory exists
111
104
const outputDir = dirname(outputPath);
112
105
await ensureDir(outputDir);
113
106
114
114
-
// Write the generated code
115
107
await Deno.writeTextFile(outputPath, generatedCode);
116
108
117
117
-
logger.success(`✅ Generated client written to: ${outputPath}`);
118
118
-
logger.info(`📊 Generated from ${lexicons.length} lexicons`);
119
119
-
logger.info(`🎯 Slice URI: ${sliceUri}`);
109
109
+
logger.success(`Generated client: ${outputPath}`);
120
110
121
111
if (!excludeSlices) {
122
122
-
logger.info("📦 Includes network.slices XRPC client methods");
112
112
+
logger.result("Includes network.slices XRPC client methods");
123
113
}
124
124
-
125
114
} catch (error) {
126
115
const err = error as Error;
127
127
-
logger.error(`❌ Code generation failed: ${err.message}`);
116
116
+
logger.error(`Code generation failed: ${err.message}`);
128
117
Deno.exit(1);
129
118
}
130
130
-
}
119
119
+
}
+19
-55
packages/cli/src/commands/lexicon/import.ts
···
1
1
import { parseArgs } from "@std/cli/parse-args";
2
2
import { resolve } from "@std/path";
3
3
-
import { AtProtoClient } from "../../generated_client.ts";
3
3
+
import type { AtProtoClient } from "../../generated_client.ts";
4
4
import { ConfigManager } from "../../auth/config.ts";
5
5
import { createAuthenticatedClient } from "../../utils/client.ts";
6
6
import { logger } from "../../utils/logger.ts";
···
11
11
printValidationSummary,
12
12
type LexiconValidationResult,
13
13
} from "../../utils/lexicon.ts";
14
14
+
import type { LexiconDoc } from "@slices/lexicon";
14
15
15
16
function showImportHelp() {
16
17
console.log(`
···
66
67
const file = validFiles[i];
67
68
stats.attempted++;
68
69
69
69
-
logger.progress("Uploading lexicons", i + 1, validFiles.length);
70
70
71
71
try {
72
72
-
const lexicon = file.content as any;
72
72
+
const lexicon = file.content as LexiconDoc & { definitions?: unknown };
73
73
const nsid = lexicon.id;
74
74
75
75
if (dryRun) {
···
81
81
limit: 1
82
82
});
83
83
existingRecord = response.records.length > 0 ? response.records[0] : null;
84
84
-
} catch (error) {
85
85
-
logger.debug(`Could not check for existing lexicon ${nsid}: ${error}`);
84
84
+
} catch (_error) {
85
85
+
// Ignore error - assume lexicon doesn't exist
86
86
}
87
87
88
88
if (existingRecord) {
···
115
115
limit: 1
116
116
});
117
117
existingRecord = response.records.length > 0 ? response.records[0] : null;
118
118
-
} catch (error) {
118
118
+
} catch (_error) {
119
119
// If getRecords fails, assume it doesn't exist and continue with create
120
120
-
logger.debug(`Could not check for existing lexicon ${nsid}: ${error}`);
121
120
}
122
121
123
122
const lexiconRecord = {
···
135
134
const defsEqual = JSON.stringify(existingDefs) === JSON.stringify(newDefs);
136
135
137
136
if (defsEqual) {
138
138
-
logger.debug(`⏭️ Skipped (unchanged): ${nsid}`);
139
137
stats.skipped++;
140
138
} else {
141
139
// Update existing record
···
145
143
updatedAt: new Date().toISOString(),
146
144
};
147
145
148
148
-
const result = await client.network.slices.lexicon.updateRecord(
146
146
+
await client.network.slices.lexicon.updateRecord(
149
147
existingRecord.uri.split('/').pop()!, // Extract record ID from URI
150
148
updateRecord
151
149
);
152
150
153
153
-
logger.debug(`🔄 Updated: ${file.path} -> ${result.uri}`);
154
151
stats.updated++;
155
152
}
156
153
} else {
···
160
157
createdAt: new Date().toISOString(),
161
158
};
162
159
163
163
-
const result = await client.network.slices.lexicon.createRecord(createRecord);
160
160
+
await client.network.slices.lexicon.createRecord(createRecord);
164
161
165
165
-
logger.debug(`✅ Created: ${file.path} -> ${result.uri}`);
166
162
stats.created++;
167
163
}
168
164
} catch (error) {
169
165
const err = error as Error;
170
170
-
logger.debug(`❌ Failed to process: ${file.path} - ${err.message}`);
171
166
stats.failed++;
172
167
stats.errors.push({
173
168
file: file.path,
···
179
174
return stats;
180
175
}
181
176
182
182
-
function printImportSummary(stats: ImportStats): void {
183
183
-
console.log("\n📤 Import Summary");
184
184
-
console.log("━━━━━━━━━━━━━━━━━━");
185
185
-
console.log(`📤 Attempted: ${stats.attempted}`);
186
186
-
console.log(`✅ Created: ${stats.created}`);
187
187
-
console.log(`🔄 Updated: ${stats.updated}`);
188
188
-
console.log(`⏭️ Skipped: ${stats.skipped}`);
189
189
-
console.log(`❌ Failed: ${stats.failed}`);
190
190
-
191
191
-
if (stats.failed > 0) {
192
192
-
console.log("\n❌ Import Failures:");
193
193
-
for (const error of stats.errors) {
194
194
-
console.log(` ${error.file}: ${error.error}`);
195
195
-
}
196
196
-
}
197
197
-
}
198
177
199
178
export async function importCommand(commandArgs: unknown[], _globalArgs: Record<string, unknown>): Promise<void> {
200
179
const args = parseArgs(commandArgs as string[], {
···
231
210
const validateOnly = args["validate-only"] as boolean;
232
211
const dryRun = args["dry-run"] as boolean;
233
212
234
234
-
logger.step("Finding lexicon files...");
235
235
-
logger.info(`Scanning directory: ${lexiconPath}`);
236
236
-
237
213
const lexiconFiles = await findLexiconFiles(lexiconPath);
238
214
239
215
if (lexiconFiles.length === 0) {
···
241
217
return;
242
218
}
243
219
244
244
-
logger.info(`Found ${lexiconFiles.length} JSON files`);
245
245
-
246
246
-
// Validate all lexicon files
247
247
-
logger.step("Validating lexicon files...");
248
248
-
const validationResult = await validateLexiconFiles(lexiconFiles);
249
249
-
250
250
-
printValidationSummary(validationResult);
220
220
+
const validationResult = await validateLexiconFiles(lexiconFiles, false);
251
221
252
222
if (validationResult.invalidFiles > 0) {
253
253
-
logger.error(`${validationResult.invalidFiles} invalid files found`);
254
254
-
if (!validateOnly) {
255
255
-
logger.error("Please fix validation errors before importing");
256
256
-
Deno.exit(1);
257
257
-
}
223
223
+
printValidationSummary(validationResult);
224
224
+
logger.error("Please fix validation errors before importing");
225
225
+
Deno.exit(1);
258
226
}
259
227
260
228
if (validateOnly) {
···
267
235
Deno.exit(1);
268
236
}
269
237
270
270
-
// Check authentication
271
238
const config = new ConfigManager();
272
239
await config.load();
273
240
···
276
243
Deno.exit(1);
277
244
}
278
245
279
279
-
// Initialize authenticated client
280
280
-
logger.step("Initializing authenticated client...");
281
246
const client = await createAuthenticatedClient(sliceUri, apiUrl);
282
247
283
248
if (dryRun) {
284
249
logger.info("DRY RUN - No actual uploads will be performed");
285
285
-
} else {
286
286
-
logger.step(`Uploading ${validationResult.validFiles} valid lexicons to ${sliceUri}...`);
287
250
}
288
251
289
289
-
// Upload lexicons
290
252
const importStats = await uploadLexicons(
291
253
validationResult,
292
254
sliceUri,
···
294
256
dryRun
295
257
);
296
258
297
297
-
printImportSummary(importStats);
298
298
-
299
259
if (importStats.failed > 0) {
300
260
logger.error(`${importStats.failed} uploads failed`);
301
261
Deno.exit(1);
302
262
}
303
263
304
264
if (dryRun) {
305
305
-
logger.success(`DRY RUN complete - ${importStats.created} files would be processed`);
265
265
+
logger.success(`DRY RUN complete - ${importStats.created + importStats.updated} files would be processed`);
306
266
} else {
307
307
-
const total = importStats.created + importStats.updated + importStats.skipped;
308
308
-
logger.success(`Import complete - ${total} lexicons processed (${importStats.created} created, ${importStats.updated} updated, ${importStats.skipped} skipped)`);
267
267
+
const total = importStats.created + importStats.updated;
268
268
+
if (total > 0) {
269
269
+
logger.success(`Imported ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`);
270
270
+
} else {
271
271
+
logger.success("All lexicons up to date");
272
272
+
}
309
273
}
310
274
}
+4
-10
packages/cli/src/commands/lexicon/list.ts
···
60
60
const response = await client.network.slices.lexicon.getRecords();
61
61
62
62
if (response.records.length === 0) {
63
63
-
logger.info("📄 No lexicons found in this slice");
63
63
+
logger.info("No lexicons found in this slice");
64
64
return;
65
65
}
66
66
67
67
-
// Group lexicons by namespace for tree view
68
68
-
const namespaceTree: Record<string, Array<{ nsid: string; record: any; lexicon: any }>> = {};
67
67
+
const namespaceTree: Record<string, Array<{ nsid: string; record: unknown; lexicon: unknown }>> = {};
69
68
70
69
for (const record of response.records) {
71
70
const lexicon = record.value;
72
72
-
const nsid = lexicon.nsid as string;
71
71
+
const nsid = (lexicon as { nsid: string }).nsid;
73
72
const parts = nsid.split('.');
74
73
75
75
-
// Group by top-level namespace (e.g., "network", "app", "com")
76
74
const topLevel = parts[0] || 'other';
77
75
78
76
if (!namespaceTree[topLevel]) {
···
82
80
namespaceTree[topLevel].push({ nsid, record, lexicon });
83
81
}
84
82
85
85
-
console.log(`\nLexicons in slice (${response.records.length} total):`);
83
83
+
logger.section(`Lexicons (${response.records.length} total)`);
86
84
87
87
-
// Sort namespaces for consistent output
88
85
const sortedNamespaces = Object.keys(namespaceTree).sort();
89
86
90
87
for (let i = 0; i < sortedNamespaces.length; i++) {
···
92
89
const lexicons = namespaceTree[namespace];
93
90
const isLastNamespace = i === sortedNamespaces.length - 1;
94
91
95
95
-
// Show namespace
96
92
console.log(`${isLastNamespace ? '└─' : '├─'} ${namespace}/`);
97
93
98
98
-
// Sort lexicons within namespace
99
94
lexicons.sort((a, b) => a.nsid.localeCompare(b.nsid));
100
95
101
96
for (let j = 0; j < lexicons.length; j++) {
···
104
99
const prefix = isLastNamespace ? ' ' : '│ ';
105
100
const branch = isLastLexicon ? '└─' : '├─';
106
101
107
107
-
// Remove the top-level namespace from display (since it's already shown)
108
102
const displayName = nsid.substring(namespace.length + 1);
109
103
110
104
console.log(`${prefix}${branch} ${displayName}`);
-2
packages/cli/src/utils/client.ts
···
1
1
import type { AuthProvider } from "@slices/client";
2
2
import { AtProtoClient } from "../generated_client.ts";
3
3
import { ConfigManager } from "../auth/config.ts";
4
4
-
import { logger } from "./logger.ts";
5
4
6
5
class DeviceAuthProvider implements AuthProvider {
7
6
private config: ConfigManager;
···
39
38
throw new Error("Not authenticated. Run 'slices login' first.");
40
39
}
41
40
42
42
-
logger.debug("🔐 Initializing authenticated client...");
43
41
44
42
// Create simple auth provider that uses stored device flow tokens
45
43
const authProvider = new DeviceAuthProvider(config);
+7
-7
packages/cli/src/utils/lexicon.ts
···
1
1
import { walk } from "@std/fs/walk";
2
2
import { extname } from "@std/path";
3
3
+
import { green, red, dim } from "@std/fmt/colors";
3
4
import { LexiconValidator, type LexiconDoc } from "@slices/lexicon";
4
5
import { logger } from "./logger.ts";
5
6
···
277
278
}
278
279
279
280
export function printValidationSummary(result: LexiconValidationResult): void {
280
280
-
console.log("\nValidation Summary");
281
281
-
console.log("─".repeat(50));
282
282
-
console.log(`Total files: ${result.totalFiles}`);
283
283
-
console.log(`${colors.green}Valid: ${result.validFiles}${colors.reset}`);
284
284
-
console.log(`${colors.red}Invalid: ${result.invalidFiles}${colors.reset}`);
281
281
+
logger.section("Validation Summary");
282
282
+
logger.result(`Total files: ${result.totalFiles}`);
283
283
+
logger.result(`Valid: ${green(result.validFiles.toString())}`);
284
284
+
logger.result(`Invalid: ${red(result.invalidFiles.toString())}`);
285
285
286
286
if (result.invalidFiles > 0) {
287
287
-
console.log(`\n${colors.red}Invalid Files:${colors.reset}`);
287
287
+
logger.section("Invalid Files");
288
288
for (const file of result.files) {
289
289
if (!file.valid) {
290
290
-
console.log(` ${colors.dim}${file.path}${colors.reset}`);
290
290
+
console.log(` ${dim(file.path)}`);
291
291
if (file.errors) {
292
292
file.errors.forEach((error, index) => {
293
293
console.log(formatError(error, index));
+40
-9
packages/cli/src/utils/logger.ts
···
1
1
-
import { cyan, green, red, yellow, bold } from "@std/fmt/colors";
1
1
+
import { cyan, green, red, yellow, bold, dim, gray } from "@std/fmt/colors";
2
2
3
3
export enum LogLevel {
4
4
DEBUG = 0,
···
20
20
21
21
debug(message: string, ...args: unknown[]) {
22
22
if (this.level <= LogLevel.DEBUG) {
23
23
-
console.log(cyan("🔍 DEBUG:"), message, ...args);
23
23
+
console.log(dim(" debug"), message, ...args);
24
24
}
25
25
}
26
26
27
27
info(message: string, ...args: unknown[]) {
28
28
if (this.level <= LogLevel.INFO) {
29
29
-
console.log(green("ℹ️ INFO:"), message, ...args);
29
29
+
console.log(" ", message, ...args);
30
30
}
31
31
}
32
32
33
33
warn(message: string, ...args: unknown[]) {
34
34
if (this.level <= LogLevel.WARN) {
35
35
-
console.warn(yellow("⚠️ WARN:"), message, ...args);
35
35
+
console.warn(yellow(" warn"), message, ...args);
36
36
}
37
37
}
38
38
39
39
error(message: string, ...args: unknown[]) {
40
40
if (this.level <= LogLevel.ERROR) {
41
41
-
console.error(red("❌ ERROR:"), message, ...args);
41
41
+
console.error(red(" error"), message, ...args);
42
42
}
43
43
}
44
44
45
45
success(message: string, ...args: unknown[]) {
46
46
-
console.log(green("✅"), message, ...args);
46
46
+
console.log(green(" ✓"), message, ...args);
47
47
}
48
48
49
49
step(message: string, ...args: unknown[]) {
50
50
-
console.log(bold(cyan("🔄")), message, ...args);
50
50
+
console.log(cyan(" →"), message, ...args);
51
51
}
52
52
53
53
progress(message: string, current: number, total: number) {
54
54
const percentage = Math.round((current / total) * 100);
55
55
-
const bar = "█".repeat(Math.floor(percentage / 5)) + "░".repeat(20 - Math.floor(percentage / 5));
56
56
-
console.log(`${cyan("📊")} ${message} [${bar}] ${percentage}% (${current}/${total})`);
55
55
+
const filled = Math.floor(percentage / 4);
56
56
+
const bar = "█".repeat(filled) + gray("░".repeat(25 - filled));
57
57
+
Deno.stdout.writeSync(new TextEncoder().encode(`\r ${cyan("→")} ${message} ${bar} ${current}/${total}`));
58
58
+
if (current === total) {
59
59
+
console.log();
60
60
+
}
61
61
+
}
62
62
+
63
63
+
section(title: string) {
64
64
+
console.log();
65
65
+
console.log(bold(title));
66
66
+
}
67
67
+
68
68
+
result(message: string, value?: string) {
69
69
+
if (value) {
70
70
+
console.log(` ${message} ${dim(value)}`);
71
71
+
} else {
72
72
+
console.log(` ${message}`);
73
73
+
}
74
74
+
}
75
75
+
76
76
+
list(items: string[]) {
77
77
+
items.forEach(item => {
78
78
+
console.log(` • ${item}`);
79
79
+
});
80
80
+
}
81
81
+
82
82
+
table(headers: string[], rows: string[][]) {
83
83
+
console.log(` ${headers.join(" ")}`);
84
84
+
console.log(` ${headers.map(h => "─".repeat(h.length)).join(" ")}`);
85
85
+
rows.forEach(row => {
86
86
+
console.log(` ${row.join(" ")}`);
87
87
+
});
57
88
}
58
89
}
59
90
-24
packages/client/src/mod.ts
···
253
253
254
254
// Try to read the response body for detailed error information
255
255
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
256
256
-
let errorDetails = "";
257
256
258
257
try {
259
258
const errorBody = await response.json();
260
259
if (errorBody?.message) {
261
260
errorMessage += ` - ${errorBody.message}`;
262
262
-
errorDetails = errorBody.message;
263
261
} else if (errorBody?.error) {
264
262
errorMessage += ` - ${errorBody.error}`;
265
265
-
errorDetails = errorBody.error;
266
263
}
267
264
268
268
-
// Log detailed error information for debugging
269
269
-
if (response.status === 401) {
270
270
-
console.error(`🔍 Authentication Debug Info:`);
271
271
-
console.error(` URL: ${url}`);
272
272
-
console.error(` Method: ${httpMethod}`);
273
273
-
console.error(` Auth Header: ${(requestInit.headers as any)?.Authorization ? 'Present' : 'Missing'}`);
274
274
-
if ((requestInit.headers as any)?.Authorization) {
275
275
-
const authHeader = (requestInit.headers as any).Authorization;
276
276
-
console.error(` Auth Type: ${authHeader.split(' ')[0]}`);
277
277
-
console.error(` Token Length: ${authHeader.split(' ')[1]?.length || 0} chars`);
278
278
-
}
279
279
-
console.error(` Error Details: ${errorDetails}`);
280
280
-
console.error(` Full Response Body:`, errorBody);
281
281
-
}
282
265
} catch {
283
266
// If we can't parse the response body, just use the status message
284
284
-
if (response.status === 401) {
285
285
-
console.error(`🔍 Authentication Debug Info:`);
286
286
-
console.error(` URL: ${url}`);
287
287
-
console.error(` Method: ${httpMethod}`);
288
288
-
console.error(` Auth Header: ${(requestInit.headers as any)?.Authorization ? 'Present' : 'Missing'}`);
289
289
-
console.error(` Could not parse error response body`);
290
290
-
}
291
267
}
292
268
293
269
throw new Error(errorMessage);