🎉 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

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:
Evandro committed 2026-06-01 00:50:06 -03:00
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' })
})
})
+513
View File
@@ -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 })
}
+103
View File
@@ -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()
})
})
+259
View File
@@ -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 }
}
+171
View File
@@ -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
})
})
})
+172
View File
@@ -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()
})
})
+214
View File
@@ -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)
}