@redential/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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +217 -0
  3. package/dist/build-bundle.js +37 -0
  4. package/dist/categorize.js +25 -0
  5. package/dist/churn-exclusions.js +51 -0
  6. package/dist/cli.js +87 -0
  7. package/dist/config.js +13 -0
  8. package/dist/credentials.js +26 -0
  9. package/dist/errors.js +20 -0
  10. package/dist/git.js +141 -0
  11. package/dist/hash.js +4 -0
  12. package/dist/http-client.js +129 -0
  13. package/dist/import-detect.js +281 -0
  14. package/dist/login.js +133 -0
  15. package/dist/logout.js +6 -0
  16. package/dist/merkle.js +20 -0
  17. package/dist/prompt.js +70 -0
  18. package/dist/public-remote.js +43 -0
  19. package/dist/salt.js +20 -0
  20. package/dist/scan-command.js +24 -0
  21. package/dist/scan.js +145 -0
  22. package/dist/secret-scan.js +37 -0
  23. package/dist/skill-detect.js +234 -0
  24. package/dist/submit-command.js +49 -0
  25. package/dist/submit.js +71 -0
  26. package/dist/summary.js +148 -0
  27. package/dist/types.js +12 -0
  28. package/dist/version-check.js +62 -0
  29. package/package.json +48 -0
  30. package/signatures/ai/vector-search.json +39 -0
  31. package/signatures/ai/whisper.json +36 -0
  32. package/signatures/auth/firebase-auth.json +43 -0
  33. package/signatures/auth/oauth-oidc.json +41 -0
  34. package/signatures/auth/supabase-auth.json +29 -0
  35. package/signatures/backend/axum.json +24 -0
  36. package/signatures/db/activerecord.json +21 -0
  37. package/signatures/db/eloquent.json +22 -0
  38. package/signatures/db/supabase.json +31 -0
  39. package/signatures/frontend/tailwind.json +28 -0
  40. package/signatures/infra/docker.json +20 -0
  41. package/signatures/infra/github-actions.json +19 -0
  42. package/signatures/infra/kubernetes.json +21 -0
  43. package/signatures/infra/terraform.json +22 -0
  44. package/signatures/package-map.json +483 -0
  45. package/signatures/testing/jest.json +25 -0
  46. package/taxonomy.json +119 -0
