@lifeaitools/clauth 0.2.1 → 0.3.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/.clauth-skill/SKILL.md +109 -66
- package/cli/commands/scrub.js +231 -0
- package/cli/commands/serve.js +378 -0
- package/cli/commands/uninstall.js +164 -0
- package/cli/index.js +94 -6
- package/package.json +1 -1
- package/supabase/functions/auth-vault/index.ts +132 -235
- package/supabase/migrations/20260317_lockout.sql +26 -0
package/.clauth-skill/SKILL.md
CHANGED
|
@@ -9,93 +9,127 @@ Hardware-bound credential vault for the LIFEAI stack. Your machine is the second
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## How Claude Interfaces with clauth
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
> **CRITICAL:** Never pipe input to clauth or use interactive prompts. The CLI uses `inquirer` which produces ANSI garbage when stdin is piped. Always use one of the two methods below.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
### Method 1 — CLI with `--pw` flag (simplest)
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
All clauth commands accept `-p` / `--pw <password>` to skip the interactive password prompt:
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
```bash
|
|
21
|
+
clauth status -p "YourPassword"
|
|
22
|
+
clauth get github -p "YourPassword"
|
|
23
|
+
clauth test -p "YourPassword"
|
|
24
|
+
clauth enable github -p "YourPassword"
|
|
25
|
+
clauth write key github -p "YourPassword" # still prompts for the key value
|
|
26
|
+
```
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
### Method 2 — Direct API calls (full control, no CLI needed)
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
Call the auth-vault Edge Function directly. This is the most reliable method for Claude.
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
**Base URL:** `https://<project-ref>.supabase.co/functions/v1/auth-vault`
|
|
33
|
+
**Auth header:** `Authorization: Bearer <supabase-anon-key>`
|
|
34
|
+
**Method:** POST (all routes)
|
|
35
|
+
**Content-Type:** `application/json`
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
#### HMAC Token Derivation (must match server)
|
|
29
38
|
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
```
|
|
39
|
+
```js
|
|
40
|
+
import { createHmac, createHash, execSync } from "crypto";
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// 1. Get machine hash (same as fingerprint.js)
|
|
43
|
+
// Windows:
|
|
44
|
+
const uuid = execSync("wmic csproduct get uuid /format:value", { encoding: "utf8" })
|
|
45
|
+
.match(/UUID=([A-F0-9-]+)/i)?.[1]?.trim();
|
|
46
|
+
const machineGuid = execSync(
|
|
47
|
+
"reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid",
|
|
48
|
+
{ encoding: "utf8" }
|
|
49
|
+
).match(/MachineGuid\s+REG_SZ\s+([a-f0-9-]+)/i)?.[1]?.trim();
|
|
50
|
+
const machineHash = createHash("sha256").update(`${uuid}:${machineGuid}`).digest("hex");
|
|
51
|
+
|
|
52
|
+
// 2. Derive HMAC token
|
|
53
|
+
const windowMs = 5 * 60 * 1000;
|
|
54
|
+
const window = Math.floor(Date.now() / windowMs);
|
|
55
|
+
const message = `${machineHash}:${window}`;
|
|
56
|
+
const token = createHmac("sha256", password).update(message).digest("hex");
|
|
57
|
+
const timestamp = window * windowMs;
|
|
37
58
|
```
|
|
38
59
|
|
|
60
|
+
#### API Routes
|
|
61
|
+
|
|
62
|
+
| Route | Body fields | Returns |
|
|
63
|
+
|-------|-------------|---------|
|
|
64
|
+
| `POST /status` | `machine_hash, token, timestamp, password` | `{ services: [...] }` |
|
|
65
|
+
| `POST /test` | `machine_hash, token, timestamp, password` | `{ ok: true }` |
|
|
66
|
+
| `POST /retrieve` | `+ service` | `{ value: "..." }` |
|
|
67
|
+
| `POST /write` | `+ service, value` | `{ ok: true }` |
|
|
68
|
+
| `POST /enable` | `+ service, enabled: bool` | `{ ok: true }` |
|
|
69
|
+
| `POST /add` | `+ name, label, key_type, description` | `{ ok: true }` |
|
|
70
|
+
| `POST /remove` | `+ service, confirm: true` | `{ ok: true }` |
|
|
71
|
+
| `POST /revoke` | `+ service, confirm: true` | `{ ok: true }` |
|
|
72
|
+
| `POST /register-machine` | `machine_hash, hmac_seed_hash, label, admin_token` | `{ ok: true }` |
|
|
73
|
+
|
|
74
|
+
All auth routes require: `machine_hash`, `token`, `timestamp`, `password`.
|
|
75
|
+
|
|
76
|
+
### Password Handling
|
|
77
|
+
|
|
78
|
+
- Ask the user for their clauth password **once per session**
|
|
79
|
+
- Store it in working memory for the duration of the conversation
|
|
80
|
+
- Never log or echo the password
|
|
81
|
+
- If the user says "use clauth" or "get my github key", ask for the password if you don't have it yet
|
|
82
|
+
|
|
39
83
|
---
|
|
40
84
|
|
|
41
|
-
|
|
85
|
+
## When someone says "install clauth"
|
|
42
86
|
|
|
43
|
-
|
|
44
|
-
```powershell
|
|
45
|
-
& "$env:USERPROFILE\.clauth\scripts\bin\bootstrap-win.exe"
|
|
46
|
-
```
|
|
87
|
+
### Step 1 — Install from npm
|
|
47
88
|
|
|
48
|
-
**Mac/Linux:**
|
|
49
89
|
```bash
|
|
50
|
-
|
|
51
|
-
# Mac:
|
|
52
|
-
~/.clauth/scripts/bin/bootstrap-macos
|
|
90
|
+
npm install -g @lifeaitools/clauth
|
|
53
91
|
```
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
|
|
93
|
+
If already installed, update:
|
|
94
|
+
```bash
|
|
95
|
+
npm update -g @lifeaitools/clauth
|
|
96
|
+
```
|
|
59
97
|
|
|
60
|
-
|
|
98
|
+
### Step 2 — Run the installer
|
|
61
99
|
|
|
62
|
-
|
|
100
|
+
Use CLI flags to avoid interactive prompts:
|
|
63
101
|
|
|
64
|
-
|
|
102
|
+
```bash
|
|
103
|
+
clauth install --ref <supabase-project-ref> --pat <personal-access-token>
|
|
104
|
+
```
|
|
65
105
|
|
|
66
|
-
**Project ref** — last part of
|
|
106
|
+
**Project ref** — last part of the Supabase project URL:
|
|
67
107
|
`https://supabase.com/dashboard/project/` **`uvojezuorjgqzmhhgluu`**
|
|
68
108
|
|
|
69
109
|
**Personal Access Token (PAT):**
|
|
70
110
|
`https://supabase.com/dashboard/account/tokens` → Generate new token
|
|
71
111
|
*(NOT the anon key or service_role — this is your account-level token)*
|
|
72
112
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
---
|
|
113
|
+
The installer provisions everything and prints a **bootstrap token** — save it.
|
|
76
114
|
|
|
77
|
-
### Step
|
|
115
|
+
### Step 3 — Setup this machine
|
|
78
116
|
|
|
79
|
-
```
|
|
80
|
-
clauth setup
|
|
117
|
+
```bash
|
|
118
|
+
clauth setup --admin-token <bootstrap-token> -p <password>
|
|
81
119
|
```
|
|
82
120
|
|
|
83
|
-
Asks: machine label, password, bootstrap token (from step 4).
|
|
84
|
-
|
|
85
121
|
Then verify:
|
|
122
|
+
```bash
|
|
123
|
+
clauth test -p <password>
|
|
124
|
+
clauth status -p <password>
|
|
86
125
|
```
|
|
87
|
-
clauth test → PASS
|
|
88
|
-
clauth status → 12 services ready
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
---
|
|
92
126
|
|
|
93
|
-
### Step
|
|
127
|
+
### Step 4 — Write your first key
|
|
94
128
|
|
|
95
129
|
```bash
|
|
96
|
-
clauth write key github # prompts for value
|
|
97
|
-
clauth enable github
|
|
98
|
-
clauth get github
|
|
130
|
+
clauth write key github -p <password> # prompts for value
|
|
131
|
+
clauth enable github -p <password>
|
|
132
|
+
clauth get github -p <password>
|
|
99
133
|
```
|
|
100
134
|
|
|
101
135
|
See `references/keys-guide.md` for where to find every credential.
|
|
@@ -105,21 +139,29 @@ See `references/keys-guide.md` for where to find every credential.
|
|
|
105
139
|
## Command reference
|
|
106
140
|
|
|
107
141
|
```
|
|
108
|
-
clauth install
|
|
109
|
-
clauth setup
|
|
110
|
-
clauth status
|
|
111
|
-
clauth test
|
|
112
|
-
clauth list
|
|
113
|
-
|
|
114
|
-
clauth write key <service>
|
|
115
|
-
clauth write pw
|
|
116
|
-
clauth enable <svc|all>
|
|
117
|
-
clauth disable <svc|all>
|
|
118
|
-
clauth get <service>
|
|
119
|
-
|
|
120
|
-
clauth add service <n>
|
|
121
|
-
clauth remove service <n>
|
|
122
|
-
clauth revoke <svc|all>
|
|
142
|
+
clauth install [--ref R] [--pat P] First-time: provision Supabase + install skill
|
|
143
|
+
clauth setup [--admin-token T] [-p P] Register this machine
|
|
144
|
+
clauth status [-p P] All services + state
|
|
145
|
+
clauth test [-p P] Verify HMAC connection
|
|
146
|
+
clauth list [-p P] Service names
|
|
147
|
+
|
|
148
|
+
clauth write key <service> [-p P] Store a credential
|
|
149
|
+
clauth write pw [-p P] Change password
|
|
150
|
+
clauth enable <svc|all> [-p P] Activate service
|
|
151
|
+
clauth disable <svc|all> [-p P] Suspend service
|
|
152
|
+
clauth get <service> [-p P] Retrieve a key
|
|
153
|
+
|
|
154
|
+
clauth add service <n> [-p P] Register new service
|
|
155
|
+
clauth remove service <n> [-p P] Remove service
|
|
156
|
+
clauth revoke <svc|all> [-p P] Delete key (destructive)
|
|
157
|
+
|
|
158
|
+
clauth scrub Scrub active transcript (most recent .jsonl)
|
|
159
|
+
clauth scrub <file> Scrub a specific file
|
|
160
|
+
clauth scrub all Scrub all transcripts in ~/.claude/projects/
|
|
161
|
+
clauth scrub all --force Rescrub everything (ignore markers)
|
|
162
|
+
|
|
163
|
+
clauth uninstall --ref R --pat P Full teardown (DB, Edge Fn, secrets, skill, config)
|
|
164
|
+
clauth uninstall --ref R --pat P --yes Skip confirmation
|
|
123
165
|
```
|
|
124
166
|
|
|
125
167
|
## Services
|
|
@@ -137,5 +179,6 @@ clauth revoke <svc|all> Delete key (destructive)
|
|
|
137
179
|
| `machine_not_found` | Run `clauth setup` |
|
|
138
180
|
| `timestamp_expired` | Sync system clock |
|
|
139
181
|
| `invalid_token` | Wrong password |
|
|
140
|
-
| `service_disabled` | `clauth enable <service>` |
|
|
141
|
-
| `no_key_stored` | `clauth write key <service>` |
|
|
182
|
+
| `service_disabled` | `clauth enable <service> -p <password>` |
|
|
183
|
+
| `no_key_stored` | `clauth write key <service> -p <password>` |
|
|
184
|
+
| ANSI garbage output | You piped stdin — use `-p` flag instead |
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
|
|
2
|
+
//
|
|
3
|
+
// clauth scrub → scrub active transcript (most recent .jsonl)
|
|
4
|
+
// clauth scrub <file> → scrub a specific file
|
|
5
|
+
// clauth scrub all → scrub every transcript .jsonl
|
|
6
|
+
// clauth scrub --force → rescrub even if already marked
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
import ora from "ora";
|
|
13
|
+
|
|
14
|
+
const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
|
|
15
|
+
const SCRUB_VERSION = "1.1";
|
|
16
|
+
|
|
17
|
+
// ──────────────────────────────────────────────
|
|
18
|
+
// Credential patterns — regex + replacement
|
|
19
|
+
// ──────────────────────────────────────────────
|
|
20
|
+
const PATTERNS = [
|
|
21
|
+
// Supabase JWTs (anon, service_role)
|
|
22
|
+
[/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
|
|
23
|
+
"[SUPABASE_JWT_REDACTED]"],
|
|
24
|
+
|
|
25
|
+
// Vercel tokens
|
|
26
|
+
[/vcp_[A-Za-z0-9]{20,80}/g,
|
|
27
|
+
"[VERCEL_TOKEN_REDACTED]"],
|
|
28
|
+
|
|
29
|
+
// R2 / S3 secret access keys (64-char hex)
|
|
30
|
+
[/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g,
|
|
31
|
+
'"secret_access_key": "[R2_SECRET_REDACTED]"'],
|
|
32
|
+
|
|
33
|
+
// R2 / S3 access key IDs (32-char hex)
|
|
34
|
+
[/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g,
|
|
35
|
+
'"access_key_id": "[R2_KEY_REDACTED]"'],
|
|
36
|
+
|
|
37
|
+
// Cloudflare admin tokens
|
|
38
|
+
[/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g,
|
|
39
|
+
'"admin_token": "[CF_TOKEN_REDACTED]"'],
|
|
40
|
+
|
|
41
|
+
// Cloudflare account IDs (32-char hex)
|
|
42
|
+
[/"account_id"\s*:\s*"[a-f0-9]{32}"/g,
|
|
43
|
+
'"account_id": "[CF_ACCOUNT_REDACTED]"'],
|
|
44
|
+
|
|
45
|
+
// GitHub tokens
|
|
46
|
+
[/ghp_[A-Za-z0-9]{36}/g,
|
|
47
|
+
"[GITHUB_TOKEN_REDACTED]"],
|
|
48
|
+
[/github_pat_[A-Za-z0-9_]{40,100}/g,
|
|
49
|
+
"[GITHUB_PAT_REDACTED]"],
|
|
50
|
+
|
|
51
|
+
// Neo4j connection strings with passwords
|
|
52
|
+
[/neo4j\+s?:\/\/[^"\\]+/g,
|
|
53
|
+
"[NEO4J_CONNSTRING_REDACTED]"],
|
|
54
|
+
|
|
55
|
+
// Generic Bearer tokens in Authorization headers
|
|
56
|
+
[/Bearer [A-Za-z0-9_-]{20,}/g,
|
|
57
|
+
"Bearer [TOKEN_REDACTED]"],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// ──────────────────────────────────────────────
|
|
61
|
+
// Marker check — read last 512 bytes
|
|
62
|
+
// ──────────────────────────────────────────────
|
|
63
|
+
function isAlreadyScrubbed(filePath) {
|
|
64
|
+
try {
|
|
65
|
+
const stat = fs.statSync(filePath);
|
|
66
|
+
const fd = fs.openSync(filePath, "r");
|
|
67
|
+
const bufSize = Math.min(512, stat.size);
|
|
68
|
+
const buf = Buffer.alloc(bufSize);
|
|
69
|
+
fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
|
|
70
|
+
fs.closeSync(fd);
|
|
71
|
+
|
|
72
|
+
const tail = buf.toString("utf-8");
|
|
73
|
+
const lastLine = tail.trim().split("\n").pop();
|
|
74
|
+
if (lastLine && lastLine.includes(SCRUB_MARKER)) {
|
|
75
|
+
try {
|
|
76
|
+
const marker = JSON.parse(lastLine);
|
|
77
|
+
return marker.version === SCRUB_VERSION;
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
} catch { return false; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ──────────────────────────────────────────────
|
|
85
|
+
// Stamp file as scrubbed
|
|
86
|
+
// ──────────────────────────────────────────────
|
|
87
|
+
function stampScrubbed(filePath) {
|
|
88
|
+
const marker = JSON.stringify({
|
|
89
|
+
type: SCRUB_MARKER,
|
|
90
|
+
version: SCRUB_VERSION,
|
|
91
|
+
scrubbed_at: new Date().toISOString(),
|
|
92
|
+
patterns: PATTERNS.length,
|
|
93
|
+
});
|
|
94
|
+
fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ──────────────────────────────────────────────
|
|
98
|
+
// Scrub a single file
|
|
99
|
+
// ──────────────────────────────────────────────
|
|
100
|
+
function scrubFile(filePath, force = false) {
|
|
101
|
+
if (!force && isAlreadyScrubbed(filePath)) {
|
|
102
|
+
return "skipped";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
106
|
+
let total = 0;
|
|
107
|
+
|
|
108
|
+
for (const [pattern, replacement] of PATTERNS) {
|
|
109
|
+
// Reset lastIndex for global regexes
|
|
110
|
+
pattern.lastIndex = 0;
|
|
111
|
+
const matches = content.match(pattern);
|
|
112
|
+
if (matches) {
|
|
113
|
+
total += matches.length;
|
|
114
|
+
content = content.replace(pattern, replacement);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (total > 0) {
|
|
119
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Stamp as clean (whether creds found or not)
|
|
123
|
+
stampScrubbed(filePath);
|
|
124
|
+
return total;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ──────────────────────────────────────────────
|
|
128
|
+
// Find all transcript .jsonl files
|
|
129
|
+
// ──────────────────────────────────────────────
|
|
130
|
+
function findTranscripts() {
|
|
131
|
+
const claudeDir = path.join(os.homedir(), ".claude", "projects");
|
|
132
|
+
if (!fs.existsSync(claudeDir)) return [];
|
|
133
|
+
|
|
134
|
+
const results = [];
|
|
135
|
+
function walk(dir) {
|
|
136
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const full = path.join(dir, entry.name);
|
|
138
|
+
if (entry.isDirectory()) walk(full);
|
|
139
|
+
else if (entry.name.endsWith(".jsonl")) results.push(full);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
walk(claudeDir);
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ──────────────────────────────────────────────
|
|
147
|
+
// Find the most recent transcript (active session)
|
|
148
|
+
// ──────────────────────────────────────────────
|
|
149
|
+
function findMostRecent() {
|
|
150
|
+
const files = findTranscripts();
|
|
151
|
+
if (files.length === 0) return null;
|
|
152
|
+
|
|
153
|
+
let newest = files[0];
|
|
154
|
+
let newestMtime = fs.statSync(files[0]).mtimeMs;
|
|
155
|
+
for (const f of files.slice(1)) {
|
|
156
|
+
const mt = fs.statSync(f).mtimeMs;
|
|
157
|
+
if (mt > newestMtime) { newest = f; newestMtime = mt; }
|
|
158
|
+
}
|
|
159
|
+
return newest;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ──────────────────────────────────────────────
|
|
163
|
+
// Exported runner
|
|
164
|
+
// ──────────────────────────────────────────────
|
|
165
|
+
export async function runScrub(target, opts = {}) {
|
|
166
|
+
const force = opts.force || false;
|
|
167
|
+
|
|
168
|
+
// Determine which files to scrub
|
|
169
|
+
let files;
|
|
170
|
+
if (target === "all") {
|
|
171
|
+
files = findTranscripts();
|
|
172
|
+
if (files.length === 0) {
|
|
173
|
+
console.log(chalk.yellow("\n No transcript files found.\n"));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
console.log(chalk.cyan(`\n Scrubbing ${files.length} transcript file(s)...\n`));
|
|
177
|
+
} else if (target && target !== "all") {
|
|
178
|
+
// Specific file
|
|
179
|
+
const resolved = path.resolve(target);
|
|
180
|
+
if (!fs.existsSync(resolved)) {
|
|
181
|
+
console.log(chalk.red(`\n File not found: ${resolved}\n`));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
files = [resolved];
|
|
185
|
+
} else {
|
|
186
|
+
// No target — find most recent
|
|
187
|
+
const recent = findMostRecent();
|
|
188
|
+
if (!recent) {
|
|
189
|
+
console.log(chalk.yellow("\n No transcript files found.\n"));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
files = [recent];
|
|
193
|
+
console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Scrub
|
|
197
|
+
const spinner = ora("Scrubbing credentials...").start();
|
|
198
|
+
let grandTotal = 0;
|
|
199
|
+
let skipped = 0;
|
|
200
|
+
let scanned = 0;
|
|
201
|
+
|
|
202
|
+
for (const f of files) {
|
|
203
|
+
const result = scrubFile(f, force);
|
|
204
|
+
if (result === "skipped") {
|
|
205
|
+
skipped++;
|
|
206
|
+
} else {
|
|
207
|
+
scanned++;
|
|
208
|
+
if (result > 0) {
|
|
209
|
+
spinner.stop();
|
|
210
|
+
console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
|
|
211
|
+
spinner.start("Scrubbing credentials...");
|
|
212
|
+
grandTotal += result;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
spinner.stop();
|
|
218
|
+
|
|
219
|
+
// Summary
|
|
220
|
+
const parts = [];
|
|
221
|
+
if (scanned) parts.push(`scanned ${scanned}`);
|
|
222
|
+
if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
|
|
223
|
+
if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
|
|
224
|
+
else if (scanned) parts.push(chalk.green("no credentials found"));
|
|
225
|
+
|
|
226
|
+
console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
|
|
227
|
+
if (skipped && !force) {
|
|
228
|
+
console.log(chalk.gray(" (use --force to rescan all files)"));
|
|
229
|
+
}
|
|
230
|
+
console.log();
|
|
231
|
+
}
|