package-radar 0.0.2

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/dist/index.mjs ADDED
@@ -0,0 +1,904 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/analyze.ts
4
+ import OpenAI from "openai";
5
+ var PACKAGE_NEWS_HOMES = {
6
+ typescript: {
7
+ label: "TypeScript blog",
8
+ url: "https://devblogs.microsoft.com/typescript/"
9
+ },
10
+ "@typescript/native": {
11
+ label: "TypeScript blog",
12
+ url: "https://devblogs.microsoft.com/typescript/"
13
+ },
14
+ expo: {
15
+ label: "Expo changelog",
16
+ url: "https://expo.dev/changelog"
17
+ },
18
+ "react-native": {
19
+ label: "React Native blog",
20
+ url: "https://reactnative.dev/blog"
21
+ },
22
+ react: {
23
+ label: "React blog",
24
+ url: "https://react.dev/blog"
25
+ },
26
+ next: {
27
+ label: "Next.js blog",
28
+ url: "https://nextjs.org/blog"
29
+ },
30
+ vue: {
31
+ label: "Vue blog",
32
+ url: "https://blog.vuejs.org/"
33
+ },
34
+ svelte: {
35
+ label: "Svelte blog",
36
+ url: "https://svelte.dev/blog"
37
+ },
38
+ "drizzle-orm": {
39
+ label: "Drizzle announcements",
40
+ url: "https://orm.drizzle.team/docs/latest-releases"
41
+ },
42
+ "@prisma/client": {
43
+ label: "Prisma blog",
44
+ url: "https://www.prisma.io/blog"
45
+ },
46
+ vite: {
47
+ label: "Vite blog",
48
+ url: "https://vite.dev/blog/"
49
+ },
50
+ tailwindcss: {
51
+ label: "Tailwind blog",
52
+ url: "https://tailwindcss.com/blog"
53
+ },
54
+ openai: {
55
+ label: "OpenAI changelog",
56
+ url: "https://developers.openai.com/changelog"
57
+ },
58
+ express: {
59
+ label: "Express releases",
60
+ url: "https://github.com/expressjs/express/releases"
61
+ },
62
+ "@nestjs/core": {
63
+ label: "NestJS releases",
64
+ url: "https://github.com/nestjs/nest/releases"
65
+ },
66
+ hono: {
67
+ label: "Hono releases",
68
+ url: "https://github.com/honojs/hono/releases"
69
+ },
70
+ "@trpc/server": {
71
+ label: "tRPC blog",
72
+ url: "https://trpc.io/blog"
73
+ },
74
+ zod: {
75
+ label: "Zod releases",
76
+ url: "https://github.com/colinhacks/zod/releases"
77
+ }
78
+ };
79
+ var SYSTEM_PROMPT = `You are Package Radar, a developer news briefing tool.
80
+
81
+ Given a scanned Node/JS/TS project, search the web for MAJOR recent package/framework NEWS that is relevant to the detected stack.
82
+
83
+ What counts as "major news":
84
+ - New major/minor releases of libraries they use
85
+ - Official blog posts, changelogs, RC/beta announcements
86
+ - Breaking-change notices, migration guides, platform roadmap updates
87
+ - Framework SDK releases (Expo, React Native, Next.js, TypeScript, etc.)
88
+
89
+ What to EXCLUDE:
90
+ - Security advisories, CVEs, vulnerability audits
91
+ - Minor patch-only noise unless it is unusually important
92
+ - Generic tips unrelated to recent public news
93
+ - Invented URLs \u2014 only cite real sources you found via web search
94
+
95
+ Rules:
96
+ - Only cover technologies present in the project.
97
+ - Prefer major releases and official announcements from the last ~1\u20134 weeks when possible; include slightly older major news if nothing fresher exists.
98
+ - 3\u20136 items max. Quality over quantity.
99
+ - Every item MUST include at least one real source URL (blog post, changelog, GitHub release, docs).
100
+ - "whyCare" should tie the news to THIS project (versions installed, file counts, package manager).
101
+ - "summary" is 1\u20132 sentences of what happened in the news.
102
+ - "recommendation" is one short sentence (e.g. "Worth testing", "Wait for stable", "Read the migration guide").
103
+ - headline like "5 things happened this week".
104
+ - Emojis: \u{1F525} \u{1F4F1} \u26A1 \u{1F9F0} \u{1F9EA} \u{1F4E6} \u{1F680} etc. Never \u{1F512} for security.
105
+
106
+ Return ONLY valid JSON:
107
+ {
108
+ "headline": string,
109
+ "items": [
110
+ {
111
+ "emoji": string,
112
+ "title": string,
113
+ "summary": string,
114
+ "whyCare": string,
115
+ "recommendation": string,
116
+ "sources": [{ "title": string, "url": string }]
117
+ }
118
+ ]
119
+ }`;
120
+ function buildUserPrompt(input2) {
121
+ const { scan, versions } = input2;
122
+ const context = {
123
+ project: {
124
+ name: scan.packageJson.name ?? null,
125
+ packageManager: scan.packageManager
126
+ },
127
+ fileCounts: scan.fileCounts,
128
+ detectedStack: scan.detected.map((d) => ({
129
+ id: d.id,
130
+ label: d.label,
131
+ packageName: d.packageName,
132
+ installedVersion: d.installedVersion,
133
+ range: d.range
134
+ })),
135
+ registryVersions: versions.map((v) => ({
136
+ packageName: v.packageName,
137
+ installedVersion: v.installedVersion,
138
+ latestOnNpm: v.latestVersion,
139
+ behindLatest: v.behind
140
+ })),
141
+ knownOfficialHomes: scan.detected.map((d) => PACKAGE_NEWS_HOMES[d.packageName]).filter(Boolean),
142
+ today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
143
+ searchHints: scan.detected.map(
144
+ (d) => `${d.label} release announcement OR changelog OR blog ${(/* @__PURE__ */ new Date()).getFullYear()}`
145
+ )
146
+ };
147
+ return `Search for major package news relevant to this project, then produce a Package Radar news briefing as JSON.
148
+
149
+ Project context:
150
+ ${JSON.stringify(context, null, 2)}
151
+
152
+ Prefer sources like official blogs, GitHub Releases, framework changelogs, and reputable tech news (e.g. TypeScript blog for TS 7, Expo changelog for SDK releases).`;
153
+ }
154
+ function isHttpUrl(url) {
155
+ try {
156
+ const u = new URL(url);
157
+ return u.protocol === "http:" || u.protocol === "https:";
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+ function normalizeSources(raw) {
163
+ if (!Array.isArray(raw)) return [];
164
+ const out = [];
165
+ for (const s of raw) {
166
+ if (!s || typeof s !== "object") continue;
167
+ const title = String(s.title ?? "").trim() || "Source";
168
+ const url = String(s.url ?? "").trim();
169
+ if (!isHttpUrl(url)) continue;
170
+ out.push({ title, url });
171
+ }
172
+ return out;
173
+ }
174
+ function parseReport(content) {
175
+ try {
176
+ const cleaned = content.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/\s*```$/i, "").trim();
177
+ const data = JSON.parse(cleaned);
178
+ if (!data || !Array.isArray(data.items)) return null;
179
+ const items = data.items.filter(
180
+ (i) => i && i.title && (i.whyCare || i.summary) && i.recommendation
181
+ ).map((i) => ({
182
+ emoji: i.emoji || "\u{1F4F0}",
183
+ title: i.title,
184
+ summary: i.summary || i.whyCare,
185
+ whyCare: i.whyCare || i.summary,
186
+ recommendation: i.recommendation,
187
+ sources: normalizeSources(i.sources)
188
+ })).filter((i) => i.sources.length > 0);
189
+ if (items.length === 0) return null;
190
+ return {
191
+ headline: data.headline || `${items.length} things happened this week`,
192
+ items
193
+ };
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ function fallbackReport(input2) {
199
+ const items = [];
200
+ for (const v of input2.versions) {
201
+ if (!v.latestVersion) continue;
202
+ const tech = input2.scan.detected.find(
203
+ (d) => d.packageName === v.packageName
204
+ );
205
+ const home = PACKAGE_NEWS_HOMES[v.packageName];
206
+ const name = tech?.label.replace(/\s+\d.*$/, "") ?? v.packageName;
207
+ if (!v.behind && !home) continue;
208
+ const sources = [];
209
+ if (home) sources.push({ title: home.label, url: home.url });
210
+ sources.push({
211
+ title: `${v.packageName} on npm`,
212
+ url: `https://www.npmjs.com/package/${v.packageName}`
213
+ });
214
+ items.push({
215
+ emoji: "\u{1F4E6}",
216
+ title: v.behind ? `${name} ${v.latestVersion}` : `${name} latest: ${v.latestVersion}`,
217
+ summary: v.behind ? `npm latest is ${v.latestVersion}; this project is on ${v.installedVersion}.` : `You are on the current npm latest (${v.latestVersion}).`,
218
+ whyCare: input2.scan.fileCounts.typescript ? `Your project has ${input2.scan.fileCounts.typescript.toLocaleString()} TypeScript files and depends on ${v.packageName}.` : `Your project depends on ${v.packageName}.`,
219
+ recommendation: v.behind ? v.latestVersion.includes("rc") || v.latestVersion.includes("beta") ? "Wait for stable, then read the release notes." : "Skim the official release notes before upgrading." : "No action needed \u2014 check official channels next week.",
220
+ sources
221
+ });
222
+ }
223
+ if (items.length === 0) {
224
+ items.push({
225
+ emoji: "\u{1F4E1}",
226
+ title: "No major package headlines found offline",
227
+ summary: "Could not reach OpenAI web search. Showing official news homes for your stack.",
228
+ whyCare: "Re-run with a valid OpenAI API key for a live news briefing.",
229
+ recommendation: "Check official blogs for your main frameworks.",
230
+ sources: input2.scan.detected.map((d) => PACKAGE_NEWS_HOMES[d.packageName]).filter((h) => Boolean(h)).map((h) => ({ title: h.label, url: h.url }))
231
+ });
232
+ }
233
+ return {
234
+ headline: `${Math.min(items.length, 6)} package news items for your stack`,
235
+ items: items.slice(0, 6)
236
+ };
237
+ }
238
+ function extractResponseText(response) {
239
+ if (response.output_text?.trim()) return response.output_text.trim();
240
+ const chunks = [];
241
+ for (const item of response.output ?? []) {
242
+ if (item.type !== "message" || !item.content) continue;
243
+ for (const part of item.content) {
244
+ if (part.type === "output_text" && part.text) chunks.push(part.text);
245
+ }
246
+ }
247
+ return chunks.join("\n").trim();
248
+ }
249
+ async function generateReport(input2) {
250
+ const client = new OpenAI({ apiKey: input2.apiKey });
251
+ const model = input2.model ?? process.env.OPENAI_MODEL ?? "gpt-4.1-mini";
252
+ const userPrompt = buildUserPrompt({
253
+ scan: input2.scan,
254
+ versions: input2.versions
255
+ });
256
+ try {
257
+ const response = await client.responses.create({
258
+ model,
259
+ temperature: 0.3,
260
+ tools: [
261
+ {
262
+ type: "web_search",
263
+ search_context_size: "medium"
264
+ }
265
+ ],
266
+ tool_choice: "auto",
267
+ instructions: SYSTEM_PROMPT,
268
+ input: userPrompt
269
+ });
270
+ const content = extractResponseText(response);
271
+ if (!content) throw new Error("Empty response from OpenAI");
272
+ const parsed = parseReport(content);
273
+ if (parsed && parsed.items.length > 0) return parsed;
274
+ const completion = await client.chat.completions.create({
275
+ model,
276
+ temperature: 0.3,
277
+ response_format: { type: "json_object" },
278
+ messages: [
279
+ {
280
+ role: "system",
281
+ content: `${SYSTEM_PROMPT}
282
+
283
+ If you cannot verify URLs, use only well-known official homes (TypeScript blog, Expo changelog, React blog, npm package pages, GitHub releases). Never invent article paths.`
284
+ },
285
+ { role: "user", content: userPrompt },
286
+ {
287
+ role: "user",
288
+ content: `Previous draft (may be partial):
289
+ ${content}
290
+
291
+ Return corrected JSON only, with real source URLs.`
292
+ }
293
+ ]
294
+ });
295
+ const retry = completion.choices[0]?.message?.content;
296
+ if (retry) {
297
+ const retried = parseReport(retry);
298
+ if (retried && retried.items.length > 0) return retried;
299
+ }
300
+ return fallbackReport(input2);
301
+ } catch (err) {
302
+ console.warn(
303
+ `OpenAI news search failed (${err instanceof Error ? err.message : String(err)}). Using registry + official blogs only.
304
+ `
305
+ );
306
+ return fallbackReport(input2);
307
+ }
308
+ }
309
+
310
+ // src/config.ts
311
+ import fs from "fs/promises";
312
+ import path from "path";
313
+ import os from "os";
314
+ import readline from "readline/promises";
315
+ import { stdin as input, stdout as output } from "process";
316
+ var CONFIG_DIR = path.join(os.homedir(), ".package-radar");
317
+ var CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
318
+ var LEGACY_CONFIG_PATH = path.join(
319
+ os.homedir(),
320
+ ".code-radar",
321
+ "config.json"
322
+ );
323
+ var GITIGNORE_SECRETS_ENTRY = ".package-radar/";
324
+ var GITIGNORE_SECRETS_COMMENT = "# OpenAI / package-radar secrets \u2014 never commit API keys";
325
+ var GITIGNORE_MATCHES = /* @__PURE__ */ new Set([
326
+ ".package-radar",
327
+ ".package-radar/",
328
+ ".package-radar/**",
329
+ "**/.package-radar",
330
+ "**/.package-radar/",
331
+ "**/.package-radar/**",
332
+ // legacy name
333
+ ".code-radar",
334
+ ".code-radar/",
335
+ ".code-radar/**",
336
+ "**/.code-radar",
337
+ "**/.code-radar/",
338
+ "**/.code-radar/**"
339
+ ]);
340
+ function getConfigPath() {
341
+ return CONFIG_PATH;
342
+ }
343
+ function gitignoreAlreadyIgnoresSecrets(content) {
344
+ for (const line of content.split(/\r?\n/)) {
345
+ const trimmed = line.trim();
346
+ if (!trimmed || trimmed.startsWith("#")) continue;
347
+ if (GITIGNORE_MATCHES.has(trimmed)) return true;
348
+ }
349
+ return false;
350
+ }
351
+ async function ensureGitignoreHasSecretsEntry(projectRoot) {
352
+ const gitignorePath = path.join(projectRoot, ".gitignore");
353
+ let content = "";
354
+ try {
355
+ content = await fs.readFile(gitignorePath, "utf8");
356
+ } catch (err) {
357
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
358
+ if (code !== "ENOENT") throw err;
359
+ }
360
+ if (gitignoreAlreadyIgnoresSecrets(content)) {
361
+ return { updated: false, gitignorePath };
362
+ }
363
+ let next = content;
364
+ if (next.length > 0 && !next.endsWith("\n")) {
365
+ next += "\n";
366
+ }
367
+ if (next.length > 0) {
368
+ next += "\n";
369
+ }
370
+ next += `${GITIGNORE_SECRETS_COMMENT}
371
+ ${GITIGNORE_SECRETS_ENTRY}
372
+ `;
373
+ await fs.writeFile(gitignorePath, next, "utf8");
374
+ return { updated: true, gitignorePath };
375
+ }
376
+ async function loadConfig() {
377
+ try {
378
+ const raw = await fs.readFile(CONFIG_PATH, "utf8");
379
+ return JSON.parse(raw);
380
+ } catch {
381
+ try {
382
+ const raw = await fs.readFile(LEGACY_CONFIG_PATH, "utf8");
383
+ return JSON.parse(raw);
384
+ } catch {
385
+ return {};
386
+ }
387
+ }
388
+ }
389
+ async function saveConfig(config) {
390
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
391
+ await fs.writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}
392
+ `, {
393
+ mode: 384
394
+ });
395
+ }
396
+ async function resolveApiKey(options) {
397
+ if (!options?.forcePrompt) {
398
+ const fromEnv = process.env.OPENAI_API_KEY?.trim();
399
+ if (fromEnv) return fromEnv;
400
+ const config = await loadConfig();
401
+ if (config.openaiApiKey?.trim()) return config.openaiApiKey.trim();
402
+ }
403
+ const rl = readline.createInterface({ input, output });
404
+ try {
405
+ const key = (await rl.question(
406
+ "Enter your OpenAI API key (saved to ~/.package-radar/config.json): "
407
+ )).trim();
408
+ if (!key) {
409
+ throw new Error("OpenAI API key is required.");
410
+ }
411
+ if (!key.startsWith("sk-")) {
412
+ console.warn(
413
+ "Warning: key does not look like a typical OpenAI key (sk-...). Continuing anyway."
414
+ );
415
+ }
416
+ const config = await loadConfig();
417
+ config.openaiApiKey = key;
418
+ await saveConfig(config);
419
+ console.log(`Saved API key to ${CONFIG_PATH}
420
+ `);
421
+ return key;
422
+ } finally {
423
+ rl.close();
424
+ }
425
+ }
426
+
427
+ // src/format.ts
428
+ var DIVIDER = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500";
429
+ function printScanHeader(scan) {
430
+ console.log("\u{1F50D} Scanning your project...\n");
431
+ console.log("Detected:\n");
432
+ for (const tech of scan.detected) {
433
+ console.log(`\u2713 ${tech.label}`);
434
+ }
435
+ console.log(`
436
+ ${DIVIDER}
437
+ `);
438
+ }
439
+ function printReport(report) {
440
+ const count = report.items.length;
441
+ const headline = report.headline || `${count} thing${count === 1 ? "" : "s"} happened this week`;
442
+ const withFire = headline.startsWith("\u{1F525}") ? headline : `\u{1F525} ${headline}`;
443
+ console.log(`${withFire}
444
+ `);
445
+ for (let i = 0; i < report.items.length; i++) {
446
+ const item = report.items[i];
447
+ const n = i + 1;
448
+ if (i === 0) {
449
+ console.log(`${n}. ${item.title}
450
+ `);
451
+ } else {
452
+ const title = item.title.startsWith(item.emoji) ? item.title : `${item.emoji} ${item.title}`;
453
+ console.log(`${title}
454
+ `);
455
+ }
456
+ if (item.summary && item.summary !== item.whyCare) {
457
+ console.log(item.summary);
458
+ console.log("");
459
+ }
460
+ console.log("You should care because:");
461
+ console.log(item.whyCare);
462
+ console.log("");
463
+ if (item.sources.length > 0) {
464
+ console.log("Sources:");
465
+ for (const source of item.sources) {
466
+ console.log(`\u2022 ${source.title}`);
467
+ console.log(` ${source.url}`);
468
+ }
469
+ console.log("");
470
+ }
471
+ console.log("Recommendation:");
472
+ console.log(item.recommendation);
473
+ console.log(`
474
+ ${DIVIDER}
475
+ `);
476
+ }
477
+ }
478
+ function printError(message) {
479
+ console.error(`Error: ${message}`);
480
+ }
481
+
482
+ // src/scan.ts
483
+ import fs2 from "fs/promises";
484
+ import path2 from "path";
485
+ var IGNORE_DIRS = /* @__PURE__ */ new Set([
486
+ "node_modules",
487
+ "dist",
488
+ "build",
489
+ ".git",
490
+ ".next",
491
+ ".expo",
492
+ "coverage",
493
+ ".turbo",
494
+ ".cache",
495
+ "ios",
496
+ "android",
497
+ "vendor",
498
+ ".yarn"
499
+ ]);
500
+ var TECH_CATALOG = [
501
+ {
502
+ id: "react-native",
503
+ packageName: "react-native",
504
+ label: (v) => v ? `React Native ${v}` : "React Native"
505
+ },
506
+ {
507
+ id: "expo",
508
+ packageName: "expo",
509
+ label: (v) => v ? `Expo SDK ${major(v)}` : "Expo"
510
+ },
511
+ {
512
+ id: "typescript",
513
+ packageName: "typescript",
514
+ label: (v) => v ? `TypeScript ${v}` : "TypeScript"
515
+ },
516
+ {
517
+ // Some projects install TS 7 under this alias while keeping TS 6 as `typescript` for tooling
518
+ id: "typescript-native",
519
+ packageName: "@typescript/native",
520
+ label: (v) => v ? `TypeScript ${v}` : "TypeScript (native)"
521
+ },
522
+ {
523
+ id: "drizzle",
524
+ packageName: "drizzle-orm",
525
+ label: () => "Drizzle ORM"
526
+ },
527
+ {
528
+ id: "prisma",
529
+ packageName: "@prisma/client",
530
+ label: () => "Prisma"
531
+ },
532
+ {
533
+ id: "next",
534
+ packageName: "next",
535
+ label: (v) => v ? `Next.js ${v}` : "Next.js"
536
+ },
537
+ {
538
+ id: "react",
539
+ packageName: "react",
540
+ label: (v) => v ? `React ${v}` : "React"
541
+ },
542
+ {
543
+ id: "vue",
544
+ packageName: "vue",
545
+ label: (v) => v ? `Vue ${v}` : "Vue"
546
+ },
547
+ {
548
+ id: "svelte",
549
+ packageName: "svelte",
550
+ label: (v) => v ? `Svelte ${v}` : "Svelte"
551
+ },
552
+ {
553
+ id: "express",
554
+ packageName: "express",
555
+ label: (v) => v ? `Express ${v}` : "Express"
556
+ },
557
+ {
558
+ id: "nestjs",
559
+ packageName: "@nestjs/core",
560
+ label: () => "NestJS"
561
+ },
562
+ {
563
+ id: "fastify",
564
+ packageName: "fastify",
565
+ label: (v) => v ? `Fastify ${v}` : "Fastify"
566
+ },
567
+ {
568
+ id: "hono",
569
+ packageName: "hono",
570
+ label: (v) => v ? `Hono ${v}` : "Hono"
571
+ },
572
+ {
573
+ id: "zod",
574
+ packageName: "zod",
575
+ label: (v) => v ? `Zod ${v}` : "Zod"
576
+ },
577
+ {
578
+ id: "tailwind",
579
+ packageName: "tailwindcss",
580
+ label: (v) => v ? `Tailwind CSS ${v}` : "Tailwind CSS"
581
+ },
582
+ {
583
+ id: "vite",
584
+ packageName: "vite",
585
+ label: (v) => v ? `Vite ${v}` : "Vite"
586
+ },
587
+ {
588
+ id: "vitest",
589
+ packageName: "vitest",
590
+ label: () => "Vitest"
591
+ },
592
+ {
593
+ id: "jest",
594
+ packageName: "jest",
595
+ label: () => "Jest"
596
+ },
597
+ {
598
+ id: "openai",
599
+ packageName: "openai",
600
+ label: () => "OpenAI SDK"
601
+ },
602
+ {
603
+ id: "trpc",
604
+ packageName: "@trpc/server",
605
+ label: () => "tRPC"
606
+ }
607
+ ];
608
+ function major(version) {
609
+ const cleaned = version.replace(/^[\^~>=<\s]*/, "");
610
+ return cleaned.split(".")[0] ?? cleaned;
611
+ }
612
+ function stripRange(range) {
613
+ return range.replace(/^[\^~>=<\s]*/, "").split(" ")[0] ?? range;
614
+ }
615
+ function mergeDependencies(pkg) {
616
+ return {
617
+ ...pkg.optionalDependencies,
618
+ ...pkg.peerDependencies,
619
+ ...pkg.devDependencies,
620
+ ...pkg.dependencies
621
+ };
622
+ }
623
+ async function findProjectRoot(startDir = process.cwd()) {
624
+ let dir = path2.resolve(startDir);
625
+ for (; ; ) {
626
+ try {
627
+ await fs2.access(path2.join(dir, "package.json"));
628
+ return dir;
629
+ } catch {
630
+ const parent = path2.dirname(dir);
631
+ if (parent === dir) {
632
+ throw new Error(
633
+ "No package.json found. Run package-radar from inside a Node project."
634
+ );
635
+ }
636
+ dir = parent;
637
+ }
638
+ }
639
+ }
640
+ async function detectPackageManager(projectRoot, pkg) {
641
+ if (pkg.packageManager?.startsWith("pnpm")) return "pnpm";
642
+ if (pkg.packageManager?.startsWith("yarn")) return "yarn";
643
+ if (pkg.packageManager?.startsWith("bun")) return "bun";
644
+ if (pkg.packageManager?.startsWith("npm")) return "npm";
645
+ const checks = [
646
+ ["yarn.lock", "yarn"],
647
+ ["pnpm-lock.yaml", "pnpm"],
648
+ ["bun.lockb", "bun"],
649
+ ["bun.lock", "bun"],
650
+ ["package-lock.json", "npm"]
651
+ ];
652
+ for (const [file, pm] of checks) {
653
+ try {
654
+ await fs2.access(path2.join(projectRoot, file));
655
+ return pm;
656
+ } catch {
657
+ }
658
+ }
659
+ return "unknown";
660
+ }
661
+ async function readInstalledVersion(projectRoot, packageName) {
662
+ const pkgPath = path2.join(
663
+ projectRoot,
664
+ "node_modules",
665
+ ...packageName.split("/"),
666
+ "package.json"
667
+ );
668
+ try {
669
+ const raw = await fs2.readFile(pkgPath, "utf8");
670
+ const json = JSON.parse(raw);
671
+ return json.version ?? null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+ async function countSourceFiles(projectRoot) {
677
+ let typescript = 0;
678
+ let javascript = 0;
679
+ async function walk(dir) {
680
+ let entries;
681
+ try {
682
+ entries = await fs2.readdir(dir, { withFileTypes: true });
683
+ } catch {
684
+ return;
685
+ }
686
+ for (const entry of entries) {
687
+ if (entry.name.startsWith(".") && entry.name !== ".env.example") {
688
+ if (entry.isDirectory()) continue;
689
+ }
690
+ if (entry.isDirectory()) {
691
+ if (IGNORE_DIRS.has(entry.name)) continue;
692
+ await walk(path2.join(dir, entry.name));
693
+ continue;
694
+ }
695
+ const ext = path2.extname(entry.name).toLowerCase();
696
+ if (ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts") {
697
+ typescript += 1;
698
+ } else if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") {
699
+ javascript += 1;
700
+ }
701
+ }
702
+ }
703
+ await walk(projectRoot);
704
+ return {
705
+ typescript,
706
+ javascript,
707
+ totalSource: typescript + javascript
708
+ };
709
+ }
710
+ async function scanProject(cwd = process.cwd()) {
711
+ const projectRoot = await findProjectRoot(cwd);
712
+ const raw = await fs2.readFile(path2.join(projectRoot, "package.json"), "utf8");
713
+ const packageJson = JSON.parse(raw);
714
+ const allDependencies = mergeDependencies(packageJson);
715
+ const packageManager = await detectPackageManager(projectRoot, packageJson);
716
+ const detected = [];
717
+ for (const tech of TECH_CATALOG) {
718
+ const range = allDependencies[tech.packageName];
719
+ if (!range) continue;
720
+ const installedVersion = await readInstalledVersion(projectRoot, tech.packageName) ?? stripRange(range);
721
+ detected.push({
722
+ id: tech.id,
723
+ label: tech.label(installedVersion),
724
+ packageName: tech.packageName,
725
+ installedVersion,
726
+ range
727
+ });
728
+ }
729
+ if (detected.length === 0) {
730
+ const top = Object.entries(allDependencies).slice(0, 8);
731
+ for (const [name, range] of top) {
732
+ const installedVersion = await readInstalledVersion(projectRoot, name) ?? stripRange(range);
733
+ detected.push({
734
+ id: name,
735
+ label: `${name}@${installedVersion}`,
736
+ packageName: name,
737
+ installedVersion,
738
+ range
739
+ });
740
+ }
741
+ }
742
+ const fileCounts = await countSourceFiles(projectRoot);
743
+ return {
744
+ projectRoot,
745
+ packageJson,
746
+ packageManager,
747
+ allDependencies,
748
+ detected,
749
+ fileCounts
750
+ };
751
+ }
752
+
753
+ // src/versions.ts
754
+ function parseSemverParts(version) {
755
+ const cleaned = version.replace(/^[\^~>=<\s]*/, "").replace(/-.*$/, "");
756
+ const parts = cleaned.split(".").map((p) => Number.parseInt(p, 10));
757
+ if (parts.length < 1 || parts.some((n) => Number.isNaN(n))) return null;
758
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
759
+ }
760
+ function isBehind(installed, latest) {
761
+ if (!installed || !latest) return null;
762
+ const a = parseSemverParts(installed);
763
+ const b = parseSemverParts(latest);
764
+ if (!a || !b) return installed !== latest;
765
+ for (let i = 0; i < 3; i++) {
766
+ if (a[i] < b[i]) return true;
767
+ if (a[i] > b[i]) return false;
768
+ }
769
+ return false;
770
+ }
771
+ async function fetchLatestVersion(packageName) {
772
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
773
+ try {
774
+ const res = await fetch(url, {
775
+ headers: { Accept: "application/json" },
776
+ signal: AbortSignal.timeout(8e3)
777
+ });
778
+ if (!res.ok) return null;
779
+ const data = await res.json();
780
+ return data.version ?? null;
781
+ } catch {
782
+ return null;
783
+ }
784
+ }
785
+ async function fetchLatestVersions(packages) {
786
+ const results = await Promise.all(
787
+ packages.map(async ({ packageName, installedVersion }) => {
788
+ const latestVersion = await fetchLatestVersion(packageName);
789
+ return {
790
+ packageName,
791
+ installedVersion,
792
+ latestVersion,
793
+ behind: isBehind(installedVersion, latestVersion)
794
+ };
795
+ })
796
+ );
797
+ return results;
798
+ }
799
+
800
+ // src/index.ts
801
+ function printHelp() {
802
+ console.log(`package-radar \u2014 package news for your codebase
803
+
804
+ Usage:
805
+ package-radar Scan current project and print this week's news radar
806
+ package-radar config Prompt and save your OpenAI API key
807
+ package-radar config --show Show whether a key is configured (does not print the key)
808
+ package-radar --help Show this help
809
+
810
+ What it does:
811
+ Detects your stack from package.json, looks up npm versions, then uses OpenAI
812
+ web search for major package/framework news with source links.
813
+
814
+ Environment:
815
+ OPENAI_API_KEY Overrides saved key for this run
816
+ OPENAI_MODEL Model name (default: gpt-4.1-mini)
817
+
818
+ Config file:
819
+ ${getConfigPath()}
820
+ `);
821
+ }
822
+ async function runConfig(args) {
823
+ if (args.includes("--show") || args.includes("-s")) {
824
+ const config = await loadConfig();
825
+ const envSet = Boolean(process.env.OPENAI_API_KEY?.trim());
826
+ const fileSet = Boolean(config.openaiApiKey?.trim());
827
+ console.log(`Config: ${getConfigPath()}`);
828
+ console.log(`Saved key: ${fileSet ? "yes" : "no"}`);
829
+ console.log(`OPENAI_API_KEY env: ${envSet ? "set" : "not set"}`);
830
+ return;
831
+ }
832
+ if (args.includes("--clear")) {
833
+ const config = await loadConfig();
834
+ delete config.openaiApiKey;
835
+ await saveConfig(config);
836
+ console.log(`Cleared saved API key at ${getConfigPath()}`);
837
+ return;
838
+ }
839
+ await resolveApiKey({ forcePrompt: true });
840
+ try {
841
+ const root = await findProjectRoot();
842
+ const gitignore = await ensureGitignoreHasSecretsEntry(root);
843
+ if (gitignore.updated) {
844
+ console.log(
845
+ `Added .package-radar/ to ${gitignore.gitignorePath} so API keys are not committed.`
846
+ );
847
+ }
848
+ } catch {
849
+ }
850
+ }
851
+ async function runRadar() {
852
+ const apiKey = await resolveApiKey();
853
+ const scan = await scanProject();
854
+ const gitignore = await ensureGitignoreHasSecretsEntry(scan.projectRoot);
855
+ if (gitignore.updated) {
856
+ console.log(
857
+ `Added .package-radar/ to ${gitignore.gitignorePath} so API keys are not committed.
858
+ `
859
+ );
860
+ }
861
+ printScanHeader(scan);
862
+ process.stdout.write("Checking npm for latest package versions...\n");
863
+ const versions = await fetchLatestVersions(
864
+ scan.detected.map((d) => ({
865
+ packageName: d.packageName,
866
+ installedVersion: d.installedVersion
867
+ }))
868
+ );
869
+ process.stdout.write(
870
+ "Searching the web for major package news (OpenAI)...\n\n"
871
+ );
872
+ const report = await generateReport({
873
+ apiKey,
874
+ scan,
875
+ versions
876
+ });
877
+ printReport(report);
878
+ }
879
+ async function main() {
880
+ const args = process.argv.slice(2);
881
+ if (args.includes("--help") || args.includes("-h")) {
882
+ printHelp();
883
+ return;
884
+ }
885
+ if (args[0] === "config") {
886
+ await runConfig(args.slice(1));
887
+ return;
888
+ }
889
+ if (args[0] === "help") {
890
+ printHelp();
891
+ return;
892
+ }
893
+ if (args.length > 0 && !args[0].startsWith("-")) {
894
+ printError(`Unknown command: ${args[0]}`);
895
+ printHelp();
896
+ process.exitCode = 1;
897
+ return;
898
+ }
899
+ await runRadar();
900
+ }
901
+ main().catch((err) => {
902
+ printError(err instanceof Error ? err.message : String(err));
903
+ process.exitCode = 1;
904
+ });