Skip to content

Commit ae35b5a

Browse files
committed
feat: wire CLI store publish, add /api/scan route, fix skipped tests
- CLI: threatcrush store publish <url> — fetches metadata, previews, publishes - API: POST /api/scan — free security header scanner (grade A-F) - Tests: unskip 3 tests, fix Supabase mock proxy to be thenable for non-.single() chains - All 128 tests passing, CLI + web builds clean
1 parent 4ea570c commit ae35b5a

6 files changed

Lines changed: 409 additions & 10 deletions

File tree

cli/dist/index.js

Lines changed: 114 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/index.ts

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { Command } from "commander";
44
import chalk from "chalk";
55
import readline from "readline";
66
import { execSync } from "node:child_process";
7-
import { readFileSync } from "node:fs";
7+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
88
import { join } from "node:path";
9+
import { homedir } from "node:os";
910

1011
let PKG_VERSION = "0.1.8";
1112
try {
@@ -266,15 +267,140 @@ program
266267
await emailGate();
267268
});
268269

269-
program
270+
const storeCmd = program
270271
.command("store")
271272
.description("Browse the module marketplace")
272-
.argument("[action]", "search | info | publish")
273-
.argument("[query]", "search query or module name")
274273
.action(async () => {
275274
await emailGate();
276275
});
277276