@@ -0,0 +1,234 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { join } from "node:path";
4
+ import { getCommitAddedLines } from "./git.js";
5
+ import { isExcludedPath, heuristicallyGeneratedPaths } from "./churn-exclusions.js";
6
+ import { extractImportedPackages } from "./import-detect.js";
7
+ import { ScanError } from "./errors.js";
8
+ // Default locations ship alongside dist/ in the published package (see
9
+ // package.json's "files") — src/ and dist/ sit at the same depth from the
10
+ // package root, so this resolves correctly both in dev and post-build.
11
+ const DEFAULT_SIGNATURES_DIR = fileURLToPath(new URL("../signatures", import.meta.url));
12
+ const DEFAULT_TAXONOMY_PATH = fileURLToPath(new URL("../taxonomy.json", import.meta.url));
13
+ const DEFAULT_PACKAGE_MAP_PATH = fileURLToPath(new URL("../signatures/package-map.json", import.meta.url));
14
+ // A single added line long enough to be minified/generated noise, not a
15
+ // hand-authored import or API call — never worth pattern-matching, and
16
+ // bounds worst-case regex time on pathological input.
17
+ const MAX_MATCHED_LINE_LENGTH = 2000;
18
+ function boundedAddedLines(addedLines) {
19
+ return addedLines
20
+ .split("\n")
21
+ .filter((line) => line.length <= MAX_MATCHED_LINE_LENGTH)
22
+ .join("\n");
23
+ }
24
+ // package-map.json is Tier 1's own data file, sitting directly under
25
+ // signatures/ (not inside a category subdirectory) — it has a different
26
+ // shape entirely ({"map": {...}}, not {"slug", "fixtures", ...}) and must
27
+ // never be picked up as if it were a Tier 2 signature file.
28
+ const PACKAGE_MAP_FILENAME = "package-map.json";
29
+ function listJsonFilesRecursive(dir) {
30
+ if (!existsSync(dir))
31
+ return [];
32
+ const out = [];
33
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
34
+ const full = join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ out.push(...listJsonFilesRecursive(full));
37
+ }
38
+ else if (entry.isFile() && entry.name.endsWith(".json") && entry.name !== PACKAGE_MAP_FILENAME) {
39
+ out.push(full);
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * Loads every `signatures/**\/*.json` file EXCEPT package-map.json (Tier
46
+ * 1's own data file — see loadPackageMap). `dir` is overridable ONLY so
47
+ * tests (including the privacy test that proves a hostile signature can't
48
+ * escape) can point detection at a fixture directory instead of the real
49
+ * shipped one — production code always uses the default.
50
+ */
51
+ export function loadSignatures(dir = DEFAULT_SIGNATURES_DIR) {
52
+ return listJsonFilesRecursive(dir).map((file) => {
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(readFileSync(file, "utf8"));
56
+ }
57
+ catch {
58
+ // Never echo file content in the error — just which file is broken.
59
+ throw new ScanError(`Malformed signature file: ${file}`);
60
+ }
61
+ const sig = parsed;
62
+ if (typeof sig.slug !== "string" || !sig.fixtures) {
63
+ throw new ScanError(`Signature file missing required fields: ${file}`);
64
+ }
65
+ return sig;
66
+ });
67
+ }
68
+ /** Same override rationale as loadSignatures. */
69
+ export function loadTaxonomySlugs(path = DEFAULT_TAXONOMY_PATH) {
70
+ const taxonomy = JSON.parse(readFileSync(path, "utf8"));
71
+ return new Set(taxonomy.skills.map((s) => s.slug));
72
+ }
73
+ /**
74
+ * Tier 1's data source (docs/signatures.md): flat package-name -> slug map.
75
+ * Same override rationale as loadSignatures/loadTaxonomySlugs — production
76
+ * always uses the shipped default.
77
+ */
78
+ export function loadPackageMap(path = DEFAULT_PACKAGE_MAP_PATH) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, "utf8"));
82
+ }
83
+ catch {
84
+ throw new ScanError(`Malformed package map file: ${path}`);
85
+ }
86
+ const map = parsed.map;
87
+ if (!map)
88
+ throw new ScanError(`Package map file missing "map" field: ${path}`);
89
+ return new Map(Object.entries(map));
90
+ }
91
+ function compile(signatures, taxonomySlugs) {
92
+ return signatures.map((sig) => {
93
+ // Defense in depth: the closed-vocabulary rule is enforced HERE, inside
94
+ // the function runScan actually calls (see scan.ts) — not just as a
95
+ // standalone check a future refactor could unwire without failing any
96
+ // test. A signature file naming a slug outside taxonomy.json can never
97
+ // reach matching, let alone the bundle.
98
+ if (!taxonomySlugs.has(sig.slug)) {
99
+ throw new ScanError(`Signature slug "${sig.slug}" is not in taxonomy.json.`);
100
+ }
101
+ try {
102
+ return compileOne(sig);
103
+ }
104
+ catch {
105
+ // Never echo the (possibly-malformed) pattern source, just which
106
+ // signature it came from.
107
+ throw new ScanError(`Signature "${sig.slug}" has an invalid regex pattern.`);
108
+ }
109
+ });
110
+ }
111
+ function matches(file, sig) {
112
+ if (sig.configFileRegexes.some((r) => r.test(file.path)))
113
+ return true;
114
+ const text = boundedAddedLines(file.addedLines);
115
+ if (sig.importRegexes.some((r) => r.test(text)))
116
+ return true;
117
+ if (sig.apiRegexes.some((r) => r.test(text)))
118
+ return true;
119
+ return false;
120
+ }
121
+ function compileOne(sig) {
122
+ return {
123
+ slug: sig.slug,
124
+ importRegexes: (sig.importPatterns ?? []).map((p) => new RegExp(p)),
125
+ apiRegexes: (sig.apiPatterns ?? []).map((p) => new RegExp(p)),
126
+ configFileRegexes: (sig.configFilePatterns ?? []).map((p) => new RegExp(p)),
127
+ };
128
+ }
129
+ /**
130
+ * Test-only entry point (exported so test/skill-detect.test.ts exercises
131
+ * the SAME matching primitive `detectSkills` uses, not a reimplementation
132
+ * of it — same rationale as the fixtures living inside each signature
133
+ * file). Not used by production scanning, which always goes through
134
+ * `detectSkills`'s per-commit loop.
135
+ */
136
+ export function fixtureMatches(sig, fixture) {
137
+ return matches({ path: fixture.path, addedLines: fixture.diff }, compileOne(sig));
138
+ }
139
+ /** Per-pattern hit map for one fixture against one signature — lets the
140
+ * generic test assert every declared pattern is exercised by at least one
141
+ * positive fixture, catching a dead or typo'd pattern that would otherwise
142
+ * silently never fire. */
143
+ export function fixtureCoverage(sig, fixture) {
144
+ const compiled = compileOne(sig);
145
+ return {
146
+ importPatterns: compiled.importRegexes.map((r) => r.test(fixture.diff)),
147
+ apiPatterns: compiled.apiRegexes.map((r) => r.test(fixture.diff)),
148
+ configFilePatterns: compiled.configFileRegexes.map((r) => r.test(fixture.path)),
149
+ };
150
+ }
151
+ /**
152
+ * Deterministic, local, two-tier skill detection (principle 3, "Bounded
153
+ * output") — see docs/signatures.md. Tier 1: generic import parsing
154
+ * (src/import-detect.ts) against signatures/package-map.json's flat
155
+ * package-name -> slug map, for the common case where a bare import
156
+ * unambiguously identifies the slug. Tier 2: the remaining signatures/*.json
157
+ * files, for config-file-based tech (no import at all, e.g. Docker) or
158
+ * usage ambiguous by import alone (e.g. @supabase/supabase-js serves both
159
+ * auth/supabase-auth and db/supabase — deliberately absent from the map,
160
+ * disambiguated by API-call shape instead). Zero network, no LLMs.
161
+ *
162
+ * Merge commits are skipped, same as getAllCommits' own numstat (which
163
+ * emits none for them) — no combined-diff handling needed. Files excluded
164
+ * from churn (lockfiles, build output, single-commit generated dumps —
165
+ * src/churn-exclusions.ts) are excluded here too, for BOTH tiers: a
166
+ * vendored dependency's content matching an import pattern would be a
167
+ * false "you wrote this" signal, not a real one.
168
+ */
169
+ export function detectSkills(userCommits, repoPath, opts = {}) {
170
+ const signatures = loadSignatures(opts.signaturesDir);
171
+ const taxonomySlugs = loadTaxonomySlugs(opts.taxonomyPath);
172
+ const compiled = compile(signatures, taxonomySlugs);
173
+ const packageMap = loadPackageMap(opts.packageMapPath);
174
+ // Same defense-in-depth rationale as compile()'s per-signature check:
175
+ // enforced inside the function runScan actually calls, not just in a
176
+ // standalone test, so a future refactor can't silently unwire it.
177
+ for (const slug of packageMap.values()) {
178
+ if (!taxonomySlugs.has(slug)) {
179
+ throw new ScanError(`Package map entry targets slug "${slug}", which is not in taxonomy.json.`);
180
+ }
181
+ }
182
+ const generatedPaths = heuristicallyGeneratedPaths(userCommits);
183
+ const matchedCommits = new Map();
184
+ const matchedDates = new Map();
185
+ const recordMatch = (slug, commit) => {
186
+ if (matchedCommits.get(slug)?.has(commit.sha))
187
+ return;
188
+ if (!matchedCommits.has(slug))
189
+ matchedCommits.set(slug, new Set());
190
+ matchedCommits.get(slug).add(commit.sha);
191
+ if (!matchedDates.has(slug))
192
+ matchedDates.set(slug, []);
193
+ matchedDates.get(slug).push(commit.authorDate);
194
+ };
195
+ for (const commit of userCommits) {
196
+ if (commit.isMerge)
197
+ continue;
198
+ const files = getCommitAddedLines(repoPath, commit.sha).filter((f) => !isExcludedPath(f.path) && !generatedPaths.has(f.path));
199
+ if (files.length === 0)
200
+ continue;
201
+ for (const file of files) {
202
+ // Tier 1: generic import detection against the flat package map.
203
+ for (const pkg of extractImportedPackages(file.addedLines, file.path)) {
204
+ const slug = packageMap.get(pkg);
205
+ if (slug)
206
+ recordMatch(slug, commit);
207
+ }
208
+ }
209
+ // Tier 2: config-file/API-usage signatures.
210
+ for (const sig of compiled) {
211
+ if (matchedCommits.get(sig.slug)?.has(commit.sha))
212
+ continue;
213
+ if (files.some((f) => matches(f, sig)))
214
+ recordMatch(sig.slug, commit);
215
+ }
216
+ }
217
+ const result = [];
218
+ for (const [slug, shas] of matchedCommits) {
219
+ const dates = matchedDates.get(slug);
220
+ const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
221
+ result.push({
222
+ slug,
223
+ commit_count: shas.size,
224
+ first_seen: sorted[0].toISOString(),
225
+ last_seen: sorted[sorted.length - 1].toISOString(),
226
+ });
227
+ }
228
+ // Deterministic output (principle 4, "User-reviewed": the printed bundle
229
+ // must be byte-identical across runs over the same history) — Map
230
+ // insertion order otherwise depends on which commit happened to match
231
+ // which signature first.
232
+ result.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
233
+ return result;
234
+ }
@@ -0,0 +1,49 @@
1
+ import { buildBundleInteractively } from "./build-bundle.js";
2
+ import { AuthError, SubmitError } from "./errors.js";
3
+ import { getSiteUrl } from "./config.js";
4
+ import { readCredentials } from "./credentials.js";
5
+ import { getRemoteUrl } from "./git.js";
6
+ import { checkVisibilityGate, postBundle } from "./submit.js";
7
+ import { promptConfirmUpload } from "./prompt.js";
8
+ import { checkForUpdate } from "./version-check.js";
9
+ /**
10
+ * `submit`'s actual behavior, independent of commander wiring. Builds the
11
+ * bundle through the exact same path `scan` uses, prints it (byte-for-byte
12
+ * what gets uploaded — see submit.ts's postBundle), then gates on: a
13
+ * matching stored session, explicit upload confirmation, and the remote
14
+ * visibility check.
15
+ */
16
+ export async function executeSubmitCommand(opts) {
17
+ const log = opts.log ?? console.log;
18
+ const warn = opts.warn ?? console.error;
19
+ const siteUrl = getSiteUrl();
20
+ const credentials = readCredentials(opts.configDir);
21
+ if (!credentials) {
22
+ throw new AuthError("Not logged in. Run `redential login` first.");
23
+ }
24
+ if (credentials.site_url !== siteUrl) {
25
+ throw new AuthError("Stored session belongs to a different site. Run `redential login` again.");
26
+ }
27
+ const bundle = await buildBundleInteractively(opts);
28
+ const bundleJson = JSON.stringify(bundle, null, 2);
29
+ log(bundleJson);
30
+ const confirmedUpload = opts.confirmUpload || (await (opts.promptConfirmUploadFn ?? promptConfirmUpload)());
31
+ if (!confirmedUpload) {
32
+ log("Aborted — nothing was uploaded.");
33
+ return;
34
+ }
35
+ const visibility = opts.probeFn
36
+ ? await checkVisibilityGate(getRemoteUrl(opts.repoPath), opts.probeFn)
37
+ : await checkVisibilityGate(getRemoteUrl(opts.repoPath));
38
+ if (visibility.message)
39
+ warn(visibility.message);
40
+ if (visibility.blocked) {
41
+ throw new SubmitError("Submit refused: see the message above.");
42
+ }
43
+ const result = await postBundle(siteUrl, credentials.access_token, bundleJson);
44
+ log(`Uploaded. Bundle id: ${result.id}`);
45
+ // Best-effort only, after the upload itself has already fully succeeded
46
+ // — never allowed to turn a successful submit into a failure
47
+ // (checkForUpdate never throws by contract).
48
+ await (opts.checkForUpdateFn ?? (() => checkForUpdate({ log: warn, currentVersion: opts.toolVersion })))();
49
+ }
package/dist/submit.js ADDED
@@ -0,0 +1,71 @@
1
+ import { NetworkError } from "./errors.js";
2
+ import { isKnownPublicHost, publicHostWarning } from "./public-remote.js";
3
+ import { headRequest, postRawJson } from "./http-client.js";
4
+ const HEAD_TIMEOUT_MS = 5000;
5
+ /**
6
+ * Converts a git remote URL (https, scp-like `git@host:org/repo.git`, or
7
+ * `ssh://`) into an https URL worth HEAD-requesting. Only ever called after
8
+ * isKnownPublicHost has already confirmed the URL carries no embedded
9
+ * credentials or token, so nothing sensitive can end up in the probe.
10
+ */
11
+ function toProbeUrl(remoteUrl) {
12
+ const scpMatch = !remoteUrl.includes("://") && remoteUrl.match(/^(?:[^@\s]+@)?([^:/\s]+):(.+)$/);
13
+ if (scpMatch) {
14
+ const [, host, path] = scpMatch;
15
+ return `https://${host}/${path.replace(/\.git$/, "")}`;
16
+ }
17
+ try {
18
+ const u = new URL(remoteUrl);
19
+ if (u.protocol !== "http:" && u.protocol !== "https:" && u.protocol !== "ssh:")
20
+ return null;
21
+ return `https://${u.host}${u.pathname.replace(/\.git$/, "")}`;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /**
28
+ * Only probes known-public-host remotes (github.com/gitlab.com/bitbucket.org
29
+ * shaped, per public-remote.ts) — never an arbitrary self-hosted URL. A
30
+ * confirmed 2xx/3xx blocks submit; anything else (private, unreachable,
31
+ * unparseable) proceeds, matching scan's "known host != publicly
32
+ * accessible" stance and the advisor-required fail-open behavior for
33
+ * network blips.
34
+ */
35
+ export async function checkVisibilityGate(remoteUrl, probeFn = headRequest) {
36
+ if (!isKnownPublicHost(remoteUrl))
37
+ return { blocked: false, message: null };
38
+ const probeUrl = toProbeUrl(remoteUrl);
39
+ if (!probeUrl)
40
+ return { blocked: false, message: publicHostWarning(remoteUrl) };
41
+ const result = await probeFn(probeUrl, HEAD_TIMEOUT_MS);
42
+ if (result === null) {
43
+ // Inconclusive (network error, timeout, host down) — never louder than
44
+ // scan's own warning, never treated as a confirmed answer either way.
45
+ return { blocked: false, message: publicHostWarning(remoteUrl) };
46
+ }
47
+ if (result.status >= 200 && result.status < 400) {
48
+ return {
49
+ blocked: true,
50
+ message: "Refusing to submit: this repository's remote answered as publicly reachable " +
51
+ `(HTTP ${result.status}). Connect the GitHub App instead — it reads the real code ` +
52
+ "and grants a stronger tier than a local metadata scan. If this repo is actually " +
53
+ "private, this check was wrong; please report it.",
54
+ };
55
+ }
56
+ return { blocked: false, message: null };
57
+ }
58
+ /**
59
+ * `bundleJson` must be the exact string already shown to the user (see
60
+ * submit-command.ts) — sent verbatim via postRawJson, never re-derived from
61
+ * the parsed object, so what was reviewed is byte-for-byte what is sent.
62
+ */
63
+ export async function postBundle(siteUrl, accessToken, bundleJson) {
64
+ const response = await postRawJson(`${siteUrl}/api/cli/bundles`, bundleJson, {
65
+ authorization: `Bearer ${accessToken}`,
66
+ });
67
+ if (typeof response.id !== "string") {
68
+ throw new NetworkError("Unexpected response from the submit server.");
69
+ }
70
+ return response;
71
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Renders a human-facing "wrapped" summary of an already-computed bundle for
3
+ * TTY stdout. Pure formatting only — every value comes from `Bundle`, no new
4
+ * data collection, no network. See `docs/scan.md`.
5
+ */
6
+ const WIDTH = 60;
7
+ const RESET = "\x1b[0m";
8
+ const BOLD = "\x1b[1m";
9
+ const DIM = "\x1b[2m";
10
+ const CYAN = "\x1b[36m";
11
+ const GREEN = "\x1b[32m";
12
+ const YELLOW = "\x1b[33m";
13
+ const GRAY = "\x1b[90m";
14
+ const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
15
+ const SPARK_LEVELS = ["·", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
16
+ function humanizeSpan(days) {
17
+ if (days <= 0)
18
+ return "a single day";
19
+ const years = Math.floor(days / 365);
20
+ if (years >= 1)
21
+ return `${years} year${years === 1 ? "" : "s"}`;
22
+ const months = Math.floor(days / 30);
23
+ if (months >= 1)
24
+ return `${months} month${months === 1 ? "" : "s"}`;
25
+ return `${days} day${days === 1 ? "" : "s"}`;
26
+ }
27
+ function pct(fraction) {
28
+ return `${Math.round(fraction * 100)}%`;
29
+ }
30
+ function bar(fraction, width) {
31
+ const clamped = Math.max(0, Math.min(1, fraction));
32
+ const filled = Math.round(clamped * width);
33
+ return "█".repeat(filled) + "░".repeat(width - filled);
34
+ }
35
+ function sparkline(values) {
36
+ const max = Math.max(...values, 0);
37
+ if (max === 0)
38
+ return SPARK_LEVELS[0].repeat(values.length);
39
+ return values
40
+ .map((v) => {
41
+ if (v === 0)
42
+ return SPARK_LEVELS[0];
43
+ const level = Math.max(1, Math.round((v / max) * (SPARK_LEVELS.length - 1)));
44
+ return SPARK_LEVELS[level];
45
+ })
46
+ .join("");
47
+ }
48
+ function hourAxis() {
49
+ const chars = new Array(24).fill(" ");
50
+ for (const [pos, label] of [
51
+ [0, "0"],
52
+ [6, "6"],
53
+ [12, "12"],
54
+ [18, "18"],
55
+ ]) {
56
+ for (let i = 0; i < label.length; i++)
57
+ chars[pos + i] = label[i];
58
+ }
59
+ return chars.join("");
60
+ }
61
+ function heading(label) {
62
+ return ` ${BOLD}${GRAY}${label}${RESET}`;
63
+ }
64
+ function sectionOrTeaser(items, render, teaser) {
65
+ return items.length > 0 ? render(items) : [` ${DIM}${teaser}${RESET}`];
66
+ }
67
+ function weekdaySection(histogram) {
68
+ const max = Math.max(...histogram, 1);
69
+ const barWidth = 20;
70
+ return histogram.map((count, i) => {
71
+ const filled = Math.round((count / max) * barWidth);
72
+ const b = "█".repeat(filled) + "░".repeat(barWidth - filled);
73
+ return ` ${WEEKDAY_LABELS[i]} ${GREEN}${b}${RESET} ${count}`;
74
+ });
75
+ }
76
+ function shareSection(items, maxItems) {
77
+ const top = [...items].sort((a, b) => b.share - a.share).slice(0, maxItems);
78
+ const max = Math.max(...top.map((i) => i.share), 0.0001);
79
+ const labelWidth = Math.max(...top.map((i) => i.label.length), 4);
80
+ return top.map((item) => {
81
+ const b = bar(item.share / max, 20);
82
+ const label = item.label.padEnd(labelWidth);
83
+ const suffix = item.suffix ? ` ${DIM}${item.suffix}${RESET}` : "";
84
+ return ` ${label} ${GREEN}${b}${RESET} ${YELLOW}${pct(item.share).padStart(4)}${RESET}${suffix}`;
85
+ });
86
+ }
87
+ function skillsSection(bundle) {
88
+ const skills = [...bundle.detected_skills].sort((a, b) => b.commit_count - a.commit_count);
89
+ if (skills.length === 0) {
90
+ return [
91
+ ` ${DIM}No skills detected yet — signature matching covers 100+`,
92
+ ` technologies (auth, payments, AI, infra, and more). Keep`,
93
+ ` committing and rerun \`redential scan\`.${RESET}`,
94
+ ];
95
+ }
96
+ const shown = skills.slice(0, 8);
97
+ const labelWidth = Math.max(...shown.map((s) => s.slug.length), 4);
98
+ const lines = shown.map((s) => ` ${s.slug.padEnd(labelWidth)} ${GREEN}${String(s.commit_count).padStart(4)} commits${RESET}`);
99
+ if (skills.length > shown.length) {
100
+ lines.push(` ${DIM}+${skills.length - shown.length} more${RESET}`);
101
+ }
102
+ return lines;
103
+ }
104
+ export function formatSummary(bundle) {
105
+ const lines = [];
106
+ // Printed after the JSON (scan-command.ts) so this is the last thing on
107
+ // screen when the command ends — the divider marks where the JSON above
108
+ // ends and the human-readable summary begins.
109
+ lines.push(` ${GRAY}${"─".repeat(WIDTH)}${RESET}`);
110
+ lines.push("");
111
+ const title = "YOUR PRIVATE REPO, WRAPPED";
112
+ const pad = Math.max(0, Math.floor((WIDTH - title.length) / 2));
113
+ lines.push(` ${CYAN}${"╔" + "═".repeat(WIDTH) + "╗"}${RESET}`);
114
+ lines.push(` ${CYAN}║${RESET}${" ".repeat(pad)}${BOLD}${CYAN}${title}${RESET}${" ".repeat(WIDTH - pad - title.length)}${CYAN}║${RESET}`);
115
+ lines.push(` ${CYAN}${"╚" + "═".repeat(WIDTH) + "╝"}${RESET}`);
116
+ lines.push("");
117
+ const commitCount = bundle.commits.user_total.toLocaleString("en-US");
118
+ lines.push(` ${BOLD}${humanizeSpan(bundle.commits.span_days)}, ${commitCount} commits${RESET}`);
119
+ lines.push("");
120
+ lines.push(heading("COMMITS BY HOUR (UTC)"));
121
+ lines.push(` ${hourAxis()}`);
122
+ lines.push(` ${GREEN}${sparkline(bundle.commits.hour_histogram)}${RESET}`);
123
+ lines.push("");
124
+ lines.push(heading("COMMITS BY WEEKDAY"));
125
+ lines.push(...weekdaySection(bundle.commits.weekday_histogram));
126
+ lines.push("");
127
+ lines.push(heading("TOP LANGUAGES"));
128
+ lines.push(...sectionOrTeaser(bundle.languages, (langs) => shareSection(langs.map((l) => ({ label: l.extension, share: l.share })), 5), "No language data — every change so far was excluded (lockfiles, build output, generated dumps)."));
129
+ lines.push("");
130
+ lines.push(heading("TOP CATEGORIES"));
131
+ lines.push(...sectionOrTeaser(bundle.categories, (cats) => shareSection(cats.map((c) => ({
132
+ label: c.name,
133
+ share: c.churn_share,
134
+ suffix: `(${c.commit_count} commit${c.commit_count === 1 ? "" : "s"})`,
135
+ })), 5), "No category data yet."));
136
+ lines.push("");
137
+ lines.push(heading("SKILLS DETECTED"));
138
+ lines.push(...skillsSection(bundle));
139
+ lines.push("");
140
+ lines.push(` ${BOLD}Ownership${RESET} ${YELLOW}${pct(bundle.ownership.user_commit_ratio)}${RESET} of this repo's commits are yours`);
141
+ lines.push(` ${BOLD}Signed commits${RESET} ${YELLOW}${pct(bundle.signed.ratio)}${RESET} of your commits are cryptographically signed`);
142
+ if (bundle.signed.ratio === 0) {
143
+ lines.push(` ${DIM}Tip: sign your commits (git config commit.gpgsign true) — signed history is the strongest anchor for your credential.${RESET}`);
144
+ }
145
+ lines.push("");
146
+ lines.push(` ${DIM}Nothing left your machine. Verify: github.com/Jppblue/redential-cli${RESET}`);
147
+ return lines.join("\n");
148
+ }
package/dist/types.js ADDED
@@ -0,0 +1,12 @@
1
+ export const CATEGORY_NAMES = [
2
+ "auth",
3
+ "payments",
4
+ "infra",
5
+ "frontend",
6
+ "backend",
7
+ "data",
8
+ "testing",
9
+ "ai-workflow",
10
+ "docs",
11
+ "other",
12
+ ];
@@ -0,0 +1,62 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { getJson } from "./http-client.js";
3
+ /**
4
+ * Best-effort, non-blocking "a newer version exists" notice, checked against
5
+ * the public npm registry. ONLY ever wired into `login.ts` and
6
+ * `submit-command.ts` — both already make network calls, so this adds no new
7
+ * network surface to the command. It must NEVER be called from
8
+ * `scan-command.ts`: `scan` makes zero network calls, full stop (CLAUDE.md's
9
+ * inviolable rule, and see docs/login-submit.md's "Version check" section
10
+ * for the boundary this file exists inside). Nothing about the scanned repo
11
+ * is ever sent — the only outbound data is a GET to a fixed, public URL.
12
+ */
13
+ const PACKAGE_NAME = "@redential/cli";
14
+ const NPM_REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
15
+ const DEFAULT_TIMEOUT_MS = 1500;
16
+ function getInstalledVersion() {
17
+ const pkgUrl = new URL("../package.json", import.meta.url);
18
+ const pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
19
+ return pkg.version;
20
+ }
21
+ /** Parses a `major.minor.patch`-leading version string; null if it doesn't
22
+ * parse cleanly — a malformed registry response must skip the notice, never
23
+ * crash the command it's attached to. */
24
+ function parseVersion(v) {
25
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
26
+ if (!m)
27
+ return null;
28
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
29
+ }
30
+ function isNewer(current, latest) {
31
+ const c = parseVersion(current);
32
+ const l = parseVersion(latest);
33
+ if (!c || !l)
34
+ return false;
35
+ for (let i = 0; i < 3; i++) {
36
+ if (l[i] > c[i])
37
+ return true;
38
+ if (l[i] < c[i])
39
+ return false;
40
+ }
41
+ return false;
42
+ }
43
+ export async function checkForUpdate(opts = {}) {
44
+ const log = opts.log ?? console.error;
45
+ const currentVersion = opts.currentVersion ?? getInstalledVersion();
46
+ const registryUrl = opts.registryUrl ?? NPM_REGISTRY_URL;
47
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
48
+ const fetchFn = opts.fetchFn ?? (getJson);
49
+ let latest;
50
+ try {
51
+ latest = await fetchFn(registryUrl, timeoutMs);
52
+ }
53
+ catch {
54
+ // fetchFn is injectable (tests, future callers) and isn't guaranteed to
55
+ // share getJson's own never-throws contract — this check must stay
56
+ // best-effort regardless of what's plugged in.
57
+ return;
58
+ }
59
+ if (!latest?.version || !isNewer(currentVersion, latest.version))
60
+ return;
61
+ log(`A newer version of ${PACKAGE_NAME} is available: ${latest.version} (you have ${currentVersion}). Run \`npm install -g ${PACKAGE_NAME}\` to update.`);
62
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@redential/cli",
3
+ "version": "0.1.0",
4
+ "description": "Local, metadata-only proof bundles from git history. Source code never leaves your machine.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "bin": {
11
+ "redential": "dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "signatures",
16
+ "taxonomy.json"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Jppblue/redential-cli.git"
21
+ },
22
+ "homepage": "https://github.com/Jppblue/redential-cli#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/Jppblue/redential-cli/issues"
25
+ },
26
+ "keywords": [
27
+ "cli",
28
+ "git",
29
+ "credential-verification",
30
+ "proof-of-work",
31
+ "developer-portfolio",
32
+ "privacy"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "dev": "tsc --watch",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "prepublishOnly": "npm test && npm run build"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^12.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.6.0",
46
+ "vitest": "^2.1.0"
47
+ }
48
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "slug": "ai/vector-search",
3
+ "importPatterns": [
4
+ "from\\s+[\"']@pinecone-database/pinecone[\"']",
5
+ "from\\s+[\"']chromadb[\"']",
6
+ "from\\s+[\"']@qdrant/js-client-rest[\"']"
7
+ ],
8
+ "apiPatterns": [
9
+ "\\.similaritySearch\\(",
10
+ "new\\s+PineconeClient\\("
11
+ ],
12
+ "configFilePatterns": [],
13
+ "fixtures": {
14
+ "positive": [
15
+ {
16
+ "path": "src/lib/pinecone.ts",
17
+ "diff": "import { PineconeClient } from \"@pinecone-database/pinecone\";\nconst pinecone = new PineconeClient();\nawait pinecone.Index(\"docs\").upsert({ vectors });"
18
+ },
19
+ {
20
+ "path": "src/lib/chroma.ts",
21
+ "diff": "import { ChromaClient } from \"chromadb\";\nconst collection = await client.getOrCreateCollection({ name: \"docs\" });\nconst results = await collection.similaritySearch(\"query text\", 5);"
22
+ },
23
+ {
24
+ "path": "src/lib/qdrant.ts",
25
+ "diff": "import { QdrantClient } from \"@qdrant/js-client-rest\";\nconst client = new QdrantClient({ url: \"http://localhost:6333\" });\nawait client.upsert(\"docs\", { points });"
26
+ }
27
+ ],
28
+ "negative": [
29
+ {
30
+ "path": "README.md",
31
+ "diff": "We evaluated pinecone, chromadb, and qdrant for vector search but decided pgvector inside Postgres covers our similarity search needs without a separate vector database."
32
+ },
33
+ {
34
+ "path": "src/db/users.ts",
35
+ "diff": "await prisma.user.upsert({ where: { id }, create: data, update: data });"
36
+ }
37
+ ]
38
+ }
39
+ }