Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5c4aadc
feat: add Xcode multi-platform template with deploy and screenshot sc…
atomantic Apr 6, 2026
c8a4a73
address review: fix Xcode template scheme/script issues and add endpo…
atomantic Apr 6, 2026
ce9ca25
address review: restrict script checks to Xcode types, add win32 chmo…
atomantic Apr 6, 2026
1de7f04
address review: scope deploy tests to iOS builds, use fs.chmod in sca…
atomantic Apr 6, 2026
a728e1e
address review: consistent xcodeScripts in API response, safer bundle…
atomantic Apr 6, 2026
68f9938
address review: expand tilde in deploy script KEY_PATH
atomantic Apr 6, 2026
b46b309
address review: fix Swift compilation, dynamic simulator detection, d…
atomantic Apr 6, 2026
48f7f3d
address review: use shared XCODE_BUNDLE_PREFIX in iOS scaffold, parse…
atomantic Apr 6, 2026
75ba0e2
address review: toBundleId fallback, quoted YAML handling, chmod erro…
atomantic Apr 6, 2026
ab7d3c3
address review: validate repoPath existence in checkScripts and insta…
atomantic Apr 6, 2026
2519b69
address review: validate project.yml values against strict allowlists
atomantic Apr 6, 2026
c877026
address review: guard git commit in deploy.sh, skip chmod on win32 in…
atomantic Apr 6, 2026
370b006
address review: fix bash tilde expansion bug and expand installScript…
atomantic Apr 6, 2026
77a386b
address review: restore process.platform descriptor cleanly in win32 …
atomantic Apr 6, 2026
ff6f6f1
address review: scope Xcode scripts by app type and validate via Zod …
atomantic Apr 6, 2026
1a92885
address review: harden toTargetName and quote .env example with spaces
atomantic Apr 6, 2026
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
10 changes: 10 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## Added

- **Xcode Multi-Platform template** — new "Xcode Multi-Platform" app template scaffolds a SwiftUI project with iOS, macOS, and watchOS targets via XcodeGen
- Generic `deploy.sh` with `--ios`, `--macos`, `--watch`, `--all` flags for TestFlight deployment
- Generic `take_screenshots.sh` and `take_screenshots_macos.sh` for App Store screenshot automation
- UI test target with `ScreenshotTests.swift` stubs for XCUITest-based screenshot capture
- Shared module for cross-platform code, macOS entitlements, watchOS companion app
- **Xcode script health check** — PortOS now detects missing management scripts (deploy, screenshots) in Xcode-managed apps and surfaces a banner in the app detail overview with one-click install
- Deploy panel now includes `--watch` (watchOS) flag option

## Fixed

- Submodule status API (`/api/git/submodules/status`) always returned empty array — `stdout.trim()` was stripping the leading space status character from `git submodule status` output, causing the regex parser to fail
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/apps/DeployPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { useAppDeploy } from '../../hooks/useAppDeploy';
const FLAG_OPTIONS = [
{ value: '--ios', label: 'iOS' },
{ value: '--macos', label: 'macOS' },
{ value: '--watch', label: 'watchOS' },
{ value: '--all', label: 'All Platforms' },
{ value: '--skip-tests', label: 'Skip Tests' },
];

const PLATFORM_FLAGS = new Set(['--ios', '--macos', '--all']);
const PLATFORM_FLAGS = new Set(['--ios', '--macos', '--watch', '--all']);

