sapper-ai 0.6.0 → 0.6.2

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.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export declare function getAuthPath(): string;
2
+ export declare function loadOpenAiApiKey(options?: {
3
+ env?: NodeJS.ProcessEnv;
4
+ authPath?: string;
5
+ }): Promise<string | null>;
6
+ export declare function maskApiKey(apiKey: string): string;
7
+ export declare function promptAndSaveOpenAiApiKey(options?: {
8
+ authPath?: string;
9
+ mask?: string;
10
+ }): Promise<string | null>;
11
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAkBA,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,wBAAsB,gBAAgB,CAAC,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAkB3H;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIjD;AAED,wBAAsB,yBAAyB,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA2B1H"}
package/dist/auth.js ADDED
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.getAuthPath = getAuthPath;
40
+ exports.loadOpenAiApiKey = loadOpenAiApiKey;
41
+ exports.maskApiKey = maskApiKey;
42
+ exports.promptAndSaveOpenAiApiKey = promptAndSaveOpenAiApiKey;
43
+ const node_os_1 = require("node:os");
44
+ const node_path_1 = require("node:path");
45
+ const password_1 = __importDefault(require("@inquirer/password"));
46
+ const fs_1 = require("./utils/fs");
47
+ function isNonEmptyString(value) {
48
+ return typeof value === 'string' && value.trim().length > 0;
49
+ }
50
+ function getAuthPath() {
51
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), '.sapperai', 'auth.json');
52
+ }
53
+ async function loadOpenAiApiKey(options = {}) {
54
+ const env = options.env ?? process.env;
55
+ const fromEnv = env.OPENAI_API_KEY;
56
+ if (isNonEmptyString(fromEnv)) {
57
+ return fromEnv.trim();
58
+ }
59
+ const authPath = options.authPath ?? getAuthPath();
60
+ const raw = await (0, fs_1.readFileIfExists)(authPath);
61
+ if (raw === null)
62
+ return null;
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ const key = parsed.openai?.apiKey;
66
+ return isNonEmptyString(key) ? key.trim() : null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function maskApiKey(apiKey) {
73
+ const trimmed = apiKey.trim();
74
+ if (trimmed.length <= 3)
75
+ return '***';
76
+ return `${trimmed.slice(0, 3)}${'█'.repeat(Math.min(24, Math.max(6, trimmed.length - 3)))}`;
77
+ }
78
+ async function promptAndSaveOpenAiApiKey(options = {}) {
79
+ const key = await (0, password_1.default)({
80
+ message: 'Enter your API key:',
81
+ mask: options.mask ?? '█',
82
+ });
83
+ if (!isNonEmptyString(key))
84
+ return null;
85
+ const authPath = options.authPath ?? getAuthPath();
86
+ const payload = {
87
+ openai: {
88
+ apiKey: key.trim(),
89
+ savedAt: new Date().toISOString(),
90
+ },
91
+ };
92
+ await (0, fs_1.atomicWriteFile)(authPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
93
+ // Best-effort: ensure perms even if the file already existed with broader mode.
94
+ // On Windows this is typically a no-op; prefer env vars there.
95
+ try {
96
+ const { chmod } = await Promise.resolve().then(() => __importStar(require('node:fs/promises')));
97
+ await chmod(authPath, 0o600);
98
+ }
99
+ catch {
100
+ }
101
+ return payload.openai.apiKey;
102
+ }
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAkBA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAuFpF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAiBA,wBAAsB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAmFpF"}
package/dist/cli.js CHANGED
@@ -39,7 +39,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.runCli = runCli;
41
41
  const node_fs_1 = require("node:fs");
42
- const node_child_process_1 = require("node:child_process");
43
42
  const node_os_1 = require("node:os");
44
43
  const node_path_1 = require("node:path");
45
44
  const readline = __importStar(require("node:readline"));
@@ -111,9 +110,6 @@ async function runCli(argv = process.argv.slice(2)) {
111
110
  }
112
111
  return (0, quarantine_1.runQuarantineRestore)({ id: parsed.id, quarantineDir: parsed.quarantineDir, force: parsed.force });
113
112
  }
114
- if (argv[0] === 'dashboard') {
115
- return runDashboard();
116
- }
117
113
  if (argv[0] !== 'init') {
118
114
  printUsage();
119
115
  return 1;
@@ -133,7 +129,8 @@ Usage:
133
129
  sapper-ai scan ./path Scan a specific file/directory
134
130
  sapper-ai scan --policy ./sapperai.config.yaml Use explicit policy path (fatal if invalid)
135
131
  sapper-ai scan --fix Quarantine blocked files
136
- sapper-ai scan --ai Deep scan with AI analysis (requires OPENAI_API_KEY)
132
+ sapper-ai scan --ai Deep scan with AI analysis (OpenAI; prompts for key in a TTY)
133
+ sapper-ai scan --no-color Disable ANSI colors
137
134
  sapper-ai scan --no-prompt Disable all prompts (CI-safe)
138
135
  sapper-ai scan --harden After scan, offer to apply recommended hardening
139
136
  sapper-ai scan --no-open Skip opening report in browser
@@ -146,7 +143,6 @@ Usage:
146
143
  sapper-ai quarantine list List quarantined files
147
144
  sapper-ai quarantine restore <id> [--force] Restore quarantined file by id
148
145
  sapper-ai init Interactive setup wizard
149
- sapper-ai dashboard Launch web dashboard
150
146
  sapper-ai --help Show this help
151
147
 
152
148
  Learn more: https://github.com/sapper-ai/sapperai
@@ -161,6 +157,7 @@ function parseScanArgs(argv) {
161
157
  let ai = false;
162
158
  let noSave = false;
163
159
  let noOpen = false;
160
+ let noColor = false;
164
161
  let noPrompt = false;
165
162
  let harden = false;
166
163
  for (let index = 0; index < argv.length; index += 1) {
@@ -190,6 +187,10 @@ function parseScanArgs(argv) {
190
187
  ai = true;
191
188
  continue;
192
189
  }
190
+ if (arg === '--no-color') {
191
+ noColor = true;
192
+ continue;
193
+ }
193
194
  if (arg === '--no-prompt') {
194
195
  noPrompt = true;
195
196
  continue;
@@ -211,12 +212,13 @@ function parseScanArgs(argv) {
211
212
  }
212
213
  targets.push(arg);
213
214
  }
214
- return { targets, policyPath, fix, deep, system, ai, noSave, noOpen, noPrompt, harden };
215
+ return { targets, policyPath, fix, deep, system, ai, noSave, noOpen, noColor, noPrompt, harden };
215
216
  }
216
217
  function parseHardenArgs(argv) {
217
218
  let apply = false;
218
219
  let includeSystem = false;
219
220
  let yes = false;
221
+ let noColor = false;
220
222
  let noPrompt = false;
221
223
  let force = false;
222
224
  let workflowVersion;
@@ -240,6 +242,10 @@ function parseHardenArgs(argv) {
240
242
  yes = true;
241
243
  continue;
242
244
  }
245
+ if (arg === '--no-color') {
246
+ noColor = true;
247
+ continue;
248
+ }
243
249
  if (arg === '--no-prompt') {
244
250
  noPrompt = true;
245
251
  continue;
@@ -268,6 +274,7 @@ function parseHardenArgs(argv) {
268
274
  apply,
269
275
  includeSystem,
270
276
  yes,
277
+ noColor,
271
278
  noPrompt,
272
279
  force,
273
280
  workflowVersion,
@@ -454,7 +461,7 @@ async function promptScanDepth() {
454
461
  choices: [
455
462
  { name: 'Quick scan (rules only) Fast regex pattern matching', value: false },
456
463
  {
457
- name: 'Deep scan (rules + AI) AI-powered analysis (requires OPENAI_API_KEY)',
464
+ name: 'Deep scan (rules + AI) AI-powered analysis (OpenAI)',
458
465
  value: true,
459
466
  },
460
467
  ],
@@ -468,6 +475,8 @@ async function resolveScanOptions(args) {
468
475
  fix: args.fix,
469
476
  noSave: args.noSave,
470
477
  noOpen: args.noOpen,
478
+ noColor: args.noColor,
479
+ noPrompt: args.noPrompt,
471
480
  policyPath: args.policyPath,
472
481
  };
473
482
  if (args.system) {
@@ -510,43 +519,6 @@ async function resolveScanOptions(args) {
510
519
  }
511
520
  return { ...common, targets: [cwd], deep: true, ai, scopeLabel: 'Current + subdirectories' };
512
521
  }
513
- async function runDashboard() {
514
- const configuredPort = process.env.PORT;
515
- const standalonePort = configuredPort ?? '4100';
516
- const devPort = configuredPort ?? '3000';
517
- try {
518
- // eslint-disable-next-line @typescript-eslint/no-var-requires
519
- const startPath = require.resolve('@sapper-ai/dashboard/bin/start');
520
- process.env.PORT = standalonePort;
521
- // eslint-disable-next-line @typescript-eslint/no-var-requires
522
- require(startPath);
523
- return await new Promise((resolveExit) => {
524
- const stop = () => resolveExit(0);
525
- process.once('SIGINT', stop);
526
- process.once('SIGTERM', stop);
527
- });
528
- }
529
- catch {
530
- }
531
- const webDir = (0, node_path_1.resolve)(__dirname, '../../../apps/web');
532
- if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(webDir, 'package.json'))) {
533
- console.log(`\n SapperAI Dashboard (dev): http://localhost:${devPort}/dashboard\n`);
534
- console.log(' Press Ctrl+C to stop\n');
535
- const child = (0, node_child_process_1.spawn)('npx', ['next', 'dev', '--port', devPort], {
536
- cwd: webDir,
537
- stdio: 'inherit',
538
- env: process.env,
539
- });
540
- process.on('SIGINT', () => child.kill('SIGINT'));
541
- process.on('SIGTERM', () => child.kill('SIGTERM'));
542
- return await new Promise((resolveExit) => {
543
- child.on('close', (code) => resolveExit(code ?? 0));
544
- });
545
- }
546
- console.error('\n Install @sapper-ai/dashboard for standalone mode:');
547
- console.error(' pnpm add @sapper-ai/dashboard\n');
548
- return 1;
549
- }
550
522
  async function runInitWizard() {
551
523
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
552
524
  const ask = (q) => new Promise((res) => rl.question(q, res));
@@ -1 +1 @@
1
- {"version":3,"file":"postinstall.d.ts","sourceRoot":"","sources":["../src/postinstall.ts"],"names":[],"mappings":"AAEA,wBAAgB,cAAc,IAAI,IAAI,CAKrC"}
1
+ {"version":3,"file":"postinstall.d.ts","sourceRoot":"","sources":["../src/postinstall.ts"],"names":[],"mappings":"AAIA,wBAAgB,cAAc,IAAI,IAAI,CAYrC"}
@@ -1,10 +1,19 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.runPostinstall = runPostinstall;
4
- const MESSAGE = "SapperAI installed. Run 'npx sapper-ai scan' and follow the prompts to harden your setup.";
7
+ const package_json_1 = __importDefault(require("../package.json"));
8
+ const format_1 = require("./utils/format");
5
9
  function runPostinstall() {
6
10
  try {
7
- console.log(MESSAGE);
11
+ const colors = (0, format_1.createColors)();
12
+ const version = typeof package_json_1.default.version === 'string' ? package_json_1.default.version : '';
13
+ const name = colors.olive ? `${colors.olive}sapper-ai${colors.reset}` : 'sapper-ai';
14
+ const ver = version ? `${colors.dim}v${version}${colors.reset}` : '';
15
+ console.log(`\n ${name} ${ver}\n`);
16
+ console.log(' Run npx sapper-ai scan to get started.\n');
8
17
  }
9
18
  catch {
10
19
  }
package/dist/scan.d.ts CHANGED
@@ -8,6 +8,8 @@ export interface ScanOptions {
8
8
  ai?: boolean;
9
9
  noSave?: boolean;
10
10
  noOpen?: boolean;
11
+ noPrompt?: boolean;
12
+ noColor?: boolean;
11
13
  }
12
14
  export interface ScanResult {
13
15
  version: '1.0';
@@ -1 +1 @@
1
- {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,EAAE,CAAC,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAgBD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,KAAK,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,EAAE;QACP,cAAc,EAAE,OAAO,CAAA;KACxB,CAAA;IACD,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;QACrB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,MAAM,CAAA;QACpB,kBAAkB,EAAE,MAAM,CAAA;QAC1B,wBAAwB,EAAE,MAAM,CAAA;QAChC,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,QAAQ,EAAE,KAAK,CAAC;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,EAAE,MAAM,CAAA;QACZ,UAAU,EAAE,MAAM,CAAA;QAClB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,OAAO,EAAE,MAAM,EAAE,CAAA;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,SAAS,EAAE,MAAM,EAAE,CAAA;QACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;QACzB,WAAW,EAAE,KAAK,CAAC;YACjB,KAAK,EAAE,MAAM,CAAA;YACb,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAA;YAC3B,SAAS,EAAE,MAAM,CAAA;YACjB,OAAO,EAAE,MAAM,CAAA;SAChB,CAAC,CAAA;KACH,CAAC,CAAA;CACH;AAgcD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAkRxE"}
1
+ {"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAwBA,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,GAAG,CAAC,EAAE,OAAO,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,EAAE,CAAC,EAAE,OAAO,CAAA;IACZ,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAiBD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,KAAK,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,EAAE,OAAO,CAAA;IACX,OAAO,EAAE;QACP,cAAc,EAAE,OAAO,CAAA;KACxB,CAAA;IACD,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;QACrB,YAAY,EAAE,MAAM,CAAA;QACpB,YAAY,EAAE,MAAM,CAAA;QACpB,kBAAkB,EAAE,MAAM,CAAA;QAC1B,wBAAwB,EAAE,MAAM,CAAA;QAChC,OAAO,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,QAAQ,EAAE,KAAK,CAAC;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,EAAE,MAAM,CAAA;QACZ,UAAU,EAAE,MAAM,CAAA;QAClB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,OAAO,EAAE,MAAM,EAAE,CAAA;QACjB,OAAO,EAAE,MAAM,CAAA;QACf,SAAS,EAAE,MAAM,EAAE,CAAA;QACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;QACzB,WAAW,EAAE,KAAK,CAAC;YACjB,KAAK,EAAE,MAAM,CAAA;YACb,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAA;YAC3B,SAAS,EAAE,MAAM,CAAA;YACjB,OAAO,EAAE,MAAM,CAAA;SAChB,CAAC,CAAA;KACH,CAAC,CAAA;CACH;AA8XD,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CA6RxE"}
package/dist/scan.js CHANGED
@@ -38,12 +38,11 @@ const promises_1 = require("node:fs/promises");
38
38
  const node_os_1 = require("node:os");
39
39
  const node_path_1 = require("node:path");
40
40
  const core_1 = require("@sapper-ai/core");
41
+ const auth_1 = require("./auth");
41
42
  const presets_1 = require("./presets");
43
+ const progress_1 = require("./utils/progress");
44
+ const format_1 = require("./utils/format");
42
45
  const repoRoot_1 = require("./utils/repoRoot");
43
- const GREEN = '\x1b[32m';
44
- const YELLOW = '\x1b[33m';
45
- const RED = '\x1b[31m';
46
- const RESET = '\x1b[0m';
47
46
  const SYSTEM_SCAN_PATHS = (() => {
48
47
  const home = (0, node_os_1.homedir)();
49
48
  return [
@@ -134,84 +133,37 @@ async function collectFiles(targetPath, deep) {
134
133
  }
135
134
  return results;
136
135
  }
137
- function riskColor(risk) {
138
- if (risk >= 0.8)
139
- return RED;
140
- if (risk >= 0.5)
141
- return YELLOW;
142
- return GREEN;
143
- }
144
- function stripAnsi(text) {
145
- return text.replace(/\x1b\[[0-9;]*m/g, '');
146
- }
147
- function truncateToWidth(text, maxWidth) {
148
- if (maxWidth <= 0) {
149
- return '';
150
- }
151
- if (text.length <= maxWidth) {
152
- return text;
153
- }
154
- if (maxWidth <= 3) {
155
- return '.'.repeat(maxWidth);
156
- }
157
- return `...${text.slice(text.length - (maxWidth - 3))}`;
158
- }
159
- function renderProgressBar(current, total, width) {
160
- const safeTotal = Math.max(1, total);
161
- const pct = Math.floor((current / safeTotal) * 100);
162
- const filled = Math.floor((current / safeTotal) * width);
163
- const bar = '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
164
- return ` ${bar} ${pct}% │ ${current}/${total} files`;
165
- }
166
136
  function extractPatternLabel(decision) {
167
137
  const reason = decision.reasons[0];
168
138
  if (!reason)
169
139
  return 'threat';
170
140
  return reason.startsWith('Detected pattern: ') ? reason.slice('Detected pattern: '.length) : reason;
171
141
  }
172
- function padRight(text, width) {
173
- if (text.length >= width)
174
- return text;
175
- return text + ' '.repeat(width - text.length);
176
- }
177
- function padRightVisual(text, width) {
178
- const visLen = stripAnsi(text).length;
179
- if (visLen >= width)
180
- return text;
181
- return text + ' '.repeat(width - visLen);
182
- }
183
- function padLeft(text, width) {
184
- if (text.length >= width)
185
- return text;
186
- return ' '.repeat(width - text.length) + text;
187
- }
188
142
  function renderFindingsTable(findings, opts) {
189
- const rows = findings.map((f, i) => {
190
- const file = f.filePath.startsWith(opts.cwd + '/') ? f.filePath.slice(opts.cwd.length + 1) : f.filePath;
191
- const pattern = extractPatternLabel(f.decision);
192
- const riskValue = f.decision.risk.toFixed(2);
193
- const riskPlain = padLeft(riskValue, 4);
194
- const risk = opts.color ? `${riskColor(f.decision.risk)}${riskPlain}${RESET}` : riskPlain;
195
- return { idx: String(i + 1), file, risk, pattern };
196
- });
197
- const idxWidth = Math.max(1, ...rows.map((r) => r.idx.length));
198
143
  const riskWidth = 4;
199
- const patternWidth = Math.min(20, Math.max('Pattern'.length, ...rows.map((r) => r.pattern.length)));
200
- const baseWidth = 2 + idxWidth + 2 + 2 + riskWidth + 2 + 2 + patternWidth + 2;
144
+ const patternWidth = Math.min(20, Math.max('Pattern'.length, ...findings.map((f) => extractPatternLabel(f.decision).length)));
145
+ const sourceWidth = opts.includeSource ? Math.max('Source'.length, 5) : 0;
201
146
  const maxTableWidth = Math.max(60, Math.min(opts.columns || 80, 120));
147
+ const sepWidth = 2;
148
+ const baseWidth = 'File'.length + sepWidth + riskWidth + sepWidth + patternWidth + (opts.includeSource ? sepWidth + sourceWidth : 0);
202
149
  const fileWidth = Math.max(20, Math.min(50, maxTableWidth - baseWidth));
203
- const top = ` ┌${'─'.repeat(idxWidth + 2)}┬${''.repeat(fileWidth + 2)}┬${''.repeat(riskWidth + 2)}┬${''.repeat(patternWidth + 2)}┐`;
204
- const header = ` │ ${padRight('#', idxWidth)} │ ${padRight('File', fileWidth)} ${padRight('Risk', riskWidth)} │ ${padRight('Pattern', patternWidth)} │`;
205
- const sep = ` ├${'─'.repeat(idxWidth + 2)}┼${''.repeat(fileWidth + 2)}┼${'─'.repeat(riskWidth + 2)}┼${'─'.repeat(patternWidth + 2)}┤`;
206
- const lines = [top, header, sep];
207
- for (const r of rows) {
208
- const file = truncateToWidth(r.file, fileWidth);
209
- const pattern = truncateToWidth(r.pattern, patternWidth);
210
- lines.push(` │ ${padRight(r.idx, idxWidth)} │ ${padRight(file, fileWidth)} │ ${padRightVisual(r.risk, riskWidth)} │ ${padRight(pattern, patternWidth)} │`);
211
- }
212
- const bottom = ` └${'─'.repeat(idxWidth + 2)}┴${'─'.repeat(fileWidth + 2)}┴${'─'.repeat(riskWidth + 2)}┴${'─'.repeat(patternWidth + 2)}┘`;
213
- lines.push(bottom);
214
- return lines;
150
+ const headers = opts.includeSource ? ['File', 'Risk', 'Pattern', 'Source'] : ['File', 'Risk', 'Pattern'];
151
+ const rows = findings.map((f) => {
152
+ const relative = f.filePath.startsWith(opts.cwd + '/') ? f.filePath.slice(opts.cwd.length + 1) : f.filePath;
153
+ const file = (0, format_1.truncateToWidth)(relative, fileWidth);
154
+ const label = extractPatternLabel(f.decision);
155
+ const patternPlain = (0, format_1.truncateToWidth)(label, patternWidth);
156
+ const pattern = `${opts.colors.dim}${patternPlain}${opts.colors.reset}`;
157
+ const riskValue = f.decision.risk.toFixed(2);
158
+ const riskPlain = (0, format_1.padLeft)(riskValue, riskWidth);
159
+ const risk = `${(0, format_1.riskColor)(f.decision.risk, opts.colors)}${riskPlain}${opts.colors.reset}`;
160
+ if (!opts.includeSource) {
161
+ return [file, risk, pattern];
162
+ }
163
+ const src = f.source === 'ai' ? `${opts.colors.olive}ai${opts.colors.reset}` : `${opts.colors.dim}rules${opts.colors.reset}`;
164
+ return [file, risk, pattern, src];
165
+ });
166
+ return (0, format_1.table)(headers, rows, opts.colors);
215
167
  }
216
168
  function isThreat(decision, policy) {
217
169
  const { riskThreshold, blockMinConfidence } = getThresholds(policy);
@@ -390,15 +342,34 @@ async function buildScanResult(params) {
390
342
  }
391
343
  async function runScan(options = {}) {
392
344
  const cwd = process.cwd();
345
+ const colors = (0, format_1.createColors)({ noColor: options.noColor });
393
346
  const policy = resolvePolicy(cwd, { policyPath: options.policyPath });
394
347
  const fix = options.fix === true;
348
+ console.log(`\n${(0, format_1.header)('scan', colors)}\n`);
395
349
  const aiEnabled = options.ai === true;
396
350
  let llmConfig = null;
397
351
  if (aiEnabled) {
398
- const apiKey = process.env.OPENAI_API_KEY;
352
+ let apiKey = await (0, auth_1.loadOpenAiApiKey)();
399
353
  if (!apiKey) {
400
- console.log('\n Error: OPENAI_API_KEY environment variable is required for --ai mode.\n');
401
- return 1;
354
+ const canPrompt = options.noPrompt !== true && process.stdout.isTTY === true && process.stdin.isTTY === true;
355
+ if (!canPrompt) {
356
+ console.log(' Error: OPENAI_API_KEY environment variable is required for --ai mode.\n');
357
+ return 1;
358
+ }
359
+ console.log(' No OpenAI API key found.\n');
360
+ console.log(` ${colors.olive}Get one at https://platform.openai.com/api-keys${colors.reset}`);
361
+ console.log();
362
+ apiKey = await (0, auth_1.promptAndSaveOpenAiApiKey)();
363
+ if (!apiKey) {
364
+ console.log('\n Error: API key is required for --ai mode.\n');
365
+ return 1;
366
+ }
367
+ const authPath = (0, auth_1.getAuthPath)();
368
+ const home = (0, node_os_1.homedir)();
369
+ const displayAuthPath = authPath === home ? '~' : authPath.startsWith(home + '/') ? `~/${authPath.slice(home.length + 1)}` : authPath;
370
+ console.log();
371
+ console.log(`${colors.dim} Key saved to ${displayAuthPath}${colors.reset}`);
372
+ console.log();
402
373
  }
403
374
  llmConfig = { provider: 'openai', apiKey, model: 'gpt-4.1-mini' };
404
375
  }
@@ -412,17 +383,6 @@ async function runScan(options = {}) {
412
383
  const detectors = (0, core_1.createDetectors)({ policy, preferredDetectors: ['rules'] });
413
384
  const quarantineDir = process.env.SAPPERAI_QUARANTINE_DIR;
414
385
  const quarantineManager = quarantineDir ? new core_1.QuarantineManager({ quarantineDir }) : new core_1.QuarantineManager();
415
- const isTTY = process.stdout.isTTY === true;
416
- const color = isTTY;
417
- const scopeLabel = options.scopeLabel ??
418
- (options.system
419
- ? 'AI system scan'
420
- : deep
421
- ? 'Current + subdirectories'
422
- : 'Current directory only');
423
- console.log('\n SapperAI Security Scanner\n');
424
- console.log(` Scope: ${scopeLabel}`);
425
- console.log();
426
386
  const fileSet = new Set();
427
387
  for (const target of targets) {
428
388
  const files = await collectFiles(target, deep);
@@ -431,139 +391,137 @@ async function runScan(options = {}) {
431
391
  }
432
392
  }
433
393
  const files = Array.from(fileSet).sort();
434
- console.log(` Collecting files... ${files.length} files found`);
435
- const eligibleByName = files.filter((f) => (0, core_1.isConfigLikeFile)(f)).length;
436
- console.log(` Filter: config-like only (${eligibleByName} eligible / ${files.length} total)`);
394
+ const eligibleFiles = files.filter((f) => (0, core_1.isConfigLikeFile)(f));
395
+ const eligibleByName = eligibleFiles.length;
396
+ const skippedNotEligible = Math.max(0, files.length - eligibleByName);
397
+ console.log(`${colors.dim} Scanning ${eligibleByName} files...${colors.reset}`);
437
398
  console.log();
438
- if (aiEnabled) {
439
- console.log(' Phase 1/2: Rules scan');
440
- console.log();
441
- }
442
399
  const scannedFindings = [];
443
400
  let scannedFiles = 0;
444
- let eligibleFiles = 0;
445
- let skippedNotEligible = 0;
446
401
  let skippedEmptyOrUnreadable = 0;
447
- const total = files.length;
448
- const progressWidth = Math.max(10, Math.min(30, (process.stdout.columns ?? 80) - 30));
449
- for (let i = 0; i < files.length; i += 1) {
450
- const filePath = files[i];
451
- if (isTTY && total > 0) {
452
- const bar = renderProgressBar(i + 1, total, progressWidth);
453
- const label = ' Scanning: ';
454
- const maxPath = Math.max(10, (process.stdout.columns ?? 80) - stripAnsi(bar).length - label.length);
455
- const scanning = `${label}${truncateToWidth(filePath, maxPath)}`;
456
- if (i === 0) {
457
- process.stdout.write(`${bar}\n${scanning}\n`);
402
+ const rulesProgress = (0, progress_1.createProgressBar)({
403
+ label: aiEnabled ? 'Phase 1 rules' : 'Scan',
404
+ total: eligibleFiles.length,
405
+ colors,
406
+ });
407
+ rulesProgress.start();
408
+ try {
409
+ for (const filePath of eligibleFiles) {
410
+ try {
411
+ const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
412
+ if (result.skipReason === 'empty_or_unreadable') {
413
+ skippedEmptyOrUnreadable += 1;
414
+ continue;
415
+ }
416
+ if (result.scanned && result.decision) {
417
+ scannedFiles += 1;
418
+ scannedFindings.push({
419
+ filePath,
420
+ decision: result.decision,
421
+ quarantinedId: result.quarantinedId,
422
+ source: aiEnabled ? 'rules' : undefined,
423
+ });
424
+ }
458
425
  }
459
- else {
460
- process.stdout.write(`\x1b[2A\x1b[2K\r${bar}\n\x1b[2K\r${scanning}\n`);
426
+ finally {
427
+ rulesProgress.tick(filePath);
461
428
  }
462
429
  }
463
- const result = await scanFile(filePath, policy, scanner, detectors, fix, quarantineManager);
464
- if (result.skipReason === 'not_eligible') {
465
- skippedNotEligible += 1;
466
- continue;
467
- }
468
- eligibleFiles += 1;
469
- if (result.skipReason === 'empty_or_unreadable') {
470
- skippedEmptyOrUnreadable += 1;
471
- continue;
472
- }
473
- if (result.scanned && result.decision) {
474
- scannedFiles += 1;
475
- scannedFindings.push({ filePath, decision: result.decision, quarantinedId: result.quarantinedId });
476
- }
477
430
  }
478
- if (isTTY && total > 0) {
479
- process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
431
+ finally {
432
+ rulesProgress.done();
480
433
  }
434
+ let aiTargetsCount = 0;
481
435
  if (aiEnabled && llmConfig) {
482
436
  const suspiciousFindings = scannedFindings.filter((f) => f.decision.risk >= 0.5);
483
437
  const maxAiFiles = 50;
484
438
  if (suspiciousFindings.length > 0) {
485
439
  const aiTargets = suspiciousFindings.slice(0, maxAiFiles);
486
- if (suspiciousFindings.length > maxAiFiles) {
487
- console.log(` Note: AI scan limited to ${maxAiFiles} files (${suspiciousFindings.length} suspicious)`);
488
- }
489
- console.log();
490
- console.log(` Phase 2/2: AI deep scan (${aiTargets.length} files)`);
491
- console.log();
440
+ aiTargetsCount = aiTargets.length;
492
441
  const detectorsList = (policy.detectors ?? ['rules']).slice();
493
442
  if (!detectorsList.includes('llm')) {
494
443
  detectorsList.push('llm');
495
444
  }
496
445
  const aiPolicy = { ...policy, llm: llmConfig, detectors: detectorsList };
497
446
  const aiDetectors = (0, core_1.createDetectors)({ policy: aiPolicy, preferredDetectors: ['rules', 'llm'] });
498
- for (let i = 0; i < aiTargets.length; i += 1) {
499
- const finding = aiTargets[i];
500
- if (isTTY) {
501
- const bar = renderProgressBar(i + 1, aiTargets.length, progressWidth);
502
- const label = ' Analyzing: ';
503
- const maxPath = Math.max(10, (process.stdout.columns ?? 80) - stripAnsi(bar).length - label.length);
504
- const scanning = `${label}${truncateToWidth(finding.filePath, maxPath)}`;
505
- if (i === 0) {
506
- process.stdout.write(`${bar}\n${scanning}\n`);
507
- }
508
- else {
509
- process.stdout.write(`\x1b[2A\x1b[2K\r${bar}\n\x1b[2K\r${scanning}\n`);
510
- }
511
- }
512
- try {
513
- const raw = await readFileIfPresent(finding.filePath);
514
- if (!raw)
515
- continue;
516
- const surface = (0, core_1.normalizeSurfaceText)(raw);
517
- const targetType = (0, core_1.classifyTargetType)(finding.filePath);
518
- const id = `${targetType}:${(0, core_1.buildEntryName)(finding.filePath)}`;
519
- const aiDecision = await scanner.scanTool(id, surface, aiPolicy, aiDetectors, {
520
- scanSource: 'file_surface',
521
- sourcePath: finding.filePath,
522
- sourceType: targetType,
523
- });
524
- const mergedReasons = uniq([...finding.decision.reasons, ...aiDecision.reasons]);
525
- const existingEvidence = finding.decision.evidence;
526
- const mergedEvidence = [...existingEvidence];
527
- for (const ev of aiDecision.evidence) {
528
- if (!mergedEvidence.some((e) => e.detectorId === ev.detectorId)) {
529
- mergedEvidence.push(ev);
447
+ const aiProgress = (0, progress_1.createProgressBar)({
448
+ label: 'Phase 2 ai',
449
+ total: aiTargets.length,
450
+ colors,
451
+ });
452
+ aiProgress.start();
453
+ try {
454
+ for (const finding of aiTargets) {
455
+ try {
456
+ const raw = await readFileIfPresent(finding.filePath);
457
+ if (!raw)
458
+ continue;
459
+ const surface = (0, core_1.normalizeSurfaceText)(raw);
460
+ const targetType = (0, core_1.classifyTargetType)(finding.filePath);
461
+ const id = `${targetType}:${(0, core_1.buildEntryName)(finding.filePath)}`;
462
+ const aiDecision = await scanner.scanTool(id, surface, aiPolicy, aiDetectors, {
463
+ scanSource: 'file_surface',
464
+ sourcePath: finding.filePath,
465
+ sourceType: targetType,
466
+ });
467
+ const aiDominates = aiDecision.risk > finding.decision.risk;
468
+ const mergedReasons = aiDominates
469
+ ? uniq([...aiDecision.reasons, ...finding.decision.reasons])
470
+ : uniq([...finding.decision.reasons, ...aiDecision.reasons]);
471
+ const existingEvidence = finding.decision.evidence;
472
+ const mergedEvidence = [...existingEvidence];
473
+ for (const ev of aiDecision.evidence) {
474
+ if (!mergedEvidence.some((e) => e.detectorId === ev.detectorId)) {
475
+ mergedEvidence.push(ev);
476
+ }
530
477
  }
531
- }
532
- const nextDecision = {
533
- ...finding.decision,
534
- reasons: mergedReasons,
535
- evidence: mergedEvidence,
536
- };
537
- if (aiDecision.risk > finding.decision.risk) {
538
- finding.decision = {
539
- ...nextDecision,
540
- action: aiDecision.action,
541
- risk: aiDecision.risk,
542
- confidence: aiDecision.confidence,
478
+ const nextDecision = {
479
+ ...finding.decision,
480
+ reasons: mergedReasons,
481
+ evidence: mergedEvidence,
543
482
  };
483
+ if (aiDominates) {
484
+ finding.source = 'ai';
485
+ finding.decision = {
486
+ ...nextDecision,
487
+ action: aiDecision.action,
488
+ risk: aiDecision.risk,
489
+ confidence: aiDecision.confidence,
490
+ };
491
+ }
492
+ else {
493
+ finding.source = finding.source ?? 'rules';
494
+ finding.decision = nextDecision;
495
+ }
496
+ finding.aiAnalysis =
497
+ aiDecision.reasons.find((r) => !r.startsWith('Detected pattern:')) ?? null;
544
498
  }
545
- else {
546
- finding.decision = nextDecision;
499
+ catch {
500
+ }
501
+ finally {
502
+ aiProgress.tick(finding.filePath);
547
503
  }
548
- finding.aiAnalysis =
549
- aiDecision.reasons.find((r) => !r.startsWith('Detected pattern:')) ?? null;
550
- }
551
- catch {
552
504
  }
553
505
  }
554
- if (isTTY && aiTargets.length > 0) {
555
- process.stdout.write('\x1b[2A\x1b[2K\r\x1b[1B\x1b[2K\r');
506
+ finally {
507
+ aiProgress.done();
556
508
  }
557
509
  }
558
510
  }
511
+ const scopeLabel = options.scopeLabel ??
512
+ (options.system
513
+ ? 'AI system scan'
514
+ : deep
515
+ ? 'Current + subdirectories'
516
+ : 'Current directory only');
559
517
  const skippedFiles = skippedNotEligible + skippedEmptyOrUnreadable;
560
518
  const threats = scannedFindings.filter((f) => isThreat(f.decision, policy));
561
519
  const scanResult = await buildScanResult({
562
520
  scope: scopeLabel,
563
521
  target: targets.join(', '),
564
522
  ai: aiEnabled,
565
- totalFiles: total,
566
- eligibleFiles,
523
+ totalFiles: files.length,
524
+ eligibleFiles: eligibleByName,
567
525
  scannedFiles,
568
526
  skippedFiles,
569
527
  skippedNotEligible,
@@ -581,8 +539,8 @@ async function runScan(options = {}) {
581
539
  const html = generateHtmlReport(scanResult);
582
540
  const htmlPath = (0, node_path_1.join)(scanDir, `${ts}.html`);
583
541
  await (0, promises_1.writeFile)(htmlPath, html, 'utf8');
584
- console.log(` Saved to ${jsonPath}`);
585
- console.log(` Report: ${htmlPath}`);
542
+ console.log(`${colors.dim} Saved to ${jsonPath}${colors.reset}`);
543
+ console.log(`${colors.dim} Report: ${htmlPath}${colors.reset}`);
586
544
  console.log();
587
545
  if (options.noOpen !== true) {
588
546
  try {
@@ -599,28 +557,33 @@ async function runScan(options = {}) {
599
557
  }
600
558
  }
601
559
  }
560
+ if (aiEnabled) {
561
+ const countWidth = Math.max(String(eligibleByName).length, String(aiTargetsCount).length);
562
+ const rulesName = `${colors.dim}rules${colors.reset}`;
563
+ const aiName = `${colors.olive}ai${colors.reset}`;
564
+ console.log(` Phase 1 ${(0, format_1.padRightVisual)(rulesName, 5)} ${(0, format_1.padLeft)(String(eligibleByName), countWidth)} files`);
565
+ console.log(` Phase 2 ${(0, format_1.padRightVisual)(aiName, 5)} ${(0, format_1.padLeft)(String(aiTargetsCount), countWidth)} files`);
566
+ console.log();
567
+ }
602
568
  if (threats.length === 0) {
603
- const msg = ` All clear — ${scannedFiles}/${eligibleFiles} eligible files scanned, 0 threats detected (${total} total files)`;
604
- console.log(color ? `${GREEN}${msg}${RESET}` : msg);
569
+ console.log(` ${colors.olive}All clear — 0 threats in ${eligibleByName} files${colors.reset}`);
605
570
  console.log();
571
+ return 0;
572
+ }
573
+ console.log(renderFindingsTable(threats, {
574
+ cwd,
575
+ columns: process.stdout.columns ?? 80,
576
+ colors,
577
+ includeSource: aiEnabled,
578
+ }));
579
+ console.log();
580
+ console.log(` ${threats.length} threats found in ${eligibleByName} files (${files.length} total)`);
581
+ console.log();
582
+ if (!fix) {
583
+ console.log(' Run npx sapper-ai scan --fix to quarantine.\n');
606
584
  }
607
585
  else {
608
- const warn = ` ⚠ ${scannedFiles}/${eligibleFiles} eligible files scanned, ${threats.length} threats detected (${total} total files)`;
609
- console.log(color ? `${RED}${warn}${RESET}` : warn);
610
586
  console.log();
611
- const tableLines = renderFindingsTable(threats, {
612
- cwd,
613
- columns: process.stdout.columns ?? 80,
614
- color,
615
- });
616
- for (const line of tableLines) {
617
- console.log(line);
618
- }
619
- console.log();
620
- if (!fix) {
621
- console.log(" Run 'npx sapper-ai scan --fix' to quarantine blocked files.");
622
- console.log();
623
- }
624
587
  }
625
- return threats.length > 0 ? 1 : 0;
588
+ return 1;
626
589
  }
@@ -0,0 +1,22 @@
1
+ export interface Colors {
2
+ olive: string;
3
+ dim: string;
4
+ bold: string;
5
+ red: string;
6
+ yellow: string;
7
+ reset: string;
8
+ }
9
+ export declare function createColors(options?: {
10
+ noColor?: boolean;
11
+ env?: NodeJS.ProcessEnv;
12
+ stdoutIsTTY?: boolean;
13
+ }): Colors;
14
+ export declare function header(command: string, colors: Colors): string;
15
+ export declare function riskColor(risk: number, colors: Colors): string;
16
+ export declare function stripAnsi(text: string): string;
17
+ export declare function truncateToWidth(text: string, maxWidth: number): string;
18
+ export declare function padRight(text: string, width: number): string;
19
+ export declare function padRightVisual(text: string, width: number): string;
20
+ export declare function padLeft(text: string, width: number): string;
21
+ export declare function table(headers: string[], rows: string[][], colors: Colors): string;
22
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;CACd;AAQD,wBAAgB,YAAY,CAC1B,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAAO,GAClF,MAAM,CAkBR;AAGD,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAG9D;AAGD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAStE;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAG5D;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIlE;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAG3D;AAID,wBAAgB,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAsBjF"}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createColors = createColors;
4
+ exports.header = header;
5
+ exports.riskColor = riskColor;
6
+ exports.stripAnsi = stripAnsi;
7
+ exports.truncateToWidth = truncateToWidth;
8
+ exports.padRight = padRight;
9
+ exports.padRightVisual = padRightVisual;
10
+ exports.padLeft = padLeft;
11
+ exports.table = table;
12
+ function supportsTruecolor(env) {
13
+ const value = env.COLORTERM?.toLowerCase();
14
+ if (!value)
15
+ return false;
16
+ return value.includes('truecolor') || value.includes('24bit');
17
+ }
18
+ function createColors(options = {}) {
19
+ const env = options.env ?? process.env;
20
+ const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY;
21
+ const disabled = env.NO_COLOR !== undefined || options.noColor === true || stdoutIsTTY !== true;
22
+ if (disabled) {
23
+ return { olive: '', dim: '', bold: '', red: '', yellow: '', reset: '' };
24
+ }
25
+ const olive = supportsTruecolor(env) ? '\x1b[38;2;107;142;35m' : '\x1b[32m';
26
+ return {
27
+ olive,
28
+ dim: '\x1b[2m',
29
+ bold: '\x1b[1m',
30
+ red: '\x1b[31m',
31
+ yellow: '\x1b[33m',
32
+ reset: '\x1b[0m',
33
+ };
34
+ }
35
+ // Header: " sapper-ai <command>"
36
+ function header(command, colors) {
37
+ const name = colors.olive ? `${colors.olive}sapper-ai${colors.reset}` : 'sapper-ai';
38
+ return ` ${name} ${command}`;
39
+ }
40
+ // Risk color by value (>= 0.8 red+bold, 0.5~0.8 yellow, < 0.5 dim)
41
+ function riskColor(risk, colors) {
42
+ if (risk >= 0.8)
43
+ return `${colors.bold}${colors.red}`;
44
+ if (risk >= 0.5)
45
+ return colors.yellow;
46
+ return colors.dim;
47
+ }
48
+ function stripAnsi(text) {
49
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
50
+ }
51
+ function truncateToWidth(text, maxWidth) {
52
+ if (maxWidth <= 0)
53
+ return '';
54
+ if (text.length <= maxWidth)
55
+ return text;
56
+ if (maxWidth <= 3) {
57
+ return '.'.repeat(maxWidth);
58
+ }
59
+ return `...${text.slice(text.length - (maxWidth - 3))}`;
60
+ }
61
+ function padRight(text, width) {
62
+ if (text.length >= width)
63
+ return text;
64
+ return text + ' '.repeat(width - text.length);
65
+ }
66
+ function padRightVisual(text, width) {
67
+ const visLen = stripAnsi(text).length;
68
+ if (visLen >= width)
69
+ return text;
70
+ return text + ' '.repeat(width - visLen);
71
+ }
72
+ function padLeft(text, width) {
73
+ if (text.length >= width)
74
+ return text;
75
+ return ' '.repeat(width - text.length) + text;
76
+ }
77
+ // Aligned table (Vercel style, no box borders).
78
+ // ANSI-aware: stripAnsi based visible width.
79
+ function table(headers, rows, colors) {
80
+ const columnCount = headers.length;
81
+ const normalizedRows = rows.map((row) => {
82
+ const out = row.slice(0, columnCount);
83
+ while (out.length < columnCount)
84
+ out.push('');
85
+ return out;
86
+ });
87
+ const headerRow = headers.map((h) => (colors.dim ? `${colors.dim}${h}${colors.reset}` : h));
88
+ const all = [headerRow, ...normalizedRows];
89
+ const widths = headers.map((_, col) => Math.max(0, ...all.map((r) => stripAnsi(r[col] ?? '').length)));
90
+ const sep = ' ';
91
+ const lines = [];
92
+ lines.push(` ${headerRow.map((cell, i) => padRightVisual(cell, widths[i])).join(sep)}`.trimEnd());
93
+ for (const row of normalizedRows) {
94
+ lines.push(` ${row.map((cell, i) => padRightVisual(cell, widths[i])).join(sep)}`.trimEnd());
95
+ }
96
+ return lines.join('\n');
97
+ }
@@ -1,5 +1,7 @@
1
1
  export declare function readFileIfExists(filePath: string): Promise<string | null>;
2
2
  export declare function ensureDir(dirPath: string): Promise<void>;
3
3
  export declare function backupFile(originalPath: string): Promise<string>;
4
- export declare function atomicWriteFile(filePath: string, content: string): Promise<void>;
4
+ export declare function atomicWriteFile(filePath: string, content: string, options?: {
5
+ mode?: number;
6
+ }): Promise<void>;
5
7
  //# sourceMappingURL=fs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../src/utils/fs.ts"],"names":[],"mappings":"AAIA,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAM/E;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAkBD,wBAAsB,UAAU,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKtE;AAED,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAStF"}
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../src/utils/fs.ts"],"names":[],"mappings":"AAIA,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAM/E;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9D;AAkBD,wBAAsB,UAAU,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKtE;AAED,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9B,OAAO,CAAC,IAAI,CAAC,CASf"}
package/dist/utils/fs.js CHANGED
@@ -37,11 +37,11 @@ async function backupFile(originalPath) {
37
37
  await (0, promises_1.copyFile)(originalPath, backupPath);
38
38
  return backupPath;
39
39
  }
40
- async function atomicWriteFile(filePath, content) {
40
+ async function atomicWriteFile(filePath, content, options = {}) {
41
41
  const dir = (0, node_path_1.dirname)(filePath);
42
42
  await ensureDir(dir);
43
43
  const tmpName = `.${(0, node_path_1.basename)(filePath)}.tmp.${process.pid}.${Date.now()}`;
44
44
  const tmpPath = (0, node_path_1.join)(dir, tmpName);
45
- await (0, promises_1.writeFile)(tmpPath, content, 'utf8');
45
+ await (0, promises_1.writeFile)(tmpPath, content, { encoding: 'utf8', mode: options.mode });
46
46
  await (0, promises_1.rename)(tmpPath, filePath);
47
47
  }
@@ -0,0 +1,40 @@
1
+ import type { Colors } from './format';
2
+ export interface ProgressStream {
3
+ isTTY?: boolean;
4
+ columns?: number;
5
+ write(text: string): boolean;
6
+ }
7
+ export interface ProgressBarOptions {
8
+ label: string;
9
+ total: number;
10
+ colors: Colors;
11
+ stream?: ProgressStream;
12
+ now?: () => number;
13
+ minIntervalMs?: number;
14
+ minBarWidth?: number;
15
+ maxBarWidth?: number;
16
+ }
17
+ export declare class ProgressBar {
18
+ private readonly options;
19
+ private readonly stream;
20
+ private readonly now;
21
+ private readonly minIntervalMs;
22
+ private readonly minBarWidth;
23
+ private readonly maxBarWidth;
24
+ private readonly enabled;
25
+ private current;
26
+ private detail;
27
+ private lastRenderAt;
28
+ private rendered;
29
+ private finished;
30
+ constructor(options: ProgressBarOptions);
31
+ start(detail?: string): void;
32
+ tick(detail?: string): void;
33
+ done(detail?: string): void;
34
+ private render;
35
+ private isComplete;
36
+ private renderLine;
37
+ private renderBar;
38
+ }
39
+ export declare function createProgressBar(options: ProgressBarOptions): ProgressBar;
40
+ //# sourceMappingURL=progress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"progress.d.ts","sourceRoot":"","sources":["../../src/utils/progress.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAGtC,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,cAAc,CAAA;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAUD,qBAAa,WAAW;IAaV,OAAO,CAAC,QAAQ,CAAC,OAAO;IAZpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgB;IACvC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IACpC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,OAAO,CAAI;IACnB,OAAO,CAAC,MAAM,CAAoB;IAClC,OAAO,CAAC,YAAY,CAAI;IACxB,OAAO,CAAC,QAAQ,CAAQ;IACxB,OAAO,CAAC,QAAQ,CAAQ;gBAEK,OAAO,EAAE,kBAAkB;IASxD,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAM5B,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAU3B,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAW3B,OAAO,CAAC,MAAM;IAYd,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,UAAU;IA4BlB,OAAO,CAAC,SAAS;CAoBlB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAE1E"}
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProgressBar = void 0;
4
+ exports.createProgressBar = createProgressBar;
5
+ const format_1 = require("./format");
6
+ const DEFAULT_MIN_INTERVAL_MS = 100;
7
+ const DEFAULT_MIN_BAR_WIDTH = 10;
8
+ const DEFAULT_MAX_BAR_WIDTH = 40;
9
+ function clamp(value, min, max) {
10
+ return Math.min(max, Math.max(min, value));
11
+ }
12
+ class ProgressBar {
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.current = 0;
16
+ this.lastRenderAt = 0;
17
+ this.rendered = false;
18
+ this.finished = false;
19
+ this.stream = options.stream ?? process.stdout;
20
+ this.now = options.now ?? Date.now;
21
+ this.minIntervalMs = options.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
22
+ this.minBarWidth = options.minBarWidth ?? DEFAULT_MIN_BAR_WIDTH;
23
+ this.maxBarWidth = options.maxBarWidth ?? DEFAULT_MAX_BAR_WIDTH;
24
+ this.enabled = this.stream.isTTY === true;
25
+ }
26
+ start(detail) {
27
+ if (!this.enabled)
28
+ return;
29
+ this.detail = detail;
30
+ this.render(true);
31
+ }
32
+ tick(detail) {
33
+ if (!this.enabled || this.finished)
34
+ return;
35
+ this.current += 1;
36
+ if (this.options.total > 0 && this.current > this.options.total) {
37
+ this.current = this.options.total;
38
+ }
39
+ this.detail = detail;
40
+ this.render(false);
41
+ }
42
+ done(detail) {
43
+ if (!this.enabled || this.finished)
44
+ return;
45
+ if (this.options.total > 0) {
46
+ this.current = this.options.total;
47
+ }
48
+ this.detail = detail;
49
+ this.render(true);
50
+ this.stream.write('\n');
51
+ this.finished = true;
52
+ }
53
+ render(force) {
54
+ const now = this.now();
55
+ if (!force && this.rendered && now - this.lastRenderAt < this.minIntervalMs && !this.isComplete()) {
56
+ return;
57
+ }
58
+ const line = this.renderLine();
59
+ this.stream.write(`\r\x1b[2K${line}`);
60
+ this.lastRenderAt = now;
61
+ this.rendered = true;
62
+ }
63
+ isComplete() {
64
+ if (this.options.total <= 0) {
65
+ return true;
66
+ }
67
+ return this.current >= this.options.total;
68
+ }
69
+ renderLine() {
70
+ const total = this.options.total;
71
+ const safeCurrent = total > 0 ? clamp(this.current, 0, total) : 0;
72
+ const ratio = total > 0 ? safeCurrent / total : 1;
73
+ const percent = Math.round(ratio * 100);
74
+ const countText = `${safeCurrent}/${total}`;
75
+ const percentText = `${String(percent).padStart(3, ' ')}%`;
76
+ const suffix = `${countText} ${percentText}`;
77
+ const columns = this.stream.columns ?? 80;
78
+ const label = this.options.label;
79
+ const baseReserved = 2 + label.length + 1 + 2 + 2 + 1 + suffix.length;
80
+ const maxFitWidth = Math.max(1, columns - baseReserved);
81
+ const availableForBar = maxFitWidth >= this.minBarWidth ? Math.min(this.maxBarWidth, maxFitWidth) : maxFitWidth;
82
+ const bar = this.renderBar(availableForBar, ratio);
83
+ const detailPrefix = ' ';
84
+ const detailMaxWidth = Math.max(0, columns - (2 + label.length + 1 + 2 + availableForBar + 2 + suffix.length + detailPrefix.length));
85
+ const rawDetail = this.detail ? (0, format_1.truncateToWidth)(this.detail, detailMaxWidth) : '';
86
+ const detail = rawDetail
87
+ ? `${detailPrefix}${this.options.colors.dim}${rawDetail}${this.options.colors.reset}`
88
+ : '';
89
+ return ` ${label} [${bar}] ${suffix}${detail}`;
90
+ }
91
+ renderBar(width, ratio) {
92
+ if (width <= 0)
93
+ return '';
94
+ const safeRatio = clamp(ratio, 0, 1);
95
+ const filled = Math.floor(safeRatio * width);
96
+ if (filled >= width) {
97
+ const body = '='.repeat(width);
98
+ return this.options.colors.olive ? `${this.options.colors.olive}${body}${this.options.colors.reset}` : body;
99
+ }
100
+ const visibleFilled = Math.max(1, filled);
101
+ const leadCount = Math.max(0, visibleFilled - 1);
102
+ const lead = '='.repeat(leadCount);
103
+ const head = '>';
104
+ const tail = '-'.repeat(Math.max(0, width - visibleFilled));
105
+ const filledPart = this.options.colors.olive ? `${this.options.colors.olive}${lead}${head}${this.options.colors.reset}` : `${lead}${head}`;
106
+ const emptyPart = this.options.colors.dim ? `${this.options.colors.dim}${tail}${this.options.colors.reset}` : tail;
107
+ return `${filledPart}${emptyPart}`;
108
+ }
109
+ }
110
+ exports.ProgressBar = ProgressBar;
111
+ function createProgressBar(options) {
112
+ return new ProgressBar(options);
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-ai",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "AI security guardrails - single install, sensible defaults",
5
5
  "keywords": [
6
6
  "security",
@@ -39,19 +39,12 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
+ "@inquirer/password": "^4.0.0",
42
43
  "@inquirer/select": "^4.0.0",
43
44
  "@sapper-ai/core": "0.2.2",
44
- "@sapper-ai/mcp": "0.3.0",
45
+ "@sapper-ai/mcp": "0.3.1",
45
46
  "@sapper-ai/types": "0.2.1"
46
47
  },
47
- "peerDependencies": {
48
- "@sapper-ai/openai": "^0.2.2"
49
- },
50
- "peerDependenciesMeta": {
51
- "@sapper-ai/openai": {
52
- "optional": true
53
- }
54
- },
55
48
  "devDependencies": {
56
49
  "@types/node": "^20.0.0",
57
50
  "typescript": "^5.3.0",