diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3103f21 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +This file provides guidance to LLM agents when working with this repository. + +## Maintenance Instructions + +This file should be committed to the repository and kept up to date. When making significant changes to the project structure, dependencies, or development workflows, update this file accordingly. Do not include local-specific information (paths, credentials, personal settings, or references to specific LLM agents). + +## Project Overview + +This is an SFTP service for Permanent.org that implements the SFTP protocol to support interaction with the Permanent API. It allows users to upload and download files from their Permanent.org archives using standard SFTP clients like rclone. + +## Tech Stack + +- **Runtime**: Node.js 24.x +- **Language**: TypeScript 5.x +- **SFTP Protocol**: ssh2 +- **Authentication**: FusionAuth +- **API Integration**: @permanentorg/sdk +- **Logging**: Winston +- **Error Monitoring**: Sentry +- **Testing**: Jest with ts-jest +- **Linting**: ESLint (eslint-config-love) with Prettier + +## Project Structure + +``` +src/ +├── classes/ # Core service classes +│ ├── AuthenticationSession.ts # User authentication session management +│ ├── AuthTokenManager.ts # OAuth token refresh handling +│ ├── PermanentFileSystem.ts # File system operations via Permanent API +│ ├── PermanentFileSystemManager.ts # Manages file system instances +│ ├── SftpSessionHandler.ts # SFTP protocol session handling +│ ├── SshConnectionHandler.ts # SSH connection management +│ ├── SshSessionHandler.ts # SSH session handling +│ └── TemporaryFileManager.ts # Temp file management for uploads +├── errors/ # Custom error classes +├── utils/ # Utility functions for file attributes and entries +├── fusionAuth.ts # FusionAuth client configuration +├── index.ts # Application entry point +├── instrument.ts # Sentry instrumentation +├── logger.ts # Winston logger configuration +└── server.ts # SSH server setup +``` + +## Common Commands + +```bash +# Install dependencies +npm install + +# Build the project +npm run build + +# Start the service +npm start + +# Start development server (with hot reload) +npm run dev + +# Run tests +npm test + +# Lint the codebase +npm run lint + +# Format code +npm run format +``` + +## Development Setup + +1. Install dependencies: `npm install` +2. Generate a host key: `ssh-keygen -f ./keys/host.key -t ed25519 -N ""` +3. Copy and configure environment: `cp .env.example .env` +4. Start development server: `npm run dev` + +## Environment Variables + +Key environment variables (see `.env.example` for full list): + +- `SSH_PORT` / `SSH_HOST` - Server binding configuration +- `SSH_HOST_KEY_PATH` - Path to SSH host key +- `PERMANENT_API_BASE_PATH` - Permanent API endpoint +- `STELA_API_BASE_PATH` - Stela API v2 endpoint +- `FUSION_AUTH_*` - FusionAuth authentication configuration +- `SENTRY_DSN` / `SENTRY_ENVIRONMENT` - Error monitoring + +## Workflow Requirements + +After making code changes, always run: + +```bash +npm run format # Auto-fix formatting issues +npm run lint # Check for linting errors +npm test # Run tests +``` + +## Code Style + +- No default exports (ESLint rule `import/no-default-export`) +- Import ordering enforced (builtin, external, internal, parent, sibling, index, object, type) +- Prettier for formatting +- TypeScript strict mode enabled + +## Testing + +Tests use Jest with ts-jest preset. Test files should be named `*.test.ts` and placed alongside the source files they test. + +```bash +npm test +``` diff --git a/README.md b/README.md index 36d6fe0..e3b83ed 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,10 @@ grant_type: authorization_code redirect_uri: http://localhost:9000/auth/callback ``` +## Working with an LLM Agent + +This project includes an `AGENTS.md` which is intended to be a general file that can be used to help improve the context understood by any LLM agent. + ## License This code is free software licensed as [AGPLv3](LICENSE), or at your option, any final, later version published by the Free Software Foundation. diff --git a/eslint.config.mjs b/eslint.config.mjs index 077f94c..f77284c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -80,13 +80,13 @@ export default defineConfig([ }, { files: ["**/*.test.ts"], - - plugins: { - jest, - }, - + ...jest.configs["flat/recommended"], + }, + { + files: ["**/*.test.ts"], rules: { "max-lines": "off", + "max-nested-callbacks": "off", "@typescript-eslint/no-magic-numbers": "off", }, }, diff --git a/jest.config.js b/jest.config.js index 26a3ac0..01cb011 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ module.exports = { - testPathIgnorePatterns: ["/lib/"], + roots: ["/src"], silent: true, passWithNoTests: true, preset: "ts-jest", diff --git a/package-lock.json b/package-lock.json index e060c9f..b320905 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@tsconfig/node24": "^24.0.3", + "@types/jest": "^30.0.0", "@types/node-fetch": "^2.6.4", "@types/ssh2": "^1.15.5", "@types/tmp": "^0.2.6", @@ -2212,6 +2213,17 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 4cfb475..f961945 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "private": true, "devDependencies": { "@tsconfig/node24": "^24.0.3", + "@types/jest": "^30.0.0", "@types/node-fetch": "^2.6.4", "@types/ssh2": "^1.15.5", "@types/tmp": "^0.2.6", diff --git a/src/utils/getArchiveSlugFromPath.test.ts b/src/utils/getArchiveSlugFromPath.test.ts new file mode 100644 index 0000000..582d31f --- /dev/null +++ b/src/utils/getArchiveSlugFromPath.test.ts @@ -0,0 +1,71 @@ +import { getArchiveSlugFromPath } from "./getArchiveSlugFromPath"; + +describe("getArchiveSlugFromPath", () => { + describe("valid paths", () => { + it("extracts slug from basic archive path", () => { + const path = "/archives/My Archive (abc-123)"; + expect(getArchiveSlugFromPath(path)).toBe("abc-123"); + }); + + it("extracts slug from path with subfolder", () => { + const path = "/archives/My Archive (abc-123)/subfolder"; + expect(getArchiveSlugFromPath(path)).toBe("abc-123"); + }); + + it("extracts slug from path with deep nesting", () => { + const path = "/archives/My Archive (abc-123)/a/b/c"; + expect(getArchiveSlugFromPath(path)).toBe("abc-123"); + }); + + it("handles archive names with parentheses", () => { + const path = "/archives/My (Cool) Archive (xyz-789)"; + expect(getArchiveSlugFromPath(path)).toBe("xyz-789"); + }); + + it("handles slugs with only letters", () => { + const path = "/archives/Test Archive (abcdef)"; + expect(getArchiveSlugFromPath(path)).toBe("abcdef"); + }); + + it("handles slugs with only numbers", () => { + const path = "/archives/Test Archive (123456)"; + expect(getArchiveSlugFromPath(path)).toBe("123456"); + }); + }); + + describe("invalid paths", () => { + it("throws error for path without archive section", () => { + const path = "/some/other/path"; + expect(() => getArchiveSlugFromPath(path)).toThrow( + "The specified path did not contain an archive slug", + ); + }); + + it("throws error for path with missing slug parentheses", () => { + const path = "/archives/My Archive"; + expect(() => getArchiveSlugFromPath(path)).toThrow( + "The specified path did not contain an archive slug", + ); + }); + + it("throws error for empty string", () => { + expect(() => getArchiveSlugFromPath("")).toThrow( + "The specified path did not contain an archive slug", + ); + }); + + it("throws error for root archives path", () => { + const path = "/archives"; + expect(() => getArchiveSlugFromPath(path)).toThrow( + "The specified path did not contain an archive slug", + ); + }); + + it("throws error for path without space before parentheses", () => { + const path = "/archives/MyArchive(abc-123)"; + expect(() => getArchiveSlugFromPath(path)).toThrow( + "The specified path did not contain an archive slug", + ); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9ef25b6..d17c172 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@tsconfig/node24/tsconfig.json", "compilerOptions": { - "outDir": "build" + "outDir": "build", + "isolatedModules": true }, "include": ["src"] }