export default function DeployPanel({ appId, appName }) {
const { output, isDeploying, error, result, startDeploy, clearDeploy } = useAppDeploy();
Expand Down
72 changes: 71 additions & 1 deletion client/src/components/apps/tabs/OverviewTab.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { FolderOpen, Terminal, Code, RefreshCw, Wrench, Archive, ArchiveRestore, Ticket, Download, Tag } from 'lucide-react';
import { FolderOpen, Terminal, Code, RefreshCw, Wrench, Archive, ArchiveRestore, Ticket, Download, Tag, AlertTriangle, Rocket, Camera } from 'lucide-react';
import toast from '../../ui/Toast';
import { NON_PM2_TYPES } from '../constants';
import BrailleSpinner from '../../BrailleSpinner';
Expand All @@ -10,12 +10,19 @@ import SlashDoPanel from '../SlashDoPanel';
import { useAppOperation } from '../../../hooks/useAppOperation';
import * as api from '../../../services/api';

const SCRIPT_ICONS = {
'deploy.sh': Rocket,
'take_screenshots.sh': Camera,
'take_screenshots_macos.sh': Camera
};

export default function OverviewTab({ app, onRefresh }) {
const [editingApp, setEditingApp] = useState(null);
const [refreshingConfig, setRefreshingConfig] = useState(false);
const [archiving, setArchiving] = useState(false);
const [jiraTickets, setJiraTickets] = useState(null);
const [loadingTickets, setLoadingTickets] = useState(false);
const [installingScripts, setInstallingScripts] = useState(false);

const onComplete = useMemo(() => () => onRefresh(), [onRefresh]);
const { steps, isOperating, operationType, error, completed, startUpdate, startStandardize } = useAppOperation({ onComplete });
Expand Down Expand Up @@ -43,6 +50,24 @@ export default function OverviewTab({ app, onRefresh }) {

const handleStandardize = () => startStandardize(app.id);

const missingScripts = app.xcodeScripts?.missing || [];

const handleInstallScripts = async (scriptNames) => {
setInstallingScripts(true);
const result = await api.installXcodeScripts(app.id, scriptNames).catch(() => null);
setInstallingScripts(false);
if (!result) return; // request() already showed error toast

if (result.installed?.length) {
toast.success(`Installed: ${result.installed.join(', ')}`);
onRefresh();
}

if (result.errors?.length) {
toast.error(`Some scripts could not be installed: ${result.errors.join(', ')}`);
}
};

const handleArchive = async () => {
setArchiving(true);
await api.archiveApp(app.id).catch(() => null);
Expand Down Expand Up @@ -90,6 +115,51 @@ export default function OverviewTab({ app, onRefresh }) {
)}
</div>

{/* Missing Xcode Scripts Banner */}
{missingScripts.length > 0 && (
<div className="bg-port-warning/10 border border-port-warning/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="text-port-warning shrink-0 mt-0.5" />
<div className="flex-1">
<div className="text-sm font-medium text-port-warning mb-2">
Missing management scripts
</div>
<div className="flex flex-wrap gap-2 mb-3">
{missingScripts.map(s => {
const Icon = SCRIPT_ICONS[s.name] || Terminal;
return (
<span key={s.name} className="inline-flex items-center gap-1.5 px-2 py-1 bg-port-card border border-port-border rounded text-xs text-gray-300">
<Icon size={12} />
<span className="font-mono">{s.name}</span>
<span className="text-gray-500">— {s.description}</span>
</span>
);
})}
</div>
<div className="flex gap-2">
<button
onClick={() => handleInstallScripts(missingScripts.map(s => s.name))}
disabled={installingScripts}
className="px-3 py-1.5 bg-port-warning/20 text-port-warning hover:bg-port-warning/30 rounded text-xs font-medium disabled:opacity-50"
>
{installingScripts ? 'Installing...' : 'Install All'}
</button>
{missingScripts.length > 1 && missingScripts.map(s => (
<button
key={s.name}
onClick={() => handleInstallScripts([s.name])}
disabled={installingScripts}
className="px-2 py-1.5 bg-port-border hover:bg-port-border/80 text-gray-300 rounded text-xs disabled:opacity-50"
>
Install {s.name}
</button>
))}
</div>
</div>
</div>
</div>
)}

{/* Start Commands */}
{app.startCommands?.length > 0 && (
<div>
Expand Down
5 changes: 3 additions & 2 deletions client/src/pages/Templates.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layers, Code, Server, Globe, Smartphone, Plus } from 'lucide-react';
import { Layers, Code, Server, Globe, Smartphone, MonitorSmartphone, Plus } from 'lucide-react';
import * as api from '../services/api';
import DirectoryPicker from '../components/DirectoryPicker';

Expand All @@ -9,7 +9,8 @@ const ICONS = {
code: Code,
server: Server,
globe: Globe,
smartphone: Smartphone
smartphone: Smartphone,
'monitor-smartphone': MonitorSmartphone
};

export default function Templates() {
Expand Down
4 changes: 4 additions & 0 deletions client/src/services/apiApps.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export const getAppLogs = (id, lines = 100, processName) => {
return request(`/apps/${id}/logs?${params}`);
};

export const installXcodeScripts = (id, scripts) => request(`/apps/${id}/xcode-scripts/install`, {
method: 'POST',
body: JSON.stringify({ scripts })
});
export const getAppDocuments = (id) => request(`/apps/${id}/documents`);
export const getAppDocument = (id, filename) => request(`/apps/${id}/documents/${filename}`);
export const saveAppDocument = (id, filename, content, commitMessage) =>
Expand Down
26 changes: 23 additions & 3 deletions server/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { safeJSONParse } from '../lib/fileUtils.js';
import { parseEcosystemFromPath, usesPm2 } from '../services/streamingDetect.js';
import { detectAppIcon, getIconContentType } from '../services/appIconDetect.js';
import { hasDeployScript } from '../services/appDeployer.js';
import { checkScripts, installScripts, XCODE_SCRIPT_NAMES } from '../services/xcodeScripts.js';

const router = Router();

Expand Down Expand Up @@ -71,7 +72,7 @@ router.get('/', asyncHandler(async (req, res) => {
const enriched = await Promise.all(apps.map(async (app) => {
// Non-PM2 apps skip PM2 enrichment entirely
if (!usesPm2(app.type)) {
return { ...app, pm2Status: {}, overallStatus: 'n/a', hasDeployScript: hasDeployScript(app) };
return { ...app, pm2Status: {}, overallStatus: 'n/a', hasDeployScript: hasDeployScript(app), xcodeScripts: checkScripts(app) };
}
Comment thread
atomantic marked this conversation as resolved.

const pm2Home = app.pm2Home || null;
Expand Down Expand Up @@ -125,7 +126,8 @@ router.get('/', asyncHandler(async (req, res) => {
apiPort,
pm2Status: statuses,
overallStatus,
hasDeployScript: hasDeployScript(app)
hasDeployScript: hasDeployScript(app),
xcodeScripts: checkScripts(app)
};
}));

Expand Down Expand Up @@ -183,7 +185,25 @@ router.get('/:id', loadApp, asyncHandler(async (req, res) => {
appVersion = pkg?.version || null;
}

res.json({ ...app, uiPort, devUiPort, apiPort, overallStatus, pm2Status: statuses, appVersion, hasDeployScript: hasDeployScript(app) });
res.json({ ...app, uiPort, devUiPort, apiPort, overallStatus, pm2Status: statuses, appVersion, hasDeployScript: hasDeployScript(app), xcodeScripts: checkScripts(app) });
}));

// POST /api/apps/:id/xcode-scripts/install - Install missing management scripts
// Restrict the request payload to the known, fixed set of script names so that
// arbitrary or oversized arrays are rejected at the validation layer.
const installScriptsSchema = z.object({
scripts: z.array(z.enum(XCODE_SCRIPT_NAMES)).min(1).max(XCODE_SCRIPT_NAMES.length)
});
router.post('/:id/xcode-scripts/install', loadApp, asyncHandler(async (req, res) => {
const { scripts } = validateRequest(installScriptsSchema, req.body);
if (!req.loadedApp.repoPath || !existsSync(req.loadedApp.repoPath)) {
throw new ServerError('App repository path not found', { status: 400, code: 'PATH_NOT_FOUND' });
}
const result = await installScripts(req.loadedApp, scripts);
if (result.errors.length && !result.installed.length) {
throw new ServerError(result.errors.join(', '), { status: 400, code: 'INSTALL_FAILED' });
}
res.json(result);
}));
Comment thread
atomantic marked this conversation as resolved.
Comment thread
atomantic marked this conversation as resolved.

// GET /api/apps/:id/icon - Serve the app's detected icon image
Expand Down
99 changes: 99 additions & 0 deletions server/routes/apps.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,26 @@ vi.mock('../services/git.js', () => ({
commit: vi.fn()
}));

vi.mock('../services/xcodeScripts.js', () => ({
checkScripts: vi.fn().mockReturnValue({ missing: [], present: [] }),
installScripts: vi.fn(),
XCODE_TEAM_ID: 'TEST_TEAM',
XCODE_BUNDLE_PREFIX: 'net.test',
XCODE_SCRIPT_NAMES: ['deploy.sh', 'take_screenshots.sh', 'take_screenshots_macos.sh'],
toBundleId: vi.fn(),
toTargetName: vi.fn(),
generateDeployScript: vi.fn(),
generateScreenshotScript: vi.fn(),
generateMacScreenshotScript: vi.fn()
}));

// Import mocked modules
import * as appsService from '../services/apps.js';
import * as pm2Service from '../services/pm2.js';
import * as history from '../services/history.js';
import * as streamingDetect from '../services/streamingDetect.js';
import { detectAppIcon, getIconContentType } from '../services/appIconDetect.js';
import { installScripts } from '../services/xcodeScripts.js';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
Expand Down Expand Up @@ -639,4 +653,89 @@ describe('Apps Routes', () => {
expect(response.status).toBe(404);
});
});

describe('POST /api/apps/:id/xcode-scripts/install', () => {
it('should install requested scripts successfully', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
installScripts.mockResolvedValue({ installed: ['deploy.sh'], skipped: [], errors: [] });

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: ['deploy.sh'] });

expect(response.status).toBe(200);
expect(response.body.installed).toEqual(['deploy.sh']);
});
Comment thread
atomantic marked this conversation as resolved.

it('should return 400 when all scripts fail', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
installScripts.mockResolvedValue({ installed: [], skipped: [], errors: ['some failure'] });

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: ['deploy.sh'] });

expect(response.status).toBe(400);
expect(response.body.code).toBe('INSTALL_FAILED');
});

it('should return 400 when scripts array contains an unknown name', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: ['bad.sh'] });

// Unknown script names are now rejected by the Zod enum validator
expect(response.status).toBe(400);
});

it('should return 400 when scripts array is empty', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: [] });

expect(response.status).toBe(400);
});

it('should return 404 when app not found', async () => {
appsService.getAppById.mockResolvedValue(null);

const response = await request(app)
.post('/api/apps/app-999/xcode-scripts/install')
.send({ scripts: ['deploy.sh'] });

expect(response.status).toBe(404);
});

it('should return partial success with errors', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/tmp' });
installScripts.mockResolvedValue({
installed: ['deploy.sh'],
skipped: [],
errors: ['Script take_screenshots_macos.sh does not apply to ios-native apps']
});

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: ['deploy.sh', 'take_screenshots_macos.sh'] });

expect(response.status).toBe(200);
expect(response.body.installed).toEqual(['deploy.sh']);
expect(response.body.errors).toHaveLength(1);
});

it('should return 400 when repoPath does not exist', async () => {
appsService.getAppById.mockResolvedValue({ id: 'app-001', name: 'Test App', type: 'xcode', repoPath: '/nonexistent/path' });

const response = await request(app)
.post('/api/apps/app-001/xcode-scripts/install')
.send({ scripts: ['deploy.sh'] });

expect(response.status).toBe(400);
expect(response.body.code).toBe('PATH_NOT_FOUND');
});
});
});
Loading