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/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..32c9001 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 {
diff --git a/src/server/routes/scripts.ts b/src/server/routes/scripts.ts
new file mode 100644
index 0000000..7497dd4
--- /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/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..4196e87 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()
@@ -24,7 +27,8 @@ export function runCommand(
cwd: string,
type: Operation['type'],
packageName?: string,
- fromVersion?: string
+ fromVersion?: string,
+ scriptName?: string
): string {
const operationId = generateOperationId()
@@ -32,6 +36,7 @@ export function runCommand(
id: operationId,
type,
packageName,
+ scriptName,
fromVersion,
command,
status: 'pending',
@@ -61,6 +66,7 @@ async function executeCommand(operationId: string, command: string, cwd: string)
command,
operationType: operation.type,
packageName: operation.packageName,
+ scriptName: operation.scriptName,
fromVersion: operation.fromVersion,
}
operationEvents.emit('event', startEvent)
@@ -77,6 +83,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 +139,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 +151,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 +175,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/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