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 .changelog/next/added-issue-4137.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- App detection now classifies Python, Go, Docker, and static repos instead of labelling them `unknown`, and PM2 standardization (which writes a Node ecosystem config) is no longer offered for them
40 changes: 36 additions & 4 deletions client/src/components/apps/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,42 @@ export function resolveLaunchPanelProcess(app, result) {
return result?.results?.[processName]?.success === false ? null : processName;
}

export const getAppTypeLabel = (type) =>
type === 'ios-native' ? '📱 iOS' :
type === 'macos-native' ? '🖥️ macOS' :
type === 'swift' ? '🐦 Swift' : '🔨 Xcode';
// Mirrors NON_NODE_TYPES / NON_STANDARDIZABLE_TYPES in
// server/services/streamingDetect.js (parity tests assert the Sets match). The
// PM2 standardizer writes a NODE ecosystem config from a prompt that opens "You
// are analyzing a Node.js application", so a Python/Go/Docker/static repo must
// not be offered the flow. It's a DENY list, not an allowlist of Node types:
// `type` is a free-form string persisted on the app record, so records in the
// wild carry legacy values this file has never heard of, and an allowlist would
// silently withdraw the button from all of them. See the server's rationale.
export const NON_NODE_TYPES = new Set(['python', 'go', 'docker', 'static']);

export const NON_STANDARDIZABLE_TYPES = new Set([...NON_PM2_TYPES, ...NON_NODE_TYPES]);

/** Whether the PM2 standardizer (which writes a NODE ecosystem config) applies. */
export const isStandardizable = (type) => !NON_STANDARDIZABLE_TYPES.has(type);

// Every app type that can reach a UI label. Kept TOTAL (with a raw-type
// fallback) rather than a ternary chain ending in '🔨 Xcode' — that default
// meant any type not explicitly listed rendered as an Xcode project.
const APP_TYPE_LABELS = {
'ios-native': '📱 iOS',
'macos-native': '🖥️ macOS',
swift: '🐦 Swift',
xcode: '🔨 Xcode',
python: '🐍 Python',
go: '🐹 Go',
docker: '🐳 Docker',
static: '📄 Static',
desktop: '🎮 Desktop',
'vite+express': '⚡ Vite + Express',
vite: '⚡ Vite',
'single-node-server': '🟢 Node',
express: '🟢 Express',
nextjs: '▲ Next.js'
};

export const getAppTypeLabel = (type) => APP_TYPE_LABELS[type] || type || 'Unknown';

