diff --git a/README.md b/README.md index 6ad01f6..bbc178c 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ Codex Git is a planned local Git surface for Codex Desktop. This repository curr ## Commands ```sh -npm install +npm ci npm run dev ``` -The placeholder UI is available at the URL printed by Vite. The server scaffold can be started separately: +The launcher attaches the placeholder Git Surface through the standalone Host Adapter and prints its URL plus the loopback health URL. The surface uses port `5173` by default; `CODEX_GIT_SURFACE_PORT` and `CODEX_GIT_PORT` override the listener ports. + +The server scaffold can also be started separately: ```sh npm run dev:server @@ -43,14 +45,14 @@ npm run build ## Workspace layout ```text -apps/launcher Runtime composition placeholder +apps/launcher Standalone runtime composition root apps/server Loopback server scaffold apps/ui Standalone React placeholder surface packages/protocol Shared protocol types packages/repository-engine Repository session boundary only packages/host-adapter Host Adapter boundary packages/host-adapter/* Codex CDP and standalone adapter placeholders -tests/ Reserved contract, integration, and end-to-end layers +tests/ Contract, integration, end-to-end, and fixture layers ``` # codex-git diff --git a/apps/launcher/package.json b/apps/launcher/package.json index 17bb540..ffecb82 100644 --- a/apps/launcher/package.json +++ b/apps/launcher/package.json @@ -2,5 +2,14 @@ "name": "@codex-git/launcher", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "exports": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@codex-git/host-adapter-standalone": "*", + "@codex-git/server": "*" + }, + "devDependencies": { + "vite": "8.2.2" + } } diff --git a/apps/launcher/src/index.ts b/apps/launcher/src/index.ts index 8019270..f3f8a36 100644 --- a/apps/launcher/src/index.ts +++ b/apps/launcher/src/index.ts @@ -1,4 +1 @@ -export const launcherState = { - product: 'codex-git', - status: 'not-implemented', -} as const; +export * from './standalone-runtime.js'; diff --git a/apps/launcher/src/main.ts b/apps/launcher/src/main.ts new file mode 100644 index 0000000..a7476db --- /dev/null +++ b/apps/launcher/src/main.ts @@ -0,0 +1,42 @@ +import { startStandaloneRuntime } from './index.js'; + +const runtime = await startStandaloneRuntime({ + healthPort: readPort('CODEX_GIT_PORT', 0), + surfacePort: readPort('CODEX_GIT_SURFACE_PORT', 5173), +}); + +console.log(`Codex Git placeholder surface: ${runtime.surfaceUrl.href}`); +console.log(`Codex Git health endpoint: ${runtime.healthUrl.href}`); + +let stopping = false; + +function stop(): void { + if (stopping) { + return; + } + + stopping = true; + void runtime.close().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} + +process.once('SIGINT', stop); +process.once('SIGTERM', stop); + +function readPort(name: string, fallback: number): number { + const value = process.env[name]; + + if (value === undefined) { + return fallback; + } + + const port = Number(value); + + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new Error(`${name} must be an integer between 0 and 65535`); + } + + return port; +} diff --git a/apps/launcher/src/standalone-runtime.ts b/apps/launcher/src/standalone-runtime.ts new file mode 100644 index 0000000..2fcf409 --- /dev/null +++ b/apps/launcher/src/standalone-runtime.ts @@ -0,0 +1,123 @@ +import type { Server } from 'node:http'; +import { fileURLToPath } from 'node:url'; + +import { createAppServer } from '@codex-git/server'; +import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; +import { createServer as createViteServer, type ViteDevServer } from 'vite'; + +const loopbackHost = '127.0.0.1'; +const uiConfigPath = fileURLToPath( + new URL('../../ui/vite.config.ts', import.meta.url), +); + +export interface StandaloneRuntimeOptions { + readonly healthPort?: number; + readonly surfacePort?: number; +} + +export interface StandaloneRuntime { + readonly healthUrl: URL; + readonly surfaceUrl: URL; + close(): Promise; +} + +export async function startStandaloneRuntime( + options: StandaloneRuntimeOptions = {}, +): Promise { + const healthServer = createAppServer(); + let surfaceServer: ViteDevServer | undefined; + let hostConnection: Awaited< + ReturnType + > | null = null; + + async function closeResources(): Promise { + await Promise.all([ + hostConnection?.dispose(), + surfaceServer?.close(), + closeServer(healthServer), + ]); + } + + try { + await listen(healthServer, options.healthPort ?? 0); + const healthUrl = serverUrl(healthServer, '/health'); + + surfaceServer = await createViteServer({ + configFile: uiConfigPath, + server: { + host: loopbackHost, + port: options.surfacePort ?? 5173, + strictPort: true, + }, + }); + await surfaceServer.listen(); + + const surfaceUrl = serverUrl(surfaceServer.httpServer, '/'); + hostConnection = await new StandaloneHostAdapter().attach({ + title: 'Codex Git', + url: surfaceUrl, + }); + + let closed = false; + + return { + healthUrl, + surfaceUrl, + async close() { + if (closed) { + return; + } + + closed = true; + await closeResources(); + }, + }; + } catch (error) { + await closeResources(); + throw error; + } +} + +function listen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.off('listening', handleListening); + reject(error); + }; + const handleListening = () => { + server.off('error', handleError); + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(port, loopbackHost); + }); +} + +function serverUrl( + server: Pick | null, + pathname: string, +): URL { + const address = server?.address(); + + if ( + address === undefined || + address === null || + typeof address === 'string' + ) { + throw new Error('Standalone runtime server has no TCP address'); + } + + return new URL(pathname, `http://${loopbackHost}:${address.port}`); +} + +function closeServer(server: Server): Promise { + if (!server.listening) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} diff --git a/apps/server/package.json b/apps/server/package.json index a0f6225..542b546 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,5 +2,10 @@ "name": "@codex-git/server", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "exports": "./src/server.ts", + "types": "./src/server.ts", + "dependencies": { + "@codex-git/protocol": "*" + } } diff --git a/apps/ui/lint-rules.js b/apps/ui/lint-rules.js new file mode 100644 index 0000000..6366cfb --- /dev/null +++ b/apps/ui/lint-rules.js @@ -0,0 +1,26 @@ +export const uiNodeImportGuard = { + files: ['apps/ui/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + 'child_process', + 'child_process/*', + 'fs', + 'fs/*', + 'node:child_process', + 'node:child_process/*', + 'node:fs', + 'node:fs/*', + ], + message: + 'The Git Surface cannot import Node filesystem or child-process modules.', + }, + ], + }, + ], + }, +}; diff --git a/eslint.config.js b/eslint.config.js index f0301d0..6b20cb0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,6 +3,8 @@ import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; +import { uiNodeImportGuard } from './apps/ui/lint-rules.js'; + export default tseslint.config( { ignores: ['coverage/', 'dist/', 'node_modules/'], @@ -24,4 +26,5 @@ export default tseslint.config( ], }, }, + uiNodeImportGuard, ); diff --git a/package-lock.json b/package-lock.json index 182bc78..928675b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,11 +34,21 @@ }, "apps/launcher": { "name": "@codex-git/launcher", - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "@codex-git/host-adapter-standalone": "*", + "@codex-git/server": "*" + }, + "devDependencies": { + "vite": "8.2.2" + } }, "apps/server": { "name": "@codex-git/server", - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "@codex-git/protocol": "*" + } }, "apps/ui": { "name": "@codex-git/ui", diff --git a/package.json b/package.json index 0c8ebe4..f7a31dc 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "scripts": { "build": "npm run typecheck && vite build --config apps/ui/vite.config.ts", - "dev": "vite --config apps/ui/vite.config.ts", + "dev": "tsx apps/launcher/src/main.ts", "dev:server": "tsx watch apps/server/src/main.ts", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/packages/host-adapter/standalone/package.json b/packages/host-adapter/standalone/package.json index 79be5af..25ca3ec 100644 --- a/packages/host-adapter/standalone/package.json +++ b/packages/host-adapter/standalone/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": "./src/index.ts", + "types": "./src/index.ts", "dependencies": { "@codex-git/host-adapter": "*" } diff --git a/packages/host-adapter/standalone/src/adapter.ts b/packages/host-adapter/standalone/src/adapter.ts new file mode 100644 index 0000000..191572b --- /dev/null +++ b/packages/host-adapter/standalone/src/adapter.ts @@ -0,0 +1,31 @@ +import type { + HostAdapter, + HostConnection, + HostContext, + NativeActionResult, + SurfaceDescriptor, +} from '@codex-git/host-adapter'; + +const standaloneContext = { + projectPath: null, + theme: 'system', +} satisfies HostContext; + +class StandaloneHostConnection implements HostConnection { + async *contexts(): AsyncIterable { + yield standaloneContext; + } + + async perform(): Promise { + return { status: 'unsupported' }; + } + + async dispose(): Promise {} +} + +export class StandaloneHostAdapter implements HostAdapter { + async attach(surface: SurfaceDescriptor): Promise { + void surface; + return new StandaloneHostConnection(); + } +} diff --git a/packages/host-adapter/standalone/src/index.ts b/packages/host-adapter/standalone/src/index.ts index c199d0a..0baac0f 100644 --- a/packages/host-adapter/standalone/src/index.ts +++ b/packages/host-adapter/standalone/src/index.ts @@ -1,3 +1 @@ -import type { HostAdapter } from '@codex-git/host-adapter'; - -export type StandaloneHostAdapterContract = HostAdapter; +export { StandaloneHostAdapter } from './adapter.js'; diff --git a/tests/contract/README.md b/tests/contract/README.md index c8a5fee..e7e1861 100644 --- a/tests/contract/README.md +++ b/tests/contract/README.md @@ -1,3 +1,3 @@ # Contract tests -Repository Engine and protocol contract tests will live here once their behavior is implemented. +Public module contracts are verified here. The bootstrap covers the standalone Host Adapter; Repository Engine and protocol contracts join this layer with their implementation issues. diff --git a/tests/contract/standalone-host-adapter.contract.test.ts b/tests/contract/standalone-host-adapter.contract.test.ts new file mode 100644 index 0000000..7aec73f --- /dev/null +++ b/tests/contract/standalone-host-adapter.contract.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { StandaloneHostAdapter } from '@codex-git/host-adapter-standalone'; + +describe('StandaloneHostAdapter contract', () => { + it('publishes the standalone Host Context after attaching a surface', async () => { + const adapter = new StandaloneHostAdapter(); + const connection = await adapter.attach({ + title: 'Codex Git', + url: new URL('http://127.0.0.1:4173'), + }); + + const contexts = connection.contexts()[Symbol.asyncIterator](); + + expect(await contexts.next()).toEqual({ + done: false, + value: { + projectPath: null, + theme: 'system', + }, + }); + + await connection.dispose(); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index b38b4b3..085f5ed 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,3 +1,3 @@ # End-to-end tests -Standalone and Codex-hosted end-to-end tests will live here once Host Adapters are implemented. +Complete runtime tests live here. The bootstrap exercises the standalone launcher, health endpoint, Host Adapter, and placeholder Git Surface; Codex-hosted coverage joins this layer with its Host Adapter issue. diff --git a/tests/e2e/standalone-runtime.e2e.test.ts b/tests/e2e/standalone-runtime.e2e.test.ts new file mode 100644 index 0000000..e3c25d7 --- /dev/null +++ b/tests/e2e/standalone-runtime.e2e.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + startStandaloneRuntime, + type StandaloneRuntime, +} from '@codex-git/launcher'; + +const runtimes: StandaloneRuntime[] = []; + +afterEach(async () => { + await Promise.all(runtimes.splice(0).map((runtime) => runtime.close())); +}); + +describe('standalone runtime', () => { + it('serves the health endpoint and placeholder Git Surface', async () => { + const runtime = await startStandaloneRuntime({ + healthPort: 0, + surfacePort: 0, + }); + runtimes.push(runtime); + + const [healthResponse, surfaceResponse] = await Promise.all([ + fetch(runtime.healthUrl), + fetch(runtime.surfaceUrl), + ]); + + expect({ + health: await healthResponse.json(), + healthStatus: healthResponse.status, + surface: await surfaceResponse.text(), + surfaceStatus: surfaceResponse.status, + }).toEqual({ + health: { + product: 'codex-git', + status: 'ok', + }, + healthStatus: 200, + surface: expect.stringContaining('Codex Git'), + surfaceStatus: 200, + }); + }); +}); diff --git a/tests/fixtures/temporary-git-repository.ts b/tests/fixtures/temporary-git-repository.ts new file mode 100644 index 0000000..8c9dca8 --- /dev/null +++ b/tests/fixtures/temporary-git-repository.ts @@ -0,0 +1,46 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const executeFile = promisify(execFile); + +export interface GitResult { + readonly stderr: string; + readonly stdout: string; +} + +export interface TemporaryGitRepository { + readonly path: string; + dispose(): Promise; + git(...args: readonly string[]): Promise; +} + +export async function createTemporaryGitRepository(): Promise { + const path = await mkdtemp(join(tmpdir(), 'codex-git-repository-')); + + const repository: TemporaryGitRepository = { + path, + async dispose() { + await rm(path, { force: true, recursive: true }); + }, + async git(...args) { + const { stderr, stdout } = await executeFile( + 'git', + ['-C', path, ...args], + { encoding: 'utf8' }, + ); + + return { stderr, stdout }; + }, + }; + + try { + await repository.git('init', '--quiet'); + return repository; + } catch (error) { + await repository.dispose(); + throw error; + } +} diff --git a/tests/integration/README.md b/tests/integration/README.md index a419d03..8b76c2d 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,3 +1,3 @@ # Integration tests -Real temporary Git repository integration tests will live here once Git behavior is implemented. +System-boundary integration tests live here. The shared temporary Git Repository fixture is established by the bootstrap; product Git behavior joins this layer with the Repository Engine issues. diff --git a/tests/integration/temporary-git-repository.integration.test.ts b/tests/integration/temporary-git-repository.integration.test.ts new file mode 100644 index 0000000..dd9c519 --- /dev/null +++ b/tests/integration/temporary-git-repository.integration.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createTemporaryGitRepository, + type TemporaryGitRepository, +} from '../fixtures/temporary-git-repository.js'; + +const repositories: TemporaryGitRepository[] = []; + +afterEach(async () => { + await Promise.all( + repositories.splice(0).map((repository) => repository.dispose()), + ); +}); + +describe('temporary Git Repository fixture', () => { + it('creates a Repository accepted by the system Git executable', async () => { + const repository = await createTemporaryGitRepository(); + repositories.push(repository); + + const result = await repository.git('rev-parse', '--is-inside-work-tree'); + + expect(result.stdout.trim()).toBe('true'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f6ca939..df7feb8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['apps/**/*.test.{ts,tsx}', 'packages/**/*.test.ts'], + include: [ + 'apps/**/*.test.{ts,tsx}', + 'packages/**/*.test.ts', + 'tests/**/*.test.ts', + ], restoreMocks: true, }, });