diff --git a/README.md b/README.md index b2b798d1..9b8c0914 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,15 @@ Each loom is a fully isolated container for your work: * **Environment Variables:** Each loom has its own environment files (`.env`, `.env.local`, `.env.development`, `.env.development.local`). Uses `development` by default, override with `DOTENV_FLOW_NODE_ENV`. See [Secret Storage Limitations](#multi-language-project-support) for frameworks with encrypted credentials. + When inside a loom shell (`il shell`), the following environment variables are automatically set: + + | Variable | Description | Example | + |----------|-------------|---------| + | `ILOOM_LOOM` | Loom identifier for PS1 customization | `issue-87` | + | `ILOOM_COLOR_HEX` | Hex color assigned to this loom (if available) | `#dcebff` | + + `ILOOM_COLOR_HEX` is useful for downstream tools that want to visually distinguish looms. For example, a Vite app can read it via `import.meta.env.VITE_ILOOM_COLOR_HEX` to tint the UI. See [Vite Integration Guide](docs/vite-iloom-color.md) for details. + * **Unique Runtime:** * **Web Apps:** Runs on a deterministic port (e.g., base port 3000 + issue #25 = 3025). diff --git a/docs/vite-iloom-color.md b/docs/vite-iloom-color.md new file mode 100644 index 00000000..a5cf0d1a --- /dev/null +++ b/docs/vite-iloom-color.md @@ -0,0 +1,101 @@ +# Using ILOOM_COLOR_HEX in a Vite App + +When you run a dev server inside a loom shell (`il shell`), the `ILOOM_COLOR_HEX` environment variable is automatically set to the hex color assigned to that loom (e.g., `#dcebff`). This lets your app visually distinguish which loom it's running in. + +## Setup + +### 1. Prefix the variable for Vite + +Vite only exposes env vars that start with `VITE_`. Add a `.env.local` (or use your existing one) in the project root: + +```bash +# .env.local +VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX +``` + +Or, if you use `il shell` and then start the dev server manually, you can set it inline: + +```bash +VITE_ILOOM_COLOR_HEX=$ILOOM_COLOR_HEX pnpm dev +``` + +### 2. Access it in your app + +```ts +// src/main.ts (or any client-side file) +const loomColor = import.meta.env.VITE_ILOOM_COLOR_HEX + +if (loomColor) { + document.documentElement.style.setProperty('--loom-color', loomColor) +} +``` + +### 3. Use the CSS variable + +```css +/* src/styles.css */ +:root { + --loom-color: transparent; /* fallback when not in a loom */ +} + +body { + border-top: 4px solid var(--loom-color); +} +``` + +## Alternative: Use `define` in vite.config.ts + +If you don't want an extra `.env.local` entry, you can inject the value at build time: + +```ts +// vite.config.ts +import { defineConfig } from 'vite' + +export default defineConfig({ + define: { + __ILOOM_COLOR_HEX__: JSON.stringify(process.env.ILOOM_COLOR_HEX ?? ''), + }, +}) +``` + +Then in your app: + +```ts +declare const __ILOOM_COLOR_HEX__: string + +if (__ILOOM_COLOR_HEX__) { + document.documentElement.style.setProperty('--loom-color', __ILOOM_COLOR_HEX__) +} +``` + +## React Example + +```tsx +// src/components/LoomIndicator.tsx +function LoomIndicator() { + const color = import.meta.env.VITE_ILOOM_COLOR_HEX + if (!color) return null + + return ( +
+ ) +} +``` + +## How It Works + +1. `il shell ` reads the loom's metadata (stored in `~/.config/iloom-ai/looms/`) and exports `ILOOM_COLOR_HEX` into the shell environment. +2. Any process spawned from that shell inherits the variable. +3. Vite picks it up (via `VITE_` prefix or `define`) and makes it available to client code. + +When you're not inside a loom shell, the variable is simply absent and your fallback styles apply. diff --git a/src/commands/dev-server.test.ts b/src/commands/dev-server.test.ts index a565181e..b8ecf8ed 100644 --- a/src/commands/dev-server.test.ts +++ b/src/commands/dev-server.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DevServerCommand } from './dev-server.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js' import { DevServerManager } from '../lib/DevServerManager.js' import { SettingsManager } from '../lib/SettingsManager.js' @@ -12,6 +13,7 @@ import fs from 'fs-extra' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') +vi.mock('../lib/MetadataManager.js') vi.mock('../lib/ProjectCapabilityDetector.js') vi.mock('../lib/DevServerManager.js') vi.mock('../utils/IdentifierParser.js') @@ -40,6 +42,7 @@ vi.mock('../utils/logger.js', () => ({ describe('DevServerCommand', () => { let command: DevServerCommand let mockGitWorktreeManager: GitWorktreeManager + let mockMetadataManager: MetadataManager let mockCapabilityDetector: ProjectCapabilityDetector let mockDevServerManager: DevServerManager let mockIdentifierParser: IdentifierParser @@ -54,11 +57,17 @@ describe('DevServerCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() + mockMetadataManager = new MetadataManager() mockCapabilityDetector = new ProjectCapabilityDetector() mockDevServerManager = new DevServerManager() mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager) mockSettingsManager = new SettingsManager() + // Mock MetadataManager - default to returning metadata with color + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: '#dcebff', + }) + // Mock DevServerManager methods vi.mocked(mockDevServerManager.isServerRunning).mockResolvedValue(false) vi.mocked(mockDevServerManager.runServerForeground).mockImplementation( @@ -83,7 +92,8 @@ describe('DevServerCommand', () => { mockCapabilityDetector, mockIdentifierParser, mockDevServerManager, - mockSettingsManager + mockSettingsManager, + mockMetadataManager ) }) @@ -458,7 +468,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - { DATABASE_URL: 'postgres://test', API_KEY: 'secret' } + expect.objectContaining({ DATABASE_URL: 'postgres://test', API_KEY: 'secret', ILOOM_LOOM: '87' }) ) }) @@ -475,7 +485,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -490,7 +500,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -513,7 +523,7 @@ describe('DevServerCommand', () => { 3087, false, expect.any(Function), - {} + expect.objectContaining({ ILOOM_LOOM: '87' }) ) }) @@ -533,4 +543,77 @@ describe('DevServerCommand', () => { expect(mockDevServerManager.runServerForeground).toHaveBeenCalled() }) }) + + describe('loom environment variables', () => { + beforeEach(() => { + vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({ + type: 'issue', + number: 87, + originalInput: '87', + }) + + vi.mocked(mockGitWorktreeManager.findWorktreeForIssue).mockResolvedValue(mockWorktree) + + const mockCapabilities: ProjectCapabilities = { + capabilities: ['web'], + binEntries: {}, + } + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities) + + vi.mocked(fs.pathExists).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n') + }) + + it('should set ILOOM_LOOM env var to original input', async () => { + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_LOOM', '87') + }) + + it('should set ILOOM_LOOM for PR identifier', async () => { + vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({ + type: 'pr', + number: 42, + originalInput: '42', + }) + vi.mocked(mockGitWorktreeManager.findWorktreeForPR).mockResolvedValue(mockWorktree) + + await command.execute({ identifier: '42' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_LOOM', '42') + }) + + it('should set ILOOM_COLOR_HEX from metadata', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: '#dcebff', + }) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).toHaveProperty('ILOOM_COLOR_HEX', '#dcebff') + }) + + it('should not set ILOOM_COLOR_HEX when metadata has no colorHex', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + colorHex: null, + }) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX') + }) + + it('should not set ILOOM_COLOR_HEX when metadata is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null) + + await command.execute({ identifier: '87' }) + + const envArg = vi.mocked(mockDevServerManager.runServerForeground).mock.calls[0]?.[4] + expect(envArg).not.toHaveProperty('ILOOM_COLOR_HEX') + }) + }) }) diff --git a/src/commands/dev-server.ts b/src/commands/dev-server.ts index c1739f72..993f6183 100644 --- a/src/commands/dev-server.ts +++ b/src/commands/dev-server.ts @@ -1,5 +1,6 @@ import path from 'path' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js' import { DevServerManager } from '../lib/DevServerManager.js' import { SettingsManager } from '../lib/SettingsManager.js' @@ -42,7 +43,8 @@ export class DevServerCommand { private capabilityDetector = new ProjectCapabilityDetector(), private identifierParser = new IdentifierParser(new GitWorktreeManager()), private devServerManager = new DevServerManager(), - private settingsManager = new SettingsManager() + private settingsManager = new SettingsManager(), + private metadataManager = new MetadataManager() ) {} /** @@ -82,6 +84,15 @@ export class DevServerCommand { } } + // 3b. Set ILOOM_LOOM for loom identification + envOverrides.ILOOM_LOOM = this.formatLoomIdentifier(parsed) + + // 3c. Set ILOOM_COLOR_HEX from loom metadata if available + const metadata = await this.metadataManager.readMetadata(worktree.path) + if (metadata?.colorHex) { + envOverrides.ILOOM_COLOR_HEX = metadata.colorHex + } + // 4. Detect project capabilities const { capabilities } = await this.capabilityDetector.detectCapabilities(worktree.path) @@ -312,4 +323,11 @@ export class DevServerCommand { } return `branch "${parsed.branchName}"${autoLabel}` } + + /** + * Format loom identifier for ILOOM_LOOM env var + */ + private formatLoomIdentifier(parsed: ParsedDevServerInput): string { + return parsed.originalInput + } } diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts index 044786f1..63721038 100644 --- a/src/commands/shell.test.ts +++ b/src/commands/shell.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ShellCommand } from './shell.js' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { SettingsManager } from '../lib/SettingsManager.js' import type { GitWorktree } from '../types/worktree.js' @@ -9,6 +10,7 @@ import { execa } from 'execa' // Mock dependencies vi.mock('../lib/GitWorktreeManager.js') +vi.mock('../lib/MetadataManager.js') vi.mock('../utils/IdentifierParser.js') vi.mock('../lib/SettingsManager.js') vi.mock('fs-extra') @@ -37,6 +39,7 @@ import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js' describe('ShellCommand', () => { let command: ShellCommand let mockGitWorktreeManager: GitWorktreeManager + let mockMetadataManager: MetadataManager let mockIdentifierParser: IdentifierParser let mockSettingsManager: SettingsManager @@ -49,6 +52,7 @@ describe('ShellCommand', () => { beforeEach(() => { mockGitWorktreeManager = new GitWorktreeManager() + mockMetadataManager = new MetadataManager() mockIdentifierParser = new IdentifierParser(mockGitWorktreeManager) mockSettingsManager = new SettingsManager() @@ -57,6 +61,27 @@ describe('ShellCommand', () => { sourceEnvOnStart: true, }) + // Default metadata mock - return colorHex + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + description: 'test', + created_at: null, + branchName: null, + worktreePath: null, + issueType: null, + issueKey: null, + issue_numbers: [], + pr_numbers: [], + issueTracker: null, + colorHex: '#dcebff', + sessionId: null, + projectPath: null, + issueUrls: {}, + prUrls: {}, + draftPrNumber: null, + capabilities: [], + parentLoom: null, + }) + // Set up env mocks vi.mocked(loadWorkspaceEnv).mockReturnValue({ parsed: { PORT: '3087', NODE_ENV: 'development' } }) vi.mocked(getDotenvFlowFiles).mockReturnValue(['.env', '.env.local', '.env.development', '.env.development.local']) @@ -64,7 +89,8 @@ describe('ShellCommand', () => { command = new ShellCommand( mockGitWorktreeManager, mockIdentifierParser, - mockSettingsManager + mockSettingsManager, + mockMetadataManager ) }) @@ -227,6 +253,52 @@ describe('ShellCommand', () => { const envArg = execaCall[2]?.env as Record expect(envArg.ILOOM_LOOM).toBe('pr-42') }) + + it('should set ILOOM_COLOR_HEX when metadata has colorHex', async () => { + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBe('#dcebff') + }) + + it('should not set ILOOM_COLOR_HEX when metadata is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue(null) + + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBeUndefined() + }) + + it('should not set ILOOM_COLOR_HEX when metadata.colorHex is null', async () => { + vi.mocked(mockMetadataManager.readMetadata).mockResolvedValue({ + description: 'test', + created_at: null, + branchName: null, + worktreePath: null, + issueType: null, + issueKey: null, + issue_numbers: [], + pr_numbers: [], + issueTracker: null, + colorHex: null, + sessionId: null, + projectPath: null, + issueUrls: {}, + prUrls: {}, + draftPrNumber: null, + capabilities: [], + parentLoom: null, + }) + + await command.execute({ identifier: '87' }) + + const execaCall = vi.mocked(execa).mock.calls[0] + const envArg = execaCall[2]?.env as Record + expect(envArg.ILOOM_COLOR_HEX).toBeUndefined() + }) }) describe('shell detection', () => { diff --git a/src/commands/shell.ts b/src/commands/shell.ts index 4a2cd09e..c97d8595 100644 --- a/src/commands/shell.ts +++ b/src/commands/shell.ts @@ -2,6 +2,7 @@ import path from 'path' import { execa } from 'execa' import fs from 'fs-extra' import { GitWorktreeManager } from '../lib/GitWorktreeManager.js' +import { MetadataManager } from '../lib/MetadataManager.js' import { SettingsManager } from '../lib/SettingsManager.js' import { IdentifierParser } from '../utils/IdentifierParser.js' import { loadWorkspaceEnv, getDotenvFlowFiles } from '../utils/env.js' @@ -29,7 +30,8 @@ export class ShellCommand { constructor( private gitWorktreeManager = new GitWorktreeManager(), private identifierParser = new IdentifierParser(new GitWorktreeManager()), - private settingsManager = new SettingsManager() + private settingsManager = new SettingsManager(), + private metadataManager = new MetadataManager() ) {} async execute(input: ShellCommandInput): Promise { @@ -66,6 +68,12 @@ export class ShellCommand { const loomIdentifier = this.formatLoomIdentifier(parsed) envVars.ILOOM_LOOM = loomIdentifier + // 5b. Set ILOOM_COLOR_HEX from loom metadata if available + const metadata = await this.metadataManager.readMetadata(worktree.path) + if (metadata?.colorHex) { + envVars.ILOOM_COLOR_HEX = metadata.colorHex + } + // 6. Detect shell const shell = this.detectShell()