@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.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/build-bundle.js +37 -0
- package/dist/categorize.js +25 -0
- package/dist/churn-exclusions.js +51 -0
- package/dist/cli.js +87 -0
- package/dist/config.js +13 -0
- package/dist/credentials.js +26 -0
- package/dist/errors.js +20 -0
- package/dist/git.js +141 -0
- package/dist/hash.js +4 -0
- package/dist/http-client.js +129 -0
- package/dist/import-detect.js +281 -0
- package/dist/login.js +133 -0
- package/dist/logout.js +6 -0
- package/dist/merkle.js +20 -0
- package/dist/prompt.js +70 -0
- package/dist/public-remote.js +43 -0
- package/dist/salt.js +20 -0
- package/dist/scan-command.js +24 -0
- package/dist/scan.js +145 -0
- package/dist/secret-scan.js +37 -0
- package/dist/skill-detect.js +234 -0
- package/dist/submit-command.js +49 -0
- package/dist/submit.js +71 -0
- package/dist/summary.js +148 -0
- package/dist/types.js +12 -0
- package/dist/version-check.js +62 -0
- package/package.json +48 -0
- package/signatures/ai/vector-search.json +39 -0
- package/signatures/ai/whisper.json +36 -0
- package/signatures/auth/firebase-auth.json +43 -0
- package/signatures/auth/oauth-oidc.json +41 -0
- package/signatures/auth/supabase-auth.json +29 -0
- package/signatures/backend/axum.json +24 -0
- package/signatures/db/activerecord.json +21 -0
- package/signatures/db/eloquent.json +22 -0
- package/signatures/db/supabase.json +31 -0
- package/signatures/frontend/tailwind.json +28 -0
- package/signatures/infra/docker.json +20 -0
- package/signatures/infra/github-actions.json +19 -0
- package/signatures/infra/kubernetes.json +21 -0
- package/signatures/infra/terraform.json +22 -0
- package/signatures/package-map.json +483 -0
- package/signatures/testing/jest.json +25 -0
- 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
|
+
}
|
package/dist/summary.js
ADDED
|
@@ -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,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
|
+
}
|