Skip to content

Commit c0759c4

Browse files
feat(lint): add --remote option to lint workflows via n8n API (#37)
* feat(lint): add --remote option to lint workflows via n8n API Enable linting workflows fetched directly from the n8n API instead of local files. This supports daily audit of active workflows to detect high-frequency schedule triggers that cause cost explosions. New options: - --remote: fetch workflows from n8n API (requires N8N_API_URL/N8N_API_KEY) - --active-only: only lint active workflows (with --remote) - --ui-url: n8n UI base URL for workflow links in output (env: N8N_UI_URL) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: nakamura-tsubasa-283 <nakamura.tsubasa.eg@gmail.com> * fix: non-null assert for Bun.serve().port in lint-remote test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Signed-off-by: nakamura-tsubasa-283 <nakamura.tsubasa.eg@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 265035d commit c0759c4

5 files changed

Lines changed: 565 additions & 78 deletions

File tree

src/cli/commands/lint.ts

Lines changed: 180 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from "commander";
2+
import { resolveContext } from "@/cli/root.ts";
23
import { hasAllTags, parseTagFilter } from "@/common/tags.ts";
34
import { findConfigFile, getRuleOptions, loadLintConfig } from "@/lint/config.ts";
45
import { 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
}

src/lint/output/json.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface JSONViolation {
1212
rule: string;
1313
message: string;
1414
severity: string;
15+
url?: string;
1516
}
1617

1718
interface JSONSummary {
@@ -34,6 +35,7 @@ export function formatJSON(result: LintResult): string {
3435
};
3536
if (v.line && v.line > 0) jv.line = v.line;
3637
if (v.column && v.column > 0) jv.column = v.column;
38+
if (v.url) jv.url = v.url;
3739
return jv;
3840
}),
3941
summary: {

src/lint/output/text.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export function formatText(result: LintResult): string {
2020

2121
const severityLabel = v.severity === "warning" ? "warning" : "error";
2222
lines.push(`${location}: ${severityLabel}[${v.rule}]: ${v.message}`);
23+
if (v.url) {
24+
lines.push(` ${v.url}`);
25+
}
2326
}
2427

2528
lines.push("");

src/lint/rules/violation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ export interface Violation {
1414
message: string;
1515
/** Severity level (error or warning) */
1616
severity: Severity;
17+
/** URL to the workflow in n8n UI (remote mode only) */
18+
url?: string;
1719
}

0 commit comments

Comments
 (0)