diff --git a/apps/web/scripts/check-structure.js b/apps/web/scripts/check-structure.js
new file mode 100644
index 0000000..ea15fa8
--- /dev/null
+++ b/apps/web/scripts/check-structure.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+/* eslint-disable */
+/**
+ * Verifies repository structure conventions.
+ * Run with: node scripts/check-structure.js
+ */
+
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SRC_DIR = path.join(__dirname, "..", "src");
+
+let errors = 0;
+let warnings = 0;
+
+function checkFileSize(filePath, maxLines = 300) {
+ const content = fs.readFileSync(filePath, "utf-8");
+ const lines = content.split("\n").length;
+ if (lines > maxLines) {
+ console.error(`❌ OVERSIZED (${lines} lines): ${path.relative(SRC_DIR, filePath)}`);
+ errors++;
+ }
+}
+
+function walk(dir, callback) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
+ walk(fullPath, callback);
+ } else {
+ callback(fullPath);
+ }
+ }
+}
+
+console.log("Checking file sizes...\n");
+walk(SRC_DIR, (filePath) => {
+ const ext = path.extname(filePath);
+ if ([".ts", ".tsx", ".py", ".css"].includes(ext)) {
+ checkFileSize(filePath);
+ }
+});
+
+console.log("\n---");
+if (errors === 0 && warnings === 0) {
+ console.log("✅ All checks passed!");
+ process.exit(0);
+} else {
+ console.log(`❌ ${errors} error(s), ${warnings} warning(s)`);
+ process.exit(1);
+}
diff --git a/apps/web/src/components/features/git/FileBrowser.test.tsx b/apps/web/src/components/features/git/FileBrowser.test.tsx
index b6e47a4..c736343 100644
--- a/apps/web/src/components/features/git/FileBrowser.test.tsx
+++ b/apps/web/src/components/features/git/FileBrowser.test.tsx
@@ -5,58 +5,58 @@ import { FileBrowser } from "./FileBrowser";
// Mock apiClient
vi.mock("../../../api/client", () => ({
- apiClient: {
- get: vi.fn(),
- },
+ apiClient: {
+ get: vi.fn(),
+ },
}));
import { apiClient } from "../../../api/client";
describe("FileBrowser", () => {
- it("renders loading state initially", () => {
- render(
-
-
-
- );
- expect(screen.getByText(/loading files/i)).toBeInTheDocument();
- });
+ it("renders loading state initially", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/loading files/i)).toBeInTheDocument();
+ });
- it("renders file entries after loading", async () => {
- const mockedGet = apiClient.get as ReturnType;
- mockedGet.mockResolvedValueOnce({
- data: {
- entries: [
- { name: "src", type: "directory", path: "src" },
- { name: "README.md", type: "file", path: "README.md" },
- ],
- },
- });
+ it("renders file entries after loading", async () => {
+ const mockedGet = apiClient.get as ReturnType;
+ mockedGet.mockResolvedValueOnce({
+ data: {
+ entries: [
+ { name: "src", type: "directory", path: "src" },
+ { name: "README.md", type: "file", path: "README.md" },
+ ],
+ },
+ });
- render(
-
-
-
- );
+ render(
+
+
+ ,
+ );
- await waitFor(() => {
- expect(screen.getByText("src")).toBeInTheDocument();
- });
- expect(screen.getByText("README.md")).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText("src")).toBeInTheDocument();
+ });
+ expect(screen.getByText("README.md")).toBeInTheDocument();
+ });
- it("renders error state on failure", async () => {
- const mockedGet = apiClient.get as ReturnType;
- mockedGet.mockRejectedValueOnce(new Error("Network error"));
+ it("renders error state on failure", async () => {
+ const mockedGet = apiClient.get as ReturnType;
+ mockedGet.mockRejectedValueOnce(new Error("Network error"));
- render(
-
-
-
- );
+ render(
+
+
+ ,
+ );
- await waitFor(() => {
- expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
- });
- });
+ await waitFor(() => {
+ expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/src/components/features/session/CreateSessionForm.test.tsx b/apps/web/src/components/features/session/CreateSessionForm.test.tsx
index 6f0747a..4da0710 100644
--- a/apps/web/src/components/features/session/CreateSessionForm.test.tsx
+++ b/apps/web/src/components/features/session/CreateSessionForm.test.tsx
@@ -5,91 +5,143 @@ import type { Project } from "@/types/project";
import type { ToolType } from "@/types/tool-type";
vi.mock("@/api/git_repositories", () => ({
- listRepositories: vi.fn(),
+ listRepositories: vi.fn(),
}));
vi.mock("@/api/sessions", () => ({
- createInstance: vi.fn(),
- startInstance: vi.fn(),
+ createInstance: vi.fn(),
+ startInstance: vi.fn(),
}));
vi.mock("@/api/settings", () => ({
- updateUserConfig: vi.fn(),
+ updateUserConfig: vi.fn(),
}));
import { listRepositories } from "@/api/git_repositories";
import { createInstance } from "@/api/sessions";
const mockProjects = [
- { id: "p1", name: "Project One", description: null, owner_id: "u1", default_ssh_key_id: null },
- { id: "p2", name: "Project Two", description: null, owner_id: "u1", default_ssh_key_id: null },
+ {
+ id: "p1",
+ name: "Project One",
+ description: null,
+ owner_id: "u1",
+ default_ssh_key_id: null,
+ },
+ {
+ id: "p2",
+ name: "Project Two",
+ description: null,
+ owner_id: "u1",
+ default_ssh_key_id: null,
+ },
] as Project[];
const mockToolTypes = [
- { id: "t1", name: "vscode", display_name: "VS Code", description: null, category: "editor", interfaces: ["web"], default_port: 8443, definition_type: "compose", compose_template: "", dockerfile_template: null, readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" },
- { id: "t2", name: "terminal", display_name: "Terminal", description: null, category: "shell", interfaces: ["terminal"], default_port: 22, definition_type: "dockerfile", compose_template: null, dockerfile_template: "", readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" },
+ {
+ id: "t1",
+ name: "vscode",
+ display_name: "VS Code",
+ description: null,
+ category: "editor",
+ interfaces: ["web"],
+ default_port: 8443,
+ definition_type: "compose",
+ compose_template: "",
+ dockerfile_template: null,
+ readiness_probe: null,
+ required_variables: [],
+ is_builtin: true,
+ build_context: null,
+ created_by_id: "u1",
+ created_at: "",
+ updated_at: "",
+ },
+ {
+ id: "t2",
+ name: "terminal",
+ display_name: "Terminal",
+ description: null,
+ category: "shell",
+ interfaces: ["terminal"],
+ default_port: 22,
+ definition_type: "dockerfile",
+ compose_template: null,
+ dockerfile_template: "",
+ readiness_probe: null,
+ required_variables: [],
+ is_builtin: true,
+ build_context: null,
+ created_by_id: "u1",
+ created_at: "",
+ updated_at: "",
+ },
] as ToolType[];
describe("CreateSessionForm", () => {
- it("renders form with create button", () => {
- render(
-
- );
+ it("renders form with create button", () => {
+ render(
+ ,
+ );
- expect(screen.getByText("Create New Session")).toBeInTheDocument();
- expect(screen.getByText("Create Session")).toBeInTheDocument();
- });
+ expect(screen.getByText("Create New Session")).toBeInTheDocument();
+ expect(screen.getByText("Create Session")).toBeInTheDocument();
+ });
- it("shows validation error when fields are missing", async () => {
- render(
-
- );
+ it("shows validation error when fields are missing", async () => {
+ render(
+ ,
+ );
- const { container } = render(
-
- );
+ const { container } = render(
+ ,
+ );
- const submitBtn = container.querySelector('button[type="submit"]') as HTMLButtonElement;
- fireEvent.click(submitBtn);
+ const submitBtn = container.querySelector(
+ 'button[type="submit"]',
+ ) as HTMLButtonElement;
+ fireEvent.click(submitBtn);
- await waitFor(() => {
- expect(
- screen.getByText(/project, repository, and tool type are required/i)
- ).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(
+ screen.getByText(/project, repository, and tool type are required/i),
+ ).toBeInTheDocument();
+ });
- expect(createInstance).not.toHaveBeenCalled();
- });
+ expect(createInstance).not.toHaveBeenCalled();
+ });
- it("loads repositories when project selected", async () => {
- const mockedList = listRepositories as ReturnType;
- mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
+ it("loads repositories when project selected", async () => {
+ const mockedList = listRepositories as ReturnType;
+ mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
- const { container } = render(
-
- );
+ const { container } = render(
+ ,
+ );
- const projectSelect = container.querySelector("select") as HTMLSelectElement;
- fireEvent.change(projectSelect, { target: { value: "p1" } });
+ const projectSelect = container.querySelector(
+ "select",
+ ) as HTMLSelectElement;
+ fireEvent.change(projectSelect, { target: { value: "p1" } });
- await waitFor(() => {
- expect(listRepositories).toHaveBeenCalledWith("p1");
- });
- });
+ await waitFor(() => {
+ expect(listRepositories).toHaveBeenCalledWith("p1");
+ });
+ });
});
diff --git a/apps/web/src/components/features/session/SessionCard.test.tsx b/apps/web/src/components/features/session/SessionCard.test.tsx
index 0951468..e4d3282 100644
--- a/apps/web/src/components/features/session/SessionCard.test.tsx
+++ b/apps/web/src/components/features/session/SessionCard.test.tsx
@@ -4,69 +4,73 @@ import { SessionCard } from "./SessionCard";
import type { Session } from "@/types/session";
const mockSession: Session = {
- id: "s1",
- display_name: "Dev Environment",
- tool_type_name: "VS Code",
- tool_icon: "code",
- tool_type_interfaces: ["web", "terminal"],
- repository_name: "my-repo",
- repository_id: "r1",
- project_name: "My Project",
- project_id: "p1",
- status: "running",
- url: "https://example.com",
+ id: "s1",
+ display_name: "Dev Environment",
+ tool_type_name: "VS Code",
+ tool_icon: "code",
+ tool_type_interfaces: ["web", "terminal"],
+ repository_name: "my-repo",
+ repository_id: "r1",
+ project_name: "My Project",
+ project_id: "p1",
+ status: "running",
+ url: "https://example.com",
};
describe("SessionCard", () => {
- it("renders active variant with display name and status", () => {
- render(
-
- );
+ it("renders active variant with display name and status", () => {
+ render(
+ ,
+ );
- expect(screen.getByText("running")).toBeInTheDocument();
- expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1);
- });
+ expect(screen.getByText("running")).toBeInTheDocument();
+ expect(
+ screen.getAllByText("Dev Environment").length,
+ ).toBeGreaterThanOrEqual(1);
+ });
- it("renders recent variant with display name", () => {
- render(
-
- );
+ it("renders recent variant with display name", () => {
+ render(
+ ,
+ );
- expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1);
- });
+ expect(
+ screen.getAllByText("Dev Environment").length,
+ ).toBeGreaterThanOrEqual(1);
+ });
- it("shows unnamed fallback when display_name is empty", () => {
- render(
-
- );
+ it("shows unnamed fallback when display_name is empty", () => {
+ render(
+ ,
+ );
- expect(screen.getByText("VS Code")).toBeInTheDocument();
- });
+ expect(screen.getByText("VS Code")).toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx b/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx
index 943f755..ca5ab85 100644
--- a/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx
+++ b/apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx
@@ -3,56 +3,58 @@ import { describe, it, expect, vi } from "vitest";
import { ToolTypesTab } from "./ToolTypesTab";
vi.mock("../../../api/tool_types", () => ({
- listToolTypes: vi.fn(() => Promise.resolve([])),
- createToolType: vi.fn(),
- deleteToolType: vi.fn(),
- updateToolType: vi.fn(),
+ listToolTypes: vi.fn(() => Promise.resolve([])),
+ createToolType: vi.fn(),
+ deleteToolType: vi.fn(),
+ updateToolType: vi.fn(),
}));
import { listToolTypes } from "../../../api/tool_types";
describe("ToolTypesTab", () => {
- it("renders loading state initially", () => {
- render();
- expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
- });
+ it("renders loading state initially", () => {
+ render();
+ expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
+ });
- it("renders tool types heading after loading", async () => {
- const mockedList = listToolTypes as ReturnType;
- mockedList.mockResolvedValueOnce([
- {
- id: "1",
- name: "code-server",
- display_name: "VS Code Server",
- description: "Web-based IDE",
- category: "editor",
- interfaces: ["web"],
- default_port: 8443,
- definition_type: "compose",
- compose_template: "version: '3.8'\nservices:\n app:",
- dockerfile_template: null,
- readiness_probe: null,
- required_variables: [],
- is_builtin: true,
- },
- ]);
+ it("renders tool types heading after loading", async () => {
+ const mockedList = listToolTypes as ReturnType;
+ mockedList.mockResolvedValueOnce([
+ {
+ id: "1",
+ name: "code-server",
+ display_name: "VS Code Server",
+ description: "Web-based IDE",
+ category: "editor",
+ interfaces: ["web"],
+ default_port: 8443,
+ definition_type: "compose",
+ compose_template: "version: '3.8'\nservices:\n app:",
+ dockerfile_template: null,
+ readiness_probe: null,
+ required_variables: [],
+ is_builtin: true,
+ },
+ ]);
- render();
+ render();
- await waitFor(() => {
- expect(screen.getByText("Tool Types")).toBeInTheDocument();
- });
- expect(screen.getByText("VS Code Server")).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByText("Tool Types")).toBeInTheDocument();
+ });
+ expect(screen.getByText("VS Code Server")).toBeInTheDocument();
+ });
- it("renders error state on failure", async () => {
- const mockedList = listToolTypes as ReturnType;
- mockedList.mockRejectedValueOnce(new Error("Network error"));
+ it("renders error state on failure", async () => {
+ const mockedList = listToolTypes as ReturnType;
+ mockedList.mockRejectedValueOnce(new Error("Network error"));
- render();
+ render();
- await waitFor(() => {
- expect(screen.getByText(/failed to load tool types/i)).toBeInTheDocument();
- });
- });
+ await waitFor(() => {
+ expect(
+ screen.getByText(/failed to load tool types/i),
+ ).toBeInTheDocument();
+ });
+ });
});
diff --git a/apps/web/src/components/ui/ErrorState.test.tsx b/apps/web/src/components/ui/ErrorState.test.tsx
index 9f4fdea..bf6ac23 100644
--- a/apps/web/src/components/ui/ErrorState.test.tsx
+++ b/apps/web/src/components/ui/ErrorState.test.tsx
@@ -3,20 +3,20 @@ import { describe, it, expect, vi } from "vitest";
import { ErrorState } from "./ErrorState";
describe("ErrorState", () => {
- it("renders message", () => {
- render();
- expect(screen.getByText("Failed to load")).toBeInTheDocument();
- });
+ it("renders message", () => {
+ render();
+ expect(screen.getByText("Failed to load")).toBeInTheDocument();
+ });
- it("does not render retry button when onRetry is absent", () => {
- render();
- expect(screen.queryByText("Retry")).not.toBeInTheDocument();
- });
+ it("does not render retry button when onRetry is absent", () => {
+ render();
+ expect(screen.queryByText("Retry")).not.toBeInTheDocument();
+ });
- it("calls onRetry when button clicked", () => {
- const onRetry = vi.fn();
- render();
- fireEvent.click(screen.getByText("Retry"));
- expect(onRetry).toHaveBeenCalledTimes(1);
- });
+ it("calls onRetry when button clicked", () => {
+ const onRetry = vi.fn();
+ render();
+ fireEvent.click(screen.getByText("Retry"));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/apps/web/src/components/ui/LoadingState.test.tsx b/apps/web/src/components/ui/LoadingState.test.tsx
index add8cb1..d9cde84 100644
--- a/apps/web/src/components/ui/LoadingState.test.tsx
+++ b/apps/web/src/components/ui/LoadingState.test.tsx
@@ -3,13 +3,13 @@ import { describe, it, expect } from "vitest";
import { LoadingState } from "./LoadingState";
describe("LoadingState", () => {
- it("renders default message", () => {
- render();
- expect(screen.getByText("Loading...")).toBeInTheDocument();
- });
+ it("renders default message", () => {
+ render();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
- it("renders custom message", () => {
- render();
- expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
- });
+ it("renders custom message", () => {
+ render();
+ expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
+ });
});
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index fa9b6ac..25256ba 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,17 +3,17 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
- plugins: [react()],
- resolve: {
- alias: {
- "@": path.resolve(__dirname, "./src"),
- },
- },
- server: {
- port: 5173
- },
- test: {
- environment: "jsdom",
- setupFiles: "./src/test/setup.ts"
- }
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 5173,
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: "./src/test/setup.ts",
+ },
});
diff --git a/docs/development/naming.md b/docs/development/naming.md
new file mode 100644
index 0000000..5f8839e
--- /dev/null
+++ b/docs/development/naming.md
@@ -0,0 +1,172 @@
+# Naming Conventions
+
+This document defines the file and identifier naming conventions for the Headquarter codebase.
+
+## Frontend (`apps/web/src/`)
+
+### React Components
+
+**File naming:** PascalCase, matching the exported component name exactly.
+
+```
+✅ Good:
+ components/features/git/FileBrowser.tsx
+ components/features/dashboard/DashboardSummary.tsx
+ pages/DashboardPage.tsx
+
+❌ Bad:
+ components/file-browser.tsx
+ pages/dashboard.tsx
+```
+
+**Component naming:** PascalCase. Page components end with `Page`.
+
+```typescript
+// Component
+export const FileBrowser = () => { ... }
+
+// Page
+export const DashboardPage = () => { ... }
+```
+
+### Hooks
+
+**File naming:** camelCase with `use` prefix.
+
+```
+✅ Good:
+ hooks/use-theme.ts
+ hooks/use-dashboard-actions.ts
+```
+
+### API Modules
+
+**File naming:** kebab-case.
+
+```
+✅ Good:
+ api/tool-types.ts
+ api/git-repositories.ts
+ api/config-folders.ts
+```
+
+### Type Modules
+
+**File naming:** kebab-case.
+
+```
+✅ Good:
+ types/tool-type.ts
+ types/git-repository.ts
+```
+
+### Utilities
+
+**File naming:** kebab-case.
+
+```
+✅ Good:
+ utils/terminal-protocol.ts
+ utils/language.ts
+```
+
+### CSS Modules
+
+**File naming:** kebab-case, matching the component file name with `.module.css` suffix.
+
+```
+✅ Good:
+ FileBrowser.tsx + FileBrowser.module.css
+```
+
+## Backend (`apps/api/src/`)
+
+### Routers
+
+**File naming:** snake_case.
+
+```
+✅ Good:
+ api/tool_instances.py
+ api/git_repositories.py
+```
+
+### Services
+
+**File naming:** snake_case.
+
+```
+✅ Good:
+ services/docker/compose.py
+ services/profile_resolver.py
+```
+
+### Models
+
+**File naming:** snake_case. Class names use PascalCase.
+
+```
+✅ Good:
+ models/tool_instance.py
+ class ToolInstance(Base):
+```
+
+### Schemas
+
+**File naming:** snake_case.
+
+```
+✅ Good:
+ schemas/tool_instance.py
+```
+
+## Tests
+
+### Frontend Tests
+
+**File naming:** Same as source file with `.test.tsx` suffix.
+
+```
+✅ Good:
+ FileBrowser.tsx + FileBrowser.test.tsx
+```
+
+### Backend Tests
+
+**File naming:** `test_` prefix + snake_case.
+
+```
+✅ Good:
+ test_tool_instances.py
+```
+
+## Directory Structure Summary
+
+```
+apps/web/src/
+├── api/ # kebab-case files
+├── components/
+│ ├── ui/ # PascalCase files
+│ ├── layout/ # PascalCase files
+│ └── features/ # PascalCase files, grouped by domain
+│ ├── git/
+│ ├── project/
+│ ├── session/
+│ └── ...
+├── hooks/ # camelCase files
+├── pages/ # PascalCase files ending with Page
+├── styles/ # kebab-case CSS files
+├── types/ # kebab-case files
+└── utils/ # kebab-case files
+
+apps/api/src/
+├── api/ # snake_case files
+├── models/ # snake_case files
+├── schemas/ # snake_case files
+├── services/ # snake_case files
+└── auth/ # snake_case files
+```
+
+## Migration Notes
+
+Some legacy files may not yet follow these conventions. When touching a file for other work, rename it to match the convention in the same PR.