mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge branch 'main' into fix/electron-node-path-finder-launch-v2
This commit is contained in:
@@ -15,6 +15,7 @@ npm install @automaker/platform
|
||||
## Exports
|
||||
|
||||
### Path Management
|
||||
|
||||
AutoMaker directory structure utilities.
|
||||
|
||||
```typescript
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
getWorktreesDir,
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Get AutoMaker directory: /project/.automaker
|
||||
@@ -49,6 +50,7 @@ await ensureAutomakerDir('/project/path');
|
||||
```
|
||||
|
||||
### Subprocess Management
|
||||
|
||||
Spawn and manage subprocesses with JSON-lines output.
|
||||
|
||||
```typescript
|
||||
@@ -60,18 +62,19 @@ const result = await spawnJSONLProcess({
|
||||
args: ['--output', 'jsonl'],
|
||||
cwd: '/project/path',
|
||||
onLine: (data) => console.log('Received:', data),
|
||||
onError: (error) => console.error('Error:', error)
|
||||
onError: (error) => console.error('Error:', error),
|
||||
});
|
||||
|
||||
// Spawn regular process
|
||||
const output = await spawnProcess({
|
||||
command: 'git',
|
||||
args: ['status'],
|
||||
cwd: '/project/path'
|
||||
cwd: '/project/path',
|
||||
});
|
||||
```
|
||||
|
||||
### Security Validation
|
||||
|
||||
Path validation and security checks.
|
||||
|
||||
```typescript
|
||||
@@ -82,7 +85,7 @@ import {
|
||||
getAllowedPaths,
|
||||
getAllowedRootDirectory,
|
||||
getDataDirectory,
|
||||
PathNotAllowedError
|
||||
PathNotAllowedError,
|
||||
} from '@automaker/platform';
|
||||
|
||||
// Initialize allowed paths from environment
|
||||
@@ -116,7 +119,7 @@ import {
|
||||
getFeatureDir,
|
||||
ensureAutomakerDir,
|
||||
spawnJSONLProcess,
|
||||
validatePath
|
||||
validatePath,
|
||||
} from '@automaker/platform';
|
||||
|
||||
async function executeFeature(projectPath: string, featureId: string) {
|
||||
@@ -138,7 +141,7 @@ async function executeFeature(projectPath: string, featureId: string) {
|
||||
if (data.type === 'progress') {
|
||||
console.log('Progress:', data.progress);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": ["automaker", "platform"],
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"platform"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* Directory creation is handled separately by ensure* functions.
|
||||
*/
|
||||
|
||||
import * as secureFs from "./secure-fs.js";
|
||||
import path from "path";
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the automaker data directory root for a project
|
||||
@@ -22,7 +22,7 @@ import path from "path";
|
||||
* @returns Absolute path to {projectPath}/.automaker
|
||||
*/
|
||||
export function getAutomakerDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".automaker");
|
||||
return path.join(projectPath, '.automaker');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ export function getAutomakerDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/features
|
||||
*/
|
||||
export function getFeaturesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "features");
|
||||
return path.join(getAutomakerDir(projectPath), 'features');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +59,8 @@ export function getFeatureDir(projectPath: string, featureId: string): string {
|
||||
* @param featureId - Feature identifier
|
||||
* @returns Absolute path to {projectPath}/.automaker/features/{featureId}/images
|
||||
*/
|
||||
export function getFeatureImagesDir(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), "images");
|
||||
export function getFeatureImagesDir(projectPath: string, featureId: string): string {
|
||||
return path.join(getFeatureDir(projectPath, featureId), 'images');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +72,7 @@ export function getFeatureImagesDir(
|
||||
* @returns Absolute path to {projectPath}/.automaker/board
|
||||
*/
|
||||
export function getBoardDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "board");
|
||||
return path.join(getAutomakerDir(projectPath), 'board');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +84,7 @@ export function getBoardDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/images
|
||||
*/
|
||||
export function getImagesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "images");
|
||||
return path.join(getAutomakerDir(projectPath), 'images');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +96,7 @@ export function getImagesDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/context
|
||||
*/
|
||||
export function getContextDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "context");
|
||||
return path.join(getAutomakerDir(projectPath), 'context');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +108,7 @@ export function getContextDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/worktrees
|
||||
*/
|
||||
export function getWorktreesDir(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "worktrees");
|
||||
return path.join(getAutomakerDir(projectPath), 'worktrees');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +120,7 @@ export function getWorktreesDir(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/app_spec.txt
|
||||
*/
|
||||
export function getAppSpecPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
|
||||
return path.join(getAutomakerDir(projectPath), 'app_spec.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +132,7 @@ export function getAppSpecPath(projectPath: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/active-branches.json
|
||||
*/
|
||||
export function getBranchTrackingPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "active-branches.json");
|
||||
return path.join(getAutomakerDir(projectPath), 'active-branches.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,7 +169,7 @@ export async function ensureAutomakerDir(projectPath: string): Promise<string> {
|
||||
* @returns Absolute path to {dataDir}/settings.json
|
||||
*/
|
||||
export function getGlobalSettingsPath(dataDir: string): string {
|
||||
return path.join(dataDir, "settings.json");
|
||||
return path.join(dataDir, 'settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +182,7 @@ export function getGlobalSettingsPath(dataDir: string): string {
|
||||
* @returns Absolute path to {dataDir}/credentials.json
|
||||
*/
|
||||
export function getCredentialsPath(dataDir: string): string {
|
||||
return path.join(dataDir, "credentials.json");
|
||||
return path.join(dataDir, 'credentials.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +195,7 @@ export function getCredentialsPath(dataDir: string): string {
|
||||
* @returns Absolute path to {projectPath}/.automaker/settings.json
|
||||
*/
|
||||
export function getProjectSettingsPath(projectPath: string): string {
|
||||
return path.join(getAutomakerDir(projectPath), "settings.json");
|
||||
return path.join(getAutomakerDir(projectPath), 'settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
* not just at the API layer. This provides defense-in-depth security.
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import type { Dirent } from "fs";
|
||||
import path from "path";
|
||||
import { validatePath } from "./security.js";
|
||||
import fs from 'fs/promises';
|
||||
import type { Dirent } from 'fs';
|
||||
import path from 'path';
|
||||
import { validatePath } from './security.js';
|
||||
|
||||
/**
|
||||
* Wrapper around fs.access that validates path first
|
||||
@@ -108,11 +108,7 @@ export async function unlink(filePath: string): Promise<void> {
|
||||
/**
|
||||
* Wrapper around fs.copyFile that validates both paths first
|
||||
*/
|
||||
export async function copyFile(
|
||||
src: string,
|
||||
dest: string,
|
||||
mode?: number
|
||||
): Promise<void> {
|
||||
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
|
||||
const validatedSrc = validatePath(src);
|
||||
const validatedDest = validatePath(dest);
|
||||
return fs.copyFile(validatedSrc, validatedDest, mode);
|
||||
@@ -133,10 +129,7 @@ export async function appendFile(
|
||||
/**
|
||||
* Wrapper around fs.rename that validates both paths first
|
||||
*/
|
||||
export async function rename(
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
export async function rename(oldPath: string, newPath: string): Promise<void> {
|
||||
const validatedOldPath = validatePath(oldPath);
|
||||
const validatedNewPath = validatePath(newPath);
|
||||
return fs.rename(validatedOldPath, validatedNewPath);
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Error thrown when a path is not allowed by security policy
|
||||
*/
|
||||
export class PathNotAllowedError extends Error {
|
||||
constructor(filePath: string) {
|
||||
super(
|
||||
`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`
|
||||
);
|
||||
this.name = "PathNotAllowedError";
|
||||
super(`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`);
|
||||
this.name = 'PathNotAllowedError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,13 +31,9 @@ export function initAllowedPaths(): void {
|
||||
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
if (rootDir) {
|
||||
allowedRootDirectory = path.resolve(rootDir);
|
||||
console.log(
|
||||
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
|
||||
);
|
||||
console.log(`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
|
||||
} else {
|
||||
console.log(
|
||||
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
|
||||
);
|
||||
console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
|
||||
}
|
||||
|
||||
// Load DATA_DIR (appData exception - always allowed)
|
||||
@@ -72,10 +66,7 @@ export function isPathAllowed(filePath: string): boolean {
|
||||
}
|
||||
|
||||
// Allow if within ALLOWED_ROOT_DIRECTORY
|
||||
if (
|
||||
allowedRootDirectory &&
|
||||
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
|
||||
) {
|
||||
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -101,17 +92,14 @@ export function validatePath(filePath: string): string {
|
||||
* Check if a path is within a directory, with protection against path traversal
|
||||
* Returns true only if resolvedPath is within directoryPath
|
||||
*/
|
||||
export function isPathWithinDirectory(
|
||||
resolvedPath: string,
|
||||
directoryPath: string
|
||||
): boolean {
|
||||
export function isPathWithinDirectory(resolvedPath: string, directoryPath: string): boolean {
|
||||
// Get the relative path from directory to the target
|
||||
const relativePath = path.relative(directoryPath, resolvedPath);
|
||||
|
||||
// If relative path starts with "..", it's outside the directory
|
||||
// If relative path is absolute, it's outside the directory
|
||||
// If relative path is empty or ".", it's the directory itself
|
||||
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
||||
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Subprocess management utilities for CLI providers
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import readline from "readline";
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import readline from 'readline';
|
||||
|
||||
export interface SubprocessOptions {
|
||||
command: string;
|
||||
@@ -23,9 +23,7 @@ export interface SubprocessResult {
|
||||
/**
|
||||
* Spawns a subprocess and streams JSONL output line-by-line
|
||||
*/
|
||||
export async function* spawnJSONLProcess(
|
||||
options: SubprocessOptions
|
||||
): AsyncGenerator<unknown> {
|
||||
export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> {
|
||||
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
|
||||
|
||||
const processEnv = {
|
||||
@@ -33,22 +31,22 @@ export async function* spawnJSONLProcess(
|
||||
...env,
|
||||
};
|
||||
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
|
||||
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(' ')}`);
|
||||
console.log(`[SubprocessManager] Working directory: ${cwd}`);
|
||||
|
||||
const childProcess: ChildProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderrOutput = "";
|
||||
let stderrOutput = '';
|
||||
let lastOutputTime = Date.now();
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
|
||||
// Collect stderr for error reporting
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
childProcess.stderr.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stderrOutput += text;
|
||||
console.error(`[SubprocessManager] stderr: ${text}`);
|
||||
@@ -64,10 +62,8 @@ export async function* spawnJSONLProcess(
|
||||
timeoutHandle = setTimeout(() => {
|
||||
const elapsed = Date.now() - lastOutputTime;
|
||||
if (elapsed >= timeout) {
|
||||
console.error(
|
||||
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
|
||||
);
|
||||
childProcess.kill("SIGTERM");
|
||||
console.error(`[SubprocessManager] Process timeout: no output for ${timeout}ms`);
|
||||
childProcess.kill('SIGTERM');
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
@@ -76,12 +72,12 @@ export async function* spawnJSONLProcess(
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
console.log("[SubprocessManager] Abort signal received, killing process");
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
console.log('[SubprocessManager] Abort signal received, killing process');
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
childProcess.kill("SIGTERM");
|
||||
childProcess.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,19 +98,16 @@ export async function* spawnJSONLProcess(
|
||||
const parsed = JSON.parse(line);
|
||||
yield parsed;
|
||||
} catch (parseError) {
|
||||
console.error(
|
||||
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
|
||||
parseError
|
||||
);
|
||||
console.error(`[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError);
|
||||
// Yield error but continue processing
|
||||
yield {
|
||||
type: "error",
|
||||
type: 'error',
|
||||
error: `Failed to parse output: ${line}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SubprocessManager] Error reading stdout:", error);
|
||||
console.error('[SubprocessManager] Error reading stdout:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
@@ -125,13 +118,13 @@ export async function* spawnJSONLProcess(
|
||||
|
||||
// Wait for process to exit
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
childProcess.on("exit", (code) => {
|
||||
childProcess.on('exit', (code) => {
|
||||
console.log(`[SubprocessManager] Process exited with code: ${code}`);
|
||||
resolve(code);
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
console.error("[SubprocessManager] Process error:", error);
|
||||
childProcess.on('error', (error) => {
|
||||
console.error('[SubprocessManager] Process error:', error);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
@@ -141,23 +134,21 @@ export async function* spawnJSONLProcess(
|
||||
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
|
||||
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
|
||||
yield {
|
||||
type: "error",
|
||||
type: 'error',
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Process completed successfully
|
||||
if (exitCode === 0 && !stderrOutput) {
|
||||
console.log("[SubprocessManager] Process completed successfully");
|
||||
console.log('[SubprocessManager] Process completed successfully');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a subprocess and collects all output
|
||||
*/
|
||||
export async function spawnProcess(
|
||||
options: SubprocessOptions
|
||||
): Promise<SubprocessResult> {
|
||||
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
|
||||
const { command, args, cwd, env, abortController } = options;
|
||||
|
||||
const processEnv = {
|
||||
@@ -169,37 +160,37 @@ export async function spawnProcess(
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (childProcess.stdout) {
|
||||
childProcess.stdout.on("data", (data: Buffer) => {
|
||||
childProcess.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
if (childProcess.stderr) {
|
||||
childProcess.stderr.on("data", (data: Buffer) => {
|
||||
childProcess.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup abort handling
|
||||
if (abortController) {
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
childProcess.kill("SIGTERM");
|
||||
reject(new Error("Process aborted"));
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
childProcess.kill('SIGTERM');
|
||||
reject(new Error('Process aborted'));
|
||||
});
|
||||
}
|
||||
|
||||
childProcess.on("exit", (code) => {
|
||||
childProcess.on('exit', (code) => {
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
});
|
||||
|
||||
childProcess.on("error", (error) => {
|
||||
childProcess.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
getAutomakerDir,
|
||||
getFeaturesDir,
|
||||
@@ -18,18 +18,18 @@ import {
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
} from "../src/paths";
|
||||
} from '../src/paths';
|
||||
|
||||
describe("paths.ts", () => {
|
||||
describe('paths.ts', () => {
|
||||
let tempDir: string;
|
||||
let projectPath: string;
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "platform-paths-test-"));
|
||||
projectPath = path.join(tempDir, "test-project");
|
||||
dataDir = path.join(tempDir, "user-data");
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'platform-paths-test-'));
|
||||
projectPath = path.join(tempDir, 'test-project');
|
||||
dataDir = path.join(tempDir, 'user-data');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
});
|
||||
|
||||
@@ -42,98 +42,88 @@ describe("paths.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("Project-level path construction", () => {
|
||||
it("should return automaker directory path", () => {
|
||||
describe('Project-level path construction', () => {
|
||||
it('should return automaker directory path', () => {
|
||||
const result = getAutomakerDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker'));
|
||||
});
|
||||
|
||||
it("should return features directory path", () => {
|
||||
it('should return features directory path', () => {
|
||||
const result = getFeaturesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "features"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features'));
|
||||
});
|
||||
|
||||
it("should return feature directory path", () => {
|
||||
const featureId = "auth-feature";
|
||||
it('should return feature directory path', () => {
|
||||
const featureId = 'auth-feature';
|
||||
const result = getFeatureDir(projectPath, featureId);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "features", featureId)
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId));
|
||||
});
|
||||
|
||||
it("should return feature images directory path", () => {
|
||||
const featureId = "auth-feature";
|
||||
it('should return feature images directory path', () => {
|
||||
const featureId = 'auth-feature';
|
||||
const result = getFeatureImagesDir(projectPath, featureId);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "features", featureId, "images")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId, 'images'));
|
||||
});
|
||||
|
||||
it("should return board directory path", () => {
|
||||
it('should return board directory path', () => {
|
||||
const result = getBoardDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "board"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'board'));
|
||||
});
|
||||
|
||||
it("should return images directory path", () => {
|
||||
it('should return images directory path', () => {
|
||||
const result = getImagesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "images"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'images'));
|
||||
});
|
||||
|
||||
it("should return context directory path", () => {
|
||||
it('should return context directory path', () => {
|
||||
const result = getContextDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "context"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'context'));
|
||||
});
|
||||
|
||||
it("should return worktrees directory path", () => {
|
||||
it('should return worktrees directory path', () => {
|
||||
const result = getWorktreesDir(projectPath);
|
||||
expect(result).toBe(path.join(projectPath, ".automaker", "worktrees"));
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'worktrees'));
|
||||
});
|
||||
|
||||
it("should return app spec file path", () => {
|
||||
it('should return app spec file path', () => {
|
||||
const result = getAppSpecPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "app_spec.txt")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'app_spec.txt'));
|
||||
});
|
||||
|
||||
it("should return branch tracking file path", () => {
|
||||
it('should return branch tracking file path', () => {
|
||||
const result = getBranchTrackingPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "active-branches.json")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'active-branches.json'));
|
||||
});
|
||||
|
||||
it("should return project settings file path", () => {
|
||||
it('should return project settings file path', () => {
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "settings.json")
|
||||
);
|
||||
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global settings path construction", () => {
|
||||
it("should return global settings path", () => {
|
||||
describe('Global settings path construction', () => {
|
||||
it('should return global settings path', () => {
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "settings.json"));
|
||||
expect(result).toBe(path.join(dataDir, 'settings.json'));
|
||||
});
|
||||
|
||||
it("should return credentials path", () => {
|
||||
it('should return credentials path', () => {
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "credentials.json"));
|
||||
expect(result).toBe(path.join(dataDir, 'credentials.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Directory creation", () => {
|
||||
it("should create automaker directory", async () => {
|
||||
describe('Directory creation', () => {
|
||||
it('should create automaker directory', async () => {
|
||||
const automakerDir = await ensureAutomakerDir(projectPath);
|
||||
|
||||
expect(automakerDir).toBe(path.join(projectPath, ".automaker"));
|
||||
expect(automakerDir).toBe(path.join(projectPath, '.automaker'));
|
||||
|
||||
const stats = await fs.stat(automakerDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be idempotent when creating automaker directory", async () => {
|
||||
it('should be idempotent when creating automaker directory', async () => {
|
||||
// Create directory first time
|
||||
const firstResult = await ensureAutomakerDir(projectPath);
|
||||
|
||||
@@ -146,7 +136,7 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create data directory", async () => {
|
||||
it('should create data directory', async () => {
|
||||
const result = await ensureDataDir(dataDir);
|
||||
|
||||
expect(result).toBe(dataDir);
|
||||
@@ -155,7 +145,7 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be idempotent when creating data directory", async () => {
|
||||
it('should be idempotent when creating data directory', async () => {
|
||||
// Create directory first time
|
||||
const firstResult = await ensureDataDir(dataDir);
|
||||
|
||||
@@ -168,13 +158,8 @@ describe("paths.ts", () => {
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should create nested directories recursively", async () => {
|
||||
const deepProjectPath = path.join(
|
||||
tempDir,
|
||||
"nested",
|
||||
"deep",
|
||||
"project"
|
||||
);
|
||||
it('should create nested directories recursively', async () => {
|
||||
const deepProjectPath = path.join(tempDir, 'nested', 'deep', 'project');
|
||||
await fs.mkdir(deepProjectPath, { recursive: true });
|
||||
|
||||
const automakerDir = await ensureAutomakerDir(deepProjectPath);
|
||||
@@ -184,29 +169,29 @@ describe("paths.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path handling with special characters", () => {
|
||||
it("should handle feature IDs with special characters", () => {
|
||||
const featureId = "feature-with-dashes_and_underscores";
|
||||
describe('Path handling with special characters', () => {
|
||||
it('should handle feature IDs with special characters', () => {
|
||||
const featureId = 'feature-with-dashes_and_underscores';
|
||||
const result = getFeatureDir(projectPath, featureId);
|
||||
expect(result).toContain(featureId);
|
||||
});
|
||||
|
||||
it("should handle paths with spaces", () => {
|
||||
const pathWithSpaces = path.join(tempDir, "path with spaces");
|
||||
it('should handle paths with spaces', () => {
|
||||
const pathWithSpaces = path.join(tempDir, 'path with spaces');
|
||||
const result = getAutomakerDir(pathWithSpaces);
|
||||
expect(result).toBe(path.join(pathWithSpaces, ".automaker"));
|
||||
expect(result).toBe(path.join(pathWithSpaces, '.automaker'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path relationships", () => {
|
||||
it("should have feature dir as child of features dir", () => {
|
||||
describe('Path relationships', () => {
|
||||
it('should have feature dir as child of features dir', () => {
|
||||
const featuresDir = getFeaturesDir(projectPath);
|
||||
const featureDir = getFeatureDir(projectPath, "test-feature");
|
||||
const featureDir = getFeatureDir(projectPath, 'test-feature');
|
||||
|
||||
expect(featureDir.startsWith(featuresDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("should have all project paths under automaker dir", () => {
|
||||
it('should have all project paths under automaker dir', () => {
|
||||
const automakerDir = getAutomakerDir(projectPath);
|
||||
const paths = [
|
||||
getFeaturesDir(projectPath),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import path from "path";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
describe("security.ts", () => {
|
||||
describe('security.ts', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -16,176 +16,162 @@ describe("security.ts", () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("initAllowedPaths", () => {
|
||||
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
describe('initAllowedPaths', () => {
|
||||
it('should load ALLOWED_ROOT_DIRECTORY if set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
});
|
||||
|
||||
it("should load DATA_DIR if set", async () => {
|
||||
it('should load DATA_DIR if set', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data/directory";
|
||||
process.env.DATA_DIR = '/data/directory';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/data/directory"));
|
||||
expect(allowed).toContain(path.resolve('/data/directory'));
|
||||
});
|
||||
|
||||
it("should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
it('should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/app/data';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/app/data"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
expect(allowed).toContain(path.resolve('/app/data'));
|
||||
});
|
||||
|
||||
it("should handle missing environment variables gracefully", async () => {
|
||||
it('should handle missing environment variables gracefully', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths } = await import("../src/security");
|
||||
const { initAllowedPaths } = await import('../src/security');
|
||||
expect(() => initAllowedPaths()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPathAllowed", () => {
|
||||
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
describe('isPathAllowed', () => {
|
||||
it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/allowed/file.txt")).toBe(true);
|
||||
expect(isPathAllowed("/allowed/subdir/file.txt")).toBe(true);
|
||||
expect(isPathAllowed('/allowed/file.txt')).toBe(true);
|
||||
expect(isPathAllowed('/allowed/subdir/file.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny paths outside ALLOWED_ROOT_DIRECTORY", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
it('should deny paths outside ALLOWED_ROOT_DIRECTORY', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/not-allowed/file.txt")).toBe(false);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(false);
|
||||
expect(isPathAllowed('/not-allowed/file.txt')).toBe(false);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it("should always allow DATA_DIR paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
it('should always allow DATA_DIR paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/app/data';
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR paths are always allowed
|
||||
expect(isPathAllowed("/app/data/settings.json")).toBe(true);
|
||||
expect(isPathAllowed("/app/data/credentials.json")).toBe(true);
|
||||
expect(isPathAllowed('/app/data/settings.json')).toBe(true);
|
||||
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow all paths when no restrictions configured", async () => {
|
||||
it('should allow all paths when no restrictions configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
expect(isPathAllowed("/etc/passwd")).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
expect(isPathAllowed('/etc/passwd')).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow all paths when only DATA_DIR is configured", async () => {
|
||||
it('should allow all paths when only DATA_DIR is configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
process.env.DATA_DIR = "/data";
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, isPathAllowed } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
// DATA_DIR should be allowed
|
||||
expect(isPathAllowed("/data/file.txt")).toBe(true);
|
||||
expect(isPathAllowed('/data/file.txt')).toBe(true);
|
||||
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
|
||||
expect(isPathAllowed("/any/path")).toBe(true);
|
||||
expect(isPathAllowed('/any/path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePath", () => {
|
||||
it("should return resolved path for allowed paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
describe('validatePath', () => {
|
||||
it('should return resolved path for allowed paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("/allowed/file.txt");
|
||||
expect(result).toBe(path.resolve("/allowed/file.txt"));
|
||||
const result = validatePath('/allowed/file.txt');
|
||||
expect(result).toBe(path.resolve('/allowed/file.txt'));
|
||||
});
|
||||
|
||||
it("should throw error for paths outside allowed directories", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
|
||||
it('should throw error for paths outside allowed directories', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath, PathNotAllowedError } =
|
||||
await import("../src/security");
|
||||
await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/not-allowed/file.txt")).toThrow(
|
||||
PathNotAllowedError
|
||||
);
|
||||
expect(() => validatePath('/not-allowed/file.txt')).toThrow(PathNotAllowedError);
|
||||
});
|
||||
|
||||
it("should resolve relative paths", async () => {
|
||||
it('should resolve relative paths', async () => {
|
||||
const cwd = process.cwd();
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const result = validatePath("./file.txt");
|
||||
expect(result).toBe(path.resolve(cwd, "./file.txt"));
|
||||
const result = validatePath('./file.txt');
|
||||
expect(result).toBe(path.resolve(cwd, './file.txt'));
|
||||
});
|
||||
|
||||
it("should not throw when no restrictions configured", async () => {
|
||||
it('should not throw when no restrictions configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, validatePath } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, validatePath } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(() => validatePath("/any/path")).not.toThrow();
|
||||
expect(() => validatePath('/any/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedPaths", () => {
|
||||
it("should return empty array when no paths configured", async () => {
|
||||
describe('getAllowedPaths', () => {
|
||||
it('should return empty array when no paths configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
@@ -193,58 +179,53 @@ describe("security.ts", () => {
|
||||
expect(allowed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return configured paths", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
process.env.DATA_DIR = "/data";
|
||||
it('should return configured paths', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/projects"));
|
||||
expect(allowed).toContain(path.resolve("/data"));
|
||||
expect(allowed).toContain(path.resolve('/projects'));
|
||||
expect(allowed).toContain(path.resolve('/data'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedRootDirectory", () => {
|
||||
it("should return the configured root directory", async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
|
||||
describe('getAllowedRootDirectory', () => {
|
||||
it('should return the configured root directory', async () => {
|
||||
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
|
||||
expect(getAllowedRootDirectory()).toBe(path.resolve('/projects'));
|
||||
});
|
||||
|
||||
it("should return null when not configured", async () => {
|
||||
it('should return null when not configured', async () => {
|
||||
delete process.env.ALLOWED_ROOT_DIRECTORY;
|
||||
|
||||
const { initAllowedPaths, getAllowedRootDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getAllowedRootDirectory()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDirectory", () => {
|
||||
it("should return the configured data directory", async () => {
|
||||
process.env.DATA_DIR = "/data";
|
||||
describe('getDataDirectory', () => {
|
||||
it('should return the configured data directory', async () => {
|
||||
process.env.DATA_DIR = '/data';
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBe(path.resolve("/data"));
|
||||
expect(getDataDirectory()).toBe(path.resolve('/data'));
|
||||
});
|
||||
|
||||
it("should return null when not configured", async () => {
|
||||
it('should return null when not configured', async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
|
||||
const { initAllowedPaths, getDataDirectory } =
|
||||
await import("../src/security");
|
||||
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
|
||||
initAllowedPaths();
|
||||
|
||||
expect(getDataDirectory()).toBeNull();
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
spawnProcess,
|
||||
type SubprocessOptions,
|
||||
} from "../src/subprocess";
|
||||
import * as cp from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { Readable } from "stream";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { spawnJSONLProcess, spawnProcess, type SubprocessOptions } from '../src/subprocess';
|
||||
import * as cp from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
vi.mock("child_process");
|
||||
vi.mock('child_process');
|
||||
|
||||
/**
|
||||
* Helper to collect all items from an async generator
|
||||
*/
|
||||
async function collectAsyncGenerator<T>(
|
||||
generator: AsyncGenerator<T>
|
||||
): Promise<T[]> {
|
||||
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for await (const item of generator) {
|
||||
results.push(item);
|
||||
@@ -23,7 +17,7 @@ async function collectAsyncGenerator<T>(
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("subprocess.ts", () => {
|
||||
describe('subprocess.ts', () => {
|
||||
let consoleSpy: {
|
||||
log: ReturnType<typeof vi.spyOn>;
|
||||
error: ReturnType<typeof vi.spyOn>;
|
||||
@@ -32,8 +26,8 @@ describe("subprocess.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -71,7 +65,7 @@ describe("subprocess.ts", () => {
|
||||
// Emit stderr lines immediately
|
||||
if (config.stderrLines) {
|
||||
for (const line of config.stderrLines) {
|
||||
stderr.emit("data", Buffer.from(line));
|
||||
stderr.emit('data', Buffer.from(line));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +73,7 @@ describe("subprocess.ts", () => {
|
||||
const emitLines = async () => {
|
||||
if (config.stdoutLines) {
|
||||
for (const line of config.stdoutLines) {
|
||||
stdout.push(line + "\n");
|
||||
stdout.push(line + '\n');
|
||||
// Small delay to allow readline to process
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@@ -90,15 +84,13 @@ describe("subprocess.ts", () => {
|
||||
stdout.push(null); // End stdout
|
||||
|
||||
// Small delay before exit
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.delayMs ?? 10)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10));
|
||||
|
||||
// Emit exit or error
|
||||
if (config.error) {
|
||||
mockProcess.emit("error", config.error);
|
||||
mockProcess.emit('error', config.error);
|
||||
} else {
|
||||
mockProcess.emit("exit", config.exitCode ?? 0);
|
||||
mockProcess.emit('exit', config.exitCode ?? 0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,14 +100,14 @@ describe("subprocess.ts", () => {
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
describe("spawnJSONLProcess", () => {
|
||||
describe('spawnJSONLProcess', () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1", "arg2"],
|
||||
cwd: "/test/dir",
|
||||
command: 'test-command',
|
||||
args: ['arg1', 'arg2'],
|
||||
cwd: '/test/dir',
|
||||
};
|
||||
|
||||
it("should yield parsed JSONL objects line by line", async () => {
|
||||
it('should yield parsed JSONL objects line by line', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"start","id":1}',
|
||||
@@ -131,19 +123,14 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "start", id: 1 });
|
||||
expect(results[1]).toEqual({ type: "progress", value: 50 });
|
||||
expect(results[2]).toEqual({ type: "complete", result: "success" });
|
||||
expect(results[0]).toEqual({ type: 'start', id: 1 });
|
||||
expect(results[1]).toEqual({ type: 'progress', value: 50 });
|
||||
expect(results[2]).toEqual({ type: 'complete', result: 'success' });
|
||||
});
|
||||
|
||||
it("should skip empty lines", async () => {
|
||||
it('should skip empty lines', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"first"}',
|
||||
"",
|
||||
" ",
|
||||
'{"type":"second"}',
|
||||
],
|
||||
stdoutLines: ['{"type":"first"}', '', ' ', '{"type":"second"}'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -153,17 +140,13 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "first" });
|
||||
expect(results[1]).toEqual({ type: "second" });
|
||||
expect(results[0]).toEqual({ type: 'first' });
|
||||
expect(results[1]).toEqual({ type: 'second' });
|
||||
});
|
||||
|
||||
it("should yield error for malformed JSON and continue processing", async () => {
|
||||
it('should yield error for malformed JSON and continue processing', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: [
|
||||
'{"type":"valid"}',
|
||||
"{invalid json}",
|
||||
'{"type":"also_valid"}',
|
||||
],
|
||||
stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -173,18 +156,18 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toEqual({ type: "valid" });
|
||||
expect(results[0]).toEqual({ type: 'valid' });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Failed to parse output"),
|
||||
type: 'error',
|
||||
error: expect.stringContaining('Failed to parse output'),
|
||||
});
|
||||
expect(results[2]).toEqual({ type: "also_valid" });
|
||||
expect(results[2]).toEqual({ type: 'also_valid' });
|
||||
});
|
||||
|
||||
it("should collect stderr output", async () => {
|
||||
it('should collect stderr output', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
stderrLines: ["Warning: something happened", "Error: critical issue"],
|
||||
stderrLines: ['Warning: something happened', 'Error: critical issue'],
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
@@ -194,17 +177,17 @@ describe("subprocess.ts", () => {
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Warning: something happened")
|
||||
expect.stringContaining('Warning: something happened')
|
||||
);
|
||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error: critical issue")
|
||||
expect.stringContaining('Error: critical issue')
|
||||
);
|
||||
});
|
||||
|
||||
it("should yield error on non-zero exit code", async () => {
|
||||
it('should yield error on non-zero exit code', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"started"}'],
|
||||
stderrLines: ["Process failed with error"],
|
||||
stderrLines: ['Process failed with error'],
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
@@ -214,14 +197,14 @@ describe("subprocess.ts", () => {
|
||||
const results = await collectAsyncGenerator(generator);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({ type: "started" });
|
||||
expect(results[0]).toEqual({ type: 'started' });
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.stringContaining("Process failed with error"),
|
||||
type: 'error',
|
||||
error: expect.stringContaining('Process failed with error'),
|
||||
});
|
||||
});
|
||||
|
||||
it("should yield error with exit code when stderr is empty", async () => {
|
||||
it('should yield error with exit code when stderr is empty', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"test"}'],
|
||||
exitCode: 127,
|
||||
@@ -234,14 +217,14 @@ describe("subprocess.ts", () => {
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "error",
|
||||
error: "Process exited with code 127",
|
||||
type: 'error',
|
||||
error: 'Process exited with code 127',
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle process spawn errors", async () => {
|
||||
it('should handle process spawn errors', async () => {
|
||||
const mockProcess = createMockProcess({
|
||||
error: new Error("Command not found"),
|
||||
error: new Error('Command not found'),
|
||||
});
|
||||
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
@@ -254,7 +237,7 @@ describe("subprocess.ts", () => {
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("should kill process on AbortController signal", async () => {
|
||||
it('should kill process on AbortController signal', async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = createMockProcess({
|
||||
stdoutLines: ['{"type":"start"}'],
|
||||
@@ -277,53 +260,51 @@ describe("subprocess.ts", () => {
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Abort signal received")
|
||||
);
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Abort signal received'));
|
||||
});
|
||||
|
||||
it("should spawn process with correct arguments", async () => {
|
||||
it('should spawn process with correct arguments', async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-command",
|
||||
args: ["--flag", "value"],
|
||||
cwd: "/work/dir",
|
||||
env: { CUSTOM_VAR: "test" },
|
||||
command: 'my-command',
|
||||
args: ['--flag', 'value'],
|
||||
cwd: '/work/dir',
|
||||
env: { CUSTOM_VAR: 'test' },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
|
||||
cwd: "/work/dir",
|
||||
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
expect(cp.spawn).toHaveBeenCalledWith('my-command', ['--flag', 'value'], {
|
||||
cwd: '/work/dir',
|
||||
env: expect.objectContaining({ CUSTOM_VAR: 'test' }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should merge env with process.env", async () => {
|
||||
it('should merge env with process.env', async () => {
|
||||
const mockProcess = createMockProcess({ exitCode: 0 });
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "test",
|
||||
command: 'test',
|
||||
args: [],
|
||||
cwd: "/test",
|
||||
env: { CUSTOM: "value" },
|
||||
cwd: '/test',
|
||||
env: { CUSTOM: 'value' },
|
||||
};
|
||||
|
||||
const generator = spawnJSONLProcess(options);
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith(
|
||||
"test",
|
||||
'test',
|
||||
[],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CUSTOM: "value",
|
||||
CUSTOM: 'value',
|
||||
// Should also include existing process.env
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
}),
|
||||
@@ -331,9 +312,9 @@ describe("subprocess.ts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex JSON objects", async () => {
|
||||
it('should handle complex JSON objects', async () => {
|
||||
const complexObject = {
|
||||
type: "complex",
|
||||
type: 'complex',
|
||||
nested: { deep: { value: [1, 2, 3] } },
|
||||
array: [{ id: 1 }, { id: 2 }],
|
||||
string: 'with "quotes" and \\backslashes',
|
||||
@@ -354,14 +335,14 @@ describe("subprocess.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnProcess", () => {
|
||||
describe('spawnProcess', () => {
|
||||
const baseOptions: SubprocessOptions = {
|
||||
command: "test-command",
|
||||
args: ["arg1"],
|
||||
cwd: "/test",
|
||||
command: 'test-command',
|
||||
args: ['arg1'],
|
||||
cwd: '/test',
|
||||
};
|
||||
|
||||
it("should collect stdout and stderr", async () => {
|
||||
it('should collect stdout and stderr', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -377,25 +358,25 @@ describe("subprocess.ts", () => {
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
stdout.push("line 1\n");
|
||||
stdout.push("line 2\n");
|
||||
stdout.push('line 1\n');
|
||||
stdout.push('line 2\n');
|
||||
stdout.push(null);
|
||||
|
||||
stderr.push("error 1\n");
|
||||
stderr.push("error 2\n");
|
||||
stderr.push('error 1\n');
|
||||
stderr.push('error 2\n');
|
||||
stderr.push(null);
|
||||
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("line 1\nline 2\n");
|
||||
expect(result.stderr).toBe("error 1\nerror 2\n");
|
||||
expect(result.stdout).toBe('line 1\nline 2\n');
|
||||
expect(result.stderr).toBe('error 1\nerror 2\n');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct exit code", async () => {
|
||||
it('should return correct exit code', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -410,7 +391,7 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 42);
|
||||
mockProcess.emit('exit', 42);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
@@ -418,7 +399,7 @@ describe("subprocess.ts", () => {
|
||||
expect(result.exitCode).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle process errors", async () => {
|
||||
it('should handle process errors', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -431,13 +412,13 @@ describe("subprocess.ts", () => {
|
||||
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
|
||||
|
||||
setTimeout(() => {
|
||||
mockProcess.emit("error", new Error("Spawn failed"));
|
||||
mockProcess.emit('error', new Error('Spawn failed'));
|
||||
}, 10);
|
||||
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
|
||||
await expect(spawnProcess(baseOptions)).rejects.toThrow('Spawn failed');
|
||||
});
|
||||
|
||||
it("should handle AbortController signal", async () => {
|
||||
it('should handle AbortController signal', async () => {
|
||||
const abortController = new AbortController();
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
@@ -452,14 +433,14 @@ describe("subprocess.ts", () => {
|
||||
|
||||
setTimeout(() => abortController.abort(), 20);
|
||||
|
||||
await expect(
|
||||
spawnProcess({ ...baseOptions, abortController })
|
||||
).rejects.toThrow("Process aborted");
|
||||
await expect(spawnProcess({ ...baseOptions, abortController })).rejects.toThrow(
|
||||
'Process aborted'
|
||||
);
|
||||
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
});
|
||||
|
||||
it("should spawn with correct options", async () => {
|
||||
it('should spawn with correct options', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -474,26 +455,26 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const options: SubprocessOptions = {
|
||||
command: "my-cmd",
|
||||
args: ["--verbose"],
|
||||
cwd: "/my/dir",
|
||||
env: { MY_VAR: "value" },
|
||||
command: 'my-cmd',
|
||||
args: ['--verbose'],
|
||||
cwd: '/my/dir',
|
||||
env: { MY_VAR: 'value' },
|
||||
};
|
||||
|
||||
await spawnProcess(options);
|
||||
|
||||
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
|
||||
cwd: "/my/dir",
|
||||
env: expect.objectContaining({ MY_VAR: "value" }),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
expect(cp.spawn).toHaveBeenCalledWith('my-cmd', ['--verbose'], {
|
||||
cwd: '/my/dir',
|
||||
env: expect.objectContaining({ MY_VAR: 'value' }),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty stdout and stderr", async () => {
|
||||
it('should handle empty stdout and stderr', async () => {
|
||||
const mockProcess = new EventEmitter() as cp.ChildProcess & {
|
||||
stdout: Readable;
|
||||
stderr: Readable;
|
||||
@@ -508,13 +489,13 @@ describe("subprocess.ts", () => {
|
||||
setTimeout(() => {
|
||||
mockProcess.stdout.push(null);
|
||||
mockProcess.stderr.push(null);
|
||||
mockProcess.emit("exit", 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
}, 10);
|
||||
|
||||
const result = await spawnProcess(baseOptions);
|
||||
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.d.ts", "src/index.ts"],
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts'],
|
||||
thresholds: {
|
||||
// Excellent coverage: 94.69% stmts, 80.48% branches, 97.14% funcs, 94.64% lines
|
||||
// All files now have comprehensive tests
|
||||
|
||||
Reference in New Issue
Block a user