@atcute/lex-cli 2.4.0 → 2.5.0

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.
Files changed (57) hide show
  1. package/README.md +40 -8
  2. package/dist/cli.js +10 -168
  3. package/dist/cli.js.map +1 -1
  4. package/dist/codegen.d.ts.map +1 -1
  5. package/dist/codegen.js +76 -78
  6. package/dist/codegen.js.map +1 -1
  7. package/dist/commands/export.d.ts +13 -0
  8. package/dist/commands/export.d.ts.map +1 -0
  9. package/dist/commands/export.js +76 -0
  10. package/dist/commands/export.js.map +1 -0
  11. package/dist/commands/generate.d.ts +13 -0
  12. package/dist/commands/generate.d.ts.map +1 -0
  13. package/dist/commands/generate.js +136 -0
  14. package/dist/commands/generate.js.map +1 -0
  15. package/dist/commands/pull.d.ts +13 -0
  16. package/dist/commands/pull.d.ts.map +1 -0
  17. package/dist/{pull.js → commands/pull.js} +35 -81
  18. package/dist/commands/pull.js.map +1 -0
  19. package/dist/config.d.ts +68 -6
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +54 -3
  22. package/dist/config.js.map +1 -1
  23. package/dist/lexicon-loader.d.ts +17 -0
  24. package/dist/lexicon-loader.d.ts.map +1 -0
  25. package/dist/lexicon-loader.js +167 -0
  26. package/dist/lexicon-loader.js.map +1 -0
  27. package/dist/pull-sources/atproto.d.ts +9 -0
  28. package/dist/pull-sources/atproto.d.ts.map +1 -0
  29. package/dist/pull-sources/atproto.js +192 -0
  30. package/dist/pull-sources/atproto.js.map +1 -0
  31. package/dist/pull-sources/git.d.ts +11 -0
  32. package/dist/pull-sources/git.d.ts.map +1 -0
  33. package/dist/pull-sources/git.js +80 -0
  34. package/dist/pull-sources/git.js.map +1 -0
  35. package/dist/pull-sources/types.d.ts +16 -0
  36. package/dist/pull-sources/types.d.ts.map +1 -0
  37. package/dist/pull-sources/types.js +2 -0
  38. package/dist/pull-sources/types.js.map +1 -0
  39. package/dist/shared-options.d.ts +6 -0
  40. package/dist/shared-options.d.ts.map +1 -0
  41. package/dist/shared-options.js +11 -0
  42. package/dist/shared-options.js.map +1 -0
  43. package/package.json +10 -7
  44. package/src/cli.ts +9 -210
  45. package/src/codegen.ts +90 -88
  46. package/src/commands/export.ts +106 -0
  47. package/src/commands/generate.ts +170 -0
  48. package/src/{pull.ts → commands/pull.ts} +49 -116
  49. package/src/config.ts +67 -4
  50. package/src/lexicon-loader.ts +199 -0
  51. package/src/pull-sources/atproto.ts +243 -0
  52. package/src/pull-sources/git.ts +103 -0
  53. package/src/pull-sources/types.ts +18 -0
  54. package/src/shared-options.ts +13 -0
  55. package/dist/pull.d.ts +0 -7
  56. package/dist/pull.d.ts.map +0 -1
  57. package/dist/pull.js.map +0 -1
@@ -1,34 +1,39 @@
1
1
  import * as fs from 'node:fs/promises';
2
- import * as os from 'node:os';
3
2
  import * as path from 'node:path';
4
3
 
5
4
  import { lexiconDoc, refineLexiconDoc, type LexiconDoc } from '@atcute/lexicon-doc';
6
- import prettier from 'prettier';
5
+ import { merge, object } from '@optique/core/constructs';
6
+ import { message } from '@optique/core/message';
7
+ import { type InferValue } from '@optique/core/parser';
8
+ import { command, constant } from '@optique/core/primitives';
7
9
  import pc from 'picocolors';
10
+ import prettier from 'prettier';
8
11
 
