secura-cli 0.1.4

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/bin/secura.mjs ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * secura — AuthTrack static analysis CLI (npm distribution).
4
+ *
5
+ * A dependency-free Node client for the backend's static-analysis SSE endpoints.
6
+ * The heavy scanning runs on the backend; this renders the streamed run and the
7
+ * final report in your terminal. See ../README.md.
8
+ */
9
+
10
+ import { parseArgs } from "../src/args.mjs";
11
+ import { runScan } from "../src/commands/scan.mjs";
12
+ import { printHelp, printScanHelp } from "../src/help.mjs";
13
+ import { red, showCursor } from "../src/ansi.mjs";
14
+
15
+ const VERSION = "0.1.0";
16
+
17
+ // Canonical long name → { type, alias }. Mirrors the Python `scan` command's
18
+ // flags, plus the API-client-only --api / --token / --upload.
19
+ const SCAN_SPEC = {
20
+ fast: { type: "boolean", alias: "f" },
21
+ language: { type: "string", alias: "l" },
22
+ "max-triage": { type: "number", alias: "t" },
23
+ "fail-on": { type: "string" },
24
+ json: { type: "string", alias: "o" },
25
+ "max-findings": { type: "number" },
26
+ "show-fixes": { type: "boolean" },
27
+ quiet: { type: "boolean", alias: "q" },
28
+ api: { type: "string" },
29
+ token: { type: "string" },
30
+ upload: { type: "boolean" },
31
+ help: { type: "boolean", alias: "h" },
32
+ };
33
+
34
+ async function main() {
35
+ const argv = process.argv.slice(2);
36
+ const cmd = argv[0];
37
+
38
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
39
+ printHelp();
40
+ return 0;
41
+ }
42
+ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
43
+ process.stdout.write(`secura ${VERSION} — AuthTrack static analysis CLI (npm)\n`);
44
+ return 0;
45
+ }
46
+ if (cmd === "scan") {
47
+ const { values, positionals, errors } = parseArgs(argv.slice(1), SCAN_SPEC);
48
+ if (values.help) {
49
+ printScanHelp();
50
+ return 0;
51
+ }
52
+ if (errors.length) {
53
+ for (const e of errors) process.stderr.write(`${red("error:")} ${e}\n`);
54
+ process.stderr.write("Run `secura scan --help` for usage.\n");
55
+ return 2;
56
+ }
57
+ return runScan(values, positionals);
58
+ }
59
+
60
+ process.stderr.write(`${red(`Unknown command '${cmd}'.`)} Try \`secura --help\`.\n`);
61
+ return 2;
62
+ }
63
+
64
+ main()
65
+ .then((code) => process.exit(code ?? 0))
66
+ .catch((err) => {
67
+ showCursor(); // never leave the terminal cursor hidden after a crash
68
+ process.stderr.write(`${red("Fatal:")} ${err && err.stack ? err.stack : String(err)}\n`);
69
+ process.exit(2);
70
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "secura-cli",
3
+ "version": "0.1.4",
4
+ "description": "SecuraAI security scanner CLI and MCP server — static analysis, recon, header audit and injection testing via AI agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "secura": "bin/secura.mjs",
8
+ "secura-mcp": "bin/secura-mcp.mjs"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.0"
20
+ },
21
+ "keywords": [
22
+ "sast",
23
+ "static-analysis",
24
+ "security",
25
+ "semgrep",
26
+ "codeql",
27
+ "gitleaks",
28
+ "osv-scanner",
29
+ "bearer",
30
+ "cli",
31
+ "vulnerability",
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "securai",
35
+ "securaai"
36
+ ],
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/ashleyalmeida07/Authtrack-Major-Project.git",
40
+ "directory": "cli"
41
+ },
42
+ "homepage": "https://github.com/ashleyalmeida07/Authtrack-Major-Project#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/ashleyalmeida07/Authtrack-Major-Project/issues"
45
+ },
46
+ "license": "MIT",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "secura": "node bin/secura.mjs",
52
+ "secura-mcp": "node bin/secura-mcp.mjs"
53
+ }
54
+ }
package/src/ansi.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Zero-dependency ANSI styling.
3
+ *
4
+ * Colour is enabled only when stdout is a TTY and `NO_COLOR` is unset (honouring
5
+ * https://no-color.org), or forced on with `FORCE_COLOR`. When disabled every
6
+ * helper is the identity function, so the same render code produces clean text
7
+ * for pipes, CI logs and `--json` redirects.
8
+ */
9
+
10
+ const enabled =
11
+ !process.env.NO_COLOR &&
12
+ (Boolean(process.env.FORCE_COLOR) || Boolean(process.stdout.isTTY));
13
+
14
+ function wrap(open, close) {
15
+ const prefix = `\x1b[${open}m`;
16
+ const suffix = `\x1b[${close}m`;
17
+ return (s) => (enabled ? prefix + String(s) + suffix : String(s));
18
+ }
19
+
20
+ export const colorEnabled = enabled;
21
+
22
+ export const bold = wrap(1, 22);
23
+ export const dim = wrap(2, 22);
24
+
25
+ export const red = wrap(31, 39);
26
+ export const green = wrap(32, 39);
27
+ export const yellow = wrap(33, 39);
28
+ export const blue = wrap(34, 39);
29
+ export const magenta = wrap(35, 39);
30
+ export const cyan = wrap(36, 39);
31
+ export const gray = wrap(90, 39); // bright_black
32
+ export const brightRed = wrap(91, 39);
33
+ export const white = wrap(37, 39);
34
+
35
+ // Mirrors SEVERITY_COLOR in backend/app/cli/main.py.
36
+ const SEVERITY_FN = {
37
+ critical: brightRed,
38
+ high: red,
39
+ medium: yellow,
40
+ low: cyan,
41
+ info: gray,
42
+ };
43
+
44
+ /** Uppercase, severity-coloured label — e.g. a red "HIGH". */
45
+ export function severityText(name) {
46
+ const key = String(name || "info").toLowerCase();
47
+ const fn = SEVERITY_FN[key] || white;
48
+ return fn(key.toUpperCase());
49
+ }
50
+
51
+ // ── Cursor control (live view) ──
52
+
53
+ export const hideCursor = () => {
54
+ if (enabled) process.stdout.write("\x1b[?25l");
55
+ };
56
+ export const showCursor = () => {
57
+ if (enabled) process.stdout.write("\x1b[?25h");
58
+ };
59
+
60
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
61
+
62
+ /** Visible length of a string, ignoring ANSI escape sequences. */
63
+ export function visibleLength(s) {
64
+ return s.replace(ANSI_RE, "").length;
65
+ }
66
+
67
+ /**
68
+ * Truncate to `width` visible columns, preserving ANSI codes and appending an
69
+ * ellipsis when clipped. Keeps the live view from wrapping in a narrow terminal
70
+ * (which would break the cursor-up redraw).
71
+ */
72
+ export function clip(s, width) {
73
+ if (width <= 0 || visibleLength(s) <= width) return s;
74
+ let out = "";
75
+ let vis = 0;
76
+ for (let i = 0; i < s.length; ) {
77
+ const rest = s.slice(i);
78
+ const m = rest.match(/^\x1b\[[0-9;]*m/);
79
+ if (m) {
80
+ out += m[0];
81
+ i += m[0].length;
82
+ continue;
83
+ }
84
+ if (vis >= width - 1) {
85
+ out += "…";
86
+ break;
87
+ }
88
+ out += s[i];
89
+ vis += 1;
90
+ i += 1;
91
+ }
92
+ return out + (enabled ? "\x1b[0m" : "");
93
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A tiny, dependency-free argv parser.
3
+ *
4
+ * Handles the shapes the `secura` commands need and nothing more:
5
+ * --flag boolean true
6
+ * --key value string / number (next token is the value)
7
+ * --key=value string / number (inline)
8
+ * -f short boolean (clusterable: -fq)
9
+ * -l value | -lvalue | -l=value short with a value
10
+ * -- everything after is positional
11
+ *
12
+ * `spec` maps a canonical long name → { type: "boolean"|"string"|"number", alias?: "x" }.
13
+ * Returns { values, positionals, errors }. Unknown options and malformed
14
+ * numbers are collected in `errors` rather than thrown, so the caller decides
15
+ * whether to abort or show usage.
16
+ */
17
+ export function parseArgs(argv, spec) {
18
+ const aliasToName = {};
19
+ for (const [name, opt] of Object.entries(spec)) {
20
+ if (opt.alias) aliasToName[opt.alias] = name;
21
+ }
22
+
23
+ const values = {};
24
+ const positionals = [];
25
+ const errors = [];
26
+
27
+ const coerce = (opt, raw, label) => {
28
+ if (opt.type === "number") {
29
+ const n = Number(raw);
30
+ if (Number.isNaN(n)) {
31
+ errors.push(`${label} expects a number, got '${raw}'`);
32
+ return undefined;
33
+ }
34
+ return n;
35
+ }
36
+ return raw;
37
+ };
38
+
39
+ for (let i = 0; i < argv.length; i += 1) {
40
+ const arg = argv[i];
41
+
42
+ if (arg === "--") {
43
+ positionals.push(...argv.slice(i + 1));
44
+ break;
45
+ }
46
+
47
+ // Long option: --name, --name=value
48
+ if (arg.startsWith("--")) {
49
+ let key = arg.slice(2);
50
+ let inline;
51
+ const eq = key.indexOf("=");
52
+ if (eq !== -1) {
53
+ inline = key.slice(eq + 1);
54
+ key = key.slice(0, eq);
55
+ }
56
+ const name = spec[key] ? key : aliasToName[key];
57
+ if (!name) {
58
+ errors.push(`unknown option --${key}`);
59
+ continue;
60
+ }
61
+ const opt = spec[name];
62
+ if (opt.type === "boolean") {
63
+ values[name] = inline === undefined ? true : /^(1|true|yes|on)$/i.test(inline);
64
+ } else {
65
+ let raw = inline;
66
+ if (raw === undefined) {
67
+ raw = argv[++i];
68
+ if (raw === undefined) {
69
+ errors.push(`--${key} needs a value`);
70
+ continue;
71
+ }
72
+ }
73
+ const v = coerce(opt, raw, `--${key}`);
74
+ if (v !== undefined) values[name] = v;
75
+ }
76
+ continue;
77
+ }
78
+
79
+ // Short option(s): -f, -fq, -l value, -lvalue, -l=value
80
+ if (arg.length > 1 && arg[0] === "-") {
81
+ const chars = arg.slice(1);
82
+ for (let c = 0; c < chars.length; c += 1) {
83
+ const ch = chars[c];
84
+ const name = aliasToName[ch];
85
+ if (!name) {
86
+ errors.push(`unknown option -${ch}`);
87
+ break;
88
+ }
89
+ const opt = spec[name];
90
+ if (opt.type === "boolean") {
91
+ values[name] = true;
92
+ continue; // allow clustering, e.g. -fq
93
+ }
94
+ // value option consumes the rest of this token, or the next argv entry
95
+ let raw = chars.slice(c + 1);
96
+ if (raw.startsWith("=")) raw = raw.slice(1);
97
+ if (!raw) {
98
+ raw = argv[++i];
99
+ if (raw === undefined) {
100
+ errors.push(`-${ch} needs a value`);
101
+ break;
102
+ }
103
+ }
104
+ const v = coerce(opt, raw, `-${ch}`);
105
+ if (v !== undefined) values[name] = v;
106
+ break; // rest of the token was the value
107
+ }
108
+ continue;
109
+ }
110
+
111
+ positionals.push(arg);
112
+ }
113
+
114
+ return { values, positionals, errors };
115
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Backend client — POSTs to the static-analysis stream endpoints and yields the
3
+ * parsed SSE events.
4
+ *
5
+ * The wire format matches backend/app/api/scan.py: newline-delimited frames of
6
+ * `data: {json}\n\n`, each carrying one of:
7
+ * { event: "start", message }
8
+ * { event: "node_update", node, state }
9
+ * { event: "complete", message }
10
+ * { event: "error", message }
11
+ *
12
+ * This mirrors the reader loop in frontend/src/lib/api.ts (split on "\n\n",
13
+ * parse `data:` lines) so the CLI and the browser consume an identical stream.
14
+ * Uses Node's global fetch + Web Streams (Node >= 18) — no dependencies.
15
+ */
16
+
17
+ function authHeaders(token, extra = {}) {
18
+ const h = { ...extra };
19
+ if (token) h.Authorization = `Bearer ${token}`;
20
+ return h;
21
+ }
22
+
23
+ async function openStream(url, token, headers, body) {
24
+ let res;
25
+ try {
26
+ res = await fetch(url, { method: "POST", headers: authHeaders(token, headers), body });
27
+ } catch (err) {
28
+ throw new Error(
29
+ `Could not reach the backend at ${url}\n` +
30
+ ` ${err.message}\n` +
31
+ ` Is it running? Point elsewhere with --api <url> or the SECURA_API env var.`
32
+ );
33
+ }
34
+ if (!res.ok) {
35
+ let detail = "";
36
+ try {
37
+ detail = (await res.text()).slice(0, 500).trim();
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ throw new Error(`Backend returned ${res.status} ${res.statusText}${detail ? `\n ${detail}` : ""}`);
42
+ }
43
+ if (!res.body) throw new Error("Backend sent an empty response body.");
44
+ return res;
45
+ }
46
+
47
+ async function* readSse(res) {
48
+ const decoder = new TextDecoder();
49
+ const reader = res.body.getReader();
50
+ let buffer = "";
51
+
52
+ const emit = function* (frame) {
53
+ // A frame may hold several lines; the backend uses a single `data:` line.
54
+ for (const rawLine of frame.split("\n")) {
55
+ if (!rawLine.startsWith("data:")) continue;
56
+ const jsonStr = rawLine.slice(rawLine.indexOf(":") + 1).trim();
57
+ if (!jsonStr) continue;
58
+ try {
59
+ yield JSON.parse(jsonStr);
60
+ } catch {
61
+ // Malformed frame — skip it (the browser client does the same).
62
+ }
63
+ }
64
+ };
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ buffer += decoder.decode(value, { stream: true });
70
+ const frames = buffer.split("\n\n");
71
+ buffer = frames.pop() || ""; // keep the trailing partial frame
72
+ for (const frame of frames) yield* emit(frame);
73
+ }
74
+ // Flush any final frame that arrived without a trailing blank line.
75
+ const tail = buffer.trim();
76
+ if (tail) yield* emit(tail);
77
+ }
78
+
79
+ /** Stream a scan of a repo URL or a co-located local path (JSON body). */
80
+ export async function* streamStatic(api, token, body) {
81
+ const res = await openStream(
82
+ `${api}/scan/stream/static`,
83
+ token,
84
+ { "Content-Type": "application/json" },
85
+ JSON.stringify(body)
86
+ );
87
+ yield* readSse(res);
88
+ }
89
+
90
+ /** Stream a scan of uploaded local code (multipart tar.gz + form fields). */
91
+ export async function* streamStaticUpload(api, token, form) {
92
+ // Let fetch set the multipart Content-Type (with boundary) from the FormData.
93
+ const res = await openStream(`${api}/scan/stream/static/upload`, token, {}, form);
94
+ yield* readSse(res);
95
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `secura scan` — drive a static-analysis run against the backend and render it.
3
+ *
4
+ * Transport is chosen by target:
5
+ * • repo URL / owner/repo → sent verbatim as target_path; the backend clones it.
6
+ * • local path + local backend (default) → the absolute path is scanned in place.
7
+ * • local path + remote backend, or --upload → the dir is tar.gz'd and uploaded.
8
+ *
9
+ * Exit codes match the Python CLI: 0 clean · 1 --fail-on gate breached ·
10
+ * 2 bad target / scan failure · 130 interrupted.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ import { resolveConfig, isLocalBackend } from "../config.mjs";
17
+ import { classifyTarget } from "../target.mjs";
18
+ import { streamStatic, streamStaticUpload } from "../client.mjs";
19
+ import { packDir, MAX_UPLOAD_BYTES } from "../pack.mjs";
20
+ import { ScanMonitor, SEVERITY_RANK } from "../monitor.mjs";
21
+ import { LiveView, renderLive, printReport } from "../render.mjs";
22
+ import { bold, dim, red, green, yellow, cyan } from "../ansi.mjs";
23
+
24
+ const errline = (s) => process.stderr.write(s + "\n");
25
+ const outline = (s = "") => process.stdout.write(s + "\n");
26
+
27
+ export async function runScan(values, positionals) {
28
+ const rawTarget = String(positionals[0] ?? ".").trim() || ".";
29
+ const includeCodeql = !values.fast;
30
+ const language = values.language || "";
31
+ const maxTriage = values["max-triage"] ?? 25;
32
+ const maxFindings = values["max-findings"] ?? 20;
33
+ const failOn = String(values["fail-on"] || "").toLowerCase();
34
+ const jsonOut = values.json || "";
35
+ const showFixes = Boolean(values["show-fixes"]);
36
+ const quiet = Boolean(values.quiet);
37
+ const forceUpload = Boolean(values.upload);
38
+
39
+ const { api, token } = resolveConfig(values);
40
+ const target = classifyTarget(rawTarget);
41
+
42
+ // Validate a local path up front (mirrors the Python CLI's exit-2 checks).
43
+ if (target.kind === "path") {
44
+ if (!fs.existsSync(target.value)) {
45
+ errline(`${red("Not found:")} '${rawTarget}' is neither an existing path nor a Git repo URL.`);
46
+ return 2;
47
+ }
48
+ if (!fs.statSync(target.value).isDirectory()) {
49
+ errline(`${red("Not a directory:")} ${rawTarget}`);
50
+ return 2;
51
+ }
52
+ }
53
+
54
+ const remote = !isLocalBackend(api);
55
+ const transport = target.kind === "url" ? "url" : forceUpload || remote ? "upload" : "path";
56
+
57
+ if (!quiet) {
58
+ outline();
59
+ outline(`${bold("secura")} scanning ${cyan(target.display)}`);
60
+ outline(dim(`backend ${api}`));
61
+ if (target.kind === "url") outline(dim("remote repo — the backend shallow-clones it, scans, then removes it"));
62
+ else if (transport === "upload") outline(dim("packaging local code and uploading it to the backend"));
63
+ else outline(dim("scanning the path in place on the backend host"));
64
+ if (values.fast) outline(dim("--fast: CodeQL skipped, no taint paths in this run"));
65
+ outline();
66
+ }
67
+
68
+ const monitor = new ScanMonitor(target.display, includeCodeql);
69
+ let cleanup = () => {};
70
+
71
+ try {
72
+ let events;
73
+ if (transport === "upload") {
74
+ const packed = packDir(target.value);
75
+ cleanup = packed.cleanup;
76
+ const { size } = fs.statSync(packed.path);
77
+ if (size > MAX_UPLOAD_BYTES) {
78
+ cleanup();
79
+ errline(
80
+ `${red("Too large:")} packaged code is ${(size / 1e6).toFixed(0)} MB ` +
81
+ `(limit ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB). ` +
82
+ `Scan a Git URL, or run the backend locally and drop --upload.`
83
+ );
84
+ return 2;
85
+ }
86
+ if (!quiet) outline(dim(`packaged ${packed.fileCount} file(s) → ${(size / 1e6).toFixed(1)} MB`));
87
+
88
+ const buf = fs.readFileSync(packed.path);
89
+ const form = new FormData();
90
+ form.append("include_codeql", String(includeCodeql));
91
+ form.append("codeql_language", language);
92
+ form.append("max_triage", String(maxTriage));
93
+ form.append("file", new Blob([buf], { type: "application/gzip" }), "code.tar.gz");
94
+ events = streamStaticUpload(api, token, form);
95
+ } else {
96
+ events = streamStatic(api, token, {
97
+ target_path: target.value,
98
+ include_codeql: includeCodeql,
99
+ codeql_language: language,
100
+ max_triage: maxTriage,
101
+ });
102
+ }
103
+
104
+ const streamCode = await consume(events, monitor, quiet);
105
+ if (streamCode !== 0) return streamCode;
106
+ } catch (err) {
107
+ errline(`\n${red("Scan failed:")} ${err && err.message ? err.message : String(err)}`);
108
+ return 2;
109
+ } finally {
110
+ cleanup();
111
+ }
112
+
113
+ printReport(monitor.report, { showFixes, maxFindings });
114
+
115
+ if (jsonOut) {
116
+ try {
117
+ const dest = path.resolve(jsonOut);
118
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
119
+ fs.writeFileSync(dest, JSON.stringify(monitor.report, null, 2), "utf8");
120
+ outline(`\n${green("Report written to")} ${cyan(jsonOut)}`);
121
+ } catch (err) {
122
+ outline(`\n${yellow(`Could not write ${jsonOut}: ${err.message}`)}`);
123
+ }
124
+ }
125
+
126
+ // ── CI gate ──
127
+ if (failOn) {
128
+ const threshold = SEVERITY_RANK[failOn];
129
+ if (threshold === undefined) {
130
+ outline(yellow(`Unknown --fail-on severity '${failOn}' — ignored.`));
131
+ } else {
132
+ const breaching = (monitor.report.confirmed_findings || []).filter(
133
+ (f) => (SEVERITY_RANK[String(f.final_severity || f.severity || "info").toLowerCase()] ?? 0) >= threshold
134
+ );
135
+ if (breaching.length) {
136
+ outline(`\n${red(`${breaching.length} confirmed finding(s) at or above ${failOn.toUpperCase()}.`)}`);
137
+ return 1;
138
+ }
139
+ outline(`\n${green(`No confirmed findings at or above ${failOn.toUpperCase()}.`)}`);
140
+ }
141
+ }
142
+
143
+ return 0;
144
+ }
145
+
146
+ /**
147
+ * Consume the event stream, driving the live view. Returns 0 on a clean run, or
148
+ * 2 if the backend emitted an error event. A spinner timer advances the live
149
+ * frame even while the backend is quiet (e.g. CodeQL building its database).
150
+ */
151
+ async function consume(events, monitor, quiet) {
152
+ const live = new LiveView();
153
+ const useLive = !quiet && live.active;
154
+ let frame = 0;
155
+ let timer = null;
156
+
157
+ if (useLive) {
158
+ live.start();
159
+ timer = setInterval(() => {
160
+ frame += 1;
161
+ live.render(renderLive(monitor, frame));
162
+ }, 120);
163
+ }
164
+
165
+ const onSigint = () => {
166
+ if (timer) clearInterval(timer);
167
+ live.stop();
168
+ errline(`\n${yellow("Interrupted.")}`);
169
+ process.exit(130);
170
+ };
171
+ process.once("SIGINT", onSigint);
172
+
173
+ try {
174
+ for await (const evt of events) {
175
+ if (!evt || typeof evt !== "object") continue;
176
+ switch (evt.event) {
177
+ case "node_update":
178
+ monitor.ingest(evt.node, evt.state);
179
+ if (useLive) live.render(renderLive(monitor, frame));
180
+ break;
181
+ case "complete":
182
+ monitor.stage = "done";
183
+ if (useLive) live.render(renderLive(monitor, frame));
184
+ break;
185
+ case "error":
186
+ if (timer) clearInterval(timer);
187
+ if (useLive) {
188
+ live.render(renderLive(monitor, frame));
189
+ live.stop();
190
+ }
191
+ errline(`\n${red("Backend error:")} ${evt.message || "unknown error"}`);
192
+ return 2;
193
+ default:
194
+ break; // "start" and any future events — ignore
195
+ }
196
+ }
197
+ return 0;
198
+ } finally {
199
+ if (timer) clearInterval(timer);
200
+ live.stop();
201
+ process.removeListener("SIGINT", onSigint);
202
+ }
203
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve where the CLI talks to and how.
3
+ *
4
+ * The backend's scan router has no auth, so the token is optional; it is sent
5
+ * as a bearer header only when present, so this works unchanged if auth is
6
+ * added later.
7
+ */
8
+
9
+ // Matches frontend/src/lib/api.ts (API_BASE) so the CLI and the web app default
10
+ // to the same backend.
11
+ const DEFAULT_API = "https://52.91.8.40.nip.io/api/v1";
12
+
13
+ export function resolveConfig(values) {
14
+ const raw = values.api || process.env.SECURA_API || DEFAULT_API;
15
+ const api = raw.replace(/\/+$/, ""); // trim trailing slashes
16
+ const token = values.token || process.env.SECURA_TOKEN || "";
17
+ return { api, token };
18
+ }
19
+
20
+ /**
21
+ * True when the API base points at this machine. A local path target can then
22
+ * be scanned in place (sent as `target_path`); otherwise it must be uploaded.
23
+ */
24
+ export function isLocalBackend(api) {
25
+ try {
26
+ const { hostname } = new URL(api);
27
+ return ["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"].includes(hostname);
28
+ } catch {
29
+ // An unparseable base is treated as local — the request will fail loudly
30
+ // either way, and this keeps the default (no upload) for odd inputs.
31
+ return true;
32
+ }
33
+ }