@atproto/lex-installer 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fs.ts DELETED
@@ -1,122 +0,0 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
- import { dirname } from 'node:path'
3
-
4
- /**
5
- * Reads and parses a JSON file from the filesystem.
6
- *
7
- * @param path - Absolute or relative path to the JSON file
8
- * @returns The parsed JSON content
9
- * @throws {Error} When the file cannot be read (e.g., ENOENT, EACCES)
10
- * @throws {SyntaxError} When the file contains invalid JSON
11
- *
12
- * @example
13
- * ```typescript
14
- * import { readJsonFile } from '@atproto/lex-installer'
15
- *
16
- * const manifest = await readJsonFile('./lexicons.manifest.json')
17
- * ```
18
- *
19
- * @example
20
- * Handle missing file:
21
- * ```typescript
22
- * import { readJsonFile, isEnoentError } from '@atproto/lex-installer'
23
- *
24
- * try {
25
- * const data = await readJsonFile('./config.json')
26
- * } catch (err) {
27
- * if (isEnoentError(err)) {
28
- * console.log('File does not exist, using defaults')
29
- * } else {
30
- * throw err
31
- * }
32
- * }
33
- * ```
34
- */
35
- export async function readJsonFile(path: string): Promise<unknown> {
36
- const contents = await readFile(path, 'utf8')
37
- return JSON.parse(contents)
38
- }
39
-
40
- /**
41
- * Writes data as formatted JSON to a file.
42
- *
43
- * The function:
44
- * - Creates parent directories if they don't exist
45
- * - Formats JSON with 2-space indentation
46
- * - Overwrites existing files
47
- * - Sets file permissions to 0o644 (rw-r--r--)
48
- *
49
- * @param path - Absolute or relative path for the output file
50
- * @param data - Data to serialize as JSON
51
- * @throws {Error} When the file cannot be written
52
- *
53
- * @example
54
- * ```typescript
55
- * import { writeJsonFile } from '@atproto/lex-installer'
56
- *
57
- * await writeJsonFile('./output/data.json', {
58
- * name: 'example',
59
- * values: [1, 2, 3],
60
- * })
61
- * ```
62
- *
63
- * @example
64
- * Write a lexicon document:
65
- * ```typescript
66
- * import { writeJsonFile } from '@atproto/lex-installer'
67
- *
68
- * await writeJsonFile('./lexicons/app/bsky/feed/post.json', lexiconDocument)
69
- * ```
70
- */
71
- export async function writeJsonFile(
72
- path: string,
73
- data: unknown,
74
- ): Promise<void> {
75
- await mkdir(dirname(path), { recursive: true })
76
- const contents = JSON.stringify(data, null, 2)
77
- await writeFile(path, contents, {
78
- encoding: 'utf8',
79
- mode: 0o644,
80
- flag: 'w', // override
81
- })
82
- }
83
-
84
- /**
85
- * Checks if an error is an ENOENT (file not found) error.
86
- *
87
- * Useful for handling cases where a file may or may not exist,
88
- * such as reading an optional configuration file.
89
- *
90
- * @param err - The error to check
91
- * @returns `true` if the error is an ENOENT error, `false` otherwise
92
- *
93
- * @example
94
- * ```typescript
95
- * import { readFile } from 'node:fs/promises'
96
- * import { isEnoentError } from '@atproto/lex-installer'
97
- *
98
- * const config = await readFile('./config.json').catch((err) => {
99
- * if (isEnoentError(err)) {
100
- * return { defaults: true }
101
- * }
102
- * throw err
103
- * })
104
- * ```
105
- *
106
- * @example
107
- * In try/catch:
108
- * ```typescript
109
- * try {
110
- * const manifest = await readFile('./lexicons.manifest.json', 'utf8')
111
- * } catch (err) {
112
- * if (isEnoentError(err)) {
113
- * // File doesn't exist, create a new manifest
114
- * return { version: 1, lexicons: [], resolutions: {} }
115
- * }
116
- * throw err
117
- * }
118
- * ```
119
- */
120
- export function isEnoentError(err: unknown): boolean {
121
- return err instanceof Error && 'code' in err && err.code === 'ENOENT'
122
- }
package/src/index.ts DELETED
@@ -1,126 +0,0 @@
1
- import { isEnoentError, readJsonFile } from './fs.js'
2
- import { LexInstaller, LexInstallerOptions } from './lex-installer.js'
3
- import {
4
- LexiconsManifest,
5
- lexiconsManifestSchema,
6
- } from './lexicons-manifest.js'
7
-
8
- /**
9
- * Options for the {@link install} function.
10
- *
11
- * Extends {@link LexInstallerOptions} with additional options for controlling
12
- * the installation behavior.
13
- *
14
- * @example
15
- * ```typescript
16
- * const options: LexInstallOptions = {
17
- * lexicons: './lexicons',
18
- * manifest: './lexicons.manifest.json',
19
- * add: ['com.example.myLexicon', 'at://did:plc:xyz/com.example.otherLexicon'],
20
- * save: true,
21
- * ci: false,
22
- * }
23
- * ```
24
- */
25
- export type LexInstallOptions = LexInstallerOptions & {
26
- /**
27
- * Array of lexicons to add to the installation. Can be NSID strings
28
- * (e.g., 'com.example.myLexicon') or AT URIs
29
- * (e.g., 'at://did:plc:xyz/com.example.myLexicon').
30
- */
31
- add?: string[]
32
-
33
- /**
34
- * Whether to save the updated manifest after installation.
35
- * When `true`, the manifest file will be written with any new lexicons.
36
- * @default false
37
- */
38
- save?: boolean
39
-
40
- /**
41
- * Enable CI mode for strict manifest verification.
42
- * When `true`, throws an error if the manifest is out of date,
43
- * useful for continuous integration pipelines.
44
- * @default false
45
- */
46
- ci?: boolean
47
- }
48
-
49
- /**
50
- * Installs lexicons from the network based on the provided options.
51
- *
52
- * This is the main entry point for programmatic lexicon installation.
53
- * It reads an existing manifest (if present), installs any new lexicons,
54
- * and optionally saves the updated manifest.
55
- *
56
- * @param options - Configuration options for the installation
57
- * @throws {Error} When the manifest file cannot be read (unless it doesn't exist)
58
- * @throws {Error} When in CI mode and the manifest is out of date
59
- *
60
- * @example
61
- * Install lexicons and save the manifest:
62
- * ```typescript
63
- * import { install } from '@atproto/lex-installer'
64
- *
65
- * await install({
66
- * lexicons: './lexicons',
67
- * manifest: './lexicons.manifest.json',
68
- * add: ['app.bsky.feed.post', 'app.bsky.actor.profile'],
69
- * save: true,
70
- * })
71
- * ```
72
- *
73
- * @example
74
- * Verify manifest in CI pipeline:
75
- * ```typescript
76
- * import { install } from '@atproto/lex-installer'
77
- *
78
- * // Throws if manifest is out of date
79
- * await install({
80
- * lexicons: './lexicons',
81
- * manifest: './lexicons.manifest.json',
82
- * ci: true,
83
- * })
84
- * ```
85
- *
86
- * @example
87
- * Install from specific AT URIs:
88
- * ```typescript
89
- * import { install } from '@atproto/lex-installer'
90
- *
91
- * await install({
92
- * lexicons: './lexicons',
93
- * manifest: './lexicons.manifest.json',
94
- * add: ['at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post'],
95
- * save: true,
96
- * })
97
- * ```
98
- */
99
- export async function install(options: LexInstallOptions) {
100
- const manifest: LexiconsManifest | undefined = await readJsonFile(
101
- options.manifest,
102
- ).then(
103
- (json) => lexiconsManifestSchema.parse(json),
104
- (cause: unknown) => {
105
- if (isEnoentError(cause)) return undefined
106
- throw new Error('Failed to read lexicons manifest', { cause })
107
- },
108
- )
109
-
110
- const additions = new Set(options.add)
111
-
112
- // Perform the installation using the existing manifest as "hint"
113
- await using installer = new LexInstaller(options)
114
-
115
- await installer.install({ additions, manifest })
116
-
117
- // Verify lockfile
118
- if (options.ci && (!manifest || !installer.equals(manifest))) {
119
- throw new Error('Lexicons manifest is out of date')
120
- }
121
-
122
- // Save changes if requested
123
- if (options.save) {
124
- await installer.save()
125
- }
126
- }
@@ -1,460 +0,0 @@
1
- import { join } from 'node:path'
2
- import { LexiconDirectoryIndexer } from '@atproto/lex-builder'
3
- import { cidForLex } from '@atproto/lex-cbor'
4
- import { Cid, lexEquals } from '@atproto/lex-data'
5
- import {
6
- LexiconDocument,
7
- LexiconParameters,
8
- LexiconPermission,
9
- LexiconRef,
10
- LexiconRefUnion,
11
- LexiconUnknown,
12
- MainLexiconDefinition,
13
- NamedLexiconDefinition,
14
- } from '@atproto/lex-document'
15
- import { LexResolver, LexResolverOptions } from '@atproto/lex-resolver'
16
- import { AtUriString, NsidString } from '@atproto/lex-schema'
17
- import { AtUri, NSID } from '@atproto/syntax'
18
- import { isEnoentError, writeJsonFile } from './fs.js'
19
- import {
20
- LexiconsManifest,
21
- normalizeLexiconsManifest,
22
- } from './lexicons-manifest.js'
23
- import { NsidMap } from './nsid-map.js'
24
- import { NsidSet } from './nsid-set.js'
25
-
26
- /**
27
- * Configuration options for the {@link LexInstaller} class.
28
- *
29
- * Extends {@link LexResolverOptions} with paths for lexicon storage
30
- * and manifest management.
31
- *
32
- * @example
33
- * ```typescript
34
- * const options: LexInstallerOptions = {
35
- * lexicons: './lexicons',
36
- * manifest: './lexicons.manifest.json',
37
- * update: false,
38
- * }
39
- * ```
40
- */
41
- export type LexInstallerOptions = LexResolverOptions & {
42
- /**
43
- * Path to the directory where lexicon JSON files will be stored.
44
- * The directory structure mirrors the NSID hierarchy
45
- * (e.g., 'app.bsky.feed.post' becomes 'app/bsky/feed/post.json').
46
- */
47
- lexicons: string
48
-
49
- /**
50
- * Path to the manifest file that tracks installed lexicons and their resolutions.
51
- */
52
- manifest: string
53
-
54
- /**
55
- * When `true`, forces re-fetching of lexicons from the network even if they
56
- * already exist locally. Useful for updating to newer versions.
57
- * @default false
58
- */
59
- update?: boolean
60
- }
61
-
62
- /**
63
- * Manages the installation of Lexicon schemas from the AT Protocol network.
64
- *
65
- * The `LexInstaller` class handles fetching, caching, and organizing lexicon
66
- * documents. It tracks dependencies between lexicons and ensures all referenced
67
- * schemas are installed. The class implements `AsyncDisposable` for proper
68
- * resource cleanup.
69
- *
70
- * @example
71
- * Basic usage with async disposal:
72
- * ```typescript
73
- * import { LexInstaller } from '@atproto/lex-installer'
74
- *
75
- * await using installer = new LexInstaller({
76
- * lexicons: './lexicons',
77
- * manifest: './lexicons.manifest.json',
78
- * })
79
- *
80
- * await installer.install({
81
- * additions: ['app.bsky.feed.post'],
82
- * })
83
- *
84
- * await installer.save()
85
- * // Resources automatically cleaned up when block exits
86
- * ```
87
- *
88
- * @example
89
- * Manual disposal:
90
- * ```typescript
91
- * const installer = new LexInstaller({
92
- * lexicons: './lexicons',
93
- * manifest: './lexicons.manifest.json',
94
- * })
95
- *
96
- * try {
97
- * await installer.install({ additions: ['app.bsky.actor.profile'] })
98
- * await installer.save()
99
- * } finally {
100
- * await installer[Symbol.asyncDispose]()
101
- * }
102
- * ```
103
- */
104
- export class LexInstaller implements AsyncDisposable {
105
- protected readonly lexiconResolver: LexResolver
106
- protected readonly indexer: LexiconDirectoryIndexer
107
- protected readonly documents = new NsidMap<LexiconDocument>()
108
- protected readonly manifest: LexiconsManifest = {
109
- version: 1,
110
- lexicons: [],
111
- resolutions: {},
112
- }
113
-
114
- constructor(protected readonly options: LexInstallerOptions) {
115
- this.lexiconResolver = new LexResolver(options)
116
- this.indexer = new LexiconDirectoryIndexer({
117
- lexicons: options.lexicons,
118
- })
119
- }
120
-
121
- async [Symbol.asyncDispose](): Promise<void> {
122
- await this.indexer[Symbol.asyncDispose]()
123
- }
124
-
125
- /**
126
- * Compares the current manifest state with another manifest for equality.
127
- *
128
- * Both manifests are normalized before comparison to ensure consistent
129
- * ordering of entries. Useful for detecting changes during CI verification.
130
- *
131
- * @param manifest - The manifest to compare against
132
- * @returns `true` if the manifests are equivalent, `false` otherwise
133
- */
134
- equals(manifest: LexiconsManifest): boolean {
135
- return lexEquals(
136
- normalizeLexiconsManifest(manifest),
137
- normalizeLexiconsManifest(this.manifest),
138
- )
139
- }
140
-
141
- /**
142
- * Installs lexicons and their dependencies.
143
- *
144
- * This method processes explicit additions and restores entries from an
145
- * existing manifest. It recursively resolves and installs all referenced
146
- * lexicons to ensure complete dependency trees.
147
- *
148
- * @param options - Installation options
149
- * @param options.additions - Iterable of lexicon identifiers to add.
150
- * Can be NSID strings or AT URIs.
151
- * @param options.manifest - Existing manifest to use as a baseline.
152
- * Previously resolved URIs are preserved unless explicitly overridden.
153
- *
154
- * @example
155
- * Install new lexicons:
156
- * ```typescript
157
- * await installer.install({
158
- * additions: ['app.bsky.feed.post', 'app.bsky.actor.profile'],
159
- * })
160
- * ```
161
- *
162
- * @example
163
- * Install with existing manifest as hint:
164
- * ```typescript
165
- * const existingManifest = await readJsonFile('./lexicons.manifest.json')
166
- * await installer.install({
167
- * additions: ['com.example.newLexicon'],
168
- * manifest: existingManifest,
169
- * })
170
- * ```
171
- *
172
- * @example
173
- * Install from specific AT URIs:
174
- * ```typescript
175
- * await installer.install({
176
- * additions: [
177
- * 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post',
178
- * ],
179
- * })
180
- * ```
181
- */
182
- async install({
183
- additions,
184
- manifest,
185
- }: {
186
- additions?: Iterable<string>
187
- manifest?: LexiconsManifest
188
- } = {}): Promise<void> {
189
- const roots = new NsidMap<AtUri | null>()
190
-
191
- // First, process explicit additions
192
- for (const lexicon of new Set(additions)) {
193
- const [nsid, uri]: [NSID, AtUri | null] = lexicon.startsWith('at://')
194
- ? ((uri) => [NSID.from(uri.rkey), uri])(new AtUri(lexicon))
195
- : [NSID.from(lexicon), null]
196
-
197
- if (roots.has(nsid)) {
198
- throw new Error(
199
- `Duplicate lexicon addition: ${nsid} (${roots.get(nsid) ?? lexicon})`,
200
- )
201
- }
202
-
203
- roots.set(nsid, uri)
204
- console.debug(`Adding new lexicon: ${nsid} (${uri ?? 'from NSID'})`)
205
- }
206
-
207
- // Next, restore previously existing manifest entries
208
- if (manifest) {
209
- for (const lexicon of manifest.lexicons) {
210
- const nsid = NSID.from(lexicon)
211
-
212
- // Skip entries already added explicitly
213
- if (!roots.has(nsid)) {
214
- const uri = manifest.resolutions[lexicon]
215
- ? new AtUri(manifest.resolutions[lexicon].uri)
216
- : null
217
-
218
- roots.set(nsid, uri)
219
-
220
- console.debug(
221
- `Adding lexicon from manifest: ${nsid} (${uri ?? 'from NSID'})`,
222
- )
223
- }
224
- }
225
- }
226
-
227
- // Install all root lexicons (and store them in the manifest)
228
- await Promise.all(
229
- Array.from(roots, async ([nsid, sourceUri]) => {
230
- console.debug(`Installing lexicon: ${nsid}`)
231
-
232
- const { lexicon: document } = sourceUri
233
- ? await this.installFromUri(sourceUri)
234
- : await this.installFromNsid(nsid)
235
-
236
- // Store the direct reference in the new manifest
237
- this.manifest.lexicons.push(document.id)
238
- }),
239
- )
240
-
241
- // Then recursively install all referenced lexicons
242
- let results: unknown[]
243
- do {
244
- results = await Promise.all(
245
- Array.from(this.getMissingIds(), async (nsid) => {
246
- console.debug(`Resolving dependency lexicon: ${nsid}`)
247
-
248
- const nsidStr = nsid.toString() as NsidString
249
- const resolvedUri = manifest?.resolutions[nsidStr]?.uri
250
- ? new AtUri(manifest.resolutions[nsidStr].uri)
251
- : null
252
- if (resolvedUri) {
253
- await this.installFromUri(resolvedUri)
254
- } else {
255
- await this.installFromNsid(nsid)
256
- }
257
- }),
258
- )
259
- } while (results.length > 0)
260
- }
261
-
262
- protected getMissingIds(): NsidSet {
263
- const missing = new NsidSet()
264
-
265
- for (const document of this.documents.values()) {
266
- for (const nsid of listDocumentNsidRefs(document)) {
267
- if (!this.documents.has(nsid)) {
268
- missing.add(nsid)
269
- }
270
- }
271
- }
272
-
273
- return missing
274
- }
275
-
276
- protected async installFromNsid(nsid: NSID) {
277
- const uri = await this.lexiconResolver.resolve(nsid)
278
- return this.installFromUri(uri)
279
- }
280
-
281
- protected async installFromUri(uri: AtUri): Promise<{
282
- lexicon: LexiconDocument
283
- uri: AtUri
284
- }> {
285
- const { lexicon, cid } = this.options.update
286
- ? await this.fetch(uri)
287
- : await this.indexer.get(uri.rkey).then(
288
- async (lexicon) => {
289
- console.debug(`Re-using existing lexicon ${uri.rkey} from indexer`)
290
- const cid = await cidForLex(lexicon)
291
- return { cid, lexicon }
292
- },
293
- (err) => {
294
- if (isEnoentError(err)) return this.fetch(uri)
295
- throw err
296
- },
297
- )
298
-
299
- this.documents.set(NSID.from(lexicon.id), lexicon)
300
- this.manifest.resolutions[lexicon.id] = {
301
- cid: cid.toString(),
302
- uri: uri.toString() as AtUriString,
303
- }
304
-
305
- return { lexicon, uri }
306
- }
307
-
308
- /**
309
- * Fetches a lexicon document from the network and saves it locally.
310
- *
311
- * The lexicon is retrieved from the specified AT URI, written to the
312
- * local lexicons directory, and its metadata is recorded for the manifest.
313
- *
314
- * @param uri - The AT URI pointing to the lexicon document
315
- * @returns An object containing the fetched lexicon document and its CID
316
- */
317
- async fetch(uri: AtUri): Promise<{ lexicon: LexiconDocument; cid: Cid }> {
318
- console.debug(`Fetching lexicon from ${uri}...`)
319
-
320
- const { lexicon, cid } = await this.lexiconResolver.fetch(uri, {
321
- noCache: this.options.update,
322
- })
323
-
324
- const basePath = join(this.options.lexicons, ...lexicon.id.split('.'))
325
- await writeJsonFile(`${basePath}.json`, lexicon)
326
-
327
- return { lexicon, cid }
328
- }
329
-
330
- /**
331
- * Saves the current manifest to disk.
332
- *
333
- * The manifest is normalized before saving to ensure consistent ordering
334
- * of entries, making it suitable for version control.
335
- */
336
- async save(): Promise<void> {
337
- await writeJsonFile(
338
- this.options.manifest,
339
- normalizeLexiconsManifest(this.manifest),
340
- )
341
- }
342
- }
343
-
344
- function* listDocumentNsidRefs(doc: LexiconDocument): Iterable<NSID> {
345
- try {
346
- for (const def of Object.values(doc.defs)) {
347
- if (def) {
348
- for (const ref of defRefs(def)) {
349
- const [nsid] = ref.split('#', 1)
350
- if (nsid) yield NSID.from(nsid)
351
- }
352
- }
353
- }
354
- } catch (cause) {
355
- throw new Error(`Failed to extract refs from lexicon ${doc.id}`, { cause })
356
- }
357
- }
358
-
359
- function* defRefs(
360
- def:
361
- | MainLexiconDefinition
362
- | NamedLexiconDefinition
363
- | LexiconPermission
364
- | LexiconUnknown
365
- | LexiconParameters
366
- | LexiconRef
367
- | LexiconRefUnion,
368
- ): Iterable<string> {
369
- switch (def.type) {
370
- case 'string':
371
- if (def.knownValues) {
372
- for (const val of def.knownValues) {
373
- // Tokens ?
374
- const { length, 0: nsid, 1: hash } = val.split('#')
375
- if (length === 2 && hash) {
376
- try {
377
- NSID.from(nsid)
378
- yield val
379
- } catch {
380
- // ignore invalid nsid
381
- }
382
- }
383
- }
384
- }
385
- return
386
- case 'array':
387
- return yield* defRefs(def.items)
388
- case 'params':
389
- case 'object':
390
- for (const prop of Object.values(def.properties)) {
391
- yield* defRefs(prop)
392
- }
393
- return
394
- case 'union':
395
- yield* def.refs
396
- return
397
- case 'ref': {
398
- yield def.ref
399
- return
400
- }
401
- case 'record':
402
- yield* defRefs(def.record)
403
- return
404
- case 'procedure':
405
- if (def.input?.schema) {
406
- yield* defRefs(def.input.schema)
407
- }
408
- // fallthrough
409
- case 'query':
410
- if (def.output?.schema) {
411
- yield* defRefs(def.output.schema)
412
- }
413
- // fallthrough
414
- case 'subscription':
415
- if (def.parameters) {
416
- yield* defRefs(def.parameters)
417
- }
418
- if ('message' in def && def.message?.schema) {
419
- yield* defRefs(def.message.schema)
420
- }
421
- return
422
- case 'permission-set':
423
- for (const permission of def.permissions) {
424
- yield* defRefs(permission)
425
- }
426
- return
427
- case 'permission':
428
- if (def.resource === 'rpc') {
429
- if (Array.isArray(def.lxm)) {
430
- for (const lxm of def.lxm) {
431
- if (typeof lxm === 'string') {
432
- yield lxm
433
- }
434
- }
435
- }
436
- } else if (def.resource === 'repo') {
437
- if (Array.isArray(def.collection)) {
438
- for (const lxm of def.collection) {
439
- if (typeof lxm === 'string') {
440
- yield lxm
441
- }
442
- }
443
- }
444
- }
445
- return
446
- case 'boolean':
447
- case 'cid-link':
448
- case 'token':
449
- case 'bytes':
450
- case 'blob':
451
- case 'integer':
452
- case 'unknown':
453
- // @NOTE We explicitly list all types here to ensure exhaustiveness
454
- // causing TS to error if a new type is added without updating this switch
455
- return
456
- default:
457
- // @ts-expect-error
458
- throw new Error(`Unknown lexicon def type: ${def.type}`)
459
- }
460
- }