9
- import { runGit, GitError } from './git.js';
10
- import type { NormalizedConfig, PullConfig, SourceConfig } from './config.js';
12
+ import { loadConfig, type NormalizedConfig, type PullConfig, type SourceConfig } from '../config.js';
13
+ import { pullAtprotoSource } from '../pull-sources/atproto.js';
14
+ import { pullGitSource } from '../pull-sources/git.js';
15
+ import type { PullResult, PulledLexicon, SourceLocation } from '../pull-sources/types.js';
16
+ import { sharedOptions } from '../shared-options.js';
17
+
18
+ export const pullCommandSchema = command(
19
+ 'pull',
20
+ merge(
21
+ object({
22
+ type: constant('pull'),
23
+ }),
24
+ sharedOptions,
25
+ ),
26
+ {
27
+ brief: message`pull lexicon documents from configured sources`,
28
+ description: message`fetches lexicon documents from configured git repositories and writes them to the output directory.`,
29
+ },
30
+ );
31
+
32
+ export type PullCommand = InferValue<typeof pullCommandSchema>;
11
33
 
12
34
  interface SourceRevision {
13
35
  source: SourceConfig;
14
- rev: string;
15
- }
16
-
17
- interface SourceLocation {
18
- absolutePath: string;
19
- relativePath: string;
20
- sourceDescription: string;
21
- }
22
-
23
- interface PulledLexicon {
24
- nsid: string;
25
- doc: LexiconDoc;
26
- location: SourceLocation;
27
- }
28
-
29
- interface PullResult {
30
- pulled: Map<string, PulledLexicon>;
31
- rev: string;
36
+ rev?: string;
32
37
  }
33
38
 
