Skip to content

Commit 3908854

Browse files
cameroncookeclaude
andcommitted
feat(runtime): Add portable resource root resolution
Add a shared resource-root resolver that prioritizes XCODEBUILDMCP_RESOURCE_ROOT, then executable-relative paths, then package-root fallback for npm/source installs. Route manifest and bundled AXe path discovery through the shared resolver so portable distributions and existing workflows use the same deterministic contract. Also export DYLD_FRAMEWORK_PATH for bundled AXe execution and add tests that cover resource-root precedence and bundled framework environment behavior. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6f1c55b commit 3908854

5 files changed

Lines changed: 216 additions & 93 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join, resolve } from 'node:path';
5+
import {
6+
getBundledAxePath,
7+
getBundledFrameworksDir,
8+
getManifestsDir,
9+
getResourceRoot,
10+
} from '../resource-root.ts';
11+
12+
describe('resource-root', () => {
13+
let originalExecPath: string;
14+
let originalResourceRoot: string | undefined;
15+
let tempDir: string;
16+
17+
beforeEach(() => {
18+
originalExecPath = process.execPath;
19+
originalResourceRoot = process.env.XCODEBUILDMCP_RESOURCE_ROOT;
20+
tempDir = mkdtempSync(join(tmpdir(), 'xbmcp-resource-root-'));
21+
});
22+
23+
afterEach(() => {
24+
process.execPath = originalExecPath;
25+
if (originalResourceRoot === undefined) {
26+
delete process.env.XCODEBUILDMCP_RESOURCE_ROOT;
27+
} else {
28+
process.env.XCODEBUILDMCP_RESOURCE_ROOT = originalResourceRoot;
29+
}
30+
rmSync(tempDir, { recursive: true, force: true });
31+
});
32+
33+
it('uses XCODEBUILDMCP_RESOURCE_ROOT when set', () => {
34+
const explicitRoot = join(tempDir, 'explicit-root');
35+
process.env.XCODEBUILDMCP_RESOURCE_ROOT = explicitRoot;
36+
37+
expect(getResourceRoot()).toBe(resolve(explicitRoot));
38+
expect(getManifestsDir()).toBe(join(resolve(explicitRoot), 'manifests'));
39+
expect(getBundledAxePath()).toBe(join(resolve(explicitRoot), 'bundled', 'axe'));
40+
});
41+
42+
it('falls back to executable-relative root when resources exist next to executable', () => {
43+
delete process.env.XCODEBUILDMCP_RESOURCE_ROOT;
44+
const executableRoot = join(tempDir, 'portable-install', 'libexec');
45+
mkdirSync(join(executableRoot, 'manifests', 'tools'), { recursive: true });
46+
process.execPath = join(executableRoot, 'xcodebuildmcp');
47+
48+
expect(getResourceRoot()).toBe(executableRoot);
49+
expect(getBundledFrameworksDir()).toBe(join(executableRoot, 'bundled', 'Frameworks'));
50+
});
51+
});

src/core/manifest/load-manifest.ts

