devaudit-cli 0.1.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.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # devaudit-cli
2
+
3
+ ## Try it locally (no publish needed)
4
+
5
+ ```
6
+ npm install
7
+ node bin/devaudit.js --help
8
+ ```
9
+
10
+ Or link it so `devaudit` works like a real global install:
11
+
12
+ ```
13
+ npm install
14
+ npm link
15
+ devaudit --help
16
+ ```
17
+
18
+ By default the CLI talks to `http://localhost:3000`. Point it at your dev server or prod:
19
+
20
+ ```
21
+ export DEVAUDIT_API_URL=http://localhost:3000
22
+ # or, once logged in, this persists to ~/.devaudit/credentials.json:
23
+ devaudit config set-url https://your-real-domain.com
24
+ ```
25
+
26
+ ## What works right now
27
+
28
+ - `devaudit init` — fully local, no backend needed. Detects your framework, repo, and the env
29
+ vars your code references, writes `devaudit.config.json`.
30
+ - `devaudit login` / `logout` / `whoami` — work once the routes in `../backend-additions` are
31
+ deployed and `NEXT_PUBLIC_SITE_URL` + `SUPABASE_SERVICE_ROLE_KEY` are set.
32
+ - `devaudit watch` / `list` / `status` — work once those routes are deployed and the
33
+ `watched_repos` table exists (see the migration).
34
+ - `devaudit fix <error-id>` — works, but returns a heuristic placeholder until the real AI fix
35
+ engine is wired into `app/api/cli/errors/[id]/fix/route.ts` (see the TODO in that file).
36
+
37
+ ## Publishing for real
38
+
39
+ Once you're happy with it:
40
+
41
+ ```
42
+ npm login
43
+ npm publish
44
+ ```
45
+
46
+ The 404 you hit is just npm saying nothing's been published under this name yet — nothing else
47
+ needs to change for `npm install -g devaudit-cli` to start working, once this is `npm publish`ed
48
+ from an account you control.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../src/index.js";
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "devaudit-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for DevAudit \u2014 authenticate, watch repos, and pull AI fixes for errors from the terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "devaudit": "./bin/devaudit.js"
8
+ },
9
+ "files": ["bin", "src", "README.md"],
10
+ "engines": { "node": ">=18" },
11
+ "license": "MIT",
12
+ "keywords": ["devaudit", "cli", "devops", "error-monitoring"],
13
+ "dependencies": {
14
+ "commander": "^12.1.0"
15
+ }
16
+ }
@@ -0,0 +1,14 @@
1
+ import { readCredentials, writeCredentials } from "../lib/config.js";
2
+ import { success, info } from "../lib/print.js";
3
+
4
+ export function setUrl(url) {
5
+ const creds = readCredentials() || {};
6
+ creds.apiUrl = url;
7
+ writeCredentials(creds);
8
+ success(`API URL set to ${url}`);
9
+ }
10
+
11
+ export function showConfig() {
12
+ const creds = readCredentials();
13
+ info(JSON.stringify(creds || {}, null, 2));
14
+ }
@@ -0,0 +1,34 @@
1
+ import { apiFetch } from "../lib/api.js";
2
+ import { requireToken } from "../lib/config.js";
3
+ import { info, fail, dim, bold } from "../lib/print.js";
4
+
5
+ // Rendered defensively (checks each field before printing it) since I haven't seen
6
+ // app/api/v1/errors/route.ts yet and don't want to assume exact field names beyond what
7
+ // errors/[id]/fix/route.ts already confirmed (id, file — repo via a `repositories` relation).
8
+ export async function listErrors(opts) {
9
+ try {
10
+ requireToken();
11
+ } catch (e) {
12
+ fail(e.message);
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ const query = opts.repo ? `?repo=${encodeURIComponent(opts.repo)}` : "";
17
+ try {
18
+ const data = await apiFetch(`/v1/errors${query}`);
19
+ const errors = data.errors || data.results || (Array.isArray(data) ? data : []);
20
+ if (!errors.length) {
21
+ info(dim("No errors found."));
22
+ return;
23
+ }
24
+ for (const e of errors) {
25
+ const repo = e.repo || e.full_name || e.repository?.full_name || "";
26
+ const loc = [e.file, e.line].filter(Boolean).join(":");
27
+ const label = e.message || e.cause || e.summary || "(no message)";
28
+ info(`${bold(e.id)} ${dim(repo)} ${dim(loc)} ${label}`);
29
+ }
30
+ } catch (e) {
31
+ fail(e.message);
32
+ process.exitCode = 1;
33
+ }
34
+ }
@@ -0,0 +1,30 @@
1
+ import { apiFetch } from "../lib/api.js";
2
+ import { requireToken } from "../lib/config.js";
3
+ import { info, success, fail, dim, bold, cyan } from "../lib/print.js";
4
+
5
+ // Matches the real /v1/errors/:id/fix contract exactly: no local file reading, no --apply — the
6
+ // fix is committed to a new branch on GitHub server-side, and this just reports where it landed.
7
+ // That route 409s if no suggested_patch exists yet, which the message below explains plainly.
8
+ export async function fixCmd(errorId) {
9
+ try {
10
+ requireToken();
11
+ } catch (e) {
12
+ fail(e.message);
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ try {
17
+ const result = await apiFetch(`/v1/errors/${encodeURIComponent(errorId)}/fix`, { method: "POST" });
18
+ success(`Committed to ${bold(result.branch)}`);
19
+ info(`Commit: ${dim(result.commit_sha)}`);
20
+ if (result.commit_url) info(`View: ${cyan(result.commit_url)}`);
21
+ info(dim("Open a pull request from that branch when you're ready to merge it."));
22
+ } catch (e) {
23
+ if (e.status === 409) {
24
+ fail("No AI-generated fix is available for this error yet.");
25
+ } else {
26
+ fail(e.message);
27
+ }
28
+ process.exitCode = 1;
29
+ }
30
+ }
@@ -0,0 +1,39 @@
1
+ import { detectFramework, detectRepoSlug, detectEnvVars } from "../lib/detect.js";
2
+ import { readProjectConfig, writeProjectConfig, projectConfigPath } from "../lib/config.js";
3
+ import { info, success, warn, dim, bold, cyan } from "../lib/print.js";
4
+
5
+ export async function init() {
6
+ const cwd = process.cwd();
7
+
8
+ if (readProjectConfig()) {
9
+ warn(`${projectConfigPath()} already exists. Delete it first if you want to regenerate it.`);
10
+ return;
11
+ }
12
+
13
+ info(dim("Scanning project..."));
14
+ const { framework } = detectFramework(cwd);
15
+ const repo = detectRepoSlug(cwd);
16
+ const envVars = detectEnvVars(cwd);
17
+
18
+ const config = {
19
+ version: 1,
20
+ repo: repo || null,
21
+ framework,
22
+ createdAt: new Date().toISOString(),
23
+ };
24
+ writeProjectConfig(config);
25
+
26
+ success(`Wrote ${bold(projectConfigPath())}`);
27
+ info(` framework: ${cyan(framework)}`);
28
+ info(` repo: ${cyan(repo || "(not detected — add it manually, or pass --repo to \`devaudit watch\`)")}`);
29
+ if (envVars.length) {
30
+ info(` env vars referenced in code (${envVars.length}): ${envVars.join(", ")}`);
31
+ }
32
+
33
+ info("");
34
+ if (repo) {
35
+ info(`Next: ${bold(`devaudit watch --repo ${repo}`)}`);
36
+ } else {
37
+ info(`Next: ${bold("devaudit watch --repo <owner/repo>")}`);
38
+ }
39
+ }
@@ -0,0 +1,26 @@
1
+ import { apiFetch } from "../lib/api.js";
2
+ import { requireToken } from "../lib/config.js";
3
+ import { info, fail, dim, bold } from "../lib/print.js";
4
+
5
+ export async function list() {
6
+ try {
7
+ requireToken();
8
+ } catch (e) {
9
+ fail(e.message);
10
+ process.exitCode = 1;
11
+ return;
12
+ }
13
+ try {
14
+ const { repos } = await apiFetch("/v1/repos");
15
+ if (!repos || repos.length === 0) {
16
+ info(dim("No repos connected yet. Connect one from your DevAudit dashboard."));
17
+ return;
18
+ }
19
+ for (const r of repos) {
20
+ info(`${bold(r.full_name)} ${dim(`watched since ${r.watched_since}`)}`);
21
+ }
22
+ } catch (e) {
23
+ fail(e.message);
24
+ process.exitCode = 1;
25
+ }
26
+ }
@@ -0,0 +1,37 @@
1
+ import readline from "node:readline/promises";
2
+ import { apiFetch } from "../lib/api.js";
3
+ import { writeCredentials, getApiUrl } from "../lib/config.js";
4
+ import { success, fail, info, dim } from "../lib/print.js";
5
+
6
+ // Matches the real auth scheme: `Authorization: Bearer $DEVAUDIT_TOKEN`, a token generated in
7
+ // account settings (see apiAuth.ts) — not a Supabase session, so there's nothing to redirect a
8
+ // browser to here. Paste-a-token is the whole flow.
9
+ export async function login(opts) {
10
+ const apiUrl = opts.apiUrl || getApiUrl();
11
+ let token = opts.token;
12
+
13
+ if (!token) {
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
15
+ info(dim("Generate a token from your DevAudit account settings, then paste it here."));
16
+ token = (await rl.question("Token: ")).trim();
17
+ rl.close();
18
+ }
19
+
20
+ if (!token) {
21
+ fail("No token provided.");
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+
26
+ writeCredentials({ token, apiUrl });
27
+
28
+ // Light verification — there's no dedicated /v1/me yet, so a successful /v1/repos call is the
29
+ // closest thing to "is this token valid" available right now.
30
+ try {
31
+ await apiFetch("/v1/repos", { apiUrl });
32
+ success("Logged in.");
33
+ } catch (e) {
34
+ fail(`Saved the token, but couldn't verify it: ${e.message}`);
35
+ process.exitCode = 1;
36
+ }
37
+ }
@@ -0,0 +1,7 @@
1
+ import { clearCredentials } from "../lib/config.js";
2
+ import { success } from "../lib/print.js";
3
+
4
+ export function logout() {
5
+ clearCredentials();
6
+ success("Logged out.");
7
+ }
@@ -0,0 +1,35 @@
1
+ import { apiFetch } from "../lib/api.js";
2
+ import { requireToken } from "../lib/config.js";
3
+ import { info, fail, dim } from "../lib/print.js";
4
+
5
+ // No dedicated /v1/status endpoint exists — this just combines /v1/repos and /v1/errors
6
+ // client-side. Drop this once/if a real status endpoint exists; it's a reasonable stand-in
7
+ // until then and doesn't require guessing at backend internals.
8
+ export async function status(opts) {
9
+ try {
10
+ requireToken();
11
+ } catch (e) {
12
+ fail(e.message);
13
+ process.exitCode = 1;
14
+ return;
15
+ }
16
+ try {
17
+ const { repos } = await apiFetch("/v1/repos");
18
+ const query = opts.repo ? `?repo=${encodeURIComponent(opts.repo)}` : "";
19
+ let errorCount = null;
20
+ try {
21
+ const data = await apiFetch(`/v1/errors${query}`);
22
+ const errors = data.errors || data.results || (Array.isArray(data) ? data : []);
23
+ errorCount = errors.length;
24
+ } catch {
25
+ // /v1/errors may not exist yet or may reject an unrecognized query param — status still
26
+ // reports what it can.
27
+ }
28
+
29
+ info(`Repos connected: ${repos?.length ?? 0}`);
30
+ info(`Open errors: ${errorCount === null ? dim("unavailable") : errorCount}`);
31
+ } catch (e) {
32
+ fail(e.message);
33
+ process.exitCode = 1;
34
+ }
35
+ }
@@ -0,0 +1,9 @@
1
+ import { info, dim } from "../lib/print.js";
2
+
3
+ // There's no POST /v1/repos (or equivalent) in the confirmed API yet — connecting a repo looks
4
+ // like a dashboard-only action right now (pick from GET /api/github/repos, presumably). This
5
+ // stays honest about that instead of guessing an endpoint that might not exist.
6
+ export async function watch() {
7
+ info("Connecting a repo isn't available from the CLI yet.");
8
+ info(dim("Connect it from your DevAudit dashboard for now — `devaudit list` will pick it up once it's there."));
9
+ }
@@ -0,0 +1,23 @@
1
+ import { apiFetch } from "../lib/api.js";
2
+ import { requireToken } from "../lib/config.js";
3
+ import { info, fail, dim } from "../lib/print.js";
4
+
5
+ // No /v1/me endpoint exists yet, so this can only confirm the token works, not whose it is.
6
+ // Swap this for a real identity check if/when one exists.
7
+ export async function whoami() {
8
+ try {
9
+ requireToken();
10
+ } catch (e) {
11
+ fail(e.message);
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ try {
16
+ const { repos } = await apiFetch("/v1/repos");
17
+ info(`Token is valid. Watching ${repos?.length ?? 0} repo(s).`);
18
+ } catch (e) {
19
+ fail(e.message);
20
+ info(dim("Run `devaudit login --token <your-token>` to log in again."));
21
+ process.exitCode = 1;
22
+ }
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,57 @@
1
+ import { Command } from "commander";
2
+ import { createRequire } from "node:module";
3
+ import { login } from "./commands/login.js";
4
+ import { logout } from "./commands/logout.js";
5
+ import { whoami } from "./commands/whoami.js";
6
+ import { init } from "./commands/init.js";
7
+ import { watch } from "./commands/watch.js";
8
+ import { list } from "./commands/list.js";
9
+ import { listErrors } from "./commands/errors.js";
10
+ import { status } from "./commands/status.js";
11
+ import { fixCmd } from "./commands/fix.js";
12
+ import { setUrl, showConfig } from "./commands/config-cmd.js";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { version } = require("../package.json");
16
+
17
+ const program = new Command();
18
+
19
+ program.name("devaudit").description("DevAudit CLI").version(version);
20
+
21
+ program
22
+ .command("login")
23
+ .description("Save a DevAudit API token (generate one from your account settings).")
24
+ .option("--token <token>", "your DevAudit API token (skips the interactive prompt)")
25
+ .option("--api-url <url>", "override the API URL for this login")
26
+ .action(login);
27
+
28
+ program.command("logout").description("Forget the stored CLI credentials.").action(logout);
29
+ program.command("whoami").description("Check whether the stored token is valid.").action(whoami);
30
+ program.command("init").description("Detect your stack and generate a devaudit.config.json in the current repo.").action(init);
31
+
32
+ program.command("watch").description("(Not yet available via API) Connect a repo to watch.").action(watch);
33
+
34
+ program.command("list").description("List repos connected to your account.").action(list);
35
+
36
+ program
37
+ .command("errors")
38
+ .description("List recent errors across your connected repos.")
39
+ .option("--repo <owner/repo>", "filter to one repo")
40
+ .action(listErrors);
41
+
42
+ program
43
+ .command("status")
44
+ .description("Summarize connected repos and open errors.")
45
+ .option("--repo <owner/repo>", "scope to one repo")
46
+ .action(status);
47
+
48
+ program
49
+ .command("fix <errorId>")
50
+ .description("Apply the AI-suggested fix for an error as a commit on a new branch.")
51
+ .action(fixCmd);
52
+
53
+ const config = program.command("config").description("Read or update local CLI configuration.");
54
+ config.command("set-url <url>").description("Set the DevAudit API URL used by this CLI.").action(setUrl);
55
+ config.command("show").description("Print the current local CLI config.").action(showConfig);
56
+
57
+ program.parseAsync(process.argv);
package/src/lib/api.js ADDED
@@ -0,0 +1,43 @@
1
+ import { getApiUrl, getToken } from "./config.js";
2
+
3
+ // Every /api/cli/* route below is written to return `{ error: string }` on failure, matching
4
+ // the shape the existing app/api routes already use (see env-vars route.ts, host-evaluator
5
+ // route.ts) — so one error-unwrapping helper here covers all of them.
6
+ export async function apiFetch(pathname, { method = "GET", body, auth = true, apiUrl } = {}) {
7
+ const base = apiUrl || getApiUrl();
8
+ const headers = { "Content-Type": "application/json" };
9
+ if (auth) {
10
+ const token = getToken();
11
+ if (token) headers["Authorization"] = `Bearer ${token}`;
12
+ }
13
+
14
+ let res;
15
+ try {
16
+ res = await fetch(`${base}${pathname}`, {
17
+ method,
18
+ headers,
19
+ body: body !== undefined ? JSON.stringify(body) : undefined,
20
+ });
21
+ } catch (e) {
22
+ throw new Error(
23
+ `Could not reach ${base}${pathname} (${e instanceof Error ? e.message : "network error"}). ` +
24
+ `Is DEVAUDIT_API_URL set correctly? Currently: ${base}`,
25
+ );
26
+ }
27
+
28
+ let data = null;
29
+ try {
30
+ data = await res.json();
31
+ } catch {
32
+ // non-JSON body — fall through, data stays null
33
+ }
34
+
35
+ if (!res.ok) {
36
+ const message = (data && data.error) || `Request failed with status ${res.status}`;
37
+ const err = new Error(message);
38
+ err.status = res.status;
39
+ throw err;
40
+ }
41
+
42
+ return data;
43
+ }
@@ -0,0 +1,71 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), ".devaudit");
6
+ const CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
7
+ const PROJECT_CONFIG_PATH = path.join(process.cwd(), "devaudit.config.json");
8
+
9
+ // The public API reference shows `https://api.devaudit.org/v1/...`. Locally, v1 routes live at
10
+ // app/v1/... (not app/api/v1/...), so they're served at http://localhost:3000/v1/... directly —
11
+ // no /api prefix. Override with DEVAUDIT_API_URL or `devaudit config set-url` once you have a
12
+ // real domain (or the api.devaudit.org subdomain) pointed at this same deployment.
13
+ const DEFAULT_API_URL = process.env.DEVAUDIT_API_URL || "http://localhost:3000";
14
+
15
+ export function readCredentials() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8"));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function writeCredentials(data) {
24
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
25
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 });
26
+ try {
27
+ fs.chmodSync(CREDENTIALS_PATH, 0o600);
28
+ } catch {
29
+ // best-effort on platforms where chmod semantics differ (Windows)
30
+ }
31
+ }
32
+
33
+ export function clearCredentials() {
34
+ try {
35
+ fs.unlinkSync(CREDENTIALS_PATH);
36
+ } catch {
37
+ // already gone
38
+ }
39
+ }
40
+
41
+ export function getApiUrl() {
42
+ const creds = readCredentials();
43
+ return (creds && creds.apiUrl) || DEFAULT_API_URL;
44
+ }
45
+
46
+ export function getToken() {
47
+ const creds = readCredentials();
48
+ return creds && creds.token;
49
+ }
50
+
51
+ export function requireToken() {
52
+ const token = getToken();
53
+ if (!token) throw new Error("Not logged in. Run `devaudit login --token <your-token>` first.");
54
+ return token;
55
+ }
56
+
57
+ export function readProjectConfig() {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(PROJECT_CONFIG_PATH, "utf8"));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function writeProjectConfig(data) {
66
+ fs.writeFileSync(PROJECT_CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", "utf8");
67
+ }
68
+
69
+ export function projectConfigPath() {
70
+ return PROJECT_CONFIG_PATH;
71
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Mirrors the framework-detection logic already shipped in the web app's host-evaluator route
5
+ // (app/api/host-evaluator/route.ts), so `devaudit init` and the "Recommend a host" feature on
6
+ // the site agree with each other on what a project is. Keep these two in sync if either changes.
7
+ export function detectFramework(cwd) {
8
+ const fileNames = safeReaddir(cwd);
9
+ const hasDockerfile = fileNames.some((f) => f.toLowerCase() === "dockerfile");
10
+ const hasPython = fileNames.includes("requirements.txt") || fileNames.includes("pyproject.toml");
11
+
12
+ let packageJsonString = "";
13
+ const pkgPath = path.join(cwd, "package.json");
14
+ if (fs.existsSync(pkgPath)) {
15
+ try {
16
+ packageJsonString = fs.readFileSync(pkgPath, "utf8");
17
+ } catch {
18
+ // unreadable package.json — fall through with framework left as the static default
19
+ }
20
+ }
21
+
22
+ let framework = "Static HTML/JS";
23
+ if (packageJsonString) {
24
+ const pkgLower = packageJsonString.toLowerCase();
25
+ if (pkgLower.includes('"next"')) framework = "Next.js";
26
+ else if (pkgLower.includes('"@remix-run') || pkgLower.includes('"remix"')) framework = "Remix";
27
+ else if (pkgLower.includes('"@sveltejs/kit"')) framework = "SvelteKit";
28
+ else if (pkgLower.includes('"react"')) framework = "React SPA";
29
+ else if (pkgLower.includes('"express"') || pkgLower.includes('"fastify"')) framework = "Node.js API";
30
+ } else if (hasDockerfile) {
31
+ framework = "Docker Container";
32
+ } else if (hasPython) {
33
+ framework = "Python Service";
34
+ }
35
+
36
+ return { framework, hasDockerfile, hasPython };
37
+ }
38
+
39
+ export function detectRepoSlug(cwd) {
40
+ try {
41
+ const gitConfigPath = path.join(cwd, ".git", "config");
42
+ const content = fs.readFileSync(gitConfigPath, "utf8");
43
+ const match = content.match(/url\s*=\s*(.+)/);
44
+ if (!match) return null;
45
+ const url = match[1].trim();
46
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
47
+ const httpsMatch = url.match(/github\.com\/([^/]+)\/(.+?)(\.git)?$/);
48
+ const m = sshMatch || httpsMatch;
49
+ if (!m) return null;
50
+ return `${m[1]}/${m[2].replace(/\.git$/, "")}`;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ const SCAN_EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".venv", "venv", "__pycache__"]);
57
+ const SCAN_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".py"]);
58
+ const MAX_DEPTH = 6;
59
+
60
+ /** Bounded recursive scan for `process.env.X` (JS/TS) and `os.environ`/`os.getenv` (Python)
61
+ * usages, same regex the host-evaluator route uses for the JS half. Depth-limited and skips
62
+ * the usual heavy directories so it stays fast on a real repo, not just the tiny file map the
63
+ * web route was originally given. */
64
+ export function detectEnvVars(cwd) {
65
+ const found = new Set();
66
+ const jsPattern = /process\.env\.([A-Z0-9_]+)/g;
67
+ const pyPattern = /os\.(?:environ(?:\.get)?|getenv)\(\s*["']([A-Z0-9_]+)["']/g;
68
+
69
+ function walk(dir, depth) {
70
+ if (depth > MAX_DEPTH) return;
71
+ let entries;
72
+ try {
73
+ entries = fs.readdirSync(dir, { withFileTypes: true });
74
+ } catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
79
+ const full = path.join(dir, entry.name);
80
+ if (entry.isDirectory()) {
81
+ if (SCAN_EXCLUDE_DIRS.has(entry.name)) continue;
82
+ walk(full, depth + 1);
83
+ } else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
84
+ let content;
85
+ try {
86
+ content = fs.readFileSync(full, "utf8");
87
+ } catch {
88
+ continue;
89
+ }
90
+ for (const m of content.matchAll(jsPattern)) found.add(m[1]);
91
+ for (const m of content.matchAll(pyPattern)) found.add(m[1]);
92
+ }
93
+ }
94
+ }
95
+
96
+ walk(cwd, 0);
97
+ return Array.from(found).sort();
98
+ }
99
+
100
+ function safeReaddir(dir) {
101
+ try {
102
+ return fs.readdirSync(dir);
103
+ } catch {
104
+ return [];
105
+ }
106
+ }
@@ -0,0 +1,24 @@
1
+ // Tiny ANSI helpers so the CLI has a little color without pulling in chalk/picocolors as a
2
+ // dependency. Falls back gracefully — most terminals honor these, and piping to a file just
3
+ // shows the raw codes stripped by most viewers, which is an acceptable MVP tradeoff.
4
+ const wrap = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
5
+
6
+ export const bold = wrap(1, 22);
7
+ export const dim = wrap(2, 22);
8
+ export const cyan = wrap(36, 39);
9
+ export const green = wrap(32, 39);
10
+ export const yellow = wrap(33, 39);
11
+ export const red = wrap(31, 39);
12
+
13
+ export function info(msg) {
14
+ console.log(msg);
15
+ }
16
+ export function success(msg) {
17
+ console.log(green("✔"), msg);
18
+ }
19
+ export function warn(msg) {
20
+ console.log(yellow("!"), msg);
21
+ }
22
+ export function fail(msg) {
23
+ console.error(red("✖"), msg);
24
+ }