Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions client/src/components/settings/InstanceFeaturesTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,40 @@ const sourceHint = (feature) => {
};

const normalizeGitHubRepo = (url) => {
const parsed = parseGitHubUrl(url);
if (!parsed) return null;
return /^git@github\.com:/i.test(String(url).trim())
? `git@github.com:${parsed.owner}/${parsed.repo}.git`
: `https://github.com/${parsed.owner}/${parsed.repo}`;
};

const buildEidoverseRepoUrl = (owner, transport) => (
transport === 'ssh'
? `git@github.com:${owner}/eidoverse-worlds.git`
: `https://github.com/${owner}/eidoverse-worlds`
);

const eidoverseTransport = (url) => (/^git@github\.com:/i.test(String(url).trim()) ? 'ssh' : 'http');

const githubBrowseUrl = (url) => {
const parsed = parseGitHubUrl(url);
return parsed ? `https://github.com/${parsed.owner}/${parsed.repo}` : null;
};

const SourceChoiceButton = ({ active, children, disabled = false, onClick }) => (
<button
type="button"
aria-pressed={active}
disabled={disabled}
onClick={onClick}
className={`min-h-[36px] px-3 py-1.5 text-xs rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${active
? 'bg-port-accent text-white'
: 'text-gray-400 hover:text-white hover:bg-port-border/70'}`}
>
{children}
</button>
);

