refactor(model-discovery): converted model discovery to synchronous file operations

- Changed loadCustomModels from async to sync using fs.readFileSync for immediate resolution.
- Updated discoverModels to return synchronously, removing await from main.ts, pipeline.ts, and commit index.ts.
- Migrated sdk.ts to use node:fs instead of node:fs/promises for synchronous operations.
- Refactored scanDirectoryForSkills to use recursive traversal instead of batch Promise.all.
- Added tarball Dockerfile for testing npm publish/install flow with verdaccio registry.
This commit is contained in:
can1357
2026-01-24 03:48:57 +01:00
parent 51d39e7eea
commit f901befd19
15 changed files with 221 additions and 93 deletions
+2 -1
View File
@@ -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);
+6 -1
View File
@@ -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 },
+2
View File
@@ -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
@@ -27,9 +27,9 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
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<void> {
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;
+1 -1
View File
@@ -41,7 +41,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
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,
@@ -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<void> {
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<void> {
private loadModels() {
// Load custom models from models.json first (to know which providers to skip/override)
let customModels: Model<Api>[] = [];
let replacedProviders: Set<string> = 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<CustomModelsResult> {
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();
@@ -72,34 +72,30 @@ export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Prom
async function scanDir(dir: string): Promise<void> {
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<void>[] = [];
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<LoadSkillsResult> {
async function scanDir(currentDir: string): Promise<void> {
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<void>[] = [];
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}` });
}
+1 -1
View File
@@ -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) {
+17 -24
View File
@@ -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<ModelRegistry> {
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<void> {
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));
@@ -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<void> {
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<void> {
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 {
@@ -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");
});
@@ -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(
-1
View File
@@ -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,
+4
View File
@@ -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 ==="
+130
View File
@@ -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