You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

311 lines
11 KiB

/**
* Write operations module
* Handles file writing, deletion, and commit operations
*/
import { join, dirname, resolve } from 'path';
import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../../logger.js';
import { sanitizeError } from '../../../utils/security.js';
import { isValidBranchName } from '../../../utils/security.js';
import { validateFilePath, validateRepoName, validateNpub } from './path-validator.js';
import { getOrCreateWorktree, removeWorktree } from './worktree-manager.js';
import { createGitCommitSignature } from '../commit-signer.js';
import type { NostrEvent } from '../../../types/nostr.js';
export interface WriteFileOptions {
npub: string;
repoName: string;
filePath: string;
content: string;
commitMessage: string;
authorName: string;
authorEmail: string;
branch?: string;
repoPath: string;
worktreePath: string;
signingOptions?: {
commitSignatureEvent?: NostrEvent;
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
};
saveCommitSignature?: (worktreePath: string, event: NostrEvent) => Promise<void>;
isRepoPrivate?: (npub: string, repoName: string) => Promise<boolean>;
}
/**
* Write file and commit changes
*/
export async function writeFile(options: WriteFileOptions): Promise<void> {
const {
npub,
repoName,
filePath,
content,
commitMessage,
authorName,
authorEmail,
branch = 'main',
repoPath,
worktreePath,
signingOptions,
saveCommitSignature,
isRepoPrivate
} = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
// Validate content size (500 MB max)
const maxFileSize = 500 * 1024 * 1024;
if (Buffer.byteLength(content, 'utf-8') > maxFileSize) {
throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`);
}
// Validate commit message
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
if (commitMessage.length > 1000) {
throw new Error('Commit message is too long (max 1000 characters)');
}
// Validate author info
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
try {
logger.operation('Writing file', { npub, repoName, filePath, branch });
const workGit: SimpleGit = simpleGit(worktreePath);
// Write the file
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(worktreePath, validatedPath);
const fileDir = dirname(fullFilePath);
// Security: ensure resolved path is within workDir
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory');
}
// Ensure directory exists
const { mkdir } = await import('fs/promises');
await mkdir(fileDir, { recursive: true });
const { writeFile: writeFileFs } = await import('fs/promises');
await writeFileFs(fullFilePath, content, 'utf-8');
// Stage the file
await workGit.add(validatedPath);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
let signatureEvent: NostrEvent | null = null;
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const result = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = result.signedMessage;
signatureEvent = signingOptions.commitSignatureEvent || result.signatureEvent;
} catch (err) {
const sanitizedErr = sanitizeError(err);
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
}
}
// Commit
const commitResult = await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
}) as string | { commit: string };
// Get commit hash
let commitHash: string;
if (typeof commitResult === 'string') {
commitHash = commitResult.trim();
} else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) {
commitHash = String(commitResult.commit);
} else {
commitHash = await workGit.revparse(['HEAD']);
}
// Save commit signature event if signing was used
if (signatureEvent && saveCommitSignature) {
try {
await saveCommitSignature(worktreePath, signatureEvent);
// Publish to relays if repo is public
if (isRepoPrivate && !(await isRepoPrivate(npub, repoName))) {
try {
const { NostrClient } = await import('../../nostr/nostr-client.js');
const { DEFAULT_NOSTR_RELAYS } = await import('../../../config.js');
const { getUserRelays } = await import('../../nostr/user-relays.js');
const { combineRelays } = await import('../../../config.js');
const { nip19 } = await import('nostr-tools');
const { requireNpubHex } = await import('../../../utils/npub-utils.js');
const userPubkeyHex = requireNpubHex(npub);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient);
const userRelays = outbox.length > 0
? combineRelays(outbox, DEFAULT_NOSTR_RELAYS)
: inbox.length > 0
? combineRelays(inbox, DEFAULT_NOSTR_RELAYS)
: DEFAULT_NOSTR_RELAYS;
const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays);
if (publishResult.success.length > 0) {
logger.debug({
eventId: signatureEvent.id,
commitHash,
relays: publishResult.success
}, 'Published commit signature event to relays');
}
} catch (publishErr) {
logger.debug({ error: publishErr }, 'Failed to publish commit signature event to relays');
}
}
} catch (err) {
logger.debug({ error: err }, 'Failed to save commit signature event');
}
}
logger.operation('File written', { npub, repoName, filePath, commitHash });
} catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error writing file');
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Delete a file
*/
export async function deleteFile(options: Omit<WriteFileOptions, 'content'>): Promise<void> {
const {
npub,
repoName,
filePath,
commitMessage,
authorName,
authorEmail,
branch = 'main',
repoPath,
worktreePath,
signingOptions,
saveCommitSignature
} = options;
// Validate inputs
const npubValidation = validateNpub(npub);
if (!npubValidation.valid) {
throw new Error(`Invalid npub: ${npubValidation.error}`);
}
const repoValidation = validateRepoName(repoName);
if (!repoValidation.valid) {
throw new Error(`Invalid repository name: ${repoValidation.error}`);
}
const pathValidation = validateFilePath(filePath);
if (!pathValidation.valid) {
throw new Error(`Invalid file path: ${pathValidation.error}`);
}
if (!isValidBranchName(branch)) {
throw new Error(`Invalid branch name: ${branch}`);
}
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required');
}
if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) {
throw new Error('Author name is required');
}
if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) {
throw new Error('Valid author email is required');
}
try {
logger.operation('Deleting file', { npub, repoName, filePath, branch });
const workGit: SimpleGit = simpleGit(worktreePath);
// Remove the file
const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(worktreePath, validatedPath);
// Security: ensure resolved path is within workDir
const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
const resolvedWorkDir = resolve(worktreePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory');
}
const { accessSync, constants, unlink } = await import('fs');
try {
accessSync(fullFilePath, constants.F_OK);
await unlink(fullFilePath);
} catch {
// File doesn't exist, that's fine - git rm will handle it
}
// Stage the deletion
await workGit.rm([validatedPath]);
// Sign commit if signing options are provided
let finalCommitMessage = commitMessage;
if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) {
try {
const { signedMessage } = await createGitCommitSignature(
commitMessage,
authorName,
authorEmail,
signingOptions
);
finalCommitMessage = signedMessage;
} catch (err) {
const sanitizedErr = sanitizeError(err);
logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
}
}
// Commit
await workGit.commit(finalCommitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
logger.operation('File deleted', { npub, repoName, filePath });
} catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error deleting file');
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
}
}