// Where an app's autonomous work items live. Mirrors WORK_TRACKERS +
// TRACKER_LABELS in server/lib/workTracker.js — shared by the Edit App picker
Expand Down
8 changes: 5 additions & 3 deletions client/src/components/apps/tabs/OverviewTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router';
import { FolderOpen, Gamepad2, Terminal, Code, RefreshCw, Wrench, Archive, ArchiveRestore, Download, Tag, AlertTriangle, Rocket, Camera, Image, Sparkles } from 'lucide-react';
import toast from '../../ui/Toast';
import { NON_PM2_TYPES } from '../constants';
import { isStandardizable } from '../constants';
import ActivityLog from '../ActivityLog';
import SlashDoPanel from '../SlashDoPanel';
import Banner from '../../ui/Banner';
Expand Down Expand Up @@ -313,8 +313,10 @@ export default function OverviewTab({ app, onRefresh }) {
{detectingIcon ? 'Scanning...' : 'Detect Icon'}
</button>
{/* PortOS's own ecosystem.config.cjs is the canonical PORTS source —
it is never regenerated from an LLM analysis (the server refuses too). */}
{!NON_PM2_TYPES.has(app.type) && app.id !== api.PORTOS_APP_ID && (
it is never regenerated from an LLM analysis (the server refuses too).
`isStandardizable` also keeps the button off non-Node repos, whose
ecosystem config the Node-shaped prompt has no business writing. */}
{isStandardizable(app.type) && app.id !== api.PORTOS_APP_ID && (
<button
onClick={handleStandardize}
disabled={isOperating}
Expand Down
7 changes: 5 additions & 2 deletions client/src/pages/Apps.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useAppOperation } from '../hooks/useAppOperation';
import useUrlParams from '../hooks/useUrlParams';
import * as api from '../services/api';
import socket from '../services/socket';
import { NON_PM2_TYPES, getAppTypeLabel } from '../components/apps/constants';
import { NON_PM2_TYPES, isStandardizable, getAppTypeLabel } from '../components/apps/constants';
import { formatBytes } from '../utils/formatters';

export default function Apps() {
Expand Down Expand Up @@ -700,7 +700,10 @@ export default function Apps() {
<RefreshCw size={14} aria-hidden="true" className={refreshingConfig[app.id] ? 'animate-spin' : ''} />
Refresh Config
</button>
{(!app.processes?.length || app.processes.some(p => !p.ports || Object.keys(p.ports).length === 0)) && (
{/* The standardizer writes a NODE ecosystem config —
never offer it for a Python/Go/Docker/static repo
(the server refuses too). */}
{isStandardizable(app.type) && (!app.processes?.length || app.processes.some(p => !p.ports || Object.keys(p.ports).length === 0)) && (
<button
onClick={() => handleStandardize(app)}
disabled={isOperating}
Expand Down
15 changes: 7 additions & 8 deletions client/src/pages/CreateApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as api from '../services/api';
import IconPicker from '../components/IconPicker';
import FolderPicker from '../components/FolderPicker';
import Banner from '../components/ui/Banner';
import { NON_PM2_TYPES } from '../components/apps/constants';
import { NON_PM2_TYPES, isStandardizable, getAppTypeLabel } from '../components/apps/constants';

const DETECTION_STEPS_PM2 = [
{ id: 'validate', label: 'Validating path' },
Expand Down Expand Up @@ -479,14 +479,13 @@ export default function CreateApp() {
</div>
)}

{/* App Type Badge */}
{detected && isNonPm2 && (
{/* App Type Badge — shown for every type the PM2 standardizer skips,
so a Python/Go/Docker/static repo says WHY the card below is gone
rather than silently offering nothing. */}
{detected && !isStandardizable(appType) && (
<Banner tone="info" size="md">
<p className="font-medium">
{appType === 'ios-native' ? '📱 iOS App' :
appType === 'macos-native' ? '🖥️ macOS App' :
appType === 'swift' ? '🐦 Swift Package' :
'🔨 Xcode Project'} — not managed by PM2
{getAppTypeLabel(appType)} — {isNonPm2 ? 'not managed by PM2' : 'not a Node.js project'}
</p>
</Banner>
)}
Expand All @@ -504,7 +503,7 @@ export default function CreateApp() {
)}

{/* Standardize PM2 config — opt-in, because it rewrites the repo */}
{detected && !isNonPm2 && !standardizeResult && (
{detected && isStandardizable(appType) && !standardizeResult && (
<div className="bg-port-card border border-port-border rounded-xl p-4 space-y-3">
<h3 className="text-xs font-medium uppercase tracking-wide text-gray-500">Optional</h3>
<p className="text-sm text-white flex items-center gap-2">
Expand Down
42 changes: 39 additions & 3 deletions client/src/pages/CreateApp.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';

Expand Down Expand Up @@ -27,8 +27,12 @@ vi.mock('../components/ui/Toast', () => ({

import CreateApp from './CreateApp';

/** Render the wizard and drive detection to completion for the given app type. */
const detectApp = async (result = { type: 'node', name: 'Example App' }) => {
/**
* Render the wizard and drive detection to completion for the given app type.
* The default is a REAL type streamingDetect emits — the standardize card is
* gated on a positive list now, so a made-up placeholder would be refused.
*/
const detectApp = async (result = { type: 'single-node-server', name: 'Example App' }) => {
render(<MemoryRouter><CreateApp /></MemoryRouter>);
// Let the mount effects (provider + default directory) settle.
await act(async () => {});
Expand Down Expand Up @@ -72,6 +76,38 @@ describe('CreateApp — PM2 standardization is opt-in', () => {
expect(standardizeEmits()).toHaveLength(0);
});

it('offers no standardization for non-Node runtimes', async () => {
// The standardizer's prompt opens "You are analyzing a Node.js application";
// on a Python/Go/Docker/static repo it doesn't fail, it confidently writes a
// Node ecosystem config. The card must not render at all.
for (const type of ['python', 'go', 'docker', 'static']) {
// Each iteration renders its own wizard — tear the previous one down so a
// stale card from an earlier type can't satisfy (or defeat) the query.
cleanup();
emit.mockClear();
for (const key of Object.keys(handlers)) delete handlers[key];

await detectApp({ type, name: 'Example App' });

expect(screen.queryByRole('button', { name: 'Standardize PM2 config' })).toBeNull();
expect(standardizeEmits()).toHaveLength(0);
}
});

it('explains why a non-Node repo gets no standardize card', async () => {
await detectApp({ type: 'python', name: 'Example App' });

expect(screen.getByText(/not a Node\.js project/)).toBeInTheDocument();
});

it('still offers standardization for an app whose type detection could not name', async () => {
// `unknown` is the persisted type of every app imported before non-Node
// classification existed — most of them are Node apps.
await detectApp({ type: 'unknown', name: 'Example App' });

expect(screen.getByRole('button', { name: 'Standardize PM2 config' })).toBeEnabled();
});

it('disables the action when no LLM provider is configured', async () => {
const api = await import('../services/api');
api.getActiveProvider.mockResolvedValueOnce(null);
Expand Down
8 changes: 7 additions & 1 deletion server/services/pm2Standardizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { safeJSONParse, tryReadFile } from '../lib/fileUtils.js';
import { runPromptThroughProvider } from '../lib/promptRunner.js';
import { getReservedPorts, getAllApps } from './apps.js';
import { PORTOS_APP_ID } from '../lib/appIdentity.js';
import { usesPm2 } from './streamingDetect.js';
import { usesPm2, isStandardizable } from './streamingDetect.js';
import { getListeningPorts } from '../lib/platform.js';

const execAsync = promisify(exec);
Expand Down Expand Up @@ -52,6 +52,12 @@ export function standardizeRefusalFor(app) {
if (!usesPm2(app?.type)) {
return `${app?.type} apps are not run under PM2, so there is no ecosystem config to standardize`;
}
if (!isStandardizable(app?.type)) {
// The analysis prompt below opens with "You are analyzing a Node.js
// application" — on a Python/Go/Docker/static repo it doesn't fail, it
// confidently writes a Node ecosystem config. A type check, not prose.
return `${app?.type} apps are not Node.js projects, so there is no Node ecosystem config to generate`;
}
return null;
}

Expand Down
16 changes: 16 additions & 0 deletions server/services/pm2Standardizer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ describe('standardizeRefusalFor', () => {
}
});

it('refuses non-Node runtimes — the analysis prompt is Node-shaped', () => {
// These types DO run under PM2, so the usesPm2 gate lets them through; the
// refusal is that there is no Node ecosystem config to generate for them.
for (const type of ['python', 'go', 'docker', 'static']) {
expect(standardizeRefusalFor({ id: 'app-1', type })).toMatch(/not Node\.js projects/);
}
});

it('still allows an app whose persisted type predates the classification', () => {
// `type` is persisted on the app record, so apps imported before non-Node
// classification existed keep `unknown` until re-detected — and most of
// them are Node apps. Refusing them would break the button on upgrade.
expect(standardizeRefusalFor({ id: 'app-1', type: 'unknown' })).toBeNull();
expect(standardizeRefusalFor({ id: 'app-1' })).toBeNull();
});

it('refuses PortOS itself — its ecosystem.config.cjs is the canonical PORTS source', () => {
expect(standardizeRefusalFor({ id: PORTOS_APP_ID, type: 'vite+express' }))
.toMatch(/manages its own ecosystem\.config\.cjs/);
Expand Down
90 changes: 89 additions & 1 deletion server/services/streamingDetect.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,83 @@ import { detectAppIcon } from './appIconDetect.js';
/** App types that do not use PM2 for process management */
export const NON_PM2_TYPES = new Set(['ios-native', 'macos-native', 'xcode', 'swift']);

/**
* Language markers, checked FIRST: a repo's LANGUAGE beats its packaging, so a
* Python service that also ships a `Dockerfile` classifies as `python` (the
* common case) rather than as a Docker stack.
*/
const LANGUAGE_MARKERS = [
['python', ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']],
['go', ['go.mod']]
];

/** Compose/Dockerfile markers — only reached when no language marker matched. */
const DOCKER_MARKERS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml', 'Dockerfile'];

/**
* Node entry points meaning "a process runs here". Checked BEFORE the docker
* and static rules, and mapped to *no* classification rather than to a type: a
* package-less repo shipping `server.mjs` is a Node app someone containerized
* (or that also serves a page), and classifying it `docker`/`static` would
* withdraw standardization from an app that genuinely wants it — the exact
* false negative this predicate exists to avoid. `index` is deliberately absent
* from the basenames: next to an `index.html` it is far more often a
* client-side script than a server entry point.
*/
const SERVER_ENTRY_BASENAMES = ['server', 'app', 'main'];
const SERVER_ENTRY_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts'];

const hasNodeServerEntry = (present) =>
// ECOSYSTEM_CONFIG_FILENAMES (below) is the one list of PM2 config names — a
// repo carrying one is unambiguously a PM2-managed process app.
ECOSYSTEM_CONFIG_FILENAMES.some(name => present.has(name)) ||
SERVER_ENTRY_BASENAMES.some(base => SERVER_ENTRY_EXTENSIONS.some(ext => present.has(`${base}${ext}`)));

/** The non-Node app types `classifyNonNodeType` can emit. */
export const NON_NODE_TYPES = new Set([...LANGUAGE_MARKERS.map(([type]) => type), 'docker', 'static']);

/**
* Classify a repo's runtime from its top-level filenames, for repos that
* carried no Node or Apple signal. Returns `null` when nothing matched — the
* caller keeps `unknown` rather than guessing.
*
* @param {string[]} files Top-level entry names (from `readdir`).
* @returns {'python'|'go'|'docker'|'static'|null}
*/
export function classifyNonNodeType(files) {
const present = new Set(files || []);
for (const [type, markers] of LANGUAGE_MARKERS) {
if (markers.some(marker => present.has(marker))) return type;
}
if (hasNodeServerEntry(present)) return null;
if (DOCKER_MARKERS.some(marker => present.has(marker))) return 'docker';
if (present.has('index.html')) return 'static';
return null;
}

/**
* App types the PM2 standardizer must REFUSE: the Apple types (never PM2 at
* all) plus the non-Node runtimes above. The standardizer's prompt opens with
* "You are analyzing a Node.js application" — on a Python/Go/Docker/static repo
* it doesn't fail, it confidently writes a Node ecosystem config into someone
* else's project. That is what this predicate exists to stop.
*
* It is a DENY list rather than an allowlist of Node types because `type` is a
* free-form string (`appSchema` in `server/lib/validation.js` defaults it to
* `express` and accepts any value) PERSISTED on the app record. Installs
* upgrade on their own schedule, so app records in the wild carry values this
* file has never heard of — `unknown` from before this classification existed,
* `node`/`react`/`web` from older detection, whatever a user typed. An
* allowlist would silently withdraw standardization from every one of them.
* Only a type we have positively identified as non-Node is refused; anything
* unrecognized degrades to the previous behavior (offer it, and let the user
* decide) instead of a button that quietly disappears on upgrade.
*/
export const NON_STANDARDIZABLE_TYPES = new Set([...NON_PM2_TYPES, ...NON_NODE_TYPES]);

/** Whether the PM2 standardizer (which writes a NODE ecosystem config) applies. */
export const isStandardizable = (type) => !NON_STANDARDIZABLE_TYPES.has(type);

/**
* App types that run a GUI/desktop process with no HTTP port (e.g. a Godot
* game binary). These are still supervised through PM2, but launched from the
Expand Down Expand Up @@ -1208,7 +1285,18 @@ export async function streamDetection(socket, dirPath) {
}
}
} else {
emit('package', 'done', { message: 'No package.json found' });
// No package.json ⇒ nothing Node owns this repo, so fall back to marker-file
// classification for the common non-Node runtimes. Without this, a Python
// service / Go binary / Docker stack / static site all landed on `unknown`
// and were offered Node PM2 standardization. Guarded on `unknown` so an
// Apple type already resolved in step 2 (a Swift repo that also ships a
// Dockerfile) isn't overwritten.
const nonNodeType = result.type === 'unknown' ? classifyNonNodeType(files) : null;
if (nonNodeType) result.type = nonNodeType;
emit('package', 'done', {
message: nonNodeType ? `No package.json — detected ${nonNodeType} project` : 'No package.json found',
type: result.type
});
}

// Native game launch is additive: web ports/processes remain the standard
Expand Down
Loading