Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"$schema": "./node_modules/fallow/schema.json",
// These roots are launched by subprocesses, electron-builder, or separate Vitest configs.
"entry": ["electron/dev-runner.ts", "scripts/after-build.js"],
"framework": [
{
"name": "corelive-electron-tests",
"enablers": ["vitest"],
"entryPoints": [
"electron/**/*.{test,spec}.{ts,mjs}",
"scripts/**/*.{test,spec}.{ts,mjs}",
],
"entryPointRole": "test",
},
{
"name": "corelive-storybook-docs",
"enablers": ["storybook"],
"entryPoints": ["src/**/*.mdx"],
"entryPointRole": "support",
},
],
// Next.js generates route declarations; theme:generate owns the repeated theme CSS.
"ignorePatterns": ["next-env.d.ts", "src/lib/themes/generated.css"],
"ignoreDependencies": [
// Next.js loads the CSS optimizer by name.
"critters",
// Explicit packaged dependencies: electron-builder must retain these transitive runtime modules.
"ms",
"node-gyp-build",
"pino-pretty",
"thread-stream",
"wrappy",
// Storybook's MDX renderer and documentation prebundling load these indirectly.
"@mdx-js/react",
"markdown-to-jsx",
],
// electron-vite emits these .ts entry points as .cjs; the output is absent in a fresh checkout.
"ignoreUnresolvedImports": [
"./SystemTrayManager.cjs",
"./NotificationManager.cjs",
"./ShortcutManager.cjs",
"./AutoUpdater.cjs",
"./MenuManager.cjs",
"./SystemIntegrationErrorHandler.cjs",
"./OAuthManager.cjs",
"./DeepLinkManager.cjs",
],
// The documented shadcn component catalog intentionally exposes its composition primitives.
"ignoreExports": [{ "file": "src/components/ui/**", "exports": ["*"] }],
"duplicates": {
// Test cases repeat setup and expected values so each scenario remains readable.
"ignore": [
"**/*.{test,spec,stories}.{ts,tsx,mjs}",
"**/__tests__/**",
"src/**/*.mdx",
],
},
"health": {
// Match the structural complexity budget used by Skills Desktop.
"maxCyclomatic": 40,
"maxCognitive": 40,
"maxCrap": 120,
"ignore": [
"**/*.{test,spec}.{ts,tsx,mjs}",
"**/__tests__/**",
".storybook/**",
"scripts/**",
"prisma/seed*.ts",
],
},
}
42 changes: 42 additions & 0 deletions .github/workflows/fallow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Fallow

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: fallow-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Fallow (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: dead-code
command: dead-code
args: ''
- name: dupes
command: dupes
args: ''
- name: health
command: health
args: --complexity
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./.github/actions/prepare
- name: Build workspace ESLint plugin
run: pnpm --filter eslint-plugin-dslint build
- name: Analyze repository
run: pnpm exec fallow ${{ matrix.command }} ${{ matrix.args }} --fail-on-issues --no-cache --format compact
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@ _trials/
.claude/auto.json
.claude/auto-notes.md
.gbrain-source

# Fallow analysis cache
.fallow/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ You can start editing the page by modifying files under `src/app/`. The page aut

This project loads no web fonts: text renders in the stock shadcn/ui + Tailwind system font stacks (`font-sans` for UI, `font-mono` for data).

### Code quality

Run `pnpm validate` before committing. Alongside tests, lint, the build, and type checks, it runs three [Fallow](https://github.com/fallow-rs/fallow) gates:

| Command | Checks |
| ----------------------- | ----------------------------------------------------- |
| `pnpm fallow:dead-code` | Unused files, exports, types, and dependency problems |
| `pnpm fallow:dupes` | Duplicated source code |
| `pnpm fallow:health` | Function complexity and estimated change risk |

The same three checks run for pull requests and pushes to `main` in `.github/workflows/fallow.yml`, following the setup in [Skills Desktop](https://github.com/laststance/skills-desktop). Fallow is pinned in `package.json`; `.fallowrc.jsonc` uses its installed schema and documents the Electron entry points, generated files, indirect runtime dependencies, and component-catalog exports. Tests and Storybook examples remain in the dependency graph, but their repeated fixtures are excluded from duplication checks. Complexity limits are 40 cyclomatic, 40 cognitive, and 120 CRAP, using Fallow's static coverage estimate rather than a machine-local coverage report.

### Ngrok

Need ngrok to recive create.user event [webhook](https://clerk.com/docs/webhooks/overview) from Clerk in local.
Expand Down
108 changes: 21 additions & 87 deletions electron/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,25 @@ export class ConfigManager {
return current as T
}

/** Applies a safe dotted path for {@link set} and {@link update} before either caller saves.
* @param configPath - Dot-separated destination in the current configuration.
* @param value - New value for the destination.
* @returns Whether the entire path is safe and was applied.
* @example this.assignConfigValue('liveEditor.opacity', 0.8)
*/
private assignConfigValue(configPath: string, value: unknown): boolean {
const keys = configPath.split('.')
// Reject the whole path before creating objects, so unsafe segments cannot redirect a write.
if (keys.some(isUnsafeKey)) return false
let current: Record<string, unknown> = this.config
for (const key of keys.slice(0, -1)) {
if (!current[key] || typeof current[key] !== 'object') current[key] = {}
current = current[key] as Record<string, unknown>
}
current[keys[keys.length - 1]!] = value
return true
}

/**
* Sets a configuration value using dot notation path.
*
Expand All @@ -763,28 +782,7 @@ export class ConfigManager {
* ```
*/
set(configPath: string, value: unknown): boolean {
const keys = configPath.split('.')
let current: Record<string, unknown> = this.config

for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!
// Block prototype pollution attacks
if (isUnsafeKey(key)) {
return false
}
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key] as Record<string, unknown>
}

const lastKey = keys[keys.length - 1]!
// Block prototype pollution attacks on the final key
if (isUnsafeKey(lastKey)) {
return false
}
current[lastKey] = value
return this.saveConfig()
return this.assignConfigValue(configPath, value) && this.saveConfig()
}

/**
Expand All @@ -796,27 +794,7 @@ export class ConfigManager {
*/
update(updates: Record<string, unknown>): boolean {
for (const [configPath, value] of Object.entries(updates)) {
// Set value in memory without saving to disk
const keys = configPath.split('.')
let current: Record<string, unknown> = this.config

for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!
// Block prototype pollution attacks
if (isUnsafeKey(key)) {
continue
}
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key] as Record<string, unknown>
}

const lastKey = keys[keys.length - 1]!
// Block prototype pollution attacks on the final key
if (!isUnsafeKey(lastKey)) {
current[lastKey] = value
}
this.assignConfigValue(configPath, value)
}
// Single disk write after all updates
return this.saveConfig()
Expand Down Expand Up @@ -992,48 +970,4 @@ export class ConfigManager {
return null
}
}