Lines changed: 2 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import * as fs from 'node:fs';
77
import * as path from 'node:path';
8-
import { fileURLToPath } from 'node:url';
98
import { parse as parseYaml } from 'yaml';
109
import {
1110
toolManifestEntrySchema,
@@ -14,78 +13,12 @@ import {
1413
type WorkflowManifestEntry,
1514
type ResolvedManifest,
1615
} from './schema.ts';
16+
import { getManifestsDir, getPackageRoot } from '../resource-root.ts';
1717

1818
// Re-export types for consumers
1919
export type { ResolvedManifest, ToolManifestEntry, WorkflowManifestEntry };
2020
import { isValidPredicate } from '../../visibility/predicate-registry.ts';
21-
22-
// Capture import.meta.url at module load time (before any CJS bundling issues)
23-
// This works because the value is captured when the module is first evaluated
24-
const importMetaUrl: string | undefined = ((): string | undefined => {
25-
try {
26-
// This will be undefined in CJS bundles but valid in ESM
27-
return import.meta.url;
28-
} catch {
29-
return undefined;
30-
}
31-
})();
32-
33-
/**
34-
* Get the current file path, handling both ESM and CJS contexts.
35-
* Smithery bundles to CJS where import.meta.url is undefined.
36-
*/
37-
function getCurrentFilePath(): string {
38-
// ESM context - use captured import.meta.url
39-
if (importMetaUrl) {
40-
return fileURLToPath(importMetaUrl);
41-
}
42-
43-
// CJS context (Smithery bundle) - __filename is shimmed by esbuild
44-
if (typeof __filename !== 'undefined' && __filename) {
45-
return __filename as string;
46-
}
47-
48-
// Fallback: try to resolve from cwd
49-
const cwd = process.cwd();
50-
const possiblePaths = [
51-
path.join(cwd, 'build', 'core', 'manifest', 'load-manifest.ts'),
52-
path.join(cwd, 'src', 'core', 'manifest', 'load-manifest.ts'),
53-
];
54-
for (const p of possiblePaths) {
55-
if (fs.existsSync(p)) {
56-
return p;
57-
}
58-
}
59-
60-
throw new Error('Cannot determine current file path in this runtime context');
61-
}
62-
63-
/**
64-
* Get the package root directory.
65-
* Works correctly for both development and npx/npm installs.
66-
*/
67-
export function getPackageRoot(): string {
68-
// Start from this file's directory and go up to find package.json
69-
const currentFile = getCurrentFilePath();
70-
let dir = path.dirname(currentFile);
71-
72-
// Walk up until we find package.json
73-
while (dir !== path.dirname(dir)) {
74-
if (fs.existsSync(path.join(dir, 'package.json'))) {
75-
return dir;
76-
}
77-
dir = path.dirname(dir);
78-
}
79-
80-
throw new Error('Could not find package root (no package.json found in parent directories)');
81-
}
82-
83-
/**
84-
* Get the manifests directory path.
85-
*/
86-
export function getManifestsDir(): string {
87-
return path.join(getPackageRoot(), 'manifests');
88-
}
21+
export { getManifestsDir, getPackageRoot } from '../resource-root.ts';
8922

9023
/**
9124
* Load all YAML files from a directory.

src/core/resource-root.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const RESOURCE_ROOT_ENV_VAR = 'XCODEBUILDMCP_RESOURCE_ROOT';
6+
7+
function hasResourceLayout(root: string): boolean {
8+
return fs.existsSync(path.join(root, 'manifests')) || fs.existsSync(path.join(root, 'bundled'));
9+
}
10+
11+
function findPackageRootFrom(startDir: string): string | null {
12+
let dir = startDir;
13+
while (dir !== path.dirname(dir)) {
14+
if (fs.existsSync(path.join(dir, 'package.json'))) {
15+
return dir;
16+
}
17+
dir = path.dirname(dir);
18+
}
19+
return null;
20+
}
21+
22+
export function getPackageRoot(): string {
23+
const candidates: string[] = [];
24+
candidates.push(path.dirname(fileURLToPath(import.meta.url)));
25+
candidates.push(process.cwd());
26+
const entry = process.argv[1];
27+
if (entry) {
28+
candidates.push(path.dirname(entry));
29+
}
30+
31+
for (const candidate of candidates) {
32+
const found = findPackageRootFrom(candidate);
33+
if (found) {
34+
return found;
35+
}
36+
}
37+
38+
throw new Error('Could not find package root (no package.json found in parent directories)');
39+
}
40+
41+
function getExecutableResourceRoot(): string | null {
42+
const execPath = process.execPath;
43+
if (!execPath) {
44+
return null;
45+
}
46+
47+
const candidateDirs = [path.dirname(execPath), path.dirname(path.dirname(execPath))];
48+
for (const candidate of candidateDirs) {
49+
if (hasResourceLayout(candidate)) {
50+
return candidate;
51+
}
52+
}
53+
54+
return null;
55+
}
56+
57+
export function getResourceRoot(): string {
58+
const explicitRoot = process.env[RESOURCE_ROOT_ENV_VAR];
59+
if (explicitRoot) {
60+
return path.resolve(explicitRoot);
61+
}
62+
63+
const executableRoot = getExecutableResourceRoot();
64+
if (executableRoot) {
65+
return executableRoot;
66+
}
67+
68+
return getPackageRoot();
69+
}
70+
71+
export function getManifestsDir(): string {
72+
return path.join(getResourceRoot(), 'manifests');
73+
}
74+
75+
export function getBundledAxePath(): string {
76+
return path.join(getResourceRoot(), 'bundled', 'axe');
77+
}
78+
79+
export function getBundledFrameworksDir(): string {
80+
return path.join(getResourceRoot(), 'bundled', 'Frameworks');
81+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { getBundledAxeEnvironment } from '../axe-helpers.ts';
6+
7+
describe('axe-helpers', () => {
8+
let originalResourceRoot: string | undefined;
9+
let originalDyldFrameworkPath: string | undefined;
10+
let tempDir: string;
11+
12+
beforeEach(() => {
13+
originalResourceRoot = process.env.XCODEBUILDMCP_RESOURCE_ROOT;
14+
originalDyldFrameworkPath = process.env.DYLD_FRAMEWORK_PATH;
15+
tempDir = mkdtempSync(join(tmpdir(), 'xbmcp-axe-helpers-'));
16+
});
17+
18+
afterEach(() => {
19+
if (originalResourceRoot === undefined) {
20+
delete process.env.XCODEBUILDMCP_RESOURCE_ROOT;
21+
} else {
22+
process.env.XCODEBUILDMCP_RESOURCE_ROOT = originalResourceRoot;
23+
}
24+
25+
if (originalDyldFrameworkPath === undefined) {
26+
delete process.env.DYLD_FRAMEWORK_PATH;
27+
} else {
28+
process.env.DYLD_FRAMEWORK_PATH = originalDyldFrameworkPath;
29+
}
30+
31+
rmSync(tempDir, { recursive: true, force: true });
32+
});
33+
34+
it('returns DYLD_FRAMEWORK_PATH when bundled axe is resolved from resource root', () => {
35+
const resourceRoot = join(tempDir, 'portable-root');
36+
const axePath = join(resourceRoot, 'bundled', 'axe');
37+
const frameworksDir = join(resourceRoot, 'bundled', 'Frameworks');
38+
mkdirSync(frameworksDir, { recursive: true });
39+
writeFileSync(axePath, '');
40+
process.env.XCODEBUILDMCP_RESOURCE_ROOT = resourceRoot;
41+
delete process.env.DYLD_FRAMEWORK_PATH;
42+
43+
const env = getBundledAxeEnvironment();
44+
expect(env).toEqual({
45+
DYLD_FRAMEWORK_PATH: frameworksDir,
46+
});
47+
});
48+
49+
it('preserves existing DYLD_FRAMEWORK_PATH entries when using bundled axe', () => {
50+
const resourceRoot = join(tempDir, 'portable-root');
51+
const axePath = join(resourceRoot, 'bundled', 'axe');
52+
const frameworksDir = join(resourceRoot, 'bundled', 'Frameworks');
53+
mkdirSync(frameworksDir, { recursive: true });
54+
writeFileSync(axePath, '');
55+
process.env.XCODEBUILDMCP_RESOURCE_ROOT = resourceRoot;
56+
process.env.DYLD_FRAMEWORK_PATH = '/existing/frameworks';
57+
58+
const env = getBundledAxeEnvironment();
59+
expect(env).toEqual({
60+
DYLD_FRAMEWORK_PATH: `${frameworksDir}:/existing/frameworks`,
61+
});
62+
});
63+
});

src/utils/axe-helpers.ts

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
*/
77

88
import { accessSync, constants, existsSync } from 'fs';
9-
import { dirname, join, resolve, delimiter } from 'path';
9+
import { delimiter, join, resolve } from 'path';
1010
import { createTextResponse } from './validation.ts';
1111
import type { ToolResponse } from '../types/common.ts';
1212
import type { CommandExecutor } from './execution/index.ts';
1313
import { getDefaultCommandExecutor } from './execution/index.ts';
1414
import { getConfig } from './config-store.ts';
15+
import { getBundledAxePath, getBundledFrameworksDir } from '../core/resource-root.ts';
1516

1617
export type AxeBinarySource = 'env' | 'bundled' | 'path';
1718

@@ -20,19 +21,6 @@ export type AxeBinary = {
2021
source: AxeBinarySource;
2122
};
2223

23-
function getPackageRoot(): string {
24-
const entry = process.argv[1];
25-
if (entry) {
26-
const entryDir = dirname(entry);
27-
return dirname(entryDir);
28-
}
29-
return process.cwd();
30-
}
31-
32-
// In the npm package, build/index.js is at the same level as bundled/
33-
// So we go up one level from build/ to get to the package root
34-
const bundledAxePath = join(getPackageRoot(), 'bundled', 'axe');
35-
3624
function isExecutable(path: string): boolean {
3725
try {
3826
accessSync(path, constants.X_OK);
@@ -50,14 +38,8 @@ function resolveAxePathFromConfig(): string | null {
5038
}
5139

5240
function resolveBundledAxePath(): string | null {
53-
const entry = process.argv[1];
5441
const candidates = new Set<string>();
55-
if (entry) {
56-
const entryDir = dirname(entry);
57-
candidates.add(join(dirname(entryDir), 'bundled', 'axe'));
58-
candidates.add(join(entryDir, 'bundled', 'axe'));
59-
}
60-
candidates.add(bundledAxePath);
42+
candidates.add(getBundledAxePath());
6143
candidates.add(join(process.cwd(), 'bundled', 'axe'));
6244

6345
for (const candidate of candidates) {
@@ -110,9 +92,22 @@ export function getAxePath(): string | null {
11092
* Get environment variables needed for bundled AXe to run
11193
*/
11294
export function getBundledAxeEnvironment(): Record<string, string> {
113-
// No special environment variables needed - bundled AXe binary
114-
// has proper @rpath configuration to find frameworks
115-
return {};
95+
const resolved = resolveAxeBinary();
96+
if (resolved?.source !== 'bundled') {
97+
return {};
98+
}
99+
100+
const frameworksDir = getBundledFrameworksDir();
101+
if (!existsSync(frameworksDir)) {
102+
return {};
103+
}
104+
105+
const currentFrameworkPath = process.env.DYLD_FRAMEWORK_PATH;
106+
const frameworkPath = currentFrameworkPath
107+
? `${frameworksDir}${delimiter}${currentFrameworkPath}`
108+
: frameworksDir;
109+
110+
return { DYLD_FRAMEWORK_PATH: frameworkPath };
116111
}
117112

118113
/**

0 commit comments

Comments
 (0)