Skip to content
PrivateAI
← Back to Home
Developer Tools

Build a Weekly Dependency-Health Digest with the Perplexity API in TypeScript

11 min read min readBy PrivateAI Team

What This Builds (and Why Not Just Use Dependabot)

Dependabot, Snyk, and Socket.dev are excellent tools. They're also SaaS products that clone your repository, index your dependencies, and store that graph on their servers. For most teams that's fine. For privacy-conscious developers running internal tooling, proprietary configs, or anything you'd rather not hand to a third party — self-hosting the intelligence layer is the cleaner call.

The Perplexity API gives you real-time web search grounded in current sources. Instead of a static CVE database updated on someone else's schedule, you're asking a live search-backed model: "Is lodash@4.17.21 currently safe to use? Any known CVEs or active exploits as of today?" The answer pulls from npm advisories, GitHub security advisories, and the wider web.

This approach won't replace npm audit — run both. But it adds narrative context that audit scores omit: deprecation warnings, ecosystem churn, alternative packages gaining traction, and active discussions about breaking changes.


Project Setup

Create a dedicated directory on your server. Keep this separate from any project it monitors — it's infrastructure, not application code.

```bash

mkdir ~/dep-digest && cd ~/dep-digest

npm init -y

npm install typescript tsx dotenv @types/node

npx tsc --init --strict --target ES2022 --module NodeNext --moduleResolution NodeNext

```

Create your environment file:

```bash

touch .env

echo "PERPLEXITY_API_KEY=your_key_here" >> .env

echo "DIGEST_OUTPUT_DIR=/home/youruser/digests" >> .env

echo "SMTP_HOST=" >> .env # optional — leave blank to skip email

echo "SMTP_PORT=587" >> .env

echo "SMTP_USER=" >> .env

echo "SMTP_PASS=" >> .env

echo "SMTP_TO=" >> .env

```

Add .env to .gitignore immediately. If you version-control this directory, also add digests/ — those reports will contain your full dependency list.


Reading Your Dependencies

The script needs to discover all package.json files in the projects you want to monitor. We'll accept a list of paths via environment variable.

```typescript

// src/discover.ts

import { readFileSync, readdirSync, statSync } from "fs";

import { join } from "path";

export interface PackageDep {

name: string;

version: string;

source: string; // which package.json it came from

}

export function collectDependencies(projectRoots: string[]): PackageDep[] {

const deps: PackageDep[] = [];

for (const root of projectRoots) {

const pkgPath = join(root, "package.json");

let raw: Record;

try {

raw = JSON.parse(readFileSync(pkgPath, "utf-8"));

} catch {

console.warn([discover] Skipping ${pkgPath} — not readable);

continue;

}

const allDeps = {

...(raw.dependencies as Record | undefined),

...(raw.devDependencies as Record | undefined),

};

for (const [name, version] of Object.entries(allDeps)) {

deps.push({ name, version: version.replace(/[\^~>=<]/, ""), source: pkgPath });

}

}

// Deduplicate: same package@version from multiple projects = one query

const seen = new Map();

for (const dep of deps) {

const key = ${dep.name}@${dep.version};

if (!seen.has(key)) seen.set(key, dep);

}

return [...seen.values()];

}

```

Deduplication matters for cost control. A monorepo with 10 apps sharing react@18.3.1 should query Perplexity once, not ten times.


Querying Perplexity for Each Package

Perplexity's API is OpenAI-compatible, which means standard fetch with a Bearer token works without an SDK dependency.

```typescript

// src/perplexity.ts

import "dotenv/config";

const API_URL = "https://api.perplexity.ai/chat/completions";

export interface DepHealth {

packageName: string;

version: string;

summary: string;

severity: "ok" | "warning" | "critical" | "unknown";

cves: string[];

deprecated: boolean;

recommendation: string;

}

const SYSTEM_PROMPT = `You are a dependency security analyst. Given an npm package name and version,

respond ONLY with valid JSON matching this schema:

