🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
@@ -0,0 +1,128 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildExtensionCatalog,
|
||||
executeExtensionInstallCommand,
|
||||
executeExtensionPackageRemoval,
|
||||
parsePiInstallCommand,
|
||||
} from './piagentui-extension-manager.js'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function makeTempRoot() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-extension-manager-'))
|
||||
tempRoots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('parsePiInstallCommand', () => {
|
||||
it('extracts source and local scope from a safe Pi install command', () => {
|
||||
expect(parsePiInstallCommand('pi install npm:pi-hud')).toEqual({ source: 'npm:pi-hud', local: false })
|
||||
expect(parsePiInstallCommand('pi install npm:@scope/pkg -l')).toEqual({ source: 'npm:@scope/pkg', local: true })
|
||||
expect(parsePiInstallCommand('pi install -l "D:/Projects/my extension"')).toEqual({
|
||||
source: 'D:/Projects/my extension',
|
||||
local: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects commands that are not a single Pi install operation', () => {
|
||||
expect(() => parsePiInstallCommand('npm install pi-hud')).toThrow(/pi install/i)
|
||||
expect(() => parsePiInstallCommand('pi remove npm:pi-hud')).toThrow(/pi install/i)
|
||||
expect(() => parsePiInstallCommand('pi install npm:pi-hud && rm -rf .')).toThrow(/unsupported/i)
|
||||
expect(() => parsePiInstallCommand('pi install')).toThrow(/missing/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildExtensionCatalog', () => {
|
||||
it('groups configured packages with metadata, resource counts, and active or inactive status', () => {
|
||||
const homeDir = makeTempRoot()
|
||||
const cwd = path.join(homeDir, 'project')
|
||||
const userPackageRoot = path.join(homeDir, '.pi', 'agent', 'npm', 'node_modules', 'pi-demo')
|
||||
const projectPackageRoot = path.join(cwd, '.pi', 'npm', 'node_modules', 'project-demo')
|
||||
const missingPackageSource = 'npm:missing-demo'
|
||||
|
||||
writeJson(path.join(homeDir, '.pi', 'agent', 'settings.json'), {
|
||||
packages: ['npm:pi-demo', { source: missingPackageSource, extensions: [] }],
|
||||
})
|
||||
writeJson(path.join(cwd, '.pi', 'settings.json'), {
|
||||
packages: [{ source: 'npm:project-demo', extensions: [] }],
|
||||
})
|
||||
|
||||
writeJson(path.join(userPackageRoot, 'package.json'), {
|
||||
name: 'pi-demo',
|
||||
version: '1.2.3',
|
||||
description: 'Demo package',
|
||||
pi: { extensions: ['./extensions'], skills: ['./skills'] },
|
||||
})
|
||||
fs.mkdirSync(path.join(userPackageRoot, 'extensions'), { recursive: true })
|
||||
fs.writeFileSync(path.join(userPackageRoot, 'extensions', 'demo.ts'), 'export default {}', 'utf8')
|
||||
fs.mkdirSync(path.join(userPackageRoot, 'skills', 'demo'), { recursive: true })
|
||||
fs.writeFileSync(path.join(userPackageRoot, 'skills', 'demo', 'SKILL.md'), '---\nname: demo\n---', 'utf8')
|
||||
|
||||
writeJson(path.join(projectPackageRoot, 'package.json'), {
|
||||
name: 'project-demo',
|
||||
version: '0.1.0',
|
||||
description: 'Project package',
|
||||
pi: { extensions: ['./extensions'] },
|
||||
})
|
||||
fs.mkdirSync(path.join(projectPackageRoot, 'extensions'), { recursive: true })
|
||||
fs.writeFileSync(path.join(projectPackageRoot, 'extensions', 'project.ts'), 'export default {}', 'utf8')
|
||||
|
||||
const catalog = buildExtensionCatalog({ cwd, homeDir })
|
||||
|
||||
expect(catalog.summary).toEqual({ total: 3, active: 1, inactive: 1, missing: 1, error: 0 })
|
||||
|
||||
const userPackage = catalog.packages.find(pkg => pkg.source === 'npm:pi-demo')
|
||||
expect(userPackage).toMatchObject({
|
||||
source: 'npm:pi-demo',
|
||||
scope: 'user',
|
||||
packageName: 'pi-demo',
|
||||
version: '1.2.3',
|
||||
description: 'Demo package',
|
||||
status: 'active',
|
||||
installedPath: userPackageRoot,
|
||||
})
|
||||
expect(userPackage?.resources.extensions).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'demo.ts',
|
||||
enabled: true,
|
||||
path: path.join(userPackageRoot, 'extensions', 'demo.ts'),
|
||||
}),
|
||||
])
|
||||
expect(userPackage?.resources.skills).toEqual([expect.objectContaining({ name: 'demo', enabled: true })])
|
||||
|
||||
const projectPackage = catalog.packages.find(pkg => pkg.source === 'npm:project-demo')
|
||||
expect(projectPackage).toMatchObject({ source: 'npm:project-demo', scope: 'project', status: 'inactive' })
|
||||
expect(projectPackage?.resources.extensions).toEqual([
|
||||
expect.objectContaining({ name: 'project.ts', enabled: false }),
|
||||
])
|
||||
|
||||
const missingPackage = catalog.packages.find(pkg => pkg.source === missingPackageSource)
|
||||
expect(missingPackage).toMatchObject({ source: missingPackageSource, scope: 'user', status: 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension package operations', () => {
|
||||
it('runs parsed install and removal through the Pi CLI without executing pasted shell text', async () => {
|
||||
const runner = vi.fn(async () => ({ stdout: 'ok', stderr: '' }))
|
||||
|
||||
await executeExtensionInstallCommand('pi install npm:pi-hud -l', { cwd: '/repo', runner })
|
||||
await executeExtensionPackageRemoval({ source: 'npm:pi-hud', scope: 'project' }, { cwd: '/repo', runner })
|
||||
|
||||
expect(runner).toHaveBeenNthCalledWith(1, ['install', 'npm:pi-hud', '-l'], { cwd: '/repo' })
|
||||
expect(runner).toHaveBeenNthCalledWith(2, ['remove', 'npm:pi-hud', '-l'], { cwd: '/repo' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
export type ExtensionPackageScope = 'user' | 'project'
|
||||
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
|
||||
export type ExtensionResourceType = 'extensions' | 'skills' | 'prompts' | 'themes'
|
||||
|
||||
export interface ExtensionResource {
|
||||
name: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface ExtensionPackageResources {
|
||||
extensions: ExtensionResource[]
|
||||
skills: ExtensionResource[]
|
||||
prompts: ExtensionResource[]
|
||||
themes: ExtensionResource[]
|
||||
}
|
||||
|
||||
export interface ExtensionPackageInfo {
|
||||
source: string
|
||||
scope: ExtensionPackageScope
|
||||
filtered: boolean
|
||||
installedPath?: string
|
||||
packageName?: string
|
||||
version?: string
|
||||
description?: string
|
||||
image?: string
|
||||
keywords?: string[]
|
||||
status: ExtensionPackageStatus
|
||||
error?: string
|
||||
resources: ExtensionPackageResources
|
||||
}
|
||||
|
||||
export interface ExtensionCatalogSummary {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
missing: number
|
||||
error: number
|
||||
}
|
||||
|
||||
export interface ExtensionCatalog {
|
||||
packages: ExtensionPackageInfo[]
|
||||
summary: ExtensionCatalogSummary
|
||||
}
|
||||
|
||||
export interface BuildExtensionCatalogOptions {
|
||||
cwd: string
|
||||
homeDir?: string
|
||||
}
|
||||
|
||||
interface PiSettings {
|
||||
packages?: PackageSourceEntry[]
|
||||
}
|
||||
|
||||
interface PackageSourceObject {
|
||||
source: string
|
||||
extensions?: string[]
|
||||
skills?: string[]
|
||||
prompts?: string[]
|
||||
themes?: string[]
|
||||
}
|
||||
|
||||
type PackageSourceEntry = string | PackageSourceObject
|
||||
|
||||
interface NormalizedPackageSource {
|
||||
source: string
|
||||
scope: ExtensionPackageScope
|
||||
filtered: boolean
|
||||
filters: Partial<Record<ExtensionResourceType, string[]>>
|
||||
}
|
||||
|
||||
interface PackageJsonMetadata {
|
||||
name?: string
|
||||
version?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
|
||||
}
|
||||
|
||||
export interface ParsedPiInstallCommand {
|
||||
source: string
|
||||
local: boolean
|
||||
}
|
||||
|
||||
export type PiCommandRunner = (args: string[], options: { cwd: string }) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
const RESOURCE_TYPES: ExtensionResourceType[] = ['extensions', 'skills', 'prompts', 'themes']
|
||||
|
||||
const RESOURCE_FILE_PATTERNS: Record<ExtensionResourceType, RegExp> = {
|
||||
extensions: /\.(?:ts|js)$/,
|
||||
skills: /\.md$/,
|
||||
prompts: /\.md$/,
|
||||
themes: /\.json$/,
|
||||
}
|
||||
|
||||
function getHomeDir(homeDir?: string): string {
|
||||
return homeDir || process.env.USERPROFILE || process.env.HOME || os.homedir()
|
||||
}
|
||||
|
||||
function getAgentDir(homeDir?: string): string {
|
||||
return path.join(getHomeDir(homeDir), '.pi', 'agent')
|
||||
}
|
||||
|
||||
function readJsonFile<T>(filePath: string): T | null {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readSettings(filePath: string): PiSettings {
|
||||
return readJsonFile<PiSettings>(filePath) ?? {}
|
||||
}
|
||||
|
||||
function normalizePackageEntry(
|
||||
entry: PackageSourceEntry,
|
||||
scope: ExtensionPackageScope,
|
||||
): NormalizedPackageSource | null {
|
||||
if (typeof entry === 'string') {
|
||||
const source = entry.trim()
|
||||
return source ? { source, scope, filtered: false, filters: {} } : null
|
||||
}
|
||||
|
||||
if (!entry || typeof entry.source !== 'string') return null
|
||||
const source = entry.source.trim()
|
||||
if (!source) return null
|
||||
|
||||
return {
|
||||
source,
|
||||
scope,
|
||||
filtered: true,
|
||||
filters: {
|
||||
extensions: entry.extensions,
|
||||
skills: entry.skills,
|
||||
prompts: entry.prompts,
|
||||
themes: entry.themes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseNpmPackageName(source: string): string | null {
|
||||
if (!source.startsWith('npm:')) return null
|
||||
const spec = source.slice('npm:'.length).trim()
|
||||
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/)
|
||||
return (match?.[1] ?? spec) || null
|
||||
}
|
||||
|
||||
function isProbablyLocalSource(source: string): boolean {
|
||||
return (
|
||||
source.startsWith('.') ||
|
||||
source.startsWith('~') ||
|
||||
path.isAbsolute(source) ||
|
||||
/^[a-zA-Z]:[\\/]/.test(source) ||
|
||||
source.includes('\\')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveLocalSource(source: string, scope: ExtensionPackageScope, cwd: string, homeDir?: string): string {
|
||||
const baseDir = scope === 'project' ? path.join(cwd, '.pi') : getAgentDir(homeDir)
|
||||
if (source.startsWith('~')) return path.resolve(path.join(getHomeDir(homeDir), source.slice(1)))
|
||||
if (path.isAbsolute(source) || /^[a-zA-Z]:[\\/]/.test(source)) return path.resolve(source)
|
||||
return path.resolve(baseDir, source)
|
||||
}
|
||||
|
||||
function parseGitInstallPathParts(source: string): { host: string; repoPath: string } | null {
|
||||
const normalized = source.replace(/^git:/, '')
|
||||
const githubShorthand = normalized.match(/^github\.com[/:]([^\s]+)$/)
|
||||
if (githubShorthand) return { host: 'github.com', repoPath: githubShorthand[1].replace(/\.git$/, '') }
|
||||
|
||||
const sshMatch = normalized.match(/^git@([^:]+):(.+)$/)
|
||||
if (sshMatch) return { host: sshMatch[1], repoPath: sshMatch[2].replace(/\.git$/, '') }
|
||||
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
if (!url.hostname || !url.pathname) return null
|
||||
return { host: url.hostname, repoPath: url.pathname.replace(/^\//, '').replace(/\.git$/, '') }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstalledPath(
|
||||
source: string,
|
||||
scope: ExtensionPackageScope,
|
||||
cwd: string,
|
||||
homeDir?: string,
|
||||
): string | undefined {
|
||||
const agentDir = getAgentDir(homeDir)
|
||||
if (source.startsWith('npm:')) {
|
||||
const packageName = parseNpmPackageName(source)
|
||||
if (!packageName) return undefined
|
||||
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'npm') : path.join(agentDir, 'npm')
|
||||
const packagePath = path.join(installRoot, 'node_modules', ...packageName.split('/'))
|
||||
return fs.existsSync(packagePath) ? packagePath : undefined
|
||||
}
|
||||
|
||||
if (isProbablyLocalSource(source)) {
|
||||
const packagePath = resolveLocalSource(source, scope, cwd, homeDir)
|
||||
return fs.existsSync(packagePath) ? packagePath : undefined
|
||||
}
|
||||
|
||||
const gitParts = parseGitInstallPathParts(source)
|
||||
if (gitParts) {
|
||||
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'git') : path.join(agentDir, 'git')
|
||||
const packagePath = path.join(installRoot, gitParts.host, ...gitParts.repoPath.split(/[\\/]+/))
|
||||
return fs.existsSync(packagePath) ? packagePath : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function collectFiles(root: string, resourceType: ExtensionResourceType): string[] {
|
||||
if (!fs.existsSync(root)) return []
|
||||
const stat = fs.statSync(root)
|
||||
if (stat.isFile()) return RESOURCE_FILE_PATTERNS[resourceType].test(root) ? [root] : []
|
||||
if (!stat.isDirectory()) return []
|
||||
|
||||
const files: string[] = []
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
const entryPath = path.join(root, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(entryPath, resourceType))
|
||||
} else if (entry.isFile() && RESOURCE_FILE_PATTERNS[resourceType].test(entryPath)) {
|
||||
files.push(entryPath)
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function stripOverridePatterns(entries: string[]): string[] {
|
||||
return entries.filter(entry => !entry.trim().startsWith('!'))
|
||||
}
|
||||
|
||||
function resolveManifestEntry(packageRoot: string, entry: string, resourceType: ExtensionResourceType): string[] {
|
||||
const normalized = entry.trim()
|
||||
if (!normalized || normalized.startsWith('!')) return []
|
||||
|
||||
if (normalized.includes('*')) {
|
||||
const conventionRoot = path.join(packageRoot, resourceType)
|
||||
return collectFiles(conventionRoot, resourceType)
|
||||
}
|
||||
|
||||
const entryPath = path.resolve(packageRoot, normalized)
|
||||
return collectFiles(entryPath, resourceType)
|
||||
}
|
||||
|
||||
function discoverResourcePaths(
|
||||
packageRoot: string,
|
||||
metadata: PackageJsonMetadata | null,
|
||||
resourceType: ExtensionResourceType,
|
||||
): string[] {
|
||||
const manifestEntries = metadata?.pi?.[resourceType]
|
||||
if (manifestEntries && manifestEntries.length > 0) {
|
||||
const files = stripOverridePatterns(manifestEntries).flatMap(entry =>
|
||||
resolveManifestEntry(packageRoot, entry, resourceType),
|
||||
)
|
||||
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
return collectFiles(path.join(packageRoot, resourceType), resourceType)
|
||||
}
|
||||
|
||||
function toRelativePosix(packageRoot: string, filePath: string): string {
|
||||
return path.relative(packageRoot, filePath).replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function wildcardToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
||||
return new RegExp(`^${escaped}$`)
|
||||
}
|
||||
|
||||
function matchesFilter(packageRoot: string, filePath: string, patterns: string[]): boolean {
|
||||
const relativePath = toRelativePosix(packageRoot, filePath)
|
||||
const normalizedPatterns = patterns.map(pattern => pattern.trim()).filter(Boolean)
|
||||
if (normalizedPatterns.length === 0) return false
|
||||
|
||||
return normalizedPatterns.some(pattern => {
|
||||
const positivePattern = pattern.startsWith('!') ? pattern.slice(1) : pattern
|
||||
if (!positivePattern) return false
|
||||
const normalizedPattern = positivePattern.replace(/\\/g, '/')
|
||||
return (
|
||||
relativePath === normalizedPattern ||
|
||||
relativePath.endsWith(`/${normalizedPattern}`) ||
|
||||
wildcardToRegExp(normalizedPattern).test(relativePath)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resourceName(filePath: string): string {
|
||||
if (path.basename(filePath).toLowerCase() === 'skill.md') return path.basename(path.dirname(filePath))
|
||||
return path.basename(filePath)
|
||||
}
|
||||
|
||||
function buildResources(
|
||||
packageRoot: string,
|
||||
metadata: PackageJsonMetadata | null,
|
||||
filters: Partial<Record<ExtensionResourceType, string[]>>,
|
||||
): ExtensionPackageResources {
|
||||
const resources = {} as ExtensionPackageResources
|
||||
|
||||
for (const resourceType of RESOURCE_TYPES) {
|
||||
const files = discoverResourcePaths(packageRoot, metadata, resourceType)
|
||||
const patterns = filters[resourceType]
|
||||
resources[resourceType] = files.map(filePath => ({
|
||||
name: resourceName(filePath),
|
||||
path: filePath,
|
||||
enabled: patterns === undefined ? true : matchesFilter(packageRoot, filePath, patterns),
|
||||
}))
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
function hasEnabledResource(resources: ExtensionPackageResources): boolean {
|
||||
return RESOURCE_TYPES.some(resourceType => resources[resourceType].some(resource => resource.enabled))
|
||||
}
|
||||
|
||||
function countByStatus(packages: ExtensionPackageInfo[]): ExtensionCatalogSummary {
|
||||
return packages.reduce<ExtensionCatalogSummary>(
|
||||
(summary, pkg) => {
|
||||
summary.total += 1
|
||||
summary[pkg.status] += 1
|
||||
return summary
|
||||
},
|
||||
{ total: 0, active: 0, inactive: 0, missing: 0, error: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
function readPackageMetadata(packageRoot: string): { metadata: PackageJsonMetadata | null; error?: string } {
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json')
|
||||
if (!fs.existsSync(packageJsonPath)) return { metadata: null }
|
||||
|
||||
try {
|
||||
return { metadata: JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonMetadata }
|
||||
} catch (error) {
|
||||
return { metadata: null, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): ExtensionCatalog {
|
||||
const homeDir = getHomeDir(options.homeDir)
|
||||
const agentDir = getAgentDir(homeDir)
|
||||
const userSettings = readSettings(path.join(agentDir, 'settings.json'))
|
||||
const projectSettings = readSettings(path.join(options.cwd, '.pi', 'settings.json'))
|
||||
const configuredPackages = [
|
||||
...(userSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'user')),
|
||||
...(projectSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'project')),
|
||||
].filter((entry): entry is NormalizedPackageSource => entry !== null)
|
||||
|
||||
const packages = configuredPackages.map<ExtensionPackageInfo>(entry => {
|
||||
const installedPath = resolveInstalledPath(entry.source, entry.scope, options.cwd, homeDir)
|
||||
if (!installedPath) {
|
||||
return {
|
||||
source: entry.source,
|
||||
scope: entry.scope,
|
||||
filtered: entry.filtered,
|
||||
status: 'missing',
|
||||
resources: { extensions: [], skills: [], prompts: [], themes: [] },
|
||||
}
|
||||
}
|
||||
|
||||
const { metadata, error } = readPackageMetadata(installedPath)
|
||||
const resources = buildResources(installedPath, metadata, entry.filters)
|
||||
const status: ExtensionPackageStatus = error ? 'error' : hasEnabledResource(resources) ? 'active' : 'inactive'
|
||||
|
||||
return {
|
||||
source: entry.source,
|
||||
scope: entry.scope,
|
||||
filtered: entry.filtered,
|
||||
installedPath,
|
||||
packageName: metadata?.name,
|
||||
version: metadata?.version,
|
||||
description: metadata?.description,
|
||||
image: metadata?.pi?.image,
|
||||
keywords: metadata?.keywords,
|
||||
status,
|
||||
error,
|
||||
resources,
|
||||
}
|
||||
})
|
||||
|
||||
packages.sort((a, b) => {
|
||||
if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1
|
||||
return (a.packageName || a.source).localeCompare(b.packageName || b.source)
|
||||
})
|
||||
|
||||
return { packages, summary: countByStatus(packages) }
|
||||
}
|
||||
|
||||
function tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index]
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current)
|
||||
current = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
if (quote) throw new Error('Unterminated quote in install command')
|
||||
if (current) tokens.push(current)
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function parsePiInstallCommand(command: string): ParsedPiInstallCommand {
|
||||
const tokens = tokenizeCommand(command.trim())
|
||||
if (
|
||||
tokens.length < 2 ||
|
||||
path
|
||||
.basename(tokens[0])
|
||||
.replace(/\.(cmd|exe)$/i, '')
|
||||
.toLowerCase() !== 'pi'
|
||||
) {
|
||||
throw new Error('Paste a pi install command, for example: pi install npm:pi-hud')
|
||||
}
|
||||
if (tokens[1] !== 'install') {
|
||||
throw new Error('Only pi install commands are supported')
|
||||
}
|
||||
|
||||
let local = false
|
||||
let source: string | null = null
|
||||
for (const token of tokens.slice(2)) {
|
||||
if (token === '-l' || token === '--local') {
|
||||
local = true
|
||||
continue
|
||||
}
|
||||
if (token.startsWith('-')) throw new Error(`Unsupported install option: ${token}`)
|
||||
if (source) throw new Error(`Unsupported extra argument: ${token}`)
|
||||
if (/[;&|<>`]/.test(token)) throw new Error(`Unsupported shell syntax in install command: ${token}`)
|
||||
source = token
|
||||
}
|
||||
|
||||
if (!source) throw new Error('Missing extension source in pi install command')
|
||||
return { source, local }
|
||||
}
|
||||
|
||||
function getPiExecutable(): string {
|
||||
return process.platform === 'win32' ? 'pi.cmd' : 'pi'
|
||||
}
|
||||
|
||||
export function defaultPiCommandRunner(
|
||||
args: string[],
|
||||
options: { cwd: string },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(getPiExecutable(), args, { cwd: options.cwd, shell: false })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
child.stdout?.on('data', chunk => {
|
||||
stdout += String(chunk)
|
||||
})
|
||||
child.stderr?.on('data', chunk => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr })
|
||||
return
|
||||
}
|
||||
reject(new Error(stderr.trim() || `pi ${args.join(' ')} exited with code ${code ?? 'unknown'}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeExtensionInstallCommand(
|
||||
command: string,
|
||||
options: { cwd: string; runner?: PiCommandRunner },
|
||||
): Promise<{ stdout: string; stderr: string; source: string; scope: ExtensionPackageScope }> {
|
||||
const parsed = parsePiInstallCommand(command)
|
||||
const runner = options.runner ?? defaultPiCommandRunner
|
||||
const args = ['install', parsed.source, ...(parsed.local ? ['-l'] : [])]
|
||||
const result = await runner(args, { cwd: options.cwd })
|
||||
return { ...result, source: parsed.source, scope: parsed.local ? 'project' : 'user' }
|
||||
}
|
||||
|
||||
export async function executeExtensionPackageRemoval(
|
||||
target: { source: string; scope: ExtensionPackageScope },
|
||||
options: { cwd: string; runner?: PiCommandRunner },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const runner = options.runner ?? defaultPiCommandRunner
|
||||
return runner(['remove', target.source, ...(target.scope === 'project' ? ['-l'] : [])], { cwd: options.cwd })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
checkoutGitBranch,
|
||||
commitGitChanges,
|
||||
createGitBranch,
|
||||
getGitStatus,
|
||||
listGitBranches,
|
||||
stageGitPaths,
|
||||
unstageGitPaths,
|
||||
type GitCommandRunner,
|
||||
} from './piagentui-git-manager.js'
|
||||
|
||||
const repoDir = path.join(os.tmpdir(), 'piagentui-git-manager-test')
|
||||
|
||||
function createRunner(outputs: Record<string, { stdout?: string; stderr?: string }>): GitCommandRunner {
|
||||
return vi.fn(async args => {
|
||||
const key = args.join(' ')
|
||||
const output = outputs[key]
|
||||
if (!output) throw new Error(`Unexpected git args: ${key}`)
|
||||
return { stdout: output.stdout ?? '', stderr: output.stderr ?? '' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('piagentui git manager', () => {
|
||||
it('parses branch output into current local and remote branches', async () => {
|
||||
const runner = createRunner({
|
||||
'branch --format=%(refname:short)|%(upstream:short)|%(objectname:short)|%(HEAD) --all': {
|
||||
stdout: [
|
||||
'main|origin/main|1111111| ',
|
||||
'feature/git-ui|origin/feature/git-ui|2222222|*',
|
||||
'origin/main||3333333| ',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await listGitBranches(repoDir, runner)
|
||||
|
||||
expect(result.current).toBe('feature/git-ui')
|
||||
expect(result.branches).toEqual([
|
||||
{ name: 'main', current: false, remote: false, upstream: 'origin/main', commit: '1111111' },
|
||||
{ name: 'feature/git-ui', current: true, remote: false, upstream: 'origin/feature/git-ui', commit: '2222222' },
|
||||
{ name: 'origin/main', current: false, remote: true, commit: '3333333' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses porcelain v2 status and ahead behind data', async () => {
|
||||
const runner = createRunner({
|
||||
'status --porcelain=v2 --branch': {
|
||||
stdout: [
|
||||
'# branch.oid abcdef1',
|
||||
'# branch.head feature/git-ui',
|
||||
'# branch.upstream origin/feature/git-ui',
|
||||
'# branch.ab +2 -1',
|
||||
'1 M. N... 100644 100644 100644 a b src/app.tsx',
|
||||
'1 .M N... 100644 100644 100644 a b src/input.tsx',
|
||||
'? src/new-file.ts',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
const status = await getGitStatus(repoDir, runner)
|
||||
|
||||
expect(status.branch).toBe('feature/git-ui')
|
||||
expect(status.upstream).toBe('origin/feature/git-ui')
|
||||
expect(status.ahead).toBe(2)
|
||||
expect(status.behind).toBe(1)
|
||||
expect(status.dirty).toBe(true)
|
||||
expect(status.files).toEqual([
|
||||
{ path: 'src/app.tsx', index: 'M', workingTree: '.', staged: true, unstaged: false, untracked: false },
|
||||
{ path: 'src/input.tsx', index: '.', workingTree: 'M', staged: false, unstaged: true, untracked: false },
|
||||
{ path: 'src/new-file.ts', index: '?', workingTree: '?', staged: false, unstaged: false, untracked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('runs safe fixed argv for branch and stage operations', async () => {
|
||||
const runner = createRunner({
|
||||
'checkout main': {},
|
||||
'checkout -b feature/new-branch': {},
|
||||
'add -- src/app.tsx src/input.tsx': {},
|
||||
'restore --staged -- src/app.tsx': {},
|
||||
})
|
||||
|
||||
await checkoutGitBranch(repoDir, { branch: 'main' }, runner)
|
||||
await createGitBranch(repoDir, { name: 'feature/new-branch' }, runner)
|
||||
await stageGitPaths(repoDir, { paths: ['src/app.tsx', 'src/input.tsx'] }, runner)
|
||||
await unstageGitPaths(repoDir, { paths: ['src/app.tsx'] }, runner)
|
||||
|
||||
expect(runner).toHaveBeenCalledWith(['checkout', 'main'], { cwd: repoDir })
|
||||
expect(runner).toHaveBeenCalledWith(['checkout', '-b', 'feature/new-branch'], { cwd: repoDir })
|
||||
expect(runner).toHaveBeenCalledWith(['add', '--', 'src/app.tsx', 'src/input.tsx'], { cwd: repoDir })
|
||||
expect(runner).toHaveBeenCalledWith(['restore', '--staged', '--', 'src/app.tsx'], { cwd: repoDir })
|
||||
})
|
||||
|
||||
it('rejects empty commit messages before running git', async () => {
|
||||
const runner = vi.fn()
|
||||
|
||||
await expect(commitGitChanges(repoDir, { message: ' ' }, runner)).rejects.toThrow('Commit message is required')
|
||||
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
export interface GitCommandResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export type GitCommandRunner = (args: string[], options: { cwd: string }) => Promise<GitCommandResult>
|
||||
|
||||
export interface GitBranchInfo {
|
||||
name: string
|
||||
current: boolean
|
||||
remote: boolean
|
||||
upstream?: string
|
||||
commit?: string
|
||||
}
|
||||
|
||||
export interface GitBranchesResult {
|
||||
current?: string
|
||||
branches: GitBranchInfo[]
|
||||
}
|
||||
|
||||
export interface GitStatusFile {
|
||||
path: string
|
||||
index: string
|
||||
workingTree: string
|
||||
staged: boolean
|
||||
unstaged: boolean
|
||||
untracked: boolean
|
||||
}
|
||||
|
||||
export interface GitStatusResult {
|
||||
branch?: string
|
||||
upstream?: string
|
||||
ahead: number
|
||||
behind: number
|
||||
dirty: boolean
|
||||
files: GitStatusFile[]
|
||||
}
|
||||
|
||||
export interface GitActionResult {
|
||||
ok: true
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
export interface CheckoutGitBranchRequest {
|
||||
branch: string
|
||||
}
|
||||
|
||||
export interface CreateGitBranchRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface GitPathsRequest {
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export interface CommitGitChangesRequest {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const runGit: GitCommandRunner = (args, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, { cwd: options.cwd, shell: false })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr })
|
||||
return
|
||||
}
|
||||
reject(new Error((stderr || stdout || `git ${args.join(' ')} failed with exit code ${code}`).trim()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function assertSafeRefName(value: string, label: string): string {
|
||||
const name = value.trim()
|
||||
if (!name) throw new Error(`${label} is required`)
|
||||
if (name.startsWith('-')) throw new Error(`Invalid ${label}`)
|
||||
if (/\s/.test(name) || /[\0~^:?*[\]\\]/.test(name) || name.includes('..') || name.includes('@{')) {
|
||||
throw new Error(`Invalid ${label}`)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function assertSafePath(value: string): string {
|
||||
const filePath = value.trim()
|
||||
if (!filePath) throw new Error('Git path is required')
|
||||
if (filePath.includes('\0')) throw new Error('Invalid Git path')
|
||||
return filePath
|
||||
}
|
||||
|
||||
function normalizeRemoteName(name: string): string {
|
||||
return name.replace(/^remotes\//, '')
|
||||
}
|
||||
|
||||
export async function listGitBranches(cwd: string, runner: GitCommandRunner = runGit): Promise<GitBranchesResult> {
|
||||
const { stdout } = await runner(
|
||||
['branch', '--format=%(refname:short)|%(upstream:short)|%(objectname:short)|%(HEAD)', '--all'],
|
||||
{
|
||||
cwd,
|
||||
},
|
||||
)
|
||||
|
||||
const branches = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [rawName, upstream, commit, head] = line.split('|')
|
||||
const name = normalizeRemoteName(rawName)
|
||||
return {
|
||||
name,
|
||||
current: head === '*',
|
||||
remote: rawName.startsWith('remotes/') || name.startsWith('origin/'),
|
||||
upstream: upstream || undefined,
|
||||
commit: commit || undefined,
|
||||
}
|
||||
})
|
||||
.filter(branch => branch.name !== 'origin/HEAD')
|
||||
|
||||
return { current: branches.find(branch => branch.current)?.name, branches }
|
||||
}
|
||||
|
||||
function parseStatusPath(line: string): string {
|
||||
if (line.includes('\t')) return line.split('\t').pop() || ''
|
||||
const parts = line.split(' ')
|
||||
return parts[parts.length - 1] || ''
|
||||
}
|
||||
|
||||
export async function getGitStatus(cwd: string, runner: GitCommandRunner = runGit): Promise<GitStatusResult> {
|
||||
const { stdout } = await runner(['status', '--porcelain=v2', '--branch'], { cwd })
|
||||
const status: GitStatusResult = { ahead: 0, behind: 0, dirty: false, files: [] }
|
||||
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
if (!line) continue
|
||||
if (line.startsWith('# branch.head ')) {
|
||||
const branch = line.slice('# branch.head '.length).trim()
|
||||
if (branch && branch !== '(detached)') status.branch = branch
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# branch.upstream ')) {
|
||||
status.upstream = line.slice('# branch.upstream '.length).trim()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# branch.ab ')) {
|
||||
const match = line.match(/\+(\d+)\s+-(\d+)/)
|
||||
if (match) {
|
||||
status.ahead = Number(match[1])
|
||||
status.behind = Number(match[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
||||
const xy = line.slice(2, 4)
|
||||
const file = {
|
||||
path: parseStatusPath(line),
|
||||
index: xy[0],
|
||||
workingTree: xy[1],
|
||||
staged: xy[0] !== '.',
|
||||
unstaged: xy[1] !== '.',
|
||||
untracked: false,
|
||||
}
|
||||
status.files.push(file)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('? ')) {
|
||||
status.files.push({
|
||||
path: line.slice(2).trim(),
|
||||
index: '?',
|
||||
workingTree: '?',
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
untracked: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
status.dirty = status.files.length > 0
|
||||
return status
|
||||
}
|
||||
|
||||
export async function checkoutGitBranch(
|
||||
cwd: string,
|
||||
request: CheckoutGitBranchRequest,
|
||||
runner: GitCommandRunner = runGit,
|
||||
): Promise<GitActionResult> {
|
||||
const branch = assertSafeRefName(request.branch, 'Branch')
|
||||
const result = await runner(['checkout', branch], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
export async function createGitBranch(
|
||||
cwd: string,
|
||||
request: CreateGitBranchRequest,
|
||||
runner: GitCommandRunner = runGit,
|
||||
): Promise<GitActionResult> {
|
||||
const name = assertSafeRefName(request.name, 'Branch name')
|
||||
const result = await runner(['checkout', '-b', name], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
function normalizePaths(request: GitPathsRequest): string[] {
|
||||
if (!Array.isArray(request.paths) || request.paths.length === 0) throw new Error('At least one path is required')
|
||||
return request.paths.map(assertSafePath)
|
||||
}
|
||||
|
||||
export async function stageGitPaths(
|
||||
cwd: string,
|
||||
request: GitPathsRequest,
|
||||
runner: GitCommandRunner = runGit,
|
||||
): Promise<GitActionResult> {
|
||||
const paths = normalizePaths(request)
|
||||
const result = await runner(['add', '--', ...paths], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
export async function unstageGitPaths(
|
||||
cwd: string,
|
||||
request: GitPathsRequest,
|
||||
runner: GitCommandRunner = runGit,
|
||||
): Promise<GitActionResult> {
|
||||
const paths = normalizePaths(request)
|
||||
const result = await runner(['restore', '--staged', '--', ...paths], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
export async function commitGitChanges(
|
||||
cwd: string,
|
||||
request: CommitGitChangesRequest,
|
||||
runner: GitCommandRunner = runGit,
|
||||
): Promise<GitActionResult> {
|
||||
const message = request.message.trim()
|
||||
if (!message) throw new Error('Commit message is required')
|
||||
const result = await runner(['commit', '-m', message], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
export async function pullGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
|
||||
const result = await runner(['pull', '--ff-only'], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
|
||||
export async function pushGitBranch(cwd: string, runner: GitCommandRunner = runGit): Promise<GitActionResult> {
|
||||
const result = await runner(['push'], { cwd })
|
||||
return { ok: true, ...result }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
isLoopbackHost,
|
||||
validateBearerToken,
|
||||
preventPathTraversal,
|
||||
validateHostAndOrigin,
|
||||
validateHostAndOriginForAccess,
|
||||
isRequestBodySizeAllowed,
|
||||
getLocalNetworkUrls,
|
||||
validateSttBaseUrl,
|
||||
} from './piagentui-server-core.js'
|
||||
|
||||
describe('PiAgentUi Server Core Security Helpers', () => {
|
||||
describe('isLoopbackHost', () => {
|
||||
it('should accept loopback hostnames and IPs', () => {
|
||||
expect(isLoopbackHost('localhost')).toBe(true)
|
||||
expect(isLoopbackHost('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHost('[::1]')).toBe(true)
|
||||
expect(isLoopbackHost('localhost:3000')).toBe(true)
|
||||
expect(isLoopbackHost('127.0.0.1:8080')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject external and LAN hostnames or IPs', () => {
|
||||
expect(isLoopbackHost('192.168.1.1')).toBe(false)
|
||||
expect(isLoopbackHost('example.com')).toBe(false)
|
||||
expect(isLoopbackHost('0.0.0.0')).toBe(false)
|
||||
expect(isLoopbackHost('10.0.0.1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBearerToken', () => {
|
||||
const sampleToken = 'test-token-value-12345'
|
||||
|
||||
it('should accept authorization headers with correct bearer token', () => {
|
||||
expect(validateBearerToken(`Bearer ${sampleToken}`, sampleToken)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject headers with incorrect token', () => {
|
||||
expect(validateBearerToken(`Bearer wrong-token`, sampleToken)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject headers with malformed format', () => {
|
||||
expect(validateBearerToken(`Basic ${sampleToken}`, sampleToken)).toBe(false)
|
||||
expect(validateBearerToken(sampleToken, sampleToken)).toBe(false)
|
||||
expect(validateBearerToken('', sampleToken)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preventPathTraversal', () => {
|
||||
const baseDir = '/workspace/app/dist'
|
||||
|
||||
it('should allow paths staying within the base directory', () => {
|
||||
expect(preventPathTraversal(baseDir, 'index.html')).toBe(true)
|
||||
expect(preventPathTraversal(baseDir, 'assets/main.js')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject paths attempting to traverse out of the base directory', () => {
|
||||
expect(preventPathTraversal(baseDir, '../index.html')).toBe(false)
|
||||
expect(preventPathTraversal(baseDir, '../../etc/passwd')).toBe(false)
|
||||
expect(preventPathTraversal(baseDir, 'assets/../../config.json')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHostAndOrigin', () => {
|
||||
it('should allow local host and local origin', () => {
|
||||
expect(validateHostAndOrigin('127.0.0.1', 'http://127.0.0.1:3000')).toBe(true)
|
||||
expect(validateHostAndOrigin('localhost', 'http://localhost')).toBe(true)
|
||||
expect(validateHostAndOrigin('localhost:5000', '')).toBe(true) // Optional origin
|
||||
})
|
||||
|
||||
it('should reject non-local host or non-local origin', () => {
|
||||
expect(validateHostAndOrigin('127.0.0.1', 'http://malicious.com')).toBe(false)
|
||||
expect(validateHostAndOrigin('external.com', 'http://localhost')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHostAndOriginForAccess', () => {
|
||||
it('should always allow loopback and only allow LAN hosts when enabled', () => {
|
||||
expect(validateHostAndOriginForAccess('127.0.0.1:58785', undefined, false)).toBe(true)
|
||||
expect(validateHostAndOriginForAccess('192.168.1.25:58785', undefined, false)).toBe(false)
|
||||
expect(validateHostAndOriginForAccess('192.168.1.25:58785', 'http://192.168.1.25:58785', true)).toBe(true)
|
||||
expect(validateHostAndOriginForAccess('192.168.1.25:58785', 'http://evil.test', true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLocalNetworkUrls', () => {
|
||||
it('should build LAN URLs from non-internal IPv4 interfaces', () => {
|
||||
const urls = getLocalNetworkUrls(58785, {
|
||||
WiFi: [
|
||||
{
|
||||
address: '192.168.1.25',
|
||||
family: 'IPv4',
|
||||
internal: false,
|
||||
netmask: '255.255.255.0',
|
||||
mac: '00:00:00:00:00:01',
|
||||
cidr: '192.168.1.25/24',
|
||||
},
|
||||
],
|
||||
Loopback: [
|
||||
{
|
||||
address: '127.0.0.1',
|
||||
family: 'IPv4',
|
||||
internal: true,
|
||||
netmask: '255.0.0.0',
|
||||
mac: '00:00:00:00:00:00',
|
||||
cidr: '127.0.0.1/8',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(urls).toEqual(['http://192.168.1.25:58785'])
|
||||
})
|
||||
|
||||
it('should prefer physical LAN adapters over WSL and Hyper-V virtual adapters', () => {
|
||||
const urls = getLocalNetworkUrls(58785, {
|
||||
'vEthernet (WSL (Hyper-V firewall))': [
|
||||
{
|
||||
address: '172.18.192.1',
|
||||
family: 'IPv4',
|
||||
internal: false,
|
||||
netmask: '255.255.240.0',
|
||||
mac: '00:15:5d:56:b1:b1',
|
||||
cidr: '172.18.192.1/20',
|
||||
},
|
||||
],
|
||||
'vEthernet (Default Switch)': [
|
||||
{
|
||||
address: '172.25.224.1',
|
||||
family: 'IPv4',
|
||||
internal: false,
|
||||
netmask: '255.255.240.0',
|
||||
mac: '00:15:5d:15:fd:c0',
|
||||
cidr: '172.25.224.1/20',
|
||||
},
|
||||
],
|
||||
Ethernet: [
|
||||
{
|
||||
address: '192.168.0.150',
|
||||
family: 'IPv4',
|
||||
internal: false,
|
||||
netmask: '255.255.255.0',
|
||||
mac: '34:5a:60:fb:39:42',
|
||||
cidr: '192.168.0.150/24',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(urls).toEqual(['http://192.168.0.150:58785'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSttBaseUrl', () => {
|
||||
it('should allow only HTTP(S) base URLs', () => {
|
||||
expect(validateSttBaseUrl('https://api.openai.com/v1')).toBe(true)
|
||||
expect(validateSttBaseUrl('http://localhost:11434/v1')).toBe(true)
|
||||
expect(validateSttBaseUrl('file:///tmp/key')).toBe(false)
|
||||
expect(validateSttBaseUrl('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRequestBodySizeAllowed', () => {
|
||||
it('should allow small request content sizes', () => {
|
||||
expect(isRequestBodySizeAllowed('100')).toBe(true)
|
||||
expect(isRequestBodySizeAllowed('1048576')).toBe(true) // 1MB
|
||||
})
|
||||
|
||||
it('should reject extremely large content sizes', () => {
|
||||
expect(isRequestBodySizeAllowed('20000000')).toBe(false) // ~20MB
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
/**
|
||||
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
|
||||
*/
|
||||
export function isLoopbackHost(host: string | undefined): boolean {
|
||||
if (!host) return false
|
||||
|
||||
// Handle bracketed IPv6 hosts (e.g. "[::1]" or "[::1]:3000")
|
||||
if (host.startsWith('[')) {
|
||||
const closingBracket = host.indexOf(']')
|
||||
if (closingBracket !== -1) {
|
||||
const hostname = host.slice(1, closingBracket)
|
||||
return hostname === '::1'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle port splitting for IPv4 or localhost
|
||||
const lastColon = host.lastIndexOf(':')
|
||||
let hostname = host
|
||||
if (lastColon !== -1) {
|
||||
const colonsCount = (host.match(/:/g) || []).length
|
||||
if (colonsCount === 1 || host.includes('localhost')) {
|
||||
hostname = host.slice(0, lastColon)
|
||||
}
|
||||
}
|
||||
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the Authorization header against the expected bearer token
|
||||
*/
|
||||
export function validateBearerToken(authHeader: string | undefined, expectedToken: string): boolean {
|
||||
if (!authHeader || !expectedToken) return false
|
||||
if (!authHeader.startsWith('Bearer ')) return false
|
||||
const token = authHeader.substring(7).trim()
|
||||
return token === expectedToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents path traversal out of the designated base directory
|
||||
*/
|
||||
export function preventPathTraversal(baseDir: string, relativePath: string): boolean {
|
||||
try {
|
||||
const resolvedBase = path.resolve(baseDir)
|
||||
const resolvedTarget = path.resolve(path.join(baseDir, relativePath))
|
||||
return resolvedTarget.startsWith(resolvedBase)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates both Host and Origin headers to be local/loopback only
|
||||
*/
|
||||
export function validateHostAndOrigin(host: string | undefined, origin: string | undefined): boolean {
|
||||
return validateHostAndOriginForAccess(host, origin, false)
|
||||
}
|
||||
|
||||
function getHostname(host: string | undefined): string {
|
||||
if (!host) return ''
|
||||
if (host.startsWith('[')) {
|
||||
const closingBracket = host.indexOf(']')
|
||||
return closingBracket === -1 ? host : host.slice(1, closingBracket)
|
||||
}
|
||||
|
||||
const lastColon = host.lastIndexOf(':')
|
||||
const colonsCount = (host.match(/:/g) || []).length
|
||||
if (lastColon !== -1 && colonsCount === 1) {
|
||||
return host.slice(0, lastColon)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function isAllowedLanHost(host: string | undefined): boolean {
|
||||
const hostname = getHostname(host)
|
||||
if (!hostname || isLoopbackHost(hostname)) return false
|
||||
if (hostname === '0.0.0.0') return false
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isLikelyVirtualInterface(name: string, entry: os.NetworkInterfaceInfo): boolean {
|
||||
const normalizedName = name.toLowerCase()
|
||||
if (
|
||||
normalizedName.includes('wsl') ||
|
||||
normalizedName.includes('hyper-v') ||
|
||||
normalizedName.includes('vethernet') ||
|
||||
normalizedName.includes('default switch') ||
|
||||
normalizedName.includes('docker') ||
|
||||
normalizedName.includes('virtualbox') ||
|
||||
normalizedName.includes('vmware') ||
|
||||
normalizedName.includes('zerotier') ||
|
||||
normalizedName.includes('tailscale')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Hyper-V virtual adapters commonly use Microsoft's 00:15:5d OUI.
|
||||
return entry.mac.toLowerCase().startsWith('00:15:5d:')
|
||||
}
|
||||
|
||||
export function validateHostAndOriginForAccess(
|
||||
host: string | undefined,
|
||||
origin: string | undefined,
|
||||
lanAccessEnabled: boolean,
|
||||
): boolean {
|
||||
const hostAllowed = isLoopbackHost(host) || (lanAccessEnabled && isAllowedLanHost(host))
|
||||
if (!hostAllowed) return false
|
||||
|
||||
if (origin) {
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
const originAllowed = isLoopbackHost(url.host) || (lanAccessEnabled && isAllowedLanHost(url.host))
|
||||
if (!originAllowed) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getLocalNetworkUrls(
|
||||
port: number,
|
||||
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
|
||||
): string[] {
|
||||
const physicalUrls = new Set<string>()
|
||||
const fallbackUrls = new Set<string>()
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.internal || entry.family !== 'IPv4') continue
|
||||
if (!isAllowedLanHost(entry.address)) continue
|
||||
|
||||
const url = `http://${entry.address}:${port}`
|
||||
if (isLikelyVirtualInterface(name, entry)) {
|
||||
fallbackUrls.add(url)
|
||||
} else {
|
||||
physicalUrls.add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const urls = physicalUrls.size > 0 ? physicalUrls : fallbackUrls
|
||||
return Array.from(urls).sort()
|
||||
}
|
||||
|
||||
export function validateSttBaseUrl(baseUrl: string | undefined): boolean {
|
||||
if (!baseUrl) return false
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against oversized requests
|
||||
*/
|
||||
export function isRequestBodySizeAllowed(
|
||||
contentLength: string | undefined,
|
||||
maxBytes: number = 10 * 1024 * 1024,
|
||||
): boolean {
|
||||
if (!contentLength) return true
|
||||
const size = parseInt(contentLength, 10)
|
||||
if (isNaN(size)) return false
|
||||
return size <= maxBytes
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPiAgentUiSettingsStore, resolvePiAgentUiSettingsPaths } from './piagentui-settings-store.js'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeTempHome(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-settings-store-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('PiAgentUI SQLite settings store', () => {
|
||||
it('persists namespaced settings in a SQLite database independent of browser origin', () => {
|
||||
const homeDir = makeTempHome()
|
||||
const paths = resolvePiAgentUiSettingsPaths(homeDir)
|
||||
const first = createPiAgentUiSettingsStore(paths)
|
||||
|
||||
first.saveSettings({
|
||||
network: { lanAccessEnabled: true },
|
||||
stt: {
|
||||
enabled: true,
|
||||
providerKind: 'openai-compatible',
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
tts: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
model: 'speaches-ai/Kokoro-82M-v1.0-ONNX',
|
||||
voice: 'pf_dora',
|
||||
responseFormat: 'wav',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
})
|
||||
first.close()
|
||||
|
||||
const second = createPiAgentUiSettingsStore(paths)
|
||||
expect(fs.existsSync(paths.databasePath)).toBe(true)
|
||||
expect(second.getSettings()).toMatchObject({
|
||||
network: { lanAccessEnabled: true },
|
||||
stt: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
tts: {
|
||||
enabled: true,
|
||||
model: 'speaches-ai/Kokoro-82M-v1.0-ONNX',
|
||||
voice: 'pf_dora',
|
||||
responseFormat: 'wav',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
})
|
||||
second.close()
|
||||
})
|
||||
|
||||
it('imports the legacy JSON settings file once into SQLite', () => {
|
||||
const homeDir = makeTempHome()
|
||||
const paths = resolvePiAgentUiSettingsPaths(homeDir)
|
||||
fs.mkdirSync(path.dirname(paths.legacyJsonPath), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
paths.legacyJsonPath,
|
||||
JSON.stringify({
|
||||
network: { lanAccessEnabled: true },
|
||||
stt: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
apiKey: 'legacy-secret',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const store = createPiAgentUiSettingsStore(paths)
|
||||
expect(store.getSettings()).toMatchObject({
|
||||
network: { lanAccessEnabled: true },
|
||||
stt: {
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
apiKey: 'legacy-secret',
|
||||
apiKeyRequired: false,
|
||||
},
|
||||
})
|
||||
expect(store.getMeta('legacy_json_imported_at')).toEqual(expect.any(String))
|
||||
store.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const { DatabaseSync } = nodeRequire('node:sqlite') as typeof import('node:sqlite')
|
||||
type DatabaseSyncInstance = InstanceType<typeof DatabaseSync>
|
||||
|
||||
export type SttProviderKind = 'openai' | 'openai-compatible'
|
||||
export type SttMode = 'file' | 'realtime'
|
||||
export type SttInsertMode = 'append' | 'replace'
|
||||
export type TtsResponseFormat = 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
|
||||
|
||||
export type StoredSttSettings = {
|
||||
enabled?: boolean
|
||||
providerKind?: SttProviderKind
|
||||
mode?: SttMode
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
apiKeyRequired?: boolean
|
||||
transcriptionEndpoint?: string
|
||||
transcriptionModel?: string
|
||||
language?: string
|
||||
insertMode?: SttInsertMode
|
||||
}
|
||||
|
||||
export type StoredTtsSettings = {
|
||||
enabled?: boolean
|
||||
providerKind?: SttProviderKind
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
apiKeyRequired?: boolean
|
||||
speechEndpoint?: string
|
||||
model?: string
|
||||
voice?: string
|
||||
responseFormat?: TtsResponseFormat
|
||||
speed?: number
|
||||
}
|
||||
|
||||
export type PiAgentUiLocalSettings = {
|
||||
schemaVersion?: number
|
||||
network?: {
|
||||
lanAccessEnabled?: boolean
|
||||
}
|
||||
stt?: StoredSttSettings
|
||||
tts?: StoredTtsSettings
|
||||
}
|
||||
|
||||
export interface PiAgentUiSettingsPaths {
|
||||
databasePath: string
|
||||
legacyJsonPath: string
|
||||
}
|
||||
|
||||
type SettingsNamespace = 'network' | 'stt' | 'tts'
|
||||
|
||||
const SETTINGS_SCHEMA_VERSION = 1
|
||||
const SETTINGS_KEYS: Record<SettingsNamespace, string> = {
|
||||
network: 'access',
|
||||
stt: 'settings',
|
||||
tts: 'settings',
|
||||
}
|
||||
|
||||
export function resolvePiAgentUiSettingsPaths(
|
||||
homeDir = process.env.USERPROFILE || process.env.HOME || '',
|
||||
): PiAgentUiSettingsPaths {
|
||||
const settingsDir = path.join(homeDir, '.pi', 'agent')
|
||||
return {
|
||||
databasePath: path.join(settingsDir, 'piagentui.db'),
|
||||
legacyJsonPath: path.join(settingsDir, 'piagentui-settings.json'),
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string | undefined): Record<string, unknown> | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return isObject(parsed) ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSettings(raw: unknown): PiAgentUiLocalSettings {
|
||||
if (!isObject(raw)) return {}
|
||||
const next: PiAgentUiLocalSettings = { schemaVersion: SETTINGS_SCHEMA_VERSION }
|
||||
|
||||
if (isObject(raw.network)) {
|
||||
next.network = { lanAccessEnabled: raw.network.lanAccessEnabled === true }
|
||||
}
|
||||
if (isObject(raw.stt)) {
|
||||
next.stt = raw.stt as StoredSttSettings
|
||||
}
|
||||
if (isObject(raw.tts)) {
|
||||
next.tts = raw.tts as StoredTtsSettings
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function readLegacyJsonSettings(legacyJsonPath: string): PiAgentUiLocalSettings | null {
|
||||
try {
|
||||
if (!fs.existsSync(legacyJsonPath)) return null
|
||||
return normalizeSettings(JSON.parse(fs.readFileSync(legacyJsonPath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class PiAgentUiSettingsStore {
|
||||
private readonly db: DatabaseSyncInstance
|
||||
|
||||
constructor(private readonly paths: PiAgentUiSettingsPaths) {
|
||||
fs.mkdirSync(path.dirname(paths.databasePath), { recursive: true })
|
||||
this.db = new DatabaseSync(paths.databasePath)
|
||||
this.initialize()
|
||||
this.importLegacyJsonOnce()
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
|
||||
getMeta(key: string): string | null {
|
||||
const row = this.db.prepare('SELECT value FROM app_meta WHERE key = ?').get(key) as { value?: string } | undefined
|
||||
return typeof row?.value === 'string' ? row.value : null
|
||||
}
|
||||
|
||||
setMeta(key: string, value: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO app_meta (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(key, value, Date.now())
|
||||
}
|
||||
|
||||
getSettings(): PiAgentUiLocalSettings {
|
||||
return {
|
||||
schemaVersion: SETTINGS_SCHEMA_VERSION,
|
||||
network: this.getNamespace('network') as PiAgentUiLocalSettings['network'],
|
||||
stt: this.getNamespace('stt') as StoredSttSettings | undefined,
|
||||
tts: this.getNamespace('tts') as StoredTtsSettings | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings(settings: PiAgentUiLocalSettings): void {
|
||||
const normalized = normalizeSettings(settings)
|
||||
this.db.exec('BEGIN')
|
||||
try {
|
||||
if (normalized.network) this.setNamespace('network', normalized.network)
|
||||
if (normalized.stt) this.setNamespace('stt', normalized.stt)
|
||||
if (normalized.tts) this.setNamespace('tts', normalized.tts)
|
||||
this.setMeta('schema_version', String(SETTINGS_SCHEMA_VERSION))
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.db.exec('PRAGMA journal_mode = WAL')
|
||||
this.db.exec('PRAGMA foreign_keys = ON')
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (namespace, key)
|
||||
);
|
||||
`)
|
||||
this.setMeta('schema_version', String(SETTINGS_SCHEMA_VERSION))
|
||||
}
|
||||
|
||||
private importLegacyJsonOnce(): void {
|
||||
if (this.getMeta('legacy_json_imported_at')) return
|
||||
const legacy = readLegacyJsonSettings(this.paths.legacyJsonPath)
|
||||
if (!legacy) return
|
||||
this.saveSettings(legacy)
|
||||
this.setMeta('legacy_json_imported_at', new Date().toISOString())
|
||||
}
|
||||
|
||||
private getNamespace(namespace: SettingsNamespace): Record<string, unknown> | undefined {
|
||||
const row = this.db
|
||||
.prepare('SELECT value_json FROM settings WHERE namespace = ? AND key = ?')
|
||||
.get(namespace, SETTINGS_KEYS[namespace]) as { value_json?: string } | undefined
|
||||
return parseJsonObject(row?.value_json)
|
||||
}
|
||||
|
||||
private setNamespace(namespace: SettingsNamespace, value: unknown): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO settings (namespace, key, value_json, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(namespace, key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(namespace, SETTINGS_KEYS[namespace], JSON.stringify(value), Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
export function createPiAgentUiSettingsStore(paths = resolvePiAgentUiSettingsPaths()): PiAgentUiSettingsStore {
|
||||
return new PiAgentUiSettingsStore(paths)
|
||||
}
|
||||
Reference in new issue
Block a user