@outputai/core 0.3.3-next.e8eff63.0 → 0.4.1-dev.622e67b.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.
@@ -1,70 +1,21 @@
1
- import { resolve, sep } from 'path';
1
+ import { dirname, resolve, sep } from 'path';
2
2
  import { pathToFileURL } from 'url';
3
3
  import { METADATA_ACCESS_SYMBOL } from '#consts';
4
- import { readdirSync } from 'fs';
4
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'fs';
5
5
 
6
6
  /**
7
- * @typedef {object} CollectedFile
8
- * @property {string} path - The file path
9
- * @property {string} url - The resolved url of the file, ready to be imported
10
- */
11
- /**
12
- * @typedef {object} Component
13
- * @property {Function} fn - The loaded component function
14
- * @property {object} metadata - Associated metadata with the component
15
- * @property {string} path - Associated metadata with the component
16
- */
17
-
18
- /**
19
- * Recursive traverse directories collection files with paths that match one of the given matches.
7
+ * Returns the real path for symlink
20
8
  *
21
- * @param {string} path - The path to scan
22
- * @param {function[]} matchers - Boolean functions to match files to add to collection
23
- * @returns {CollectedFile[]} An array containing the collected files
24
- */
25
- const findByNameRecursively = ( parentPath, matchers, ignoreDirNames = [ 'vendor', 'node_modules' ] ) => {
26
- const collection = [];
27
- for ( const entry of readdirSync( parentPath, { withFileTypes: true } ) ) {
28
- if ( ignoreDirNames.includes( entry.name ) ) {
29
- continue;
30
- }
31
-
32
- const path = resolve( parentPath, entry.name );
33
- if ( entry.isDirectory() ) {
34
- collection.push( ...findByNameRecursively( path, matchers ) );
35
- } else if ( matchers.some( m => m( path ) ) ) {
36
- collection.push( { path, url: pathToFileURL( path ).href } );
37
- }
38
- }
39
-
40
- return collection;
41
- };
42
-
43
- /**
44
- * Scan a path for files testing each path against a matching function.
45
- *
46
- * For each file found, dynamic import it and for each exports on that file, yields it.
9
+ * If the link is broken, returns null
47
10
  *
48
- * @remarks
49
- * - Only yields exports that have the METADATA_ACCESS_SYMBOL, as they are output components (steps, evaluators, etc).
50
- *
51
- * @generator
52
- * @async
53
- * @function importComponents
54
- * @param {string} target - Place to look for files
55
- * @param {function[]} matchers - Boolean functions to match files
56
- * @yields {Component}
11
+ * @param {string} link - The symlink to resolve
12
+ * @returns {string|null} The real path or null if it is unresolvable
57
13
  */