{

"summary": "2-3 sentence plain-English status",

"severity": "ok" | "warning" | "critical" | "unknown",

"cves": ["CVE-YYYY-NNNNN", ...],

"deprecated": true | false,

"recommendation": "one actionable sentence"

}

Base your answer on npm advisories, GitHub security advisories, and current web sources.

Do not fabricate CVE IDs. If no issues found, severity is "ok".`;

export async function queryPackageHealth(

name: string,

version: string

): Promise {

const userPrompt = `Package: ${name}@${version}

Date: ${new Date().toISOString().split("T")[0]}

Are there any known vulnerabilities, deprecations, or security concerns?`;

const response = await fetch(API_URL, {

method: "POST",

headers: {

"Content-Type": "application/json",

Authorization: Bearer ${process.env.PERPLEXITY_API_KEY},

},

body: JSON.stringify({

model: "sonar",

messages: [

{ role: "system", content: SYSTEM_PROMPT },

{ role: "user", content: userPrompt },

],

temperature: 0.1, // low temp = consistent JSON output

max_tokens: 300,

}),

});

if (!response.ok) {

throw new Error(Perplexity API error: ${response.status} ${await response.text()});

}

const data = await response.json();

const content: string = data.choices[0].message.content;

// Strip markdown fences if the model wraps its JSON

const jsonStr = content.replace(/``json\n?|\n?``/g, "").trim();

let parsed: Omit;

try {

parsed = JSON.parse(jsonStr);

} catch {

return {

packageName: name,

version,

summary: "Failed to parse response",

severity: "unknown",

cves: [],

deprecated: false,

recommendation: "Re-run manually or check npm advisories directly",

};

}

return { packageName: name, version, ...parsed };

}

```

A note on what Perplexity receives: each API call sends the package name, version, and today's date. It does not receive your source code, file paths, or anything else. If even that feels like too much exposure for a given package, skip it by name in an exclusions list before querying.


Rate Limiting and Cost Control

Don't fire all queries in parallel. Perplexity's sonar model is inexpensive (~$1/1000 requests at time of writing), but 200 packages in parallel will hit rate limits.

```typescript

// src/throttle.ts

export async function pMap(

items: T[],

fn: (item: T) => Promise,

concurrency = 5

): Promise {

const results: R[] = [];

for (let i = 0; i < items.length; i += concurrency) {

const batch = items.slice(i, i + concurrency);

const batchResults = await Promise.all(batch.map(fn));

results.push(...batchResults);

if (i + concurrency < items.length) {

await new Promise((r) => setTimeout(r, 1000)); // 1s between batches

}

}

return results;

}

```

Five concurrent requests, 1-second pause between batches. For a typical project with 80-120 dependencies after deduplication, the full run completes in under 4 minutes.


Building the Digest Report

```typescript

// src/report.ts

import { writeFileSync, mkdirSync } from "fs";

import { join } from "path";

import type { DepHealth } from "./perplexity.js";

export function generateMarkdownReport(results: DepHealth[], date: string): string {

const critical = results.filter((r) => r.severity === "critical");

const warnings = results.filter((r) => r.severity === "warning");

const ok = results.filter((r) => r.severity === "ok");

const deprecated = results.filter((r) => r.deprecated);

const lines: string[] = [

# Dependency Health Digest — ${date},

"",

Scanned: ${results.length} packages | +

Critical: ${critical.length} | Warnings: ${warnings.length} | OK: ${ok.length},

"",

];

if (critical.length > 0) {

lines.push("## 🔴 Critical Issues");

for (const dep of critical) {

lines.push(### \${dep.packageName}@${dep.version}\``);

lines.push(dep.summary);

if (dep.cves.length > 0) lines.push(CVEs: ${dep.cves.join(", ")});

lines.push(Action: ${dep.recommendation});

lines.push("");

}

}

if (deprecated.length > 0) {

lines.push("## 🟡 Deprecated Packages");

for (const dep of deprecated) {

lines.push(- \${dep.packageName}@${dep.version}\ — ${dep.recommendation});

}

lines.push("");

}

