flagshark 1.3.1 → 1.4.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 (2) hide show
  1. package/dist/cli.js +93 -157
  2. package/package.json +6 -4
package/dist/cli.js CHANGED
@@ -1,140 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync, existsSync } from "node:fs";
4
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
5
5
  import { parse as parseYaml } from "yaml";
6
- import { scanRepo, FlagsharkConfigSchema } from "@flagshark/core";
7
-
8
- // src/formatter.ts
9
- var VERSION = "1.2.0";
10
- function pad(str, width) {
11
- if (str.length > width) {
12
- return str.slice(0, width - 1) + "\u2026";
13
- }
14
- return str.padEnd(width);
15
- }
16
- function buildTable(flags) {
17
- const cols = {
18
- flag: 16,
19
- file: 22,
20
- added: 13,
21
- signal: 28
22
- };
23
- const hBorder = (left, mid, right) => `${left}${"\u2500".repeat(cols.flag + 2)}${mid}${"\u2500".repeat(cols.file + 2)}${mid}${"\u2500".repeat(cols.added + 2)}${mid}${"\u2500".repeat(cols.signal + 2)}${right}`;
24
- const lines = [];
25
- lines.push(hBorder("\u250C", "\u252C", "\u2510"));
26
- lines.push(
27
- `\u2502 ${pad("Flag", cols.flag)} \u2502 ${pad("File", cols.file)} \u2502 ${pad("Added", cols.added)} \u2502 ${pad("Signal", cols.signal)} \u2502`
28
- );
29
- lines.push(hBorder("\u251C", "\u253C", "\u2524"));
30
- for (const sf of flags) {
31
- const fileRef = `${sf.filePath}:${sf.lineNumber}`;
32
- const signalText = sf.signals.map((s) => {
33
- if (s.type === "age") {
34
- return "Age > threshold";
35
- }
36
- if (s.type === "low-usage") {
37
- return "Single file";
38
- }
39
- return s.description;
40
- }).join(", ");
41
- lines.push(
42
- `\u2502 ${pad(sf.name, cols.flag)} \u2502 ${pad(fileRef, cols.file)} \u2502 ${pad(sf.age ?? "unknown", cols.added)} \u2502 ${pad(signalText, cols.signal)} \u2502`
43
- );
44
- }
45
- lines.push(hBorder("\u2514", "\u2534", "\u2518"));
46
- return lines.join("\n");
47
- }
48
- function formatText(result, options) {
49
- const lines = [];
50
- lines.push(`\u{1F988} FlagShark v${VERSION}`);
51
- lines.push("");
52
- const langCount = Object.keys(result.languageBreakdown).length;
53
- lines.push(`Scanned ${result.filesScanned} files across ${langCount} language${langCount === 1 ? "" : "s"}`);
54
- if (result.excludedCount && result.excludedCount > 0) {
55
- lines.push(`(${result.excludedCount} excluded via .flagsharkignore + excludes)`);
56
- }
57
- if (result.totalFlags === 0) {
58
- lines.push("No feature flags detected.");
59
- lines.push("");
60
- lines.push("Supported providers: LaunchDarkly, Unleash, Flipt, Split.io, PostHog, and more.");
61
- lines.push("Run flagshark scan --help for configuration options.");
62
- return lines.join("\n");
63
- }
64
- if (result.detectedProviders.length > 0) {
65
- lines.push(`Detected providers: ${result.detectedProviders.join(", ")}`);
66
- }
67
- const uniqueStaleNames = new Set(result.staleFlags.map((f) => f.name));
68
- const staleCount = uniqueStaleNames.size;
69
- lines.push(`Found ${result.totalFlags} feature flags, ${staleCount} stale`);
70
- if (staleCount > 0) {
71
- lines.push("");
72
- lines.push("Stale flags:");
73
- const displayCount = options.verbose ? staleCount : Math.min(staleCount, options.maxDisplay);
74
- const displayFlags = result.staleFlags.slice(0, displayCount);
75
- lines.push(buildTable(displayFlags));
76
- const remaining = staleCount - displayCount;
77
- if (remaining > 0) {
78
- lines.push("");
79
- lines.push(`... and ${remaining} more (use --verbose to see all)`);
80
- }
81
- }
82
- lines.push("");
83
- if (staleCount === 0) {
84
- lines.push(`Flag Health Score: ${result.healthScore}/100 \u2713 All flags look healthy!`);
85
- } else {
86
- lines.push(
87
- `Flag Health Score: ${result.healthScore}/100 (${staleCount}/${result.totalFlags} flags are stale)`
88
- );
89
- lines.push("");
90
- lines.push("Automate cleanup \u2192 https://flagshark.com");
91
- lines.push("Open source CLI \u2192 https://github.com/FlagShark/flagshark");
92
- }
93
- if (result.excludedPaths && result.excludedPaths.length > 0) {
94
- lines.push("");
95
- lines.push(`Excluded files (${result.excludedPaths.length}):`);
96
- for (const p of result.excludedPaths) {
97
- lines.push(` ${p}`);
98
- }
99
- }
100
- return lines.join("\n");
101
- }
102
- function formatJson(result) {
103
- const languages = { ...result.languageBreakdown };
104
- const flags = result.staleFlags.map((sf) => ({
105
- name: sf.name,
106
- file: sf.filePath,
107
- line: sf.lineNumber,
108
- language: sf.language,
109
- provider: sf.provider,
110
- stale: true,
111
- signals: sf.signals.map((s) => ({
112
- type: s.type,
113
- description: s.description
114
- })),
115
- age: sf.age ?? null
116
- }));
117
- const output = {
118
- version: VERSION,
119
- totalFlags: result.totalFlags,
120
- staleFlags: new Set(result.staleFlags.map((f) => f.name)).size,
121
- healthScore: result.healthScore,
122
- detectedProviders: result.detectedProviders,
123
- languages,
124
- flags,
125
- excludedPaths: result.excludedPaths,
126
- scanDuration: result.scanDuration,
127
- links: {
128
- dashboard: "https://flagshark.com",
129
- cli: "https://github.com/FlagShark/flagshark",
130
- npm: "https://www.npmjs.com/package/flagshark"
131
- }
132
- };
133
- return JSON.stringify(output, null, 2);
6
+ import { scanRepo, FlagsharkConfigSchema, selectFormatter } from "@flagshark/core";
7
+ function toErrorMessage(err) {
8
+ return err instanceof Error ? err.message : String(err);
134
9
  }
135
-
136
- // src/cli.ts
137
- var VERSION2 = "1.3.1";
10
+ var VERSION = "1.3.1";
138
11
  var HELP_TEXT = `
139
12
  flagshark scan [options]
140
13
 
@@ -151,15 +24,27 @@ Configuration:
151
24
  --no-config Skip config file discovery
152
25
  --no-ignore-file Skip .flagsharkignore discovery
153
26
  --show-excluded Show excluded files in text output
27
+
28
+ Output:
29
+ --format <fmt> Output format: text | json | markdown | csv | sarif (default: text)
30
+ --output <path> | -o Write output to this file instead of stdout
31
+ --json Shorthand for --format json (deprecated, will be removed in v2)
32
+
33
+ Platform integration:
34
+ --no-cache Skip platform-flag cache, force re-fetch
35
+ --fail-on-error Fail on any missing-in-platform flag (default: true)
36
+ --no-fail-on-error Disable fail-on-error
154
37
  `.trim();
155
38
  function parseArgs(argv) {
156
39
  const args = {
157
40
  json: false,
41
+ format: "text",
158
42
  diff: null,
159
43
  threshold: void 0,
160
44
  verbose: false,
161
45
  help: false,
162
- version: false
46
+ version: false,
47
+ failOnError: true
163
48
  };
164
49
  let i = 2;
165
50
  while (i < argv.length) {
@@ -172,6 +57,19 @@ function parseArgs(argv) {
172
57
  switch (arg) {
173
58
  case "--json":
174
59
  args.json = true;
60
+ args.format = "json";
61
+ break;
62
+ case "--format": {
63
+ const v = argv[++i];
64
+ if (!["text", "json", "markdown", "csv", "sarif"].includes(v)) {
65
+ throw new Error(`--format must be one of text, json, markdown, csv, sarif; got '${v}'`);
66
+ }
67
+ args.format = v;
68
+ break;
69
+ }
70
+ case "--output":
71
+ case "-o":
72
+ args.output = argv[++i];
175
73
  break;
176
74
  case "--diff":
177
75
  i++;
@@ -201,9 +99,7 @@ function parseArgs(argv) {
201
99
  case "--engine": {
202
100
  const value = argv[++i];
203
101
  if (value !== "regex" && value !== "tree-sitter") {
204
- process.stderr.write(`Error: --engine must be 'regex' or 'tree-sitter', got '${value}'
205
- `);
206
- process.exit(2);
102
+ throw new Error(`--engine must be 'regex' or 'tree-sitter', got '${value}'`);
207
103
  }
208
104
  args.engine = value;
209
105
  break;
@@ -224,6 +120,15 @@ function parseArgs(argv) {
224
120
  case "--show-excluded":
225
121
  args.showExcluded = true;
226
122
  break;
123
+ case "--no-cache":
124
+ args.noCache = true;
125
+ break;
126
+ case "--no-fail-on-error":
127
+ args.failOnError = false;
128
+ break;
129
+ case "--fail-on-error":
130
+ args.failOnError = true;
131
+ break;
227
132
  case "scan":
228
133
  break;
229
134
  default:
@@ -245,16 +150,23 @@ function createLogger(verbose) {
245
150
  error: (...args) => console.error("[error]", ...args)
246
151
  };
247
152
  }
248
- async function main() {
249
- const args = parseArgs(process.argv);
153
+ async function runCli(argv, io) {
154
+ let args;
155
+ try {
156
+ args = parseArgs(argv);
157
+ } catch (err) {
158
+ io.stderr.write(`[error] ${toErrorMessage(err)}
159
+ `);
160
+ return 2;
161
+ }
250
162
  if (args.version) {
251
- process.stdout.write(`flagshark v${VERSION2}
163
+ io.stdout.write(`flagshark v${VERSION}
252
164
  `);
253
- process.exit(0);
165
+ return 0;
254
166
  }
255
167
  if (args.help) {
256
- process.stdout.write(HELP_TEXT + "\n");
257
- process.exit(0);
168
+ io.stdout.write(HELP_TEXT + "\n");
169
+ return 0;
258
170
  }
259
171
  const logger = createLogger(args.verbose);
260
172
  if (args.diff) {
@@ -265,22 +177,29 @@ async function main() {
265
177
  let configOverride;
266
178
  if (args.configPath) {
267
179
  if (!existsSync(args.configPath)) {
268
- process.stderr.write(`Error: config file not found: ${args.configPath}
180
+ io.stderr.write(`Error: config file not found: ${args.configPath}
269
181
  `);
270
- process.exit(2);
182
+ return 2;
271
183
  }
272
184
  const raw = readFileSync(args.configPath, "utf-8");
273
- const parsed = parseYaml(raw);
185
+ let parsed;
186
+ try {
187
+ parsed = parseYaml(raw);
188
+ } catch (err) {
189
+ io.stderr.write(`Error: invalid YAML at ${args.configPath}: ${toErrorMessage(err)}
190
+ `);
191
+ return 2;
192
+ }
274
193
  const configResult = FlagsharkConfigSchema.safeParse(parsed);
275
194
  if (!configResult.success) {
276
- process.stderr.write(`Error: invalid config at ${args.configPath}: ${configResult.error.message}
195
+ io.stderr.write(`Error: invalid config at ${args.configPath}: ${configResult.error.message}
277
196
  `);
278
- process.exit(2);
197
+ return 2;
279
198
  }
280
199
  configOverride = configResult.data;
281
200
  }
282
201
  const result = await scanRepo({
283
- cwd: process.cwd(),
202
+ cwd: io.cwd,
284
203
  threshold: args.threshold,
285
204
  diff: args.diff ?? void 0,
286
205
  engine: args.engine,
@@ -288,6 +207,7 @@ async function main() {
288
207
  noConfig: args.noConfig,
289
208
  noIgnoreFile: args.noIgnoreFile,
290
209
  collectExcludedPaths: args.showExcluded,
210
+ noCache: args.noCache,
291
211
  logger
292
212
  });
293
213
  if (args.verbose && result.effectiveExcludes) {
@@ -299,20 +219,36 @@ async function main() {
299
219
  ...r.ignoreFile.map((p) => `.flagsharkignore: ${p}`)
300
220
  ];
301
221
  if (allRules.length > 0) {
302
- process.stderr.write("Effective excludes:\n");
303
- for (const rule of allRules) process.stderr.write(` ${rule}
222
+ io.stderr.write("Effective excludes:\n");
223
+ for (const rule of allRules) io.stderr.write(` ${rule}
304
224
  `);
305
225
  }
306
226
  }
307
- const output = args.json ? formatJson(result) + "\n" : formatText(result, { json: false, verbose: args.verbose, maxDisplay: 10 }) + "\n";
308
- const exitCode = result.staleFlags.length > 0 ? 1 : 0;
309
- if (process.stdout.write(output)) {
310
- process.exit(exitCode);
311
- } else {
312
- process.stdout.once("drain", () => process.exit(exitCode));
227
+ const formatter = selectFormatter(args.format);
228
+ const output = formatter(result, {
229
+ version: VERSION,
230
+ scanMode: args.diff ? "changed" : "full",
231
+ verbose: args.verbose
232
+ });
233
+ const hasErrorSignals = result.staleFlags.some(
234
+ (f) => f.signals.some((s) => s.severity === "error")
235
+ );
236
+ const exitCode = args.failOnError && hasErrorSignals ? 1 : result.staleFlags.length > 0 ? 1 : 0;
237
+ if (args.output) {
238
+ writeFileSync(args.output, output);
239
+ return exitCode;
313
240
  }
241
+ const finalOutput = output.endsWith("\n") ? output : output + "\n";
242
+ io.stdout.write(finalOutput);
243
+ return exitCode;
314
244
  }
315
- main().catch((err) => {
245
+
246
+ // src/main.ts
247
+ runCli(process.argv, {
248
+ stdout: process.stdout,
249
+ stderr: process.stderr,
250
+ cwd: process.cwd()
251
+ }).then((code) => process.exit(code)).catch((err) => {
316
252
  console.error(`[error] ${err instanceof Error ? err.message : String(err)}`);
317
253
  process.exit(2);
318
254
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagshark",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Find stale feature flags in your codebase",
6
6
  "license": "MIT",
@@ -17,17 +17,19 @@
17
17
  "main": "./dist/cli.js",
18
18
  "files": ["dist/", "bin/"],
19
19
  "scripts": {
20
- "build": "esbuild src/cli.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/cli.js --external:zod --external:@flagshark/core --external:yaml",
21
- "test": "vitest run",
20
+ "build": "esbuild src/main.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/cli.js --external:zod --external:@flagshark/core --external:yaml",
21
+ "test": "bun run build && vitest run",
22
+ "test:coverage": "bun run build && vitest run --coverage",
22
23
  "typecheck": "tsc --noEmit"
23
24
  },
24
25
  "dependencies": {
25
- "@flagshark/core": "1.3.1",
26
+ "@flagshark/core": "1.4.0",
26
27
  "yaml": "^2.4.0",
27
28
  "zod": "^3.23.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@types/node": "^22.0.0",
32
+ "@vitest/coverage-v8": "^3.0.0",
31
33
  "esbuild": "^0.24.0",
32
34
  "typescript": "^5.7.0",
33
35
  "vitest": "^3.0.0"