11import type { Command } from "commander" ;
2+ import { resolveContext } from "@/cli/root.ts" ;
23import { hasAllTags , parseTagFilter } from "@/common/tags.ts" ;
34import { findConfigFile , getRuleOptions , loadLintConfig } from "@/lint/config.ts" ;
45import { formatJSON } from "@/lint/output/json.ts" ;
@@ -15,12 +16,18 @@ export function registerLintCommand(program: Command): void {
1516 . description ( "Lint workflow definition files" )
1617 . option ( "-d, --dir <directory>" , "Directory to scan for workflow files" )
1718 . option ( "-f, --file <files...>" , "Specific files to lint (can be repeated)" )
19+ . option (
20+ "--remote" ,
21+ "Fetch workflows from n8n API instead of local files (requires N8N_API_URL and N8N_API_KEY)" ,
22+ )
23+ . option ( "--active-only" , "Only lint active workflows (requires --remote)" )
24+ . option ( "--ui-url <url>" , "n8n UI base URL for workflow links (env: N8N_UI_URL)" )
1825 . option ( "-c, --config <path>" , "Path to .n8nlintrc.json config file" )
1926 . option ( "--disable-rule <rules...>" , "Disable specific rules (can be repeated)" )
2027 . option ( "--list-rules" , "List all available rules and exit" )
2128 . option ( "-o, --output <format>" , "Output format: text, json" , "text" )
2229 . option ( "--tags <tags>" , "Filter by tags (comma-separated, AND condition)" )
23- . action ( async ( opts ) => {
30+ . action ( async ( opts , command ) => {
2431 const registry = registerDefaultRules ( ) ;
2532
2633 // List rules mode
@@ -57,93 +64,188 @@ export function registerLintCommand(program: Command): void {
5764 }
5865 }
5966
60- // Collect files to lint
61- let files : string [ ] = [ ] ;
62- if ( opts . file ) {
63- files = opts . file ;
64- } else if ( opts . dir ) {
65- files = scanFiles ( opts . dir ) ;
67+ if ( opts . remote ) {
68+ // Remote mode: fetch workflows from n8n API
69+ if ( opts . dir || opts . file ) {
70+ console . error ( "Error: --remote cannot be used with --dir or --file" ) ;
71+ process . exit ( 1 ) ;
72+ }
73+
74+ const ctx = resolveContext ( command . parent ! ) ;
75+ const workflows = await ctx . workflowService . listAllWorkflows ( {
76+ active : opts . activeOnly ? true : undefined ,
77+ tags : filterByTags . length > 0 ? filterByTags : undefined ,
78+ } ) ;
79+
80+ const uiURL = opts . uiUrl ?? process . env . N8N_UI_URL ?? deriveUIURL ( ctx . config . apiURL ) ;
81+
82+ await lintRemote ( workflows , enabledRules , config , uiURL , opts ) ;
6683 } else {
67- console . error ( "Error: specify --dir or --file to indicate files to lint" ) ;
68- process . exit ( 1 ) ;
84+ // Local mode: read files from filesystem
85+ await lintLocal ( enabledRules , config , filterByTags , opts ) ;
6986 }
87+ } ) ;
88+ }
89+
90+ /**
91+ * Derive the UI URL from the API URL by removing common API-only subdomains.
92+ * e.g. "https://n8n-direct.ubie.dev" → "https://n8n.ubie.dev"
93+ */
94+ function deriveUIURL ( apiURL : string ) : string {
95+ return apiURL . replace ( "n8n-direct." , "n8n." ) ;
96+ }
97+
98+ /** Display name for a remote workflow used in violation output. */
99+ function workflowDisplayName ( name : string , id : string | undefined ) : string {
100+ return id ? `${ name } (${ id } )` : name ;
101+ }
102+
103+ /** Build the n8n UI URL for a workflow. */
104+ function workflowURL ( baseURL : string , id : string | undefined ) : string | undefined {
105+ if ( ! id ) return undefined ;
106+ const base = baseURL . replace ( / \/ + $ / , "" ) ;
107+ return `${ base } /workflow/${ id } ` ;
108+ }
70109
71- if ( files . length === 0 ) {
72- console . error ( "No files found to lint" ) ;
73- process . exit ( 1 ) ;
110+ /** Lint workflows fetched from the n8n API. */
111+ async function lintRemote (
112+ workflows : import ( "@/api/types.ts" ) . Workflow [ ] ,
113+ enabledRules : ReturnType < ReturnType < typeof registerDefaultRules > [ "enabledRulesWithConfig" ] > ,
114+ config : ReturnType < typeof loadLintConfig > ,
115+ uiURL : string ,
116+ opts : { output ?: string } ,
117+ ) : Promise < void > {
118+ const result : LintResult = {
119+ violations : [ ] ,
120+ filesChecked : 0 ,
121+ filesFailed : 0 ,
122+ } ;
123+
124+ const failedWorkflows = new Set < string > ( ) ;
125+
126+ for ( const workflow of workflows ) {
127+ result . filesChecked ++ ;
128+ const displayName = workflowDisplayName ( workflow . name , workflow . id ) ;
129+ const rawJSON = JSON . stringify ( workflow ) ;
130+ const url = workflowURL ( uiURL , workflow . id ) ;
131+
132+ for ( const { rule, severity } of enabledRules ) {
133+ const violations = rule . check ( workflow , rawJSON , getRuleOptions ( config , rule . name ) ) ;
134+ for ( const v of violations ) {
135+ result . violations . push ( {
136+ ...v ,
137+ file : v . file ?? displayName ,
138+ url,
139+ severity,
140+ } ) ;
141+ failedWorkflows . add ( displayName ) ;
74142 }
143+ }
144+ }
75145
76- // Run linting
77- const result : LintResult = {
78- violations : [ ] ,
79- filesChecked : 0 ,
80- filesFailed : 0 ,
81- } ;
82-
83- const failedFiles = new Set < string > ( ) ;
84-
85- for ( const filePath of files ) {
86- result . filesChecked ++ ;
87-
88- const outcome = await loadFileForLint ( filePath , filterByTags ) ;
89- if ( outcome . status === "skipped" ) {
90- result . violations . push ( {
91- file : filePath ,
92- rule : "file-read" ,
93- severity : "warning" ,
94- message : outcome . message ,
95- } ) ;
96- result . filesChecked -- ;
97- continue ;
98- }
99- if ( outcome . status === "error" ) {
100- result . violations . push ( {
101- file : filePath ,
102- rule : "file-read" ,
103- severity : "error" ,
104- message : outcome . message ,
105- } ) ;
106- failedFiles . add ( filePath ) ;
107- continue ;
108- }
146+ result . filesFailed = failedWorkflows . size ;
109147
110- const { rawJSON, workflow } = outcome . data ;
148+ const outputFormat = opts . output ?? "text" ;
149+ if ( outputFormat === "json" ) {
150+ console . log ( formatJSON ( result ) ) ;
151+ } else {
152+ console . log ( formatText ( result ) ) ;
153+ }
111154
112- // Filter by tags
113- if ( workflow && filterByTags . length > 0 ) {
114- if ( ! hasAllTags ( workflow . tags , filterByTags ) ) {
115- result . filesChecked -- ; // Don't count filtered files
116- continue ;
117- }
118- }
155+ if ( hasErrors ( result ) ) {
156+ process . exit ( 1 ) ;
157+ }
158+ }
119159
120- // Run each enabled rule
121- for ( const { rule, severity } of enabledRules ) {
122- const violations = rule . check ( workflow , rawJSON , getRuleOptions ( config , rule . name ) ) ;
123- for ( const v of violations ) {
124- result . violations . push ( {
125- ...v ,
126- file : v . file ?? filePath ,
127- severity,
128- } ) ;
129- failedFiles . add ( filePath ) ;
130- }
131- }
160+ /** Lint workflow files from the local filesystem. */
161+ async function lintLocal (
162+ enabledRules : ReturnType < ReturnType < typeof registerDefaultRules > [ "enabledRulesWithConfig" ] > ,
163+ config : ReturnType < typeof loadLintConfig > ,
164+ filterByTags : string [ ] ,
165+ opts : { dir ?: string ; file ?: string [ ] ; output ?: string } ,
166+ ) : Promise < void > {
167+ let files : string [ ] = [ ] ;
168+ if ( opts . file ) {
169+ files = opts . file ;
170+ } else if ( opts . dir ) {
171+ files = scanFiles ( opts . dir ) ;
172+ } else {
173+ console . error ( "Error: specify --dir, --file, or --remote to indicate files to lint" ) ;
174+ process . exit ( 1 ) ;
175+ }
176+
177+ if ( files . length === 0 ) {
178+ console . error ( "No files found to lint" ) ;
179+ process . exit ( 1 ) ;
180+ }
181+
182+ const result : LintResult = {
183+ violations : [ ] ,
184+ filesChecked : 0 ,
185+ filesFailed : 0 ,
186+ } ;
187+
188+ const failedFiles = new Set < string > ( ) ;
189+
190+ for ( const filePath of files ) {
191+ result . filesChecked ++ ;
192+
193+ const outcome = await loadFileForLint ( filePath , filterByTags ) ;
194+ if ( outcome . status === "skipped" ) {
195+ result . violations . push ( {
196+ file : filePath ,
197+ rule : "file-read" ,
198+ severity : "warning" ,
199+ message : outcome . message ,
200+ } ) ;
201+ result . filesChecked -- ;
202+ continue ;
203+ }
204+ if ( outcome . status === "error" ) {
205+ result . violations . push ( {
206+ file : filePath ,
207+ rule : "file-read" ,
208+ severity : "error" ,
209+ message : outcome . message ,
210+ } ) ;
211+ failedFiles . add ( filePath ) ;
212+ continue ;
213+ }
214+
215+ const { rawJSON, workflow } = outcome . data ;
216+
217+ // Filter by tags
218+ if ( workflow && filterByTags . length > 0 ) {
219+ if ( ! hasAllTags ( workflow . tags , filterByTags ) ) {
220+ result . filesChecked -- ;
221+ continue ;
132222 }
223+ }
224+
225+ // Run each enabled rule
226+ for ( const { rule, severity } of enabledRules ) {
227+ const violations = rule . check ( workflow , rawJSON , getRuleOptions ( config , rule . name ) ) ;
228+ for ( const v of violations ) {
229+ result . violations . push ( {
230+ ...v ,
231+ file : v . file ?? filePath ,
232+ severity,
233+ } ) ;
234+ failedFiles . add ( filePath ) ;
235+ }
236+ }
237+ }
133238
134- result . filesFailed = failedFiles . size ;
239+ result . filesFailed = failedFiles . size ;
135240
136- // Output results
137- const outputFormat = opts . output ?? "text" ;
138- if ( outputFormat === "json" ) {
139- console . log ( formatJSON ( result ) ) ;
140- } else {
141- console . log ( formatText ( result ) ) ;
142- }
241+ const outputFormat = opts . output ?? "text" ;
242+ if ( outputFormat === "json" ) {
243+ console . log ( formatJSON ( result ) ) ;
244+ } else {
245+ console . log ( formatText ( result ) ) ;
246+ }
143247
144- // Exit with error code if there are errors
145- if ( hasErrors ( result ) ) {
146- process . exit ( 1 ) ;
147- }
148- } ) ;
248+ if ( hasErrors ( result ) ) {
249+ process . exit ( 1 ) ;
250+ }
149251}
0 commit comments