export function InstanceFeaturesTab() {
const { features, error, reload } = useInstanceFeatures();
const [savingId, setSavingId] = useState(null);
Expand Down Expand Up @@ -126,6 +156,11 @@ export function InstanceFeaturesTab() {
const needsInstall = isEidoverse && setup?.installed !== true;
const installing = savingId === feature.id;
const selectedRepoUrl = eidoverseRepoUrl ?? setup?.worldsRepoUrl ?? '';
const selectedRepo = parseGitHubUrl(selectedRepoUrl);
const selectedTransport = eidoverseTransport(selectedRepoUrl);
const selfOwner = setup?.sourceOwners?.self || null;
const upstreamOwner = setup?.sourceOwners?.upstream || 'anima-research';
const worldsBrowseUrl = githubBrowseUrl(setup?.worldsRepoUrl);
const repoIsValid = isGitHubRepoUrl(selectedRepoUrl);
const canInstall = repoIsValid && setup?.registryAvailable !== false;
const canUpdateSource = repoIsValid
Expand Down Expand Up @@ -155,21 +190,57 @@ export function InstanceFeaturesTab() {
{needsInstall && <p>
PortOS will install Bun if needed, clone your selected Worlds repository and the upstream video runtime as separate AGPL-3.0 repositories, install their dependencies, and register Worlds under Apps. It will not start the server automatically.
</p>}
<label className="block pt-2" htmlFor="eidoverse-worlds-repo">
<span className="block text-gray-300 mb-1">Worlds GitHub repository</span>
<div className="pt-2">
<label className="block text-gray-300 mb-1" htmlFor="eidoverse-worlds-repo">
Worlds GitHub repository
</label>
<span className="flex flex-wrap gap-2 mb-2">
<span role="group" aria-label="Worlds repository owner" className="inline-flex gap-1 rounded-lg border border-port-border p-1">
<SourceChoiceButton
active={Boolean(selfOwner) && selectedRepo?.owner?.toLowerCase() === selfOwner.toLowerCase()}
disabled={savingId !== null || !selfOwner}
onClick={() => setEidoverseRepoUrl(buildEidoverseRepoUrl(selfOwner, selectedTransport))}
>
Self
</SourceChoiceButton>
<SourceChoiceButton
active={selectedRepo?.owner?.toLowerCase() === upstreamOwner.toLowerCase()}
disabled={savingId !== null}
onClick={() => setEidoverseRepoUrl(buildEidoverseRepoUrl(upstreamOwner, selectedTransport))}
>
Upstream
</SourceChoiceButton>
</span>
<span role="group" aria-label="Worlds repository protocol" className="inline-flex gap-1 rounded-lg border border-port-border p-1">
<SourceChoiceButton
active={selectedTransport === 'http'}
disabled={savingId !== null || !selectedRepo}
onClick={() => setEidoverseRepoUrl(buildEidoverseRepoUrl(selectedRepo.owner, 'http'))}
>
HTTP
</SourceChoiceButton>
<SourceChoiceButton
active={selectedTransport === 'ssh'}
disabled={savingId !== null || !selectedRepo}
onClick={() => setEidoverseRepoUrl(buildEidoverseRepoUrl(selectedRepo.owner, 'ssh'))}
>
SSH
</SourceChoiceButton>
</span>
</span>
<input
id="eidoverse-worlds-repo"
type="url"
type="text"
required
value={selectedRepoUrl}
onChange={(event) => setEidoverseRepoUrl(event.target.value)}
disabled={savingId !== null}
aria-invalid={!repoIsValid}
aria-describedby={!repoIsValid ? 'eidoverse-worlds-repo-error' : undefined}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden disabled:opacity-50"
placeholder="https://github.com/your-account/eidoverse-worlds"
placeholder="https://github.com/example-owner/eidoverse-worlds"
/>
</label>
</div>
{!repoIsValid && (
<p id="eidoverse-worlds-repo-error" role="alert" className="text-port-error">
{selectedRepoUrl === ''
Expand Down Expand Up @@ -216,8 +287,8 @@ export function InstanceFeaturesTab() {
)}
{setup?.installed && (
<div className="flex flex-wrap items-center gap-3 mt-3 text-xs">
{setup.worldsRepoUrl && (
<a className="text-port-accent hover:text-white transition-colors" href={setup.worldsRepoUrl} target="_blank" rel="noreferrer">
{worldsBrowseUrl && (
<a className="text-port-accent hover:text-white transition-colors" href={worldsBrowseUrl} target="_blank" rel="noreferrer">
Worlds repository
</a>
)}
Expand Down
35 changes: 35 additions & 0 deletions client/src/components/settings/InstanceFeaturesTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const EIDOVERSE_FEATURE = {
uiPort: 8940,
runtimeStatus: 'not_registered',
worldsRepoUrl: 'https://github.com/anima-research/eidoverse-worlds',
sourceOwners: { self: 'example-owner', upstream: 'anima-research' },
},
};

Expand Down Expand Up @@ -145,6 +146,40 @@ describe('InstanceFeaturesTab', () => {
));
});

it('builds Self and Upstream sources with the selected Git transport', async () => {
mock.getInstanceFeatures.mockResolvedValue({ features: [EIDOVERSE_FEATURE] });
render(<MemoryRouter><InstanceFeaturesTab /></MemoryRouter>);

const ownerGroup = await screen.findByRole('group', { name: 'Worlds repository owner' });
const protocolGroup = screen.getByRole('group', { name: 'Worlds repository protocol' });
expect(ownerGroup.querySelector('[aria-pressed="true"]')).toHaveTextContent('Upstream');
expect(protocolGroup.querySelector('[aria-pressed="true"]')).toHaveTextContent('HTTP');

fireEvent.click(screen.getByRole('button', { name: 'Self' }));
fireEvent.click(screen.getByRole('button', { name: 'SSH' }));

expect(screen.getByRole('textbox', { name: 'Worlds GitHub repository' }))
.toHaveValue('git@github.com:example-owner/eidoverse-worlds.git');
fireEvent.click(screen.getByRole('button', { name: 'Install & enable' }));
await waitFor(() => expect(mock.installEidoverseFeature).toHaveBeenCalledWith(
'git@github.com:example-owner/eidoverse-worlds.git',
{ silent: true },
));
});

it('disables Self when the PortOS origin is not a GitHub repository', async () => {
mock.getInstanceFeatures.mockResolvedValue({
features: [{
...EIDOVERSE_FEATURE,
setup: { ...EIDOVERSE_FEATURE.setup, sourceOwners: { self: null, upstream: 'anima-research' } },
}],
});
render(<MemoryRouter><InstanceFeaturesTab /></MemoryRouter>);

expect(await screen.findByRole('button', { name: 'Self' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Upstream' })).toHaveAttribute('aria-pressed', 'true');
});

it('updates the origin of an installed Worlds checkout in place', async () => {
const installed = {
...EIDOVERSE_FEATURE,
Expand Down
5 changes: 4 additions & 1 deletion server/routes/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ vi.mock('../services/datadog.js', () => ({
vi.mock('../services/jira.js', () => ({
hasConfiguredInstances: vi.fn(async () => false),
}));
vi.mock('../lib/gitRemote.js', () => ({
getOriginInfo: vi.fn(async () => ({ isGithub: true, owner: 'example-owner' })),
}));
vi.mock('../services/eidoverse.js', () => ({
DEFAULT_EIDOVERSE_WORLDS_REPO: 'https://github.com/anima-research/eidoverse-worlds',
normalizeEidoverseWorldsRepo: vi.fn((url) => url),
Expand Down Expand Up @@ -195,7 +198,7 @@ describe('Settings routes — instance feature participation', () => {
});

it('updates the installed Eidoverse source without changing feature participation', async () => {
const worldsRepoUrl = 'https://github.com/example-owner/eidoverse-worlds';
const worldsRepoUrl = 'git@github.com:example-owner/eidoverse-worlds.git';
store = { instanceFeatures: { eidoverse: { enabled: true } } };

const res = await request(buildApp())
Expand Down
45 changes: 26 additions & 19 deletions server/services/eidoverse.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export function normalizeEidoverseWorldsRepo(worldsRepoUrl) {
code: 'EIDOVERSE_REPO_INVALID',
});
}
return `https://github.com/${parsed.owner}/${parsed.repo}`;
return /^git@github\.com:/i.test(String(worldsRepoUrl).trim())
? `git@github.com:${parsed.owner}/${parsed.repo}.git`
: `https://github.com/${parsed.owner}/${parsed.repo}`;
}

export function getEidoversePaths(worldsRepoUrl = DEFAULT_EIDOVERSE_WORLDS_REPO) {
Expand Down Expand Up @@ -200,6 +202,7 @@ async function performInstall(worldsRepoUrl) {
paths.worlds === configuredPaths.worlds ? cloneRepo(worldsRepoUrl) : Promise.resolve(),
cloneRepo(EIDOVERSE_VIDEO_REPO),
]);
await updateOriginAtPath(paths.worlds, worldsRepoUrl);

await Promise.all([
installDependencies(paths.worlds, bun),
Expand Down Expand Up @@ -293,6 +296,27 @@ export async function getEidoverseStatus({ worldsRepoUrl = DEFAULT_EIDOVERSE_WOR
};
}

async function updateOriginAtPath(repoPath, worldsRepoUrl) {
const result = await execGit(
['remote', 'set-url', 'origin', worldsRepoUrl],
repoPath,
{ ignoreExitCode: true },
);
if (result.exitCode === 0) return;

const added = await execGit(
['remote', 'add', 'origin', worldsRepoUrl],
repoPath,
{ ignoreExitCode: true },
);
if (added.exitCode !== 0) {
throw new ServerError('The Eidoverse Worlds checkout has no usable Git origin.', {
status: 422,
code: 'EIDOVERSE_ORIGIN_UPDATE_FAILED',
});
}
}

/**
* Change the Worlds checkout's fetch origin without moving the checkout or
* touching its working tree. This is intentionally separate from installation:
Expand All @@ -319,24 +343,7 @@ export async function setEidoverseWorldsOrigin(worldsRepoUrl) {
});
}

const result = await execGit(
['remote', 'set-url', 'origin', normalizedRepoUrl],
repoPath,
{ ignoreExitCode: true },
);
if (result.exitCode !== 0) {
const added = await execGit(
['remote', 'add', 'origin', normalizedRepoUrl],
repoPath,
{ ignoreExitCode: true },
);
if (added.exitCode !== 0) {
throw new ServerError('The Eidoverse Worlds checkout has no usable Git origin.', {
status: 422,
code: 'EIDOVERSE_ORIGIN_UPDATE_FAILED',
});
}
}
await updateOriginAtPath(repoPath, normalizedRepoUrl);

notifyAppsChanged('update', app.id);
return { appId: app.id, worldsRepoUrl: normalizedRepoUrl };
Expand Down
30 changes: 25 additions & 5 deletions server/services/eidoverse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from './eidoverse.js';

const SELECTED_WORLDS_REPO = 'https://github.com/example-owner/eidoverse-worlds';
const SELECTED_WORLDS_REPO_SSH = 'git@github.com:example-owner/eidoverse-worlds.git';
const selectedPaths = getEidoversePaths(SELECTED_WORLDS_REPO);

describe('Eidoverse managed-app installer', () => {
Expand All @@ -84,9 +85,9 @@ describe('Eidoverse managed-app installer', () => {
__resetEidoverseInstallForTests();

mock.cloneRepo.mockImplementation(async (url) => {
mock.existing.add(url === SELECTED_WORLDS_REPO
? join(selectedPaths.worlds, '.git')
: join(selectedPaths.video, '.git'));
mock.existing.add(url === EIDOVERSE_VIDEO_REPO
? join(selectedPaths.video, '.git')
: join(selectedPaths.worlds, '.git'));
});
mock.spawn.mockImplementation(async (command, _args, options = {}) => {
if (command === 'powershell' || command === 'bash') {
Expand Down Expand Up @@ -119,6 +120,11 @@ describe('Eidoverse managed-app installer', () => {

expect(mock.cloneRepo).toHaveBeenCalledWith(SELECTED_WORLDS_REPO);
expect(mock.cloneRepo).toHaveBeenCalledWith(EIDOVERSE_VIDEO_REPO);
expect(mock.execGit).toHaveBeenCalledWith(
['remote', 'set-url', 'origin', SELECTED_WORLDS_REPO],
selectedPaths.worlds,
{ ignoreExitCode: true },
);
expect(mock.spawn).toHaveBeenCalledWith('bun', ['install', '--frozen-lockfile'], expect.objectContaining({ cwd: selectedPaths.worlds }));
expect(mock.spawn).toHaveBeenCalledWith('bun', ['install', '--frozen-lockfile'], expect.objectContaining({ cwd: join(selectedPaths.worlds, 'client') }));
expect(mock.atomicWrite).toHaveBeenCalledWith(
Expand Down Expand Up @@ -161,11 +167,25 @@ describe('Eidoverse managed-app installer', () => {
expect(status).toMatchObject({ installed: true, appId: 'app-eidoverse' });
});

it('uses the canonical upstream by default and normalizes a selected fork URL', () => {
it('configures a fresh checkout with the selected SSH origin', async () => {
const status = await installEidoverse({ worldsRepoUrl: SELECTED_WORLDS_REPO_SSH });

expect(mock.cloneRepo).toHaveBeenCalledWith(SELECTED_WORLDS_REPO_SSH);
expect(mock.execGit).toHaveBeenCalledWith(
['remote', 'set-url', 'origin', SELECTED_WORLDS_REPO_SSH],
selectedPaths.worlds,
{ ignoreExitCode: true },
);
expect(status).toMatchObject({ installed: true, worldsRepoUrl: SELECTED_WORLDS_REPO_SSH });
});

it('uses the canonical upstream by default and preserves the selected Git transport', () => {
expect(getEidoversePaths().worlds).toBe(join('/example/data/repos', 'anima-research', 'eidoverse-worlds'));
expect(DEFAULT_EIDOVERSE_WORLDS_REPO).toBe('https://github.com/anima-research/eidoverse-worlds');
expect(normalizeEidoverseWorldsRepo('git@github.com:example-owner/eidoverse-worlds.git'))
expect(normalizeEidoverseWorldsRepo('https://github.com/example-owner/eidoverse-worlds.git'))
.toBe(SELECTED_WORLDS_REPO);
expect(normalizeEidoverseWorldsRepo('git@github.com:example-owner/eidoverse-worlds.git'))
.toBe('git@github.com:example-owner/eidoverse-worlds.git');
});

it('selects the official unattended Bun installer for each supported platform', () => {
Expand Down
18 changes: 15 additions & 3 deletions server/services/instanceFeatures.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ServerError } from '../lib/errorHandler.js';
import { getOriginInfo } from '../lib/gitRemote.js';
import { parseGitHubUrl } from '../lib/githubRepoUrl.js';
import { INSTANCE_FEATURES, INSTANCE_FEATURE_IDS } from '../lib/instanceFeatureRegistry.js';
import { isPlainObject } from '../lib/objects.js';
Expand Down Expand Up @@ -123,7 +124,7 @@ const configuredEidoverseRepo = (settings) => {
if (typeof configured !== 'string') return DEFAULT_EIDOVERSE_WORLDS_REPO;
const parsed = parseGitHubUrl(configured);
return parsed
? `https://github.com/${parsed.owner}/${parsed.repo}`
? normalizeEidoverseWorldsRepo(configured)
: DEFAULT_EIDOVERSE_WORLDS_REPO;
};

Expand All @@ -133,9 +134,20 @@ export async function assertConfiguredEidoverseInstalled() {
}

const attachSetupStatus = async (features, settings) => {
const eidoverse = await getEidoverseStatus({ worldsRepoUrl: configuredEidoverseRepo(settings) });
const [eidoverse, portosOrigin] = await Promise.all([
getEidoverseStatus({ worldsRepoUrl: configuredEidoverseRepo(settings) }),
getOriginInfo(),
]);
const upstream = parseGitHubUrl(DEFAULT_EIDOVERSE_WORLDS_REPO)?.owner || null;
const sourceOwners = {
// A stock clone points at atomantic/PortOS, which identifies the project
// owner rather than the current user's GitHub account. Only a non-upstream
// GitHub origin gives us a defensible owner for the "Self" shortcut.
self: portosOrigin.isGithub && !portosOrigin.isUpstream ? portosOrigin.owner : null,
upstream,
};
return features.map((feature) => (
feature.id === 'eidoverse' ? { ...feature, setup: eidoverse } : feature
feature.id === 'eidoverse' ? { ...feature, setup: { ...eidoverse, sourceOwners } } : feature
));
};

Expand Down
Loading