if (warnings.length > 0) {

lines.push("## ⚠️ Warnings");

for (const dep of warnings) {

lines.push(- \${dep.packageName}@${dep.version}\ — ${dep.summary});

}

lines.push("");

}

lines.push("## ✅ No Issues");

lines.push(${ok.length} packages reported clean.);

lines.push("");

lines.push(---);

lines.push(Generated by dep-digest on ${date}. Powered by Perplexity Sonar API.);

return lines.join("\n");

}

export function saveReport(content: string, outputDir: string, date: string): string {

mkdirSync(outputDir, { recursive: true });

const filename = digest-${date}.md;

const filepath = join(outputDir, filename);

writeFileSync(filepath, content, "utf-8");

return filepath;

}

```


The Main Entry Point

```typescript

// src/index.ts

import "dotenv/config";

import { collectDependencies } from "./discover.js";

import { queryPackageHealth } from "./perplexity.js";

import { pMap } from "./throttle.js";

import { generateMarkdownReport, saveReport } from "./report.js";

const PROJECT_ROOTS = (process.env.PROJECT_ROOTS ?? "")

.split(",")

.map((p) => p.trim())

.filter(Boolean);

const OUTPUT_DIR = process.env.DIGEST_OUTPUT_DIR ?? ${process.env.HOME}/digests;

const DATE = new Date().toISOString().split("T")[0];

async function main() {

if (PROJECT_ROOTS.length === 0) {

console.error("Set PROJECT_ROOTS in .env (comma-separated paths)");

process.exit(1);

}

console.log([dep-digest] Scanning ${PROJECT_ROOTS.length} project(s)...);

const deps = collectDependencies(PROJECT_ROOTS);

console.log([dep-digest] Found ${deps.length} unique packages. Querying Perplexity...);

const results = await pMap(deps, (d) => queryPackageHealth(d.name, d.version), 5);

const report = generateMarkdownReport(results, DATE);

const filepath = saveReport(report, OUTPUT_DIR, DATE);

console.log([dep-digest] Report saved: ${filepath});

const critical = results.filter((r) => r.severity === "critical").length;

if (critical > 0) {

console.warn([dep-digest] ⚠️ ${critical} critical issue(s) found — check report);

process.exit(2); // non-zero exit lets cron notify you via MAILTO

}

}

main().catch((err) => {

console.error(err);

process.exit(1);

});

```

Add the run script to package.json:

```json

"scripts": {

"digest": "tsx src/index.ts"

}

```


Scheduling with Cron

On your server, open your crontab:

```bash

crontab -e

```

Add this line to run every Sunday at 7 AM server time:

```

MAILTO=you@yourdomain.com

0 7 0 cd /home/youruser/dep-digest && /usr/local/bin/npm run digest >> /home/youruser/dep-digest/cron.log 2>&1

```

The MAILTO directive at the top of your crontab makes cron email you the output if anything is written to stderr — which happens when critical issues are found (exit code 2) or the script errors out. No extra notification tooling required.

If you use systemd instead of cron, a .timer unit gives you the same behavior with better logging via journalctl.


Privacy Considerations

Three things leave your server:

  1. Package names and versions — to Perplexity's API
  2. Today's date — included in the prompt
  3. Your API key — in the Authorization header over TLS

Your source code, file paths, environment variables, and project names stay local. If you want to minimize even the package list exposure, run the digest through a VPN exit node or a Wireguard tunnel that exits at a different IP than your other services.

Perplexity Pro includes higher rate limits and the sonar-pro model, which retrieves more sources per query and produces more reliable CVE attribution. For teams monitoring 300+ packages across multiple monorepos, the upgrade pays for itself in accuracy alone.

Affiliate Disclosure: This article may contain affiliate links. If you make a purchase through these links, we may earn a small commission at no extra cost to you. We only recommend products we genuinely believe in. This helps support our work and allows us to continue providing free content.