diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index a4f59e471..a416eb658 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -458,7 +458,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!ollamaInstalled)("Ollama (local)", () => { let ollamaProcess: ChildProcess | null = null; - let model: Model<"openai-completions">; + let model: Model<"openai-completions"> | undefined; beforeAll(async () => { // Check if model is available, if not pull it @@ -519,6 +519,7 @@ describe("Context overflow error handling", () => { }); it("gpt-oss:20b - should detect overflow via isContextOverflow (ollama silently truncates)", async () => { + if (!model) return; const result = await testContextOverflow(model, "ollama"); logResult(result); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 34ad3e731..53de23f42 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1360,7 +1360,7 @@ describe("Generate E2E Tests", () => { } describe.skipIf(!ollamaInstalled)("Ollama Provider (gpt-oss-20b via OpenAI Completions)", () => { - let llm: Model<"openai-completions">; + let llm: Model<"openai-completions"> | undefined; let ollamaProcess: ChildProcess | null = null; beforeAll(async () => { @@ -1430,6 +1430,7 @@ describe("Generate E2E Tests", () => { it( "should complete basic text generation", async () => { + if (!llm) return; await basicTextGeneration(llm, { apiKey: "test" }); }, { retry: 3 }, @@ -1438,6 +1439,7 @@ describe("Generate E2E Tests", () => { it( "should handle tool calling", async () => { + if (!llm) return; await handleToolCall(llm, { apiKey: "test" }); }, { retry: 3 }, @@ -1446,6 +1448,7 @@ describe("Generate E2E Tests", () => { it( "should handle streaming", async () => { + if (!llm) return; await handleStreaming(llm, { apiKey: "test" }); }, { retry: 3 }, @@ -1454,6 +1457,7 @@ describe("Generate E2E Tests", () => { it( "should handle thinking mode", async () => { + if (!llm) return; await handleThinking(llm, { apiKey: "test", reasoningEffort: "medium" }); }, { retry: 3 }, @@ -1462,6 +1466,7 @@ describe("Generate E2E Tests", () => { it( "should handle multi-turn with thinking and tools", async () => { + if (!llm) return; await multiTurn(llm, { apiKey: "test", reasoningEffort: "medium" }); }, { retry: 3 }, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5282edaed..5e91264be 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -49,6 +49,7 @@ - Added color manipulation utilities (hexToHsv, hsvToHex, shiftHue) to pi-utils for accessible theme adjustments - Added color-blind mode setting for improved accessibility - Added filesystem error type guards (isEnoent, isEacces, isPerm, isEnotempty, isFsError, hasFsCode) to pi-utils for safe error handling +- Added tarball installation test Dockerfile to validate npm publish/install flow ### Changed - Changed changelog diff truncation limit to be configurable via settings @@ -109,6 +110,7 @@ - Improved error logging in settings manager for config file access failures - Migrated node module imports from named to namespace imports across all packages for consistency with project guidelines - Improved filesystem error handling in extension loader with additional type guards (isEacces, hasFsCode) for permission and EPERM errors +- Changed model discovery to synchronous file operations for more immediate initialization ### Fixed - Fixed database busy errors during concurrent access by adding retry logic with exponential backoff when opening storage diff --git a/packages/coding-agent/src/commit/agentic/index.ts b/packages/coding-agent/src/commit/agentic/index.ts index ae9ce3c82..275927a10 100644 --- a/packages/coding-agent/src/commit/agentic/index.ts +++ b/packages/coding-agent/src/commit/agentic/index.ts @@ -27,9 +27,9 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { const cwd = process.cwd(); const git = new ControlledGit(cwd); const [settingsManager, authStorage] = await Promise.all([SettingsManager.create(cwd), discoverAuthStorage()]); - const modelRegistryPromise = discoverModels(authStorage); writeStdout("● Resolving model..."); + const modelRegistry = discoverModels(authStorage); const stagedFilesPromise = (async () => { let stagedFiles = await git.getStagedFiles(); if (stagedFiles.length === 0) { @@ -40,7 +40,6 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { return stagedFiles; })(); - const modelRegistry = await modelRegistryPromise; const primaryModelPromise = resolvePrimaryModel(args.model, settingsManager, modelRegistry); const [primaryModelResult, stagedFiles] = await Promise.all([primaryModelPromise, stagedFilesPromise]); const { model: primaryModel, apiKey: primaryApiKey } = primaryModelResult; diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index c9a198508..855cffbe8 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -41,7 +41,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { const settingsManager = await SettingsManager.create(cwd); const commitSettings = settingsManager.getCommitSettings(); const authStorage = await discoverAuthStorage(); - const modelRegistry = await discoverModels(authStorage); + const modelRegistry = discoverModels(authStorage); const { model: primaryModel, apiKey: primaryApiKey } = await resolvePrimaryModel( args.model, diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index fa62990e8..41f97e8cb 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -2,8 +2,8 @@ * Model registry - manages built-in and custom models, provides API key resolution. */ +import * as fs from "node:fs"; import * as path from "node:path"; - import { type Api, getGitHubCopilotBaseUrl, @@ -149,6 +149,8 @@ export class ModelRegistry { } return undefined; }); + // Load models synchronously in constructor + this.loadModels(); } /** @@ -191,10 +193,10 @@ export class ModelRegistry { /** * Reload models from disk (built-in + custom from models.json). */ - async refresh(): Promise { + refresh(): void { this.customProviderApiKeys.clear(); this.loadError = undefined; - await this.loadModels(); + this.loadModels(); } /** @@ -204,7 +206,7 @@ export class ModelRegistry { return this.loadError; } - private async loadModels(): Promise { + private loadModels() { // Load custom models from models.json first (to know which providers to skip/override) let customModels: Model[] = []; let replacedProviders: Set = new Set(); @@ -216,7 +218,7 @@ export class ModelRegistry { } for (const modelsPath of pathsToCheck) { - const result = await this.loadCustomModels(modelsPath); + const result = this.loadCustomModels(modelsPath); if (!result.found) { continue; // File doesn't exist, try next path } @@ -266,10 +268,10 @@ export class ModelRegistry { }); } - private async loadCustomModels(modelsPath: string): Promise { + private loadCustomModels(modelsPath: string): CustomModelsResult { let content: string; try { - content = await Bun.file(modelsPath).text(); + content = fs.readFileSync(modelsPath, "utf-8"); } catch (error) { if (isEnoent(error)) { return emptyCustomModelsResult(); diff --git a/packages/coding-agent/src/extensibility/skills.ts b/packages/coding-agent/src/extensibility/skills.ts index bb931c101..12eb72ad5 100644 --- a/packages/coding-agent/src/extensibility/skills.ts +++ b/packages/coding-agent/src/extensibility/skills.ts @@ -72,34 +72,30 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom async function scanDir(dir: string): Promise { try { + // First check if this directory itself is a skill + const selfSkillFile = path.join(dir, "SKILL.md"); + try { + const s = await fs.stat(selfSkillFile); + if (s.isFile()) { + await addSkill(selfSkillFile, dir, path.basename(dir)); + // This directory is a skill, don't recurse + return; + } + } catch { + // No SKILL.md in this directory + } + + // Recurse into subdirectories const entries = await fs.readdir(dir, { withFileTypes: true }); - const tasks: Promise[] = []; for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - const skillFile = path.join(fullPath, "SKILL.md"); - tasks.push( - fs - .stat(skillFile) - .then((s) => { - if (s.isFile()) { - return addSkill(skillFile, fullPath, entry.name); - } - }) - .catch(() => { - // No SKILL.md in this directory - }), - ); - tasks.push(scanDir(fullPath)); - } else if (entry.isFile() && entry.name === "SKILL.md") { - tasks.push(addSkill(fullPath, dir, path.basename(dir))); + await scanDir(fullPath); } } - - await Promise.all(tasks); } catch (err) { warnings.push({ skillPath: dir, message: `Failed to read directory: ${err}` }); } @@ -144,34 +140,30 @@ async function scanDirectoryForSkills(dir: string): Promise { async function scanDir(currentDir: string): Promise { try { + // First check if this directory itself is a skill + const selfSkillFile = path.join(currentDir, "SKILL.md"); + try { + const s = await fs.stat(selfSkillFile); + if (s.isFile()) { + await addSkill(selfSkillFile, currentDir, path.basename(currentDir)); + // This directory is a skill, don't recurse + return; + } + } catch { + // No SKILL.md in this directory + } + + // Recurse into subdirectories const entries = await fs.readdir(currentDir, { withFileTypes: true }); - const tasks: Promise[] = []; for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { - const skillFile = path.join(fullPath, "SKILL.md"); - tasks.push( - fs - .stat(skillFile) - .then((s) => { - if (s.isFile()) { - return addSkill(skillFile, fullPath, entry.name); - } - }) - .catch(() => { - // No SKILL.md in this directory - }), - ); - tasks.push(scanDir(fullPath)); - } else if (entry.isFile() && entry.name === "SKILL.md") { - tasks.push(addSkill(fullPath, currentDir, path.basename(currentDir))); + await scanDir(fullPath); } } - - await Promise.all(tasks); } catch (err) { warnings.push({ skillPath: currentDir, message: `Failed to read directory: ${err}` }); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 94a88a10f..0d6be9f82 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -571,7 +571,7 @@ export async function main(args: string[]) { // Create AuthStorage and ModelRegistry upfront const authStorage = await discoverAuthStorage(); - const modelRegistry = await discoverModels(authStorage); + const modelRegistry = discoverModels(authStorage); time("discoverModels"); if (parsed.version) { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index f289cdeeb..957ac66a7 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -26,7 +26,7 @@ * ``` */ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import * as path from "node:path"; import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai"; @@ -274,20 +274,19 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir( * Prefers models.yml over models.json. Reads from primary path first, * then falls back to legacy paths (.pi, .claude). */ -export async function discoverModels( - authStorage: AuthStorage, - agentDir: string = getDefaultAgentDir(), -): Promise { +export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry { const yamlPath = path.join(agentDir, "models.yml"); const jsonPath = path.join(agentDir, "models.json"); // Check existence of yaml and json files - let [yamlExists, jsonExists] = await Promise.all([Bun.file(yamlPath).exists(), Bun.file(jsonPath).exists()]); + let yamlExists = fs.existsSync(yamlPath); + let jsonExists = fs.existsSync(jsonPath); // Migrate models.json to models.yml if yaml doesn't exist but json does if (!yamlExists && jsonExists) { - await migrateModelsJsonToYaml(jsonPath, yamlPath); - [yamlExists, jsonExists] = await Promise.all([Bun.file(yamlPath).exists(), Bun.file(jsonPath).exists()]); + migrateModelsJsonToYaml(jsonPath, yamlPath); + yamlExists = fs.existsSync(yamlPath); + jsonExists = fs.existsSync(jsonPath); } // Prefer models.yml, fall back to models.json @@ -297,36 +296,30 @@ export async function discoverModels( const yamlPaths = getConfigDirPaths("models.yml", { project: false }); const jsonPaths = getConfigDirPaths("models.json", { project: false }); const allPaths = [...yamlPaths, ...jsonPaths]; - const existenceResults = await Promise.all( - allPaths.map((p) => - Bun.file(p) - .exists() - .then((exists) => ({ p, exists })), - ), - ); + const existenceResults = allPaths.map((p) => { + return { p, exists: fs.existsSync(p) }; + }); const fallbackPaths = existenceResults.filter(({ p, exists }) => p !== primaryPath && exists).map(({ p }) => p); logger.debug("discoverModels", { primaryPath, fallbackPaths }); - - const registry = new ModelRegistry(authStorage, primaryPath, fallbackPaths); - await registry.refresh(); - return registry; + return new ModelRegistry(authStorage, primaryPath, fallbackPaths); } /** * Migrate models.json to models.yml. * Creates models.yml from models.json and renames the json file to .bak. */ -async function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): Promise { +function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): void { try { - const content = await Bun.file(jsonPath).text(); + const content = fs.readFileSync(jsonPath, "utf-8"); const parsed = JSON.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { logger.warn("migrateModelsJsonToYaml: invalid models.json structure", { path: jsonPath }); return; } - await Bun.write(yamlPath, YAML.stringify(parsed, null, 2)); - await fs.rename(jsonPath, `${jsonPath}.bak`); + fs.mkdirSync(path.dirname(yamlPath), { recursive: true }); + fs.writeFileSync(yamlPath, YAML.stringify(parsed, null, 2)); + fs.renameSync(jsonPath, `${jsonPath}.bak`); logger.debug("migrateModelsJsonToYaml: migrated models.json to models.yml", { from: jsonPath, to: yamlPath }); } catch (error) { logger.warn("migrateModelsJsonToYaml: migration failed", { error: String(error) }); @@ -608,7 +601,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Use provided or create AuthStorage and ModelRegistry const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir)); - const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir)); + const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir); time("discoverModels"); const settingsManager = options.settingsManager ?? (await SettingsManager.create(cwd, agentDir)); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 8576a16df..48f507672 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -27,14 +27,15 @@ import type { import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; +import * as fs from "node:fs"; import type { Rule } from "../capability/rule"; import { getAgentDbPath } from "../config"; import type { ModelRegistry } from "../config/model-registry"; import { parseModelString } from "../config/model-resolver"; import { expandPromptTemplate, - type PromptTemplate, parseCommandArgs, + type PromptTemplate, renderPromptTemplate, } from "../config/prompt-templates"; import type { SettingsManager, SkillsSettings } from "../config/settings-manager"; @@ -69,10 +70,10 @@ import { resolveToCwd } from "../tools/path-utils"; import type { TodoItem } from "../tools/todo-write"; import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions"; import { - type CompactionResult, calculateContextTokens, collectEntriesForBranchSummary, compact, + type CompactionResult, estimateTokens, generateBranchSummary, prepareCompaction, @@ -80,8 +81,8 @@ import { } from "./compaction"; import { type BashExecutionMessage, - type BranchSummaryMessage, bashExecutionToText, + type BranchSummaryMessage, type CompactionSummaryMessage, type CustomMessage, type FileMentionMessage, @@ -577,7 +578,7 @@ export class AgentSession { this._streamingEditFileCache.clear(); } - private _preCacheStreamingEditFile(event: AgentEvent): void { + private async _preCacheStreamingEditFile(event: AgentEvent): Promise { if (!this.settingsManager.getEditStreamingAbort()) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; @@ -598,14 +599,14 @@ export class AgentSession { if (!path) return; const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); - void this._ensureFileCache(resolvedPath); + this._ensureFileCache(resolvedPath); } - private async _ensureFileCache(resolvedPath: string): Promise { + private _ensureFileCache(resolvedPath: string): void { if (this._streamingEditFileCache.has(resolvedPath)) return; try { - const rawText = await Bun.file(resolvedPath).text(); + const rawText = fs.readFileSync(resolvedPath, "utf-8"); const { text } = stripBom(rawText); this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); } catch { diff --git a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts index eedd13a80..97463f529 100644 --- a/packages/coding-agent/test/core/apply-patch-adverserial.test.ts +++ b/packages/coding-agent/test/core/apply-patch-adverserial.test.ts @@ -219,11 +219,11 @@ describe("applyPatch adversarial inputs", () => { test("preserves UTF-8 BOM and CRLF endings", async () => { const filePath = path.join(tempDir, "bom.txt"); - await Bun.write(filePath, "\uFEFFfoo\r\nbar\r\n"); + fs.writeFileSync(filePath, "\uFEFFfoo\r\nbar\r\n"); await applyPatch({ path: "bom.txt", op: "update", diff: "@@\n-foo\n+FOO" }, { cwd: tempDir }); - const content = await Bun.file(filePath).text(); + const content = fs.readFileSync(filePath, "utf-8"); expect(content).toBe("\uFEFFFOO\r\nbar\r\n"); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 186669f8e..3f046a228 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -65,7 +65,7 @@ describe("ModelRegistry", () => { } describe("baseUrl override (no custom models)", () => { - test("overriding baseUrl keeps all built-in models", () => { + test("overriding baseUrl keeps all built-in models", async () => { writeRawModelsJson({ anthropic: overrideConfig("https://my-proxy.example.com/v1"), }); @@ -78,7 +78,7 @@ describe("ModelRegistry", () => { expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); }); - test("overriding baseUrl changes URL on all built-in models", () => { + test("overriding baseUrl changes URL on all built-in models", async () => { writeRawModelsJson({ anthropic: overrideConfig("https://my-proxy.example.com/v1"), }); @@ -92,7 +92,7 @@ describe("ModelRegistry", () => { } }); - test("overriding headers merges with model headers", () => { + test("overriding headers merges with model headers", async () => { writeRawModelsJson({ anthropic: overrideConfig("https://my-proxy.example.com/v1", { "X-Custom-Header": "custom-value", @@ -107,7 +107,7 @@ describe("ModelRegistry", () => { } }); - test("baseUrl-only override does not affect other providers", () => { + test("baseUrl-only override does not affect other providers", async () => { writeRawModelsJson({ anthropic: overrideConfig("https://my-proxy.example.com/v1"), }); @@ -120,7 +120,7 @@ describe("ModelRegistry", () => { expect(googleModels[0].baseUrl).not.toBe("https://my-proxy.example.com/v1"); }); - test("can mix baseUrl override and full replacement", () => { + test("can mix baseUrl override and full replacement", async () => { writeRawModelsJson({ // baseUrl-only for anthropic anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"), @@ -164,7 +164,7 @@ describe("ModelRegistry", () => { }); describe("provider replacement (with custom models)", () => { - test("custom provider with same name as built-in replaces built-in models", () => { + test("custom provider with same name as built-in replaces built-in models", async () => { writeModelsJson({ anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]), }); @@ -177,7 +177,7 @@ describe("ModelRegistry", () => { expect(anthropicModels[0].baseUrl).toBe("https://my-proxy.example.com/v1"); }); - test("custom provider with same name as built-in does not affect other built-in providers", () => { + test("custom provider with same name as built-in does not affect other built-in providers", async () => { writeModelsJson({ anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]), }); @@ -188,7 +188,7 @@ describe("ModelRegistry", () => { expect(getModelsForProvider(registry, "openai").length).toBeGreaterThan(0); }); - test("multiple built-in providers can be overridden", () => { + test("multiple built-in providers can be overridden", async () => { writeModelsJson({ anthropic: providerConfig("https://anthropic-proxy.example.com/v1", [{ id: "claude-proxy" }]), google: providerConfig( diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 7d8236c61..ae4d4510d 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -108,7 +108,6 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json")); const modelRegistry = new ModelRegistry(authStorage, tempDir); - const session = new AgentSession({ agent, sessionManager, diff --git a/scripts/install-tests/run.sh b/scripts/install-tests/run.sh index edafd6cbc..e82a3c451 100755 --- a/scripts/install-tests/run.sh +++ b/scripts/install-tests/run.sh @@ -10,5 +10,9 @@ echo "" echo "=== Testing source install ===" podman build -f scripts/install-tests/source.dockerfile -t omp-test-source . +echo "" +echo "=== Testing tarball install (publish simulation) ===" +podman build -f scripts/install-tests/tarball.dockerfile -t omp-test-tarball . + echo "" echo "=== All tests passed ===" diff --git a/scripts/install-tests/tarball.dockerfile b/scripts/install-tests/tarball.dockerfile new file mode 100644 index 000000000..de0359640 --- /dev/null +++ b/scripts/install-tests/tarball.dockerfile @@ -0,0 +1,130 @@ +# Test tarball install (simulates npm publish flow) +# Uses verdaccio as local registry to test full publish/install cycle +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y curl ca-certificates unzip jq procps && rm -rf /var/lib/apt/lists/* + +# Install bun +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:$PATH" + +# Install Node.js (needed for verdaccio and npm) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Install verdaccio (local npm registry) +RUN npm install -g verdaccio + +# Copy local repo +WORKDIR /repo +COPY . . + +# Build the project +RUN bun install --frozen-lockfile + +# Create verdaccio config (allow anonymous publish) +RUN mkdir -p /root/.config/verdaccio && cat > /root/.config/verdaccio/config.yaml <<'EOF' +storage: /verdaccio/storage +auth: + htpasswd: + file: /verdaccio/htpasswd + max_users: -1 +uplinks: + npmjs: + url: https://registry.npmjs.org/ +packages: + '@oh-my-pi/*': + access: $all + publish: $all + unpublish: $all + '**': + access: $all + publish: $all + unpublish: $all + proxy: npmjs +publish: + allow_offline: true +security: + api: + legacy: true +log: { type: stdout, format: pretty, level: warn } +EOF + +# Create storage and htpasswd +RUN mkdir -p /verdaccio/storage && touch /verdaccio/htpasswd + +# Create .npmrc with auth token +RUN echo '//localhost:4873/:_authToken="fake-token"' > /root/.npmrc + +# Create script to resolve workspace:* versions and publish +RUN cat > /repo/scripts/publish-local.sh <<'SCRIPT' +#!/bin/bash +set -e + +REGISTRY="http://localhost:4873" +PACKAGES=(pi-utils ai agent tui stats coding-agent) + +# Build version map from all package.json files +declare -A VERSION_MAP +for pkg in "${PACKAGES[@]}"; do + name=$(jq -r '.name' "packages/$pkg/package.json") + version=$(jq -r '.version' "packages/$pkg/package.json") + VERSION_MAP[$name]=$version + echo "Found $name@$version" +done + +# Resolve workspace:* in each package.json and publish +for pkg in "${PACKAGES[@]}"; do + echo "" + echo "=== Publishing packages/$pkg ===" + cd "/repo/packages/$pkg" + + # Backup original + cp package.json package.json.bak + + # Resolve workspace:* references + for dep_name in "${!VERSION_MAP[@]}"; do + dep_version="${VERSION_MAP[$dep_name]}" + # Replace "workspace:*" with actual version for this dependency + jq --arg name "$dep_name" --arg ver "$dep_version" \ + '(.dependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end) | + (.devDependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end) | + (.peerDependencies[$name] // empty) |= (if . == "workspace:*" then $ver else . end)' \ + package.json > package.json.tmp && mv package.json.tmp package.json + done + + # Show what we're publishing + echo "Dependencies:" + jq '.dependencies | to_entries[] | select(.value | startswith("@oh-my-pi") or startswith("workspace"))' package.json 2>/dev/null || true + + # Publish + npm publish --registry "$REGISTRY" + + # Restore original + mv package.json.bak package.json + + cd /repo +done + +echo "" +echo "=== All packages published ===" +SCRIPT +RUN chmod +x /repo/scripts/publish-local.sh + +# Start verdaccio and publish all packages +RUN verdaccio --config /root/.config/verdaccio/config.yaml &>/dev/null & \ + sleep 3 && \ + /repo/scripts/publish-local.sh && \ + pkill -f verdaccio + +# Clean install in fresh directory +WORKDIR /test +RUN verdaccio --config /root/.config/verdaccio/config.yaml &>/dev/null & \ + sleep 3 && \ + bun add @oh-my-pi/pi-coding-agent --registry http://localhost:4873 && \ + pkill -f verdaccio + +# Verify the installed package works +ENV PATH="/test/node_modules/.bin:$PATH" +RUN omp --version