/**
* Clean up old backup files (keep only the latest 5).
*/
cleanupBackups(): number {
try {
const files = fs.readdirSync(this.configDir)
const backupFiles = files
.filter(
(file) => file.startsWith('config-backup-') && file.endsWith('.json'),
)
.flatMap((file) => {
// Handle race condition: file may be deleted between readdirSync and statSync
const filePath = path.join(this.configDir, file)
try {
const stat = fs.statSync(filePath)
return [{ name: file, path: filePath, stat }]
} catch {
// File was likely deleted between listing and stat - skip it
log.debug(`Skipping backup file (stat failed): ${file}`)
return []
}
})
.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())

// Keep only the latest 5 backups
const filesToDelete = backupFiles.slice(5)

for (const file of filesToDelete) {
fs.unlinkSync(file.path)
}

return filesToDelete.length
} catch (error) {
log.error('Failed to cleanup backups:', error)
return 0
}
}
}

// ============================================================================
// Default Export
// ============================================================================

export default ConfigManager
6 changes: 3 additions & 3 deletions electron/MemoryProfiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { log } from './logger'
// ============================================================================

/** Memory profiler options */
export interface MemoryProfilerOptions {
interface MemoryProfilerOptions {
monitoringInterval?: number
warningThreshold?: number
criticalThreshold?: number
Expand Down Expand Up @@ -49,7 +49,7 @@ interface RendererMemory {
}

/** Memory snapshot */
export interface MemorySnapshot {
interface MemorySnapshot {
timestamp: number
mainProcess: MainProcessMemory
rendererProcesses: RendererMemory
Expand All @@ -69,7 +69,7 @@ type CleanupCallback = (level: CleanupLevel) => void
/**
* Monitors memory usage and triggers cleanup operations.
*/
export class MemoryProfiler extends EventEmitter {
class MemoryProfiler extends EventEmitter {
/** Configuration options */
private options: Required<MemoryProfilerOptions>

Expand Down
Loading