58
- export async function *importComponents( target, matchers ) {
59
- for ( const { url, path } of findByNameRecursively( target, matchers ) ) {
60
- const imported = await import( url );
61
- for ( const fn of Object.values( imported ) ) {
62
- const metadata = fn[METADATA_ACCESS_SYMBOL];
63
- if ( !metadata ) {
64
- continue;
65
- }
66
- yield { fn, metadata, path };
67
- }
14
+ export const resolveSymlink = link => {
15
+ try {
16
+ return realpathSync( link );
17
+ } catch {
18
+ return null;
68
19
  }
69
20
  };
70
21
 
@@ -130,3 +81,224 @@ export const staticMatchers = {
130
81
  */
131
82
  sharedEvaluatorsDir: v => v.includes( `${sep}shared${sep}evaluators${sep}` ) && v.endsWith( '.js' )
132
83
  };
84
+
85
+ /**
86
+ * @typedef {object} File
87
+ * @property {string} path - The file path
88
+ * @property {string} url - The resolved url of the file, ready to be imported
89
+ */
90
+ /**
91
+ * @typedef {object} Component
92
+ * @property {Function} fn - The loaded component function
93
+ * @property {object} metadata - Associated metadata with the component
94
+ * @property {string} path - Associated metadata with the component
95
+ */
96
+
97
+ /**
98
+ * Recursive traverse directories collection files with paths that match one of the given matches.
99
+ *
100
+ * Follows symlinks to directories
101
+ *
102
+ * @param {string} parentPath - The path to scan
103
+ * @param {function[]} matchers - Boolean functions to match files to add to collection
104
+ * @returns {File[]} An array containing the collected files
105
+ */
106
+ export const matchFiles = ( parentPath, matchers, ignoreDirNames = [ 'vendor', 'node_modules' ] ) => {
107
+ const collection = [];
108
+ for ( const entry of readdirSync( parentPath, { withFileTypes: true } ) ) {
109
+ if ( ignoreDirNames.includes( entry.name ) ) {
110
+ continue;
111
+ }
112
+ const path = resolve( parentPath, entry.name );
113
+ const realPath = entry.isSymbolicLink() ? resolveSymlink( path ) : path;
114
+ if ( !realPath ) {
115
+ continue;
116
+ }
117
+ const stat = lstatSync( realPath );
118
+ if ( stat.isDirectory() ) {
119
+ collection.push( ...matchFiles( realPath, matchers ) );
120
+ } else if ( stat.isFile() && matchers.some( m => m( realPath ) ) ) {
121
+ collection.push( { path, url: pathToFileURL( realPath ).href } );
122
+ }
123
+ }
124
+ return collection;
125
+ };
126
+
127
+ /**
128
+ * Returns true if given package.json indicates that its workflows are exposed for external usage.
129
+ *
130
+ * @param {string} pkgJsonPath
131
+ * @returns {boolean}
132
+ */
133
+ export const packageExposesWorkflows = pkgJsonPath => {
134
+ if ( !existsSync( pkgJsonPath ) ) {
135
+ return false;
136
+ }
137
+ const pkgJsonRawContent = readFileSync( pkgJsonPath );
138
+ try {
139
+ const packageContent = JSON.parse( pkgJsonRawContent );
140
+ return packageContent['outputai']?.workflows?.expose === true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Normalize path separators for cross-platform path matching.
148
+ *
149
+ * @param {string} path
150
+ * @returns {string}
151
+ */
152
+ const normalizeSlashes = path => path.replace( /\\/g, '/' );
153
+
154
+ /**
155
+ * Returns true if the given path is inside a node_modules tree.
156
+ *
157
+ * @param {string} path
158
+ * @returns {boolean}
159
+ */
160
+ export const isPathDescendentFromNodeModules = path =>
161
+ /(^|\/)node_modules(\/|$)/.test( normalizeSlashes( path ) );
162
+
163
+ /**
164
+ * Returns true if the given path is an installed package root inside node_modules.
165
+ *
166
+ * Matches both unscoped packages and scoped packages.
167
+ *
168
+ * @param {string} path
169
+ * @returns {boolean}
170
+ */
171
+ export const isPackageRoot = path =>
172
+ /\/node_modules\/(?:@[^/]+\/[^/]+|[^/@][^/]*)$/.test( normalizeSlashes( path ) );
173
+
174
+ /**
175
+ * Walk upward from a file path to find the closest installed package root under node_modules.
176
+ *
177
+ * @param {string} path
178
+ * @returns {string|null}
179
+ */
180
+ export const findPackageRoot = path => {
181
+ if ( isPackageRoot( path ) && existsSync( resolve( path, 'package.json' ) ) ) {
182
+ return path;
183
+ }
184
+ const parent = dirname( path );
185
+ return parent !== path ? findPackageRoot( parent ) : null;
186
+ };
187
+
188
+ /**
189
+ * Resolves the closest node_modules directory
190
+ *
191
+ * @param {string} targetPath - A reference path to start the search, can be a dir or a file path
192
+ * @returns {string|null} The closest node_modules/ or null
193
+ */
194
+ export const resolveNodeModulesPath = targetPath => {
195
+ if ( !existsSync( targetPath ) ) {
196
+ return null;
197
+ }
198
+ const path = lstatSync( targetPath ).isDirectory() ? targetPath : dirname( targetPath );
199
+ const nodeModulesPath = resolve( path, 'node_modules' );
200
+ if ( existsSync( nodeModulesPath ) ) {
201
+ const stat = lstatSync( nodeModulesPath );
202
+ if ( stat.isDirectory() ) {
203
+ return nodeModulesPath;
204
+ }
205
+ if ( stat.isSymbolicLink() ) {
206
+ const symlinkTarget = resolveSymlink( nodeModulesPath );
207
+ if ( symlinkTarget && lstatSync( symlinkTarget ).isDirectory() ) {
208
+ return symlinkTarget;
209
+ }
210
+ }
211
+ }
212
+
213
+ const parentPath = resolve( path, '..' );
214
+ return parentPath !== path ? resolveNodeModulesPath( parentPath ) : null;
215
+ };
216
+
217
+ /**
218
+ * Scans a node_modules/ path and look for all projects that contain workflows.
219
+ *
220
+ * A project contains workflows when packageExposesWorkflows() resolves to true.
221
+ *
222
+ * For each of these projects, load workflows using matchFiles().
223
+ *
224
+ * @param {string} nodeModulesPath
225
+ * @returns {File[]} An array containing the collected files
226
+ *
227
+ */
228
+ export const findWorkflowsInPackages = nodeModulesPath => {
229
+ const collection = [];
230
+ for ( const entry of readdirSync( nodeModulesPath, { withFileTypes: true } ) ) {
231
+ const path = resolve( nodeModulesPath, entry.name );
232
+ const realPath = entry.isSymbolicLink() ? resolveSymlink( path ) : path;
233
+
234
+ if ( realPath && lstatSync( realPath ).isDirectory() ) {
235
+ if ( entry.name.startsWith( '@' ) ) { // scoped package root
236
+ collection.push( ...findWorkflowsInPackages( realPath ) );
237
+ } else if ( packageExposesWorkflows( resolve( realPath, 'package.json' ) ) ) { // is a package folder
238
+ collection.push( ...matchFiles( realPath, [ staticMatchers.workflowFile ] ) );
239
+ }
240
+ }
241
+ }
242
+ return collection;
243
+ };
244
+
245
+ /**
246
+ * Recursive traverse the closest node_modules/ directory loading workflows.
247
+ *
248
+ * Deduplicates by file url.
249
+ *
250
+ * @param {string} parentPath - The starting path
251
+ * @returns {File[]} An array containing the collected files
252
+ */
253
+ export const findWorkflowsInNodeModules = parentPath => {
254
+ const nodeModulesPath = resolveNodeModulesPath( parentPath );
255
+ if ( !nodeModulesPath ) {
256
+ return [];
257
+ }
258
+ const collection = findWorkflowsInPackages( nodeModulesPath );
259
+
260
+ // deduplicate collection by .url in case symlinked packages end up resolving the same dependencies more than once
261
+ return collection.reduce( ( map, value ) => map.set( value.url, value ), new Map() ).values().toArray();
262
+ };
263
+
264
+ /**
265
+ * Based on workflow urls, traverse those projects looking for shared activities (steps, evaluators).
266
+ *
267
+ * @param {Component[]} workflows
268
+ * @returns {File[]}
269
+ */
270
+ export const findSharedActivitiesFromWorkflows = workflows => {
271
+ const paths = workflows.map( wf => findPackageRoot( wf.path ) );
272
+ const uniquePaths = new Set( paths.filter( p => !!p ) ).values().toArray();
273
+ const files = [];
274
+ for ( const path of uniquePaths ) {
275
+ files.push( ...matchFiles( path, [ staticMatchers.sharedStepsDir, staticMatchers.sharedEvaluatorsDir ] ) );
276
+ }
277
+ return files;
278
+ };
279
+
280
+ /**
281
+ * Receives an array of Files and import each one.
282
+ *
283
+ * For each exported function from that file that has metadata, yields the path, metadata and the function itself.
284
+ *
285
+ * metadata is accessible thru METADATA_ACCESS_SYMBOL.
286
+ *
287
+ * @generator
288
+ * @async
289
+ * @function importComponents
290
+ * @param {File} Files - Collected files to load
291
+ * @yields {Component}
292
+ */
293
+ export async function *importComponents( files ) {
294
+ for ( const { url, path } of files ) {
295
+ const imported = await import( url );
296
+ for ( const fn of Object.values( imported ) ) {
297
+ const metadata = fn[METADATA_ACCESS_SYMBOL];
298
+ if ( !metadata ) {
299
+ continue;
300
+ }
301
+ yield { fn, metadata, path };
302
+ }
303
+ }
304
+ };