diff --git a/src/client/components/packages/PackageList.vue b/src/client/components/packages/PackageList.vue
index c26edfa..e56648c 100644
--- a/src/client/components/packages/PackageList.vue
+++ b/src/client/components/packages/PackageList.vue
@@ -5,7 +5,9 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import PackageItem from './PackageItem.vue'
+import SmartFiltersPanel from './SmartFiltersPanel.vue'
import { useSearch, type FilterType } from '@/composables/useSearch'
+import { useSmartFilters } from '@/composables/useSmartFilters'
import type { Package } from '@shared/types'
const props = defineProps<{
@@ -25,16 +27,67 @@ const emit = defineEmits<{
const packagesRef = computed(() => props.packages)
const { query, filter, filtered, counts, setQuery, setFilter } = useSearch(packagesRef)
-const filters: { value: FilterType; label: string; countKey: keyof typeof counts.value }[] = [
+const {
+ availableLicenses,
+ isLoading: isSmartLoading,
+ hasData: hasSmartData,
+ selectedLicenses,
+ popularityFilter,
+ ageFilter,
+ filteredNames,
+ activeFilterCount,
+ loadSmartData,
+ clearFilters,
+} = useSmartFilters(packagesRef)
+
+const baseFilters: { value: FilterType; label: string; countKey: keyof typeof counts.value }[] = [
{ value: 'all', label: 'All', countKey: 'total' },
{ value: 'outdated', label: 'Outdated', countKey: 'outdated' },
{ value: 'dependencies', label: 'Dependencies', countKey: 'dependencies' },
{ value: 'devDependencies', label: 'Dev', countKey: 'devDependencies' },
]
-const displayedPackages = computed(() =>
- props.limit ? filtered.value.slice(0, props.limit) : filtered.value
-)
+const popularityLabels: Record
= {
+ 'very-popular': '>1M / wk',
+ popular: '>100K / wk',
+ niche: '<100K / wk',
+}
+
+const ageLabels: Record = {
+ active: '<6 months',
+ recent: '6–12 months',
+ stable: '1–2 years',
+ stale: '>2 years',
+}
+
+// Tags to show below the controls row for active smart filters
+const activeTags = computed(() => {
+ const tags: { key: string; label: string; onRemove: () => void }[] = []
+ for (const lic of selectedLicenses.value) {
+ tags.push({ key: `lic:${lic}`, label: lic, onRemove: () => {
+ selectedLicenses.value = selectedLicenses.value.filter(l => l !== lic)
+ }})
+ }
+ if (popularityFilter.value !== 'all') {
+ tags.push({ key: 'pop', label: popularityLabels[popularityFilter.value] ?? popularityFilter.value, onRemove: () => {
+ popularityFilter.value = 'all'
+ }})
+ }
+ if (ageFilter.value !== 'all') {
+ tags.push({ key: 'age', label: ageLabels[ageFilter.value] ?? ageFilter.value, onRemove: () => {
+ ageFilter.value = 'all'
+ }})
+ }
+ return tags
+})
+
+const displayedPackages = computed(() => {
+ let result = filtered.value
+ if (filteredNames.value !== null) {
+ result = result.filter(pkg => filteredNames.value!.has(pkg.name))
+ }
+ return props.limit ? result.slice(0, props.limit) : result
+})
const hasMore = computed(() =>
!!props.limit && props.packages.length > props.limit
@@ -42,10 +95,10 @@ const hasMore = computed(() =>
-
-
+
+
-
+
+
+
+
+ Filtered by:
+
+
+
diff --git a/src/client/components/packages/SmartFiltersPanel.vue b/src/client/components/packages/SmartFiltersPanel.vue
new file mode 100644
index 0000000..43bd2d3
--- /dev/null
+++ b/src/client/components/packages/SmartFiltersPanel.vue
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fetching package metadata…
+
+
+
+
+
+
+
+ Load license, popularity, and age data from npm.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Popularity (weekly downloads)
+
+
+
+
+
+
+
+
+
+
+
+ Last Published
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/client/components/ui/badge/Badge.vue b/src/client/components/ui/badge/Badge.vue
index 3800e7c..2073cdb 100644
--- a/src/client/components/ui/badge/Badge.vue
+++ b/src/client/components/ui/badge/Badge.vue
@@ -9,6 +9,7 @@ interface Props {
const props = withDefaults(defineProps
(), {
variant: 'default',
+ class: undefined,
})
diff --git a/src/client/components/ui/button/Button.vue b/src/client/components/ui/button/Button.vue
index f2eae50..556df95 100644
--- a/src/client/components/ui/button/Button.vue
+++ b/src/client/components/ui/button/Button.vue
@@ -14,6 +14,7 @@ const props = withDefaults(defineProps(), {
as: 'button',
variant: 'default',
size: 'default',
+ class: undefined,
})
const delegatedProps = computed(() => {
diff --git a/src/client/composables/useDepcheck.ts b/src/client/composables/useDepcheck.ts
new file mode 100644
index 0000000..961d4fe
--- /dev/null
+++ b/src/client/composables/useDepcheck.ts
@@ -0,0 +1,37 @@
+import { ref } from 'vue'
+import { useApi } from './useApi'
+import type { DepcheckResult } from '@shared/types'
+
+export function useDepcheck() {
+ const { get } = useApi()
+
+ const result = ref(null)
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ async function run() {
+ isLoading.value = true
+ error.value = null
+ try {
+ result.value = await get('/depcheck')
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to run analysis'
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ async function refresh() {
+ isLoading.value = true
+ error.value = null
+ try {
+ result.value = await get('/depcheck/refresh')
+ } catch (err) {
+ error.value = err instanceof Error ? err.message : 'Failed to run analysis'
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ return { result, isLoading, error, run, refresh }
+}
diff --git a/src/client/composables/useOperations.ts b/src/client/composables/useOperations.ts
index dc1ee2f..ff61c5b 100644
--- a/src/client/composables/useOperations.ts
+++ b/src/client/composables/useOperations.ts
@@ -1,10 +1,12 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import { useOperationsStore } from '@/stores/operations'
+import { useScriptsStore } from '@/stores/scripts'
import type { OperationEvent } from '@shared/types'
export function useOperations() {
const store = useOperationsStore()
+ const scriptsStore = useScriptsStore()
const queryClient = useQueryClient()
const isConnected = ref(false)
let eventSource: EventSource | null = null
@@ -29,6 +31,7 @@ export function useOperations() {
}
store.handleEvent(data as OperationEvent)
+ scriptsStore.handleEvent(data as OperationEvent)
if (data.type === 'complete' || data.type === 'error') {
queryClient.invalidateQueries({ queryKey: ['operations', 'history'] })
diff --git a/src/client/composables/useScripts.ts b/src/client/composables/useScripts.ts
new file mode 100644
index 0000000..d098653
--- /dev/null
+++ b/src/client/composables/useScripts.ts
@@ -0,0 +1,31 @@
+import { useQuery, useMutation } from '@tanstack/vue-query'
+import { useApi } from './useApi'
+import type { ScriptDefinition } from '@shared/types'
+
+export function useScripts() {
+ const api = useApi()
+
+ const scripts = useQuery({
+ queryKey: ['scripts'],
+ queryFn: () => api.get('/scripts'),
+ staleTime: 60 * 1000,
+ })
+
+ const runScript = useMutation({
+ mutationFn: (name: string) =>
+ api.post<{ operationId: string }, Record>(
+ `/scripts/${encodeURIComponent(name)}/run`,
+ {}
+ ),
+ })
+
+ const stopScript = useMutation({
+ mutationFn: ({ name, operationId }: { name: string; operationId: string }) =>
+ api.post<{ killed: boolean }, { operationId: string }>(
+ `/scripts/${encodeURIComponent(name)}/stop`,
+ { operationId }
+ ),
+ })
+
+ return { scripts, runScript, stopScript }
+}
diff --git a/src/client/composables/useSmartFilters.ts b/src/client/composables/useSmartFilters.ts
new file mode 100644
index 0000000..de56578
--- /dev/null
+++ b/src/client/composables/useSmartFilters.ts
@@ -0,0 +1,119 @@
+import { ref, computed, type Ref } from 'vue'
+import { useQuery } from '@tanstack/vue-query'
+import { useApi } from './useApi'
+import type { SmartPackageData } from '@shared/types'
+
+export type PopularityFilter = 'all' | 'very-popular' | 'popular' | 'niche'
+export type AgeFilter = 'all' | 'active' | 'recent' | 'stable' | 'stale'
+
+export function normalizeLicense(license: string | null): string {
+ if (!license) return 'Unknown'
+ return license.replace(/^\(|\)$/g, '').trim()
+}
+
+function getAgeCategory(lastPublished: string | null): Exclude {
+ if (!lastPublished) return 'stale'
+ const ageDays = (Date.now() - new Date(lastPublished).getTime()) / 86_400_000
+ if (ageDays < 180) return 'active'
+ if (ageDays < 365) return 'recent'
+ if (ageDays < 730) return 'stable'
+ return 'stale'
+}
+
+function getPopularityCategory(weeklyDownloads: number | null): Exclude {
+ if (!weeklyDownloads) return 'niche'
+ if (weeklyDownloads >= 1_000_000) return 'very-popular'
+ if (weeklyDownloads >= 100_000) return 'popular'
+ return 'niche'
+}
+
+export function useSmartFilters(_packages: Ref) {
+ const { get } = useApi()
+
+ const enabled = ref(false)
+
+ const { data: smartData, isLoading, isFetching } = useQuery({
+ queryKey: ['packages', 'smart-data'],
+ queryFn: () => get('/packages/smart-data'),
+ enabled,
+ staleTime: 5 * 60 * 1000,
+ })
+
+ const selectedLicenses = ref([])
+ const popularityFilter = ref('all')
+ const ageFilter = ref('all')
+
+ const smartDataMap = computed(() => {
+ const map = new Map()
+ smartData.value?.forEach(d => map.set(d.name, d))
+ return map
+ })
+
+ const availableLicenses = computed(() => {
+ if (!smartData.value) return []
+ const licenses = new Set()
+ smartData.value.forEach(d => licenses.add(normalizeLicense(d.license)))
+ return [...licenses].sort()
+ })
+
+ // Returns null when no smart filters are active (skip filtering), otherwise a Set of passing names
+ const filteredNames = computed((): Set | null => {
+ if (!smartData.value) return null
+
+ const hasFilters = selectedLicenses.value.length > 0
+ || popularityFilter.value !== 'all'
+ || ageFilter.value !== 'all'
+
+ if (!hasFilters) return null
+
+ return new Set(
+ smartData.value
+ .filter(d => {
+ if (selectedLicenses.value.length > 0) {
+ if (!selectedLicenses.value.includes(normalizeLicense(d.license))) return false
+ }
+ if (popularityFilter.value !== 'all') {
+ if (getPopularityCategory(d.weeklyDownloads) !== popularityFilter.value) return false
+ }
+ if (ageFilter.value !== 'all') {
+ if (getAgeCategory(d.lastPublished) !== ageFilter.value) return false
+ }
+ return true
+ })
+ .map(d => d.name)
+ )
+ })
+
+ const activeFilterCount = computed(() => {
+ let count = 0
+ if (selectedLicenses.value.length > 0) count++
+ if (popularityFilter.value !== 'all') count++
+ if (ageFilter.value !== 'all') count++
+ return count
+ })
+
+ function loadSmartData() {
+ enabled.value = true
+ }
+
+ function clearFilters() {
+ selectedLicenses.value = []
+ popularityFilter.value = 'all'
+ ageFilter.value = 'all'
+ }
+
+ return {
+ smartData,
+ smartDataMap,
+ isLoading: computed(() => isLoading.value || isFetching.value),
+ hasData: computed(() => !!smartData.value),
+ selectedLicenses,
+ popularityFilter,
+ ageFilter,
+ availableLicenses,
+ filteredNames,
+ activeFilterCount,
+ loadSmartData,
+ clearFilters,
+ }
+}
diff --git a/src/client/composables/useTheme.ts b/src/client/composables/useTheme.ts
new file mode 100644
index 0000000..ae41e0c
--- /dev/null
+++ b/src/client/composables/useTheme.ts
@@ -0,0 +1,30 @@
+import { ref } from 'vue'
+
+type Theme = 'system' | 'light' | 'dark'
+
+const STORAGE_KEY = 'packy-theme'
+const theme = ref((localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system')
+const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
+
+function apply() {
+ const isDark = theme.value === 'dark' || (theme.value === 'system' && mediaQuery.matches)
+ document.documentElement.classList.toggle('dark', isDark)
+}
+
+mediaQuery.addEventListener('change', apply)
+apply()
+
+export function useTheme() {
+ function setTheme(value: Theme) {
+ theme.value = value
+ localStorage.setItem(STORAGE_KEY, value)
+ apply()
+ }
+
+ function toggleTheme() {
+ const cycle: Theme[] = ['light', 'dark', 'system']
+ setTheme(cycle[(cycle.indexOf(theme.value) + 1) % cycle.length])
+ }
+
+ return { theme, setTheme, toggleTheme }
+}
diff --git a/src/client/router.ts b/src/client/router.ts
index 3779df2..374aa0d 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -24,6 +24,11 @@ const router = createRouter({
name: 'security',
component: () => import('./views/SecurityAuditView.vue'),
},
+ {
+ path: '/scripts',
+ name: 'scripts',
+ component: () => import('./views/ScriptsView.vue'),
+ },
{
path: '/activity',
name: 'activity',
diff --git a/src/client/stores/scripts.ts b/src/client/stores/scripts.ts
new file mode 100644
index 0000000..1628ac6
--- /dev/null
+++ b/src/client/stores/scripts.ts
@@ -0,0 +1,134 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { OperationEvent, OperationLogEntry } from '@shared/types'
+
+interface ScriptRun {
+ operationId: string
+ scriptName: string
+ startedAt: Date
+ status: 'running' | 'completed' | 'failed'
+ completedAt?: Date
+ exitCode?: number
+}
+
+const MAX_RUNS_PER_SCRIPT = 5
+
+export const useScriptsStore = defineStore('scripts', () => {
+ // operationId → ScriptRun
+ const runs = ref
+import { ref, computed, watch, nextTick } from 'vue'
+import { useScripts } from '@/composables/useScripts'
+import { useScriptsStore } from '@/stores/scripts'
+import { useOperations } from '@/composables/useOperations'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Play, Square, Trash2, Terminal } from 'lucide-vue-next'
+import type { ScriptDefinition } from '@shared/types'
+
+const { scripts, runScript, stopScript } = useScripts()
+const store = useScriptsStore()
+useOperations()
+
+const selectedScript = ref(null)
+const selectedRunId = ref(null)
+const outputEl = ref(null)
+
+const scriptList = computed(() => scripts.data.value ?? [])
+const isLoading = computed(() => scripts.isLoading.value)
+
+// Select first script on load
+watch(scriptList, (list) => {
+ if (!selectedScript.value && list.length > 0) {
+ selectedScript.value = list[0].name
+ }
+}, { immediate: true })
+
+// Auto-select the latest run when script changes
+watch(selectedScript, (name) => {
+ if (!name) return
+ const runs = store.getRunsForScript(name)
+ selectedRunId.value = runs.length > 0 ? runs[runs.length - 1].operationId : null
+})
+
+const currentRuns = computed(() => {
+ if (!selectedScript.value) return []
+ return store.getRunsForScript(selectedScript.value)
+})
+
+const currentLogs = computed(() => {
+ if (!selectedRunId.value) return []
+ return store.getLogsForRun(selectedRunId.value)
+})
+
+const currentRun = computed(() => {
+ if (!selectedRunId.value) return null
+ return store.runs.get(selectedRunId.value) ?? null
+})
+
+// Scroll terminal to bottom when new logs arrive
+watch(currentLogs, () => {
+ nextTick(() => {
+ if (outputEl.value) {
+ outputEl.value.scrollTop = outputEl.value.scrollHeight
+ }
+ })
+}, { deep: true })
+
+function selectScript(name: string) {
+ selectedScript.value = name
+}
+
+function selectRun(operationId: string) {
+ selectedRunId.value = operationId
+}
+
+async function handleRun(name: string) {
+ const result = await runScript.mutateAsync(name)
+ selectedScript.value = name
+ selectedRunId.value = result.operationId
+}
+
+function handleStop(name: string) {
+ const opId = store.activeRun.get(name)
+ if (!opId) return
+ stopScript.mutate({ name, operationId: opId })
+}
+
+function handleClear(operationId: string) {
+ store.clearRun(operationId)
+ if (selectedRunId.value === operationId) {
+ const runs = selectedScript.value ? store.getRunsForScript(selectedScript.value) : []
+ selectedRunId.value = runs.length > 0 ? runs[runs.length - 1].operationId : null
+ }
+}
+
+function stripAnsi(text: string): string {
+
+ return text.replace(/\x1B\[[0-9;]*[mGKHF]/g, '')
+}
+
+function formatRunLabel(operationId: string, idx: number): string {
+ const run = store.runs.get(operationId)
+ if (!run) return `Run ${idx + 1}`
+ const time = run.startedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+ return `Run ${idx + 1} · ${time}`
+}
+
+function runStatusClass(operationId: string): string {
+ const run = store.runs.get(operationId)
+ if (!run) return ''
+ if (run.status === 'running') return 'text-blue-600'
+ if (run.status === 'completed') return 'text-green-600'
+ if (run.status === 'failed') return 'text-red-600'
+ return ''
+}
+
+
+
+
+
+
+
+ Scripts
+
+
+
+
+
+
+
+
+ package.json scripts
+
+
+
+
+
+
+
+
+ No scripts found in package.json
+
+
+
+ -
+
+
+
+ {{ script.name }}
+
+
+ {{ script.command }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedScript }}
+ Select a script
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a script from the left to get started
+
+
+
+
+
Click Run to execute this script
+
+
+
+
+
+ ▶ Running…
+
+
+ ✔ Exited with code {{ currentRun.exitCode }}
+
+
+ ✖ Exited with code {{ currentRun.exitCode }}
+
+
+
+ {{ stripAnsi(log.data) }}
+
+
+
+ Waiting for output…
+
+
+
+
+
+
+
diff --git a/src/server/app.ts b/src/server/app.ts
index 85aa495..76d41f3 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -7,6 +7,8 @@ import { createRegistryRouter } from './routes/registry.js'
import { createOperationsRouter } from './routes/operations.js'
import { createProjectRouter } from './routes/project.js'
import { createAuditRouter } from './routes/audit.js'
+import { createScriptsRouter } from './routes/scripts.js'
+import { createDepcheckRouter } from './routes/depcheck.js'
export function createServer(projectPath: string, clientDistPath: string, port?: number, token?: string): Express {
const app = express()
@@ -32,6 +34,8 @@ export function createServer(projectPath: string, clientDistPath: string, port?:
app.use('/api/operations', createOperationsRouter(projectPath))
app.use('/api/project', createProjectRouter(projectPath))
app.use('/api/audit', createAuditRouter(projectPath))
+ app.use('/api/scripts', createScriptsRouter(projectPath))
+ app.use('/api/depcheck', createDepcheckRouter(projectPath))
if (existsSync(clientDistPath)) {
const rawHtml = readFileSync(join(clientDistPath, 'index.html'), 'utf-8')
diff --git a/src/server/routes/audit.ts b/src/server/routes/audit.ts
index 177bc27..9d33d4e 100644
--- a/src/server/routes/audit.ts
+++ b/src/server/routes/audit.ts
@@ -81,8 +81,7 @@ export function createAuditRouter(projectPath: string): Router {
return
}
- const command = getAuditCommand(packageManager)
- const operationId = runCommand(command, projectPath, 'audit')
+ const operationId = runCommand(getAuditCommand(packageManager), projectPath, 'audit')
// Accumulate stdout from the operation event stream directly — no second spawn needed
let stdout = ''
@@ -169,11 +168,11 @@ export function createAuditRouter(projectPath: string): Router {
return
}
- const command = getAuditFixCommand(packageManager, force, packageName)
- console.log('[AUDIT FIX] Running command:', command)
+ const auditFixArgv = getAuditFixCommand(packageManager, force, packageName)
+ console.log('[AUDIT FIX] Running command:', auditFixArgv.join(' '))
console.log('[AUDIT FIX] Force:', force, 'Package:', packageName)
- const operationId = runCommand(command, projectPath, 'audit-fix', packageName)
+ const operationId = runCommand(auditFixArgv, projectPath, 'audit-fix', packageName)
// Log the fix attempt
addFixLog(projectPath, packageName, force, operationId)
diff --git a/src/server/routes/depcheck.ts b/src/server/routes/depcheck.ts
new file mode 100644
index 0000000..d37e5ab
--- /dev/null
+++ b/src/server/routes/depcheck.ts
@@ -0,0 +1,48 @@
+import { Router, type Request, type Response } from 'express'
+import { runDepcheck } from '../services/depcheckService.js'
+import type { DepcheckResult } from '@shared/types'
+
+interface CacheEntry {
+ result: DepcheckResult
+ timestamp: number
+}
+
+const CACHE_TTL = 60 * 1000 // 60 seconds
+
+let cache: CacheEntry | null = null
+
+export function createDepcheckRouter(projectPath: string): Router {
+ const router = Router()
+
+ // GET /api/depcheck - run analysis (cached for 60s)
+ router.get('/', async (_req: Request, res: Response) => {
+ try {
+ if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
+ res.json({ success: true, data: cache.result })
+ return
+ }
+
+ const result = await runDepcheck(projectPath)
+ cache = { result, timestamp: Date.now() }
+ res.json({ success: true, data: result })
+ } catch (error) {
+ console.error('Depcheck error:', error)
+ res.status(500).json({ success: false, error: 'Failed to run dependency analysis' })
+ }
+ })
+
+ // GET /api/depcheck/refresh - force fresh run
+ router.get('/refresh', async (_req: Request, res: Response) => {
+ try {
+ cache = null
+ const result = await runDepcheck(projectPath)
+ cache = { result, timestamp: Date.now() }
+ res.json({ success: true, data: result })
+ } catch (error) {
+ console.error('Depcheck error:', error)
+ res.status(500).json({ success: false, error: 'Failed to run dependency analysis' })
+ }
+ })
+
+ return router
+}
diff --git a/src/server/routes/packages.ts b/src/server/routes/packages.ts
index 63c970f..3545f49 100644
--- a/src/server/routes/packages.ts
+++ b/src/server/routes/packages.ts
@@ -1,6 +1,6 @@
import { Router, type Request, type Response } from 'express'
import { getPackages } from '../services/packageJson.js'
-import { getLatestVersion } from '../services/registryClient.js'
+import { getLatestVersion, getSmartMetadata } from '../services/registryClient.js'
import {
detectPackageManager,
getUninstallCommand,
@@ -45,6 +45,23 @@ export function createPackagesRouter(projectPath: string): Router {
}
})
+ // GET /api/packages/smart-data - Batch enriched metadata for smart filters
+ router.get('/smart-data', async (_req: Request, res: Response) => {
+ try {
+ const packages = getPackages(projectPath)
+ const smartData = await Promise.all(
+ packages.map(async (pkg) => {
+ const meta = await getSmartMetadata(pkg.name)
+ return { name: pkg.name, ...meta }
+ })
+ )
+ res.json({ success: true, data: smartData })
+ } catch (error) {
+ console.error('Error fetching smart data:', error)
+ res.status(500).json({ success: false, error: 'Failed to fetch smart metadata' })
+ }
+ })
+
// GET /api/packages/:name - Get a single package
router.get('/:name', async (req: Request<{ name: string }>, res: Response) => {
try {
@@ -100,8 +117,7 @@ export function createPackagesRouter(projectPath: string): Router {
return
}
- const command = getUpdateAllCommand(packageManager, outdated)
- const operationId = runCommand(command, projectPath, 'update', undefined, undefined)
+ const operationId = runCommand(getUpdateAllCommand(packageManager, outdated), projectPath, 'update', undefined, undefined)
res.json({ success: true, data: { operationId } })
} catch (error) {
@@ -117,8 +133,7 @@ export function createPackagesRouter(projectPath: string): Router {
// Handle scoped packages - the name might be URL encoded
const packageName = decodeURIComponent(name)
- const command = getUninstallCommand(packageManager, packageName)
- const operationId = runCommand(command, projectPath, 'remove', packageName)
+ const operationId = runCommand(getUninstallCommand(packageManager, packageName), projectPath, 'remove', packageName)
res.json({
success: true,
@@ -146,8 +161,7 @@ export function createPackagesRouter(projectPath: string): Router {
const packageName = decodeURIComponent(name)
const fromVersion = getPackages(projectPath).find((p) => p.name === packageName)?.current
- const command = getUpdateCommand(packageManager, packageName, version)
- const operationId = runCommand(command, projectPath, 'update', packageName, fromVersion)
+ const operationId = runCommand(getUpdateCommand(packageManager, packageName, version), projectPath, 'update', packageName, fromVersion)
res.json({
success: true,
diff --git a/src/server/routes/scripts.ts b/src/server/routes/scripts.ts
new file mode 100644
index 0000000..2841d41
--- /dev/null
+++ b/src/server/routes/scripts.ts
@@ -0,0 +1,107 @@
+import { Router, type Request, type Response } from 'express'
+import { readPackageJson } from '../services/packageJson.js'
+import { runCommand, killOperation, getOperationStatus } from '../services/operationRunner.js'
+import type { ApiResponse, ScriptDefinition } from '@shared/types'
+
+type ParamsDictionary = Record
+
+export function createScriptsRouter(projectPath: string): Router {
+ const router = Router()
+
+ // Map scriptName → operationId for currently running scripts
+ const runningScripts = new Map()
+
+ // GET /api/scripts — list all scripts from package.json
+ router.get('/', (_req: Request, res: Response) => {
+ try {
+ const pkg = readPackageJson(projectPath)
+ const scripts: ScriptDefinition[] = Object.entries(pkg.scripts ?? {}).map(
+ ([name, command]) => ({ name, command })
+ )
+ const response: ApiResponse = { success: true, data: scripts }
+ res.json(response)
+ } catch (error) {
+ const response: ApiResponse = {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to read scripts',
+ }
+ res.status(500).json(response)
+ }
+ })
+
+ // POST /api/scripts/:name/run — run a script
+ router.post('/:name/run', (req: Request, res: Response) => {
+ const name = req.params.name
+
+ try {
+ const pkg = readPackageJson(projectPath)
+ const command = pkg.scripts?.[name]
+
+ if (!command) {
+ const response: ApiResponse = { success: false, error: `Script "${name}" not found` }
+ res.status(404).json(response)
+ return
+ }
+
+ // Only one instance of each script at a time
+ const existingOpId = runningScripts.get(name)
+ if (existingOpId) {
+ const op = getOperationStatus(existingOpId)
+ if (op && (op.status === 'pending' || op.status === 'running')) {
+ const response: ApiResponse = {
+ success: false,
+ error: `Script "${name}" is already running`,
+ }
+ res.status(409).json(response)
+ return
+ }
+ }
+
+ const operationId = runCommand(
+ ['npm', 'run', name],
+ projectPath,
+ 'script',
+ undefined,
+ undefined,
+ name
+ )
+
+ runningScripts.set(name, operationId)
+
+ const response: ApiResponse<{ operationId: string }> = {
+ success: true,
+ data: { operationId },
+ }
+ res.json(response)
+ } catch (error) {
+ const response: ApiResponse = {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to run script',
+ }
+ res.status(500).json(response)
+ }
+ })
+
+ // POST /api/scripts/:name/stop — stop a running script
+ router.post('/:name/stop', (req: Request, res: Response) => {
+ const name = req.params.name
+ const operationId = (req.body as { operationId?: string }).operationId ?? runningScripts.get(name)
+
+ if (!operationId) {
+ const response: ApiResponse = {
+ success: false,
+ error: `No running instance found for script "${name}"`,
+ }
+ res.status(404).json(response)
+ return
+ }
+
+ const killed = killOperation(operationId)
+ runningScripts.delete(name)
+
+ const response: ApiResponse<{ killed: boolean }> = { success: true, data: { killed } }
+ res.json(response)
+ })
+
+ return router
+}
diff --git a/src/server/services/auditService.ts b/src/server/services/auditService.ts
index 2fd7ae7..b966583 100644
--- a/src/server/services/auditService.ts
+++ b/src/server/services/auditService.ts
@@ -205,16 +205,16 @@ export function clearCache(): void {
/**
* Gets the audit command for a package manager
*/
-export function getAuditCommand(packageManager: PackageManager): string {
+export function getAuditCommand(packageManager: PackageManager): string[] {
switch (packageManager) {
case 'npm':
- return 'npm audit --json'
+ return ['npm', 'audit', '--json']
case 'yarn':
- return 'yarn audit --json'
+ return ['yarn', 'audit', '--json']
case 'pnpm':
- return 'pnpm audit --json'
+ return ['pnpm', 'audit', '--json']
default:
- return 'npm audit --json'
+ return ['npm', 'audit', '--json']
}
}
@@ -225,29 +225,29 @@ export function getAuditFixCommand(
packageManager: PackageManager,
force: boolean = false,
packageName?: string
-): string {
+): string[] {
if (packageName) {
// Fix specific package by updating it
switch (packageManager) {
case 'npm':
- return `npm update ${packageName}`
+ return ['npm', 'update', packageName]
case 'yarn':
- return `yarn upgrade ${packageName}`
+ return ['yarn', 'upgrade', packageName]
case 'pnpm':
- return `pnpm update ${packageName}`
+ return ['pnpm', 'update', packageName]
}
}
// Fix all vulnerabilities
switch (packageManager) {
case 'npm':
- return force ? 'npm audit fix --force' : 'npm audit fix'
+ return force ? ['npm', 'audit', 'fix', '--force'] : ['npm', 'audit', 'fix']
case 'yarn':
// yarn audit fix doesn't exist; npx yarn-audit-fix is the community equivalent
- return 'npx yarn-audit-fix@10.1.1'
+ return ['npx', 'yarn-audit-fix@10.1.1']
case 'pnpm':
- return 'pnpm audit fix'
+ return ['pnpm', 'audit', 'fix']
default:
- return force ? 'npm audit fix --force' : 'npm audit fix'
+ return force ? ['npm', 'audit', 'fix', '--force'] : ['npm', 'audit', 'fix']
}
}
diff --git a/src/server/services/depcheckService.ts b/src/server/services/depcheckService.ts
new file mode 100644
index 0000000..1d4f4f8
--- /dev/null
+++ b/src/server/services/depcheckService.ts
@@ -0,0 +1,27 @@
+import { createRequire } from 'module'
+import type { DepcheckResult } from '@shared/types'
+
+const require = createRequire(import.meta.url)
+
+interface DepcheckUnused {
+ dependencies: string[]
+ devDependencies: string[]
+ missing: Record
+}
+
+type DepcheckFn = (path: string, options: Record) => Promise
+
+export async function runDepcheck(projectPath: string): Promise {
+ const depcheck = require('depcheck') as DepcheckFn
+
+ const result = await depcheck(projectPath, {
+ ignorePatterns: ['dist', 'build', '.packy-logs', 'coverage'],
+ ignoreMatches: ['@types/*', '@tailwindcss/postcss', 'autoprefixer', '@vue/test-utils', '@shared/*'],
+ })
+
+ return {
+ dependencies: result.dependencies,
+ devDependencies: result.devDependencies,
+ missing: result.missing,
+ }
+}
diff --git a/src/server/services/operationRunner.ts b/src/server/services/operationRunner.ts
index 35d52a2..d0474f3 100644
--- a/src/server/services/operationRunner.ts
+++ b/src/server/services/operationRunner.ts
@@ -13,6 +13,9 @@ function generateOperationId(): string {
// Store active operations
const activeOperations = new Map()
+// Store active child processes for kill support
+const activeProcesses = new Map>()
+
// Event emitter for SSE
export const operationEvents = new EventEmitter()
@@ -20,18 +23,21 @@ export const operationEvents = new EventEmitter()
* Runs a shell command and streams output via events
*/
export function runCommand(
- command: string,
+ argv: string[],
cwd: string,
type: Operation['type'],
packageName?: string,
- fromVersion?: string
+ fromVersion?: string,
+ scriptName?: string
): string {
const operationId = generateOperationId()
+ const command = argv.join(' ')
const operation: Operation = {
id: operationId,
type,
packageName,
+ scriptName,
fromVersion,
command,
status: 'pending',
@@ -42,34 +48,32 @@ export function runCommand(
// Start operation asynchronously
setImmediate(() => {
- executeCommand(operationId, command, cwd)
+ executeCommand(operationId, argv, cwd)
})
return operationId
}
-async function executeCommand(operationId: string, command: string, cwd: string): Promise {
+async function executeCommand(operationId: string, argv: string[], cwd: string): Promise {
const operation = activeOperations.get(operationId)
if (!operation) return
operation.status = 'running'
+ const [cmd, ...args] = argv
+
// Emit start event
const startEvent: OperationEvent = {
type: 'start',
operationId,
- command,
+ command: operation.command,
operationType: operation.type,
packageName: operation.packageName,
+ scriptName: operation.scriptName,
fromVersion: operation.fromVersion,
}
operationEvents.emit('event', startEvent)
- // Parse command into parts
- const parts = command.split(' ')
- const cmd = parts[0]
- const args = parts.slice(1)
-
let childProcess: ChildProcess
try {
@@ -77,6 +81,7 @@ async function executeCommand(operationId: string, command: string, cwd: string)
cwd,
env: { ...process.env, FORCE_COLOR: '1' },
})
+ activeProcesses.set(operationId, childProcess)
} catch (error) {
const errorEvent: OperationEvent = {
type: 'error',
@@ -132,6 +137,8 @@ async function executeCommand(operationId: string, command: string, cwd: string)
}
operationEvents.emit('event', completeEvent)
+ activeProcesses.delete(operationId)
+
// Clean up after a delay
setTimeout(() => {
activeOperations.delete(operationId)
@@ -142,6 +149,7 @@ async function executeCommand(operationId: string, command: string, cwd: string)
childProcess.on('error', (error) => {
operation.status = 'failed'
operation.completedAt = new Date()
+ activeProcesses.delete(operationId)
const errorEvent: OperationEvent = {
type: 'error',
@@ -165,3 +173,13 @@ export function getOperationStatus(operationId: string): Operation | undefined {
export function getActiveOperations(): Operation[] {
return Array.from(activeOperations.values())
}
+
+/**
+ * Kills a running operation by its ID
+ */
+export function killOperation(operationId: string): boolean {
+ const process = activeProcesses.get(operationId)
+ if (!process) return false
+ process.kill()
+ return true
+}
diff --git a/src/server/services/packageManager.ts b/src/server/services/packageManager.ts
index d45cd74..eda2a74 100644
--- a/src/server/services/packageManager.ts
+++ b/src/server/services/packageManager.ts
@@ -45,15 +45,15 @@ export function getInstallCommand(
export function getUninstallCommand(
pm: PackageManager,
packageName: string
-): string {
+): string[] {
switch (pm) {
case 'pnpm':
- return `pnpm remove ${packageName}`
+ return ['pnpm', 'remove', packageName]
case 'yarn':
- return `yarn remove ${packageName}`
+ return ['yarn', 'remove', packageName]
case 'npm':
default:
- return `npm uninstall ${packageName}`
+ return ['npm', 'uninstall', packageName]
}
}
@@ -64,34 +64,34 @@ export function getUpdateCommand(
pm: PackageManager,
packageName: string,
version?: string
-): string {
+): string[] {
const pkg = version ? `${packageName}@${version}` : `${packageName}@latest`
switch (pm) {
case 'pnpm':
- return `pnpm add ${pkg}`
+ return ['pnpm', 'add', pkg]
case 'yarn':
- return `yarn add ${pkg}`
+ return ['yarn', 'add', pkg]
case 'npm':
default:
- return `npm install ${pkg}`
+ return ['npm', 'install', pkg]
}
}
/**
* Returns a command to update all given packages to latest
*/
-export function getUpdateAllCommand(pm: PackageManager, packageNames: string[]): string {
- const pkgs = packageNames.map((name) => `${name}@latest`).join(' ')
+export function getUpdateAllCommand(pm: PackageManager, packageNames: string[]): string[] {
+ const pkgs = packageNames.map((name) => `${name}@latest`)
switch (pm) {
case 'pnpm':
- return `pnpm add ${pkgs}`
+ return ['pnpm', 'add', ...pkgs]
case 'yarn':
- return `yarn add ${pkgs}`
+ return ['yarn', 'add', ...pkgs]
case 'npm':
default:
- return `npm install ${pkgs}`
+ return ['npm', 'install', ...pkgs]
}
}
diff --git a/src/server/services/registryClient.ts b/src/server/services/registryClient.ts
index 8f2b5d5..933fd9f 100644
--- a/src/server/services/registryClient.ts
+++ b/src/server/services/registryClient.ts
@@ -190,3 +190,66 @@ export function clearCache(packageName?: string): void {
cache.clear()
}
}
+
+// Cache for last-published date lookups
+const publishDateCache = new Map()
+
+/**
+ * Fetches the last published date for the latest version of a package.
+ */
+export async function getLastPublished(packageName: string): Promise {
+ const cached = publishDateCache.get(packageName)
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
+ return cached.date
+ }
+
+ try {
+ const encodedName = packageName.startsWith('@')
+ ? `@${encodeURIComponent(packageName.slice(1))}`
+ : encodeURIComponent(packageName)
+
+ const response = await fetch(`${NPM_REGISTRY}/${encodedName}`, {
+ headers: { Accept: 'application/vnd.npm.install-v1+json' },
+ })
+
+ if (!response.ok) {
+ publishDateCache.set(packageName, { date: null, timestamp: Date.now() })
+ return null
+ }
+
+ const data = await response.json() as {
+ 'dist-tags': { latest: string }
+ time?: Record
+ }
+
+ const latestVersion = data['dist-tags']?.latest
+ const date = (latestVersion && data.time?.[latestVersion]) ?? data.time?.modified ?? null
+
+ publishDateCache.set(packageName, { date, timestamp: Date.now() })
+ return date
+ } catch {
+ publishDateCache.set(packageName, { date: null, timestamp: Date.now() })
+ return null
+ }
+}
+
+/**
+ * Fetches smart filter metadata (license, downloads, lastPublished) for a package.
+ */
+export async function getSmartMetadata(packageName: string): Promise<{
+ license: string | null
+ weeklyDownloads: number | null
+ lastPublished: string | null
+}> {
+ const [info, downloads, lastPublished] = await Promise.all([
+ getPackageInfo(packageName),
+ getDownloadCounts(packageName),
+ getLastPublished(packageName),
+ ])
+
+ return {
+ license: info?.license ?? null,
+ weeklyDownloads: downloads?.weekly ?? null,
+ lastPublished,
+ }
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index add64c6..b4740df 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -44,8 +44,9 @@ export type PackageManager = 'npm' | 'yarn' | 'pnpm'
// Operation types
export interface Operation {
id: string
- type: 'install' | 'update' | 'remove' | 'audit' | 'audit-fix'
+ type: 'install' | 'update' | 'remove' | 'audit' | 'audit-fix' | 'script'
packageName?: string
+ scriptName?: string
fromVersion?: string
toVersion?: string
command: string
@@ -55,6 +56,12 @@ export interface Operation {
exitCode?: number
}
+// Script types
+export interface ScriptDefinition {
+ name: string
+ command: string
+}
+
export interface OperationLogEntry {
operationId: string
timestamp: Date
@@ -64,7 +71,7 @@ export interface OperationLogEntry {
// SSE Event types
export type OperationEvent =
- | { type: 'start'; operationId: string; command: string; operationType: Operation['type']; packageName?: string; fromVersion?: string }
+ | { type: 'start'; operationId: string; command: string; operationType: Operation['type']; packageName?: string; scriptName?: string; fromVersion?: string }
| { type: 'stdout'; operationId: string; data: string }
| { type: 'stderr'; operationId: string; data: string }
| { type: 'complete'; operationId: string; exitCode: number; fromVersion?: string; toVersion?: string }
@@ -151,6 +158,21 @@ export interface AuditFixRequest {
packageName?: string
}
+// Smart filter types
+export interface SmartPackageData {
+ name: string
+ license: string | null
+ weeklyDownloads: number | null
+ lastPublished: string | null
+}
+
+// Depcheck types
+export interface DepcheckResult {
+ dependencies: string[]
+ devDependencies: string[]
+ missing: Record
+}
+
// Audit log types
export interface AuditLogEntry {
id: string