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
1 change: 1 addition & 0 deletions client/src/components/apps/tabs/RepositorySourcePanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export default function RepositorySourcePanel({ appId, appName, onUpdated, refre
<li>Pull the application checkout from its configured origin</li>
{companions.length > 0 && <li>Pull {companions.length} independent companion checkout{companions.length === 1 ? '' : 's'}</li>}
<li>Install dependencies and run the app&apos;s setup script when configured</li>
<li>Rebuild the production UI when a build script is configured</li>
{status?.updateRestartsApp && <li>Restart the app&apos;s managed processes</li>}
</ul>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ describe('managed app repository sources', () => {
fireEvent.click(await screen.findByRole('button', { name: 'Sync fork & update app' }));
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveTextContent('Pull 1 independent companion checkout');
expect(dialog).toHaveTextContent('Rebuild the production UI when a build script is configured');
expect(dialog).toHaveTextContent('Restart the app\'s managed processes');

fireEvent.click(screen.getByRole('button', { name: 'Sync fork and update' }));
Expand Down
36 changes: 27 additions & 9 deletions server/services/appUpdater.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { bufferedSpawnOrThrow } from '../lib/bufferedSpawn.js';
import { parseCommandArgs } from '../lib/commandSecurity.js';
import { isDetachedRunning, spawnDetached } from '../lib/detachedSpawn.js';
import { PORTOS_APP_ID } from '../lib/appIdentity.js';
import { parseBuildCommand } from './appBuilder.js';
import { syncManagedAppFork } from './managedAppRepositories.js';

const CMD_TIMEOUT_MS = 5 * 60 * 1000;
Expand Down Expand Up @@ -70,7 +71,10 @@ async function startDashboardHandoff(app) {
* 2. install dependencies in each package directory (Bun apps use their
* frozen lockfile; existing apps retain npm install)
* 3. run setup with the same package manager when the script exists
* 4. Restart PM2 processes
* 4. rebuild the production UI when a build command or `scripts.build` exists
* (a pull that only restarts leaves `client/dist` stale and PortOS then
* reports "install out of sync")
* 5. Restart PM2 processes
*
* @param {object} app - The app object (must have repoPath, pm2ProcessNames, pm2Home)
* @param {function} emit - Callback (step, status, message) for progress updates
Expand Down Expand Up @@ -143,14 +147,28 @@ async function _doUpdate(app, emit, { syncFork }) {
}

const pkgPath = join(dir, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
if (pkg.scripts?.setup) {
emit('setup', 'running', 'Running setup...');
await runCommand(packageManagerCommand, ['run', 'setup'], dir);
emit('setup', 'done', 'Setup complete');
steps.push({ step: 'setup', success: true });
}
const pkg = existsSync(pkgPath) ? JSON.parse(await readFile(pkgPath, 'utf-8')) : null;
if (pkg?.scripts?.setup) {
emit('setup', 'running', 'Running setup...');
await runCommand(packageManagerCommand, ['run', 'setup'], dir);
emit('setup', 'done', 'Setup complete');
steps.push({ step: 'setup', success: true });
}

const configuredBuild = typeof app.buildCommand === 'string' ? app.buildCommand.trim() : '';
let build;
if (configuredBuild) {
const parsed = parseBuildCommand(configuredBuild);
if (!parsed.ok) throw new Error(parsed.message);
build = { cmd: parsed.cmd, args: parsed.args };
} else if (pkg?.scripts?.build) {
build = { cmd: packageManagerCommand, args: ['run', 'build'] };
}
if (build) {
emit('build', 'running', 'Building production UI...');
await runCommand(build.cmd, build.args, dir);
emit('build', 'done', 'Production UI built');
steps.push({ step: 'build', success: true });
}

const processNames = app.pm2ProcessNames || [];
Expand Down
71 changes: 70 additions & 1 deletion server/services/appUpdater.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ const mock = vi.hoisted(() => ({

vi.mock('./git.js', () => ({ pull: mock.pull }));
vi.mock('./pm2.js', () => ({ restartApp: mock.restart }));
vi.mock('../lib/bufferedSpawn.js', () => ({ bufferedSpawnOrThrow: mock.spawn }));
vi.mock('../lib/bufferedSpawn.js', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, bufferedSpawnOrThrow: mock.spawn };
});
vi.mock('../lib/detachedSpawn.js', () => ({
isDetachedRunning: mock.dashboardRunning,
spawnDetached: mock.dashboardOpen,
Expand Down Expand Up @@ -54,6 +57,7 @@ describe('managed app updates', () => {
});

it('uses Bun and its frozen lockfile for Bun-managed apps', async () => {
await writeFile(join(repo, 'package.json'), JSON.stringify({ scripts: { setup: 'example-setup', build: 'vite build' } }));
const emit = vi.fn();
const companionRepo = join(repo, '..', 'eidoverse-video');
const bunCommand = join(repo, 'tools with spaces', 'bun');
Expand All @@ -72,6 +76,7 @@ describe('managed app updates', () => {
expect(mock.spawn).toHaveBeenCalledWith(bunCommand, ['install', '--frozen-lockfile'], expect.objectContaining({ cwd: repo }));
expect(mock.spawn).toHaveBeenCalledWith(bunCommand, ['install', '--frozen-lockfile'], expect.objectContaining({ cwd: join(repo, 'client') }));
expect(mock.spawn).toHaveBeenCalledWith(bunCommand, ['run', 'setup'], expect.objectContaining({ cwd: repo }));
expect(mock.spawn).toHaveBeenCalledWith(bunCommand, ['run', 'build'], expect.objectContaining({ cwd: repo }));
expect(mock.spawn).not.toHaveBeenCalledWith('npm', expect.anything(), expect.anything());
expect(emit).toHaveBeenCalledWith('git-pull:companion-1', 'done', 'Already up to date');
expect(emit).toHaveBeenCalledWith('bun-install:root', 'done', 'root dependencies installed');
Expand Down Expand Up @@ -139,4 +144,68 @@ describe('managed app updates', () => {
expect(mock.dashboardOpen).not.toHaveBeenCalled();
expect(mock.restart).toHaveBeenCalledWith('portos-server', undefined);
});

it('rebuilds the production UI before restarting so a managed update does not leave a stale client bundle', async () => {
const emit = vi.fn();
const managed = {
id: 'portos-default',
name: 'PortOS',
type: 'express',
repoPath: repo,
buildCommand: 'npm run build',
pm2ProcessNames: ['portos-server'],
};

const result = await updateApp(managed, emit);

expect(result.success).toBe(true);
expect(result.steps.some((step) => step.step === 'build' && step.success)).toBe(true);
expect(mock.spawn).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({ cwd: repo }),
);
const buildCall = mock.spawn.mock.invocationCallOrder[
mock.spawn.mock.calls.findIndex((call) => call[0] === 'npm' && call[1]?.[1] === 'build')
];
expect(buildCall).toBeLessThan(mock.restart.mock.invocationCallOrder[0]);
expect(emit).toHaveBeenCalledWith('build', 'done', 'Production UI built');
});

it('rebuilds from package.json when no explicit build command is configured', async () => {
await writeFile(join(repo, 'package.json'), JSON.stringify({ scripts: { build: 'vite build' } }));
const emit = vi.fn();

await updateApp({ name: 'Example App', type: 'express', repoPath: repo, pm2ProcessNames: [] }, emit);

expect(mock.spawn).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({ cwd: repo }),
);
expect(emit).toHaveBeenCalledWith('build', 'done', 'Production UI built');
});

it('does not invent a production build when the app has none', async () => {
const emit = vi.fn();

await updateApp({ name: 'Example App', type: 'express', repoPath: repo, pm2ProcessNames: [] }, emit);

expect(mock.spawn).not.toHaveBeenCalledWith('npm', ['run', 'build'], expect.anything());
expect(emit).not.toHaveBeenCalledWith('build', expect.anything(), expect.anything());
});

it('refuses a disallowed build command before restarting', async () => {
const emit = vi.fn();

await expect(updateApp({
name: 'Example App',
type: 'express',
repoPath: repo,
buildCommand: 'rm -rf /',
pm2ProcessNames: ['example-app'],
}, emit)).rejects.toThrow(/not allowed/);

expect(mock.restart).not.toHaveBeenCalled();
});
});