277+
storeCmd
278+
.command("search <query>")
279+
.description("Search for modules in the store")
280+
.action(async () => {
281+
await emailGate();
282+
});
283+
284+
storeCmd
285+
.command("publish <url>")
286+
.description("Publish a module from a git URL or web URL")
287+
.action(async (url: string) => {
288+
console.log(LOGO);
289+
290+
// 1. Get email
291+
const configPath = join(homedir(), ".threatcrush", "config.json");
292+
let email = "";
293+
try {
294+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
295+
email = config.email || "";
296+
} catch {}
297+
298+
if (!email) {
299+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
300+
email = await new Promise<string>((resolve) => {
301+
rl.question(chalk.green(" Enter your email: "), (answer) => {
302+
rl.close();
303+
resolve(answer.trim());
304+
});
305+
});
306+
307+
if (!email || !email.includes("@")) {
308+
console.log(chalk.red("\n Invalid email.\n"));
309+
return;
310+
}
311+
312+
// Save email
313+
try {
314+
const dir = join(homedir(), ".threatcrush");
315+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
316+
writeFileSync(configPath, JSON.stringify({ email }, null, 2));
317+
console.log(chalk.dim(` Saved email to ${configPath}`));
318+
} catch {}
319+
}
320+
321+
console.log(chalk.dim(`\n Fetching metadata from ${url}...\n`));
322+
323+
// 2. Fetch metadata
324+
let meta: Record<string, unknown>;
325+
try {
326+
const res = await fetch(`${API_URL}/api/modules/fetch-meta`, {
327+
method: "POST",
328+
headers: { "Content-Type": "application/json" },
329+
body: JSON.stringify({ url, git_url: url }),
330+
});
331+
if (!res.ok) {
332+
const err = await res.json().catch(() => ({ error: res.statusText }));
333+
console.log(chalk.red(` ✗ Failed to fetch metadata: ${(err as Record<string, string>).error}\n`));
334+
return;
335+
}
336+
meta = await res.json() as Record<string, unknown>;
337+
} catch (err) {
338+
console.log(chalk.red(` ✗ Failed to fetch metadata: ${err instanceof Error ? err.message : err}\n`));
339+
return;
340+
}
341+
342+
// 3. Preview
343+
console.log(chalk.green(" ── Module Preview ──\n"));
344+
console.log(` ${chalk.bold("Name:")} ${meta.name || chalk.dim("(none)")}`);
345+
console.log(` ${chalk.bold("Display:")} ${meta.display_name || chalk.dim("(none)")}`);
346+
console.log(` ${chalk.bold("Description:")} ${meta.description || chalk.dim("(none)")}`);
347+
console.log(` ${chalk.bold("Version:")} ${meta.version || "0.1.0"}`);
348+
console.log(` ${chalk.bold("License:")} ${meta.license || "MIT"}`);
349+
console.log(` ${chalk.bold("Author:")} ${meta.author_name || chalk.dim("(none)")}`);
350+
console.log(` ${chalk.bold("Homepage:")} ${meta.homepage_url || chalk.dim("(none)")}`);
351+
console.log(` ${chalk.bold("Git:")} ${meta.git_url || url}`);
352+
if (Array.isArray(meta.tags) && meta.tags.length > 0) {
353+
console.log(` ${chalk.bold("Tags:")} ${(meta.tags as string[]).join(", ")}`);
354+
}
355+
if (meta.stars) {
356+
console.log(` ${chalk.bold("Stars:")}${meta.stars}`);
357+
}
358+
console.log();
359+
360+
// 4. Confirm
361+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
362+
const confirm = await new Promise<string>((resolve) => {
363+
rl.question(chalk.yellow(" Publish this module? (y/N): "), (answer) => {
364+
rl.close();
365+
resolve(answer.trim().toLowerCase());
366+
});
367+
});
368+
369+
if (confirm !== "y" && confirm !== "yes") {
370+
console.log(chalk.dim("\n Cancelled.\n"));
371+
return;
372+
}
373+
374+
// 5. Publish
375+
console.log(chalk.dim("\n Publishing..."));
376+
try {
377+
const res = await fetch(`${API_URL}/api/modules`, {
378+
method: "POST",
379+
headers: { "Content-Type": "application/json" },
380+
body: JSON.stringify({
381+
...meta,
382+
author_email: email,
383+
git_url: (meta.git_url as string) || url,
384+
homepage_url: (meta.homepage_url as string) || url,
385+
}),
386+
});
387+
388+
const result = await res.json() as Record<string, unknown>;
389+
390+
if (!res.ok) {
391+
console.log(chalk.red(`\n ✗ Publish failed: ${(result as Record<string, string>).error}\n`));
392+
return;
393+
}
394+
395+
const mod = result.module as Record<string, string>;
396+
const slug = mod?.slug || meta.name;
397+
console.log(chalk.green(`\n ✓ Module published!`));
398+
console.log(chalk.dim(` ${API_URL}/store/${slug}\n`));
399+
} catch (err) {
400+
console.log(chalk.red(`\n ✗ Publish failed: ${err instanceof Error ? err.message : err}\n`));
401+
}
402+
});
403+
278404
// Default action (no command — show help)
279405
program.action(() => {
280406
console.log(LOGO);

src/app/api/modules/[slug]/__tests__/route.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ vi.mock("@/lib/supabase", () => ({
1717
function createChainable(result: { data: unknown; error: unknown }) {
1818
const handler: ProxyHandler<object> = {
1919
get(_target, prop: string) {
20-
if (prop === "then") return undefined;
20+
if (prop === "then") {
21+
// Make the proxy thenable — when awaited directly, resolve to result
22+
return (resolve: (v: unknown) => void) => resolve(result);
23+
}
2124
if (prop === "single" || prop === "maybeSingle") {
2225
return vi.fn().mockResolvedValue(result);
2326
}
@@ -58,7 +61,7 @@ describe("GET /api/modules/:slug", () => {
5861
};
5962
});
6063

61-
it.skip("returns module with versions and reviews", async () => {
64+
it("returns module with versions and reviews", async () => {
6265
const req = makeRequest("http://localhost/api/modules/test-scanner");
6366
const res = await GET(req, makeContext("test-scanner"));
6467
const body = await res.json();
@@ -145,7 +148,7 @@ describe("DELETE /api/modules/:slug", () => {
145148
vi.clearAllMocks();
146149
});
147150

148-
it.skip("deletes a module", async () => {
151+
it("deletes a module", async () => {
149152
mockFromResponses = {
150153
modules: createChainable({
151154
data: { id: "mod-001", author_email: "test@example.com" },

src/app/api/modules/__tests__/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe("POST /api/modules", () => {
112112
resetChain();
113113
});
114114

115-
it.skip("creates a module with valid data", async () => {
115+
it("creates a module with valid data", async () => {
116116
const newModule = { ...TEST_MODULE, id: "mod-new" };
117117
// slug check returns no existing, then insert returns new module
118118
resetChain({

0 commit comments

Comments
 (0)