34
39
  const ensurePullConfig = (config: NormalizedConfig): PullConfig => {
@@ -119,99 +124,13 @@ const writeLexicon = async (
119
124
  await fs.writeFile(target, code);
120
125
  };
121
126
 
122
- /**
123
- * pulls lexicon documents from a git repository source
124
- * @param source git source configuration
125
- * @returns pulled lexicons and commit hash
126
- */
127
- const pullGitSource = async (source: SourceConfig & { type: 'git' }): Promise<PullResult> => {
128
- const tempParent = await fs.mkdtemp(path.join(os.tmpdir(), 'lex-cli-pull-'));
129
-
130
- const cloneDir = path.join(tempParent, 'repo');
131
-
132
- try {
133
- await runGit(
134
- [
135
- 'clone',
136
- '--filter=blob:none',
137
- '--depth',
138
- '1',
139
- '--sparse',
140
- ...(source.ref ? ['--branch', source.ref, '--single-branch'] : []),
141
- source.remote,
142
- cloneDir,
143
- ],
144
- { timeoutMs: 60_000 },
145
- );
146
- } catch (err) {
147
- if (err instanceof GitError) {
148
- console.error(pc.bold(pc.red(`git clone failed for ${source.remote}:`)));
149
- console.error(err.stderr || err.message);
150
- process.exit(1);
151
- }
152
-
153
- throw err;
154
- }
155
-
156
- try {
157
- await runGit(['-C', cloneDir, 'sparse-checkout', 'set', '--no-cone', ...source.pattern], {
158
- timeoutMs: 30_000,
159
- });
160
- } catch (err) {
161
- if (err instanceof GitError) {
162
- console.error(pc.bold(pc.red(`git sparse-checkout failed for ${source.remote}:`)));
163
- console.error(err.stderr || err.message);
164
- process.exit(1);
165
- }
166
-
167
- throw err;
168
- }
169
-
170
- const pulled = new Map<string, PulledLexicon>();
171
-
172
- for await (const filename of fs.glob(source.pattern, { cwd: cloneDir })) {
173
- const absolute = path.join(cloneDir, filename);
174
- const stat = await fs.stat(absolute);
175
-
176
- if (!stat.isFile()) {
177
- continue;
178
- }
179
-
180
- const location: SourceLocation = {
181
- absolutePath: absolute,
182
- relativePath: filename,
183
- sourceDescription: source.remote,
184
- };
185
-
186
- const doc = await parseLexiconFile(location);
187
-
188
- pulled.set(doc.id, { nsid: doc.id, doc, location });
189
- }
190
-
191
- // get the commit hash
192
- let rev: string;
193
- try {
194
- const result = await runGit(['-C', cloneDir, 'rev-parse', 'HEAD'], { timeoutMs: 10_000 });
195
- rev = result.stdout.trim();
196
- } catch (err) {
197
- if (err instanceof GitError) {
198
- console.error(pc.bold(pc.red(`git rev-parse failed for ${source.remote}:`)));
199
- console.error(err.stderr || err.message);
200
- process.exit(1);
201
- }
202
-
203
- throw err;
204
- }
205
-
206
- await fs.rm(tempParent, { recursive: true, force: true });
207
-
208
- return { pulled, rev };
209
- };
210
-
211
127
  const pullSource = async (source: SourceConfig): Promise<PullResult> => {
212
128
  switch (source.type) {
213
129
  case 'git': {
214
- return pullGitSource(source);
130
+ return pullGitSource(source, parseLexiconFile);
131
+ }
132
+ case 'atproto': {
133
+ return pullAtprotoSource(source);
215
134
  }
216
135
  }
217
136
  };
@@ -232,7 +151,19 @@ const writeSourceReadme = async (
232
151
  switch (source.type) {
233
152
  case 'git': {
234
153
  lines.push(`- ${source.remote}${source.ref ? ` (ref: ${source.ref})` : ``}`);
235
- lines.push(` - commit: ${rev}`);
154
+ if (rev) {
155
+ lines.push(` - commit: ${rev}`);
156
+ }
157
+ break;
158
+ }
159
+ case 'atproto': {
160
+ if (source.mode === 'nsids') {
161
+ lines.push(`- atproto (nsids: ${source.nsids.join(', ')})`);
162
+ } else {
163
+ lines.push(
164
+ `- atproto (authority: ${source.authority}${source.pattern ? `, pattern: ${source.pattern.join(', ')}` : ''})`,
165
+ );
166
+ }
236
167
  break;
237
168
  }
238
169
  }
@@ -250,11 +181,13 @@ const writeSourceReadme = async (
250
181
  };
251
182
 
252
183
  /**
253
- * pulls lexicon documents from configured sources and writes them to disk using nsid-based paths.
254
- * @param config normalized lex-cli configuration
184
+ * runs the pull command to fetch lexicon documents from configured sources
185
+ * @param args parsed command arguments
255
186
  */
256
- export const runPull = async (config: NormalizedConfig): Promise<void> => {
187
+ export const runPull = async (args: PullCommand): Promise<void> => {
188
+ const config = await loadConfig(args.config);
257
189
  const pullConfig = ensurePullConfig(config);
190
+
258
191
  const outdir = path.resolve(config.root, pullConfig.outdir);
259
192
  const prettierConfig = await prettier.resolveConfig(config.root, { editorconfig: true });
260
193
 
package/src/config.ts CHANGED
@@ -1,10 +1,12 @@
1
+ import * as fs from 'node:fs/promises';
1
2
  import * as path from 'node:path';
2
3
  import * as url from 'node:url';
3
4
 
4
5
  import * as v from '@badrap/valita';
5
6
  import pc from 'picocolors';
6
7
 
7
- import { isNsid } from '@atcute/lexicons/syntax';
8
+ import { isAtprotoDid } from '@atcute/identity';
9
+ import { isHandle, isNsid } from '@atcute/lexicons/syntax';
8
10
 
9
11
  import type { ImportMapping } from './codegen.js';
10
12
 
@@ -20,7 +22,32 @@ const gitSourceConfigSchema = v.object({
20
22
  .assert((value) => value.length > 0, `must include at least one glob pattern`),
21
23
  });
22
24
 
23
- const sourceConfigSchema = v.union(gitSourceConfigSchema);
25
+ const atprotoNsidsSourceConfigSchema = v.object({
26
+ type: v.literal('atproto'),
27
+ mode: v.literal('nsids'),
28
+ nsids: v
29
+ .array(v.string().assert((value) => isNsid(value), `must be valid nsid`))
30
+ .assert((value) => value.length > 0, `must include at least one nsid`),
31
+ });
32
+
33
+ const atprotoAuthoritySourceConfigSchema = v.object({
34
+ type: v.literal('atproto'),
35
+ mode: v.literal('authority'),
36
+ authority: v
37
+ .string()
38
+ .assert((value) => isHandle(value) || isAtprotoDid(value), `must a valid at-identifier`),
39
+ pattern: v
40
+ .array(
41
+ v
42
+ .string()
43
+ .assert((value) => isValidLexiconPattern(value), `must be valid nsid or pattern ending with .*`),
44
+ )
45
+ .optional(),
46
+ });
47
+
48
+ const atprotoSourceConfigSchema = v.union(atprotoNsidsSourceConfigSchema, atprotoAuthoritySourceConfigSchema);
49
+
50
+ const sourceConfigSchema = v.union(gitSourceConfigSchema, atprotoSourceConfigSchema);
24
51
 
25
52
  const pullConfigSchema = v.object({
26
53
  outdir: v.string().assert((value) => value.length > 0, `must not be empty`),
@@ -30,9 +57,19 @@ const pullConfigSchema = v.object({
30
57
  .assert((value) => value.length > 0, `must include at least one source`),
31
58
  });
32
59
 
60
+ const exportConfigSchema = v.object({
61
+ outdir: v.string().assert((value) => value.length > 0, `must not be empty`),
62
+ files: v.array(v.string().assert((value) => value.length > 0, `must not be empty`)).optional(),
63
+ clean: v.boolean().optional(),
64
+ });
65
+
33
66
  export type GitSourceConfig = v.Infer<typeof gitSourceConfigSchema>;
67
+ export type AtprotoNsidsSourceConfig = v.Infer<typeof atprotoNsidsSourceConfigSchema>;
68
+ export type AtprotoAuthoritySourceConfig = v.Infer<typeof atprotoAuthoritySourceConfigSchema>;
69
+ export type AtprotoSourceConfig = v.Infer<typeof atprotoSourceConfigSchema>;
34
70
  export type SourceConfig = v.Infer<typeof sourceConfigSchema>;
35
71
  export type PullConfig = v.Infer<typeof pullConfigSchema>;
72
+ export type ExportConfig = v.Infer<typeof exportConfigSchema>;
36
73
 
37
74
  const isValidLexiconPattern = (pattern: string): boolean => {
38
75
  if (pattern.endsWith('.*')) {
@@ -90,6 +127,7 @@ export const lexiconConfigSchema = v.object({
90
127
  .partial()
91
128
  .optional(),
92
129
  pull: pullConfigSchema.optional(),
130
+ export: exportConfigSchema.optional(),
93
131
  });
94
132
 
95
133
  export type LexiconConfig = v.Infer<typeof lexiconConfigSchema>;
@@ -98,8 +136,33 @@ export interface NormalizedConfig extends LexiconConfig {
98
136
  root: string;
99
137
  }
100
138
 
101
- export const loadConfig = async (configPath: string): Promise<NormalizedConfig> => {
102
- const configFilename = path.resolve(configPath);
139
+ export const loadConfig = async (configPath?: string): Promise<NormalizedConfig> => {
140
+ let configFilename: string | undefined;
141
+
142
+ if (configPath) {
143
+ configFilename = path.resolve(configPath);
144
+ } else {
145
+ // try to find lex.config.js or lex.config.ts in the current directory
146
+ const candidates = ['lex.config.js', 'lex.config.ts'];
147
+
148
+ for (const candidate of candidates) {
149
+ const candidatePath = path.resolve(candidate);
150
+ try {
151
+ await fs.access(candidatePath);
152
+ configFilename = candidatePath;
153
+ break;
154
+ } catch {
155
+ // file doesn't exist, try next candidate
156
+ }
157
+ }
158
+
159
+ if (!configFilename) {
160
+ console.error(pc.bold(pc.red(`config file not found`)));
161
+ console.error(`looked for: ${candidates.join(', ')}`);
162
+ process.exit(1);
163
+ }
164
+ }
165
+
103
166
  const configDirname = path.dirname(configFilename);
104
167
 
105
168
  let rawConfig: unknown;
@@ -0,0 +1,199 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as url from 'node:url';
4
+
5
+ import { lexiconDoc, refineLexiconDoc, type LexiconDoc } from '@atcute/lexicon-doc';
6
+ import { build, type LexDocumentBuilder } from '@atcute/lexicon-doc/builder';
7
+
8
+ import pc from 'picocolors';
9
+
10
+ /** file extensions recognized as module files */
11
+ const MODULE_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts']);
12
+
13
+ /**
14
+ * represents a loaded lexicon document with its source file
15
+ */
16
+ export interface LoadedLexicon {
17
+ nsid: string;
18
+ doc: LexiconDoc;
19
+ filename: string;
20
+ }
21
+
22
+ /**
23
+ * checks if a filename is a module file based on extension
24
+ * @param filename the filename to check
25
+ * @returns true if it's a module file
26
+ */
27
+ const isModuleFile = (filename: string): boolean => {
28
+ const ext = path.extname(filename);
29
+ return MODULE_EXTENSIONS.has(ext);
30
+ };
31
+
32
+ /**
33
+ * basic validation that a value looks like a LexDocumentBuilder
34
+ * @param value the value to check
35
+ * @returns true if it appears to be a LexDocumentBuilder
36
+ */
37
+ const isLexDocumentBuilder = (value: unknown): value is LexDocumentBuilder => {
38
+ return (
39
+ typeof value === 'object' &&
40
+ value !== null &&
41
+ 'id' in value &&
42
+ typeof (value as any).id === 'string' &&
43
+ 'defs' in value &&
44
+ typeof (value as any).defs === 'object'
45
+ );
46
+ };
47
+
48
+ /**
49
+ * loads and validates a lexicon document from a JSON file
50
+ * @param absolutePath absolute path to the JSON file
51
+ * @param relativePath relative path for error messages
52
+ * @returns parsed and validated lexicon document
53
+ */
54
+ const loadJsonFile = async (absolutePath: string, relativePath: string): Promise<LexiconDoc> => {
55
+ let source: string;
56
+ try {
57
+ source = await fs.readFile(absolutePath, 'utf8');
58
+ } catch (err) {
59
+ console.error(pc.bold(pc.red(`file read error with "${relativePath}"`)));
60
+ console.error(err);
61
+ process.exit(1);
62
+ }
63
+
64
+ let json: unknown;
65
+ try {
66
+ json = JSON.parse(source);
67
+ } catch (err) {
68
+ console.error(pc.bold(pc.red(`json parse error in "${relativePath}"`)));
69
+ console.error(err);
70
+ process.exit(1);
71
+ }
72
+
73
+ const result = lexiconDoc.try(json, { mode: 'strip' });
74
+ if (!result.ok) {
75
+ console.error(pc.bold(pc.red(`schema validation failed for "${relativePath}"`)));
76
+ console.error(result.message);
77
+
78
+ for (const issue of result.issues) {
79
+ console.log(`- ${issue.code} at .${issue.path.join('.')}`);
80
+ }
81
+
82
+ process.exit(1);
83
+ }
84
+
85
+ const issues = refineLexiconDoc(result.value, true);
86
+ if (issues.length > 0) {
87
+ console.error(pc.bold(pc.red(`lint validation failed for "${relativePath}"`)));
88
+
89
+ for (const issue of issues) {
90
+ console.log(`- ${issue.message} at .${issue.path.join('.')}`);
91
+ }
92
+
93
+ process.exit(1);
94
+ }
95
+
96
+ return result.value;
97
+ };
98
+
99
+ /**
100
+ * loads a LexDocumentBuilder from a module file
101
+ * @param absolutePath absolute path to the module file
102
+ * @param relativePath relative path for error messages
103
+ * @returns the LexDocumentBuilder from the module's default export
104
+ */
105
+ const loadModuleBuilder = async (absolutePath: string, relativePath: string): Promise<LexDocumentBuilder> => {
106
+ let mod: unknown;
107
+ try {
108
+ const fileUrl = url.pathToFileURL(absolutePath);
109
+ mod = await import(fileUrl.href);
110
+ } catch (err) {
111
+ console.error(pc.bold(pc.red(`failed to import module "${relativePath}"`)));
112
+ console.error(err);
113
+ process.exit(1);
114
+ }
115
+
116
+ const defaultExport = (mod as any)?.default;
117
+ if (!isLexDocumentBuilder(defaultExport)) {
118
+ console.error(pc.bold(pc.red(`module "${relativePath}" default export is not a valid LexDocumentBuilder`)));
119
+ console.error(`expected default export to be a LexDocumentBuilder (object with 'id' and 'defs')`);
120
+ process.exit(1);
121
+ }
122
+
123
+ return defaultExport;
124
+ };
125
+
126
+ /**
127
+ * loads lexicon documents from glob patterns
128
+ * @param patterns glob patterns to match files
129
+ * @param root root directory for resolving paths
130
+ * @returns array of loaded lexicon documents
131
+ */
132
+ export const loadLexicons = async (patterns: string[], root: string): Promise<LoadedLexicon[]> => {
133
+ const results: LoadedLexicon[] = [];
134
+ const seen = new Map<string, string>();
135
+
136
+ // collect JSON docs and module builders separately
137
+ const jsonDocs: Array<{ doc: LexiconDoc; filename: string }> = [];
138
+ const moduleBuilders: Array<{ builder: LexDocumentBuilder; filename: string }> = [];
139
+
140
+ for await (const filename of fs.glob(patterns, { cwd: root })) {
141
+ const absolutePath = path.join(root, filename);
142
+
143
+ if (isModuleFile(filename)) {
144
+ const builder = await loadModuleBuilder(absolutePath, filename);
145
+ moduleBuilders.push({ builder, filename });
146
+ } else {
147
+ // assume JSON for anything else (including .json)
148
+ const doc = await loadJsonFile(absolutePath, filename);
149
+ jsonDocs.push({ doc, filename });
150
+ }
151
+ }
152
+
153
+ // add JSON docs directly (already built)
154
+ for (const { doc, filename } of jsonDocs) {
155
+ const existing = seen.get(doc.id);
156
+ if (existing) {
157
+ console.error(pc.bold(pc.red(`duplicate lexicon "${doc.id}"`)));
158
+ console.error(`- found in ${filename}`);
159
+ console.error(`- already found in ${existing}`);
160
+ process.exit(1);
161
+ }
162
+
163
+ seen.set(doc.id, filename);
164
+ results.push({ nsid: doc.id, doc, filename });
165
+ }
166
+
167
+ // build all module builders together (for cross-references)
168
+ if (moduleBuilders.length > 0) {
169
+ // check for duplicates in builders
170
+ const buildersByNsid = new Map<string, string>();
171
+ for (const { builder, filename } of moduleBuilders) {
172
+ const existing = buildersByNsid.get(builder.id) ?? seen.get(builder.id);
173
+ if (existing) {
174
+ console.error(pc.bold(pc.red(`duplicate lexicon "${builder.id}"`)));
175
+ console.error(`- found in ${filename}`);
176
+ console.error(`- already found in ${existing}`);
177
+ process.exit(1);
178
+ }
179
+ buildersByNsid.set(builder.id, filename);
180
+ }
181
+
182
+ let built: Record<string, LexiconDoc>;
183
+ try {
184
+ built = build({ documents: moduleBuilders.map((m) => m.builder) });
185
+ } catch (err) {
186
+ console.error(pc.bold(pc.red(`build failed for module lexicons`)));
187
+ console.error(err);
188
+ process.exit(1);
189
+ }
190
+
191
+ for (const { builder, filename } of moduleBuilders) {
192
+ const doc = built[builder.id];
193
+ seen.set(builder.id, filename);
194
+ results.push({ nsid: builder.id, doc, filename });
195
+ }
196
+ }
197
+
198
+ return results;
199
+ };