Optional
diff --git a/client/src/pages/CreateApp.test.jsx b/client/src/pages/CreateApp.test.jsx
index 26171c6143..6c82a63ff2 100644
--- a/client/src/pages/CreateApp.test.jsx
+++ b/client/src/pages/CreateApp.test.jsx
@@ -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';
@@ -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();
// Let the mount effects (provider + default directory) settle.
await act(async () => {});
@@ -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);
diff --git a/server/services/pm2Standardizer.js b/server/services/pm2Standardizer.js
index 76c0f062dd..5c792ff5c0 100644
--- a/server/services/pm2Standardizer.js
+++ b/server/services/pm2Standardizer.js
@@ -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);
@@ -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;
}
diff --git a/server/services/pm2Standardizer.test.js b/server/services/pm2Standardizer.test.js
index 9d1876ad22..30269efa52 100644
--- a/server/services/pm2Standardizer.test.js
+++ b/server/services/pm2Standardizer.test.js
@@ -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/);
diff --git a/server/services/streamingDetect.js b/server/services/streamingDetect.js
index c5f840ec49..324e0db08c 100644
--- a/server/services/streamingDetect.js
+++ b/server/services/streamingDetect.js
@@ -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
@@ -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
diff --git a/server/services/streamingDetect.test.js b/server/services/streamingDetect.test.js
index 29a82bf688..108f61d17e 100644
--- a/server/services/streamingDetect.test.js
+++ b/server/services/streamingDetect.test.js
@@ -1,8 +1,13 @@
-import { describe, it, expect, afterEach } from 'vitest';
+import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
-import { detectGodotNativeLaunch, parseEcosystemConfig, resolveViteConfigPortForProcess, rewriteEcosystemPorts, rewriteEcosystemPortsByProcess, writeEcosystemPorts, writeEcosystemPortsByProcess, writeEcosystemPortEdits, DESKTOP_TYPES, NON_PM2_TYPES } from './streamingDetect.js';
+import { detectGodotNativeLaunch, parseEcosystemConfig, resolveViteConfigPortForProcess, rewriteEcosystemPorts, rewriteEcosystemPortsByProcess, writeEcosystemPorts, writeEcosystemPortsByProcess, writeEcosystemPortEdits, streamDetection, classifyNonNodeType, isStandardizable, usesPm2, DESKTOP_TYPES, NON_PM2_TYPES, NON_NODE_TYPES, NON_STANDARDIZABLE_TYPES } from './streamingDetect.js';
+
+// streamDetection shells out to PM2 and scans for an app icon; neither is under
+// test here and both are slow/environment-dependent.
+vi.mock('./pm2.js', () => ({ execPm2: vi.fn(async () => ({ stdout: '[]' })) }));
+vi.mock('./appIconDetect.js', () => ({ detectAppIcon: vi.fn(async () => null) }));
describe('detectGodotNativeLaunch', () => {
let dir;
@@ -1022,4 +1027,222 @@ describe('client mirror of the app-type sets', () => {
const client = await import('../../client/src/components/apps/constants.js');
expect([...client.NON_PM2_TYPES].sort()).toEqual([...NON_PM2_TYPES].sort());
});
+
+ it('matches NON_NODE_TYPES', async () => {
+ const client = await import('../../client/src/components/apps/constants.js');
+ expect([...client.NON_NODE_TYPES].sort()).toEqual([...NON_NODE_TYPES].sort());
+ });
+
+ it('matches NON_STANDARDIZABLE_TYPES', async () => {
+ const client = await import('../../client/src/components/apps/constants.js');
+ expect([...client.NON_STANDARDIZABLE_TYPES].sort()).toEqual([...NON_STANDARDIZABLE_TYPES].sort());
+ });
+});
+
+describe('streamDetection app-type classification', () => {
+ let dir;
+ afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = null; });
+
+ /** Run detection over a temp repo built from `{ filename: contents }`. */
+ const detect = async (files) => {
+ dir = mkdtempSync(join(tmpdir(), 'detect-type-'));
+ for (const [name, contents] of Object.entries(files)) {
+ writeFileSync(join(dir, name), contents);
+ }
+ let completed = null;
+ const socket = {
+ emit: (event, payload) => { if (event === 'detect:complete') completed = payload; }
+ };
+ await streamDetection(socket, dir);
+ return completed;
+ };
+
+ it('classifies a Python repo instead of leaving it unknown', async () => {
+ const { success, result } = await detect({ 'pyproject.toml': '[project]\nname = "example"\n' });
+ expect(success).toBe(true);
+ expect(result.type).toBe('python');
+ expect(isStandardizable(result.type)).toBe(false);
+ });
+
+ it('classifies a Go repo', async () => {
+ const { result } = await detect({ 'go.mod': 'module example.com/demo\n', 'main.go': 'package main\n' });
+ expect(result.type).toBe('go');
+ });
+
+ it('classifies a compose-only stack as docker', async () => {
+ const { result } = await detect({ 'docker-compose.yml': 'services:\n web:\n image: nginx\n' });
+ expect(result.type).toBe('docker');
+ });
+
+ it('classifies a bare index.html as static', async () => {
+ const { result } = await detect({ 'index.html': '
Example' });
+ expect(result.type).toBe('static');
+ });
+
+ it('keeps a served repo standardizable even when it ships an index.html', async () => {
+ const { result } = await detect({
+ 'index.html': '',
+ 'server.js': 'require("http").createServer().listen(3000);\n'
+ });
+ expect(result.type).toBe('unknown');
+ expect(isStandardizable(result.type)).toBe(true);
+ });
+
+ it('leaves a package.json repo alone — Node tooling owns it, so `unknown` stays honest', async () => {
+ // A Node repo whose deps PortOS doesn't recognize must NOT be relabelled
+ // `static` just because it also ships an index.html; `unknown` remains
+ // standardizable, which is the correct answer for a Node project.
+ const { result } = await detect({
+ 'package.json': JSON.stringify({ name: 'example', dependencies: { lodash: '^4' } }),
+ 'index.html': ''
+ });
+ expect(result.type).toBe('unknown');
+ expect(isStandardizable(result.type)).toBe(true);
+ });
+
+ it('does not overwrite an Apple type detected from the repo layout', async () => {
+ // A Swift package that also ships a Dockerfile is still `swift`.
+ const { result } = await detect({ 'Package.swift': '// swift-tools-version:5.9\n', Dockerfile: 'FROM swift\n' });
+ expect(result.type).toBe('swift');
+ });
+
+ it('still recognizes the Node types from package.json deps', async () => {
+ const { result } = await detect({
+ 'package.json': JSON.stringify({ name: 'example', dependencies: { vite: '^5', express: '^4' } })
+ });
+ expect(result.type).toBe('vite+express');
+ });
+});
+
+describe('classifyNonNodeType', () => {
+ it('classifies python from any of its marker files', () => {
+ for (const marker of ['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']) {
+ expect(classifyNonNodeType(['README.md', marker])).toBe('python');
+ }
+ });
+
+ it('classifies go from go.mod', () => {
+ expect(classifyNonNodeType(['go.mod', 'go.sum', 'main.go'])).toBe('go');
+ });
+
+ it('classifies docker from a compose file or Dockerfile', () => {
+ for (const marker of ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml', 'Dockerfile']) {
+ expect(classifyNonNodeType([marker])).toBe('docker');
+ }
+ });
+
+ it('classifies static from a bare index.html', () => {
+ expect(classifyNonNodeType(['index.html', 'style.css'])).toBe('static');
+ });
+
+ it('does not call an index.html beside a server entry point static', () => {
+ // "index.html with NO server" is the rule. A served app that happens to
+ // have an index.html must fall through to `unknown` (still standardizable)
+ // rather than have the flow withdrawn from it. Every JS/TS extension counts
+ // — `server.mjs` is as much an entry point as `server.js`.
+ for (const server of [
+ 'server.js', 'server.mjs', 'server.cjs', 'server.ts',
+ 'app.js', 'app.mjs', 'main.js', 'main.ts',
+ 'ecosystem.config.js', 'ecosystem.config.cjs'
+ ]) {
+ expect(classifyNonNodeType(['index.html', server])).toBeNull();
+ }
+ });
+
+ it('does not call a Node server with a Dockerfile a docker stack', () => {
+ // A containerized Node service is still a Node service — the entry point
+ // outranks the packaging, the same way a language marker does.
+ expect(classifyNonNodeType(['server.js', 'Dockerfile'])).toBeNull();
+ expect(classifyNonNodeType(['app.mjs', 'docker-compose.yml'])).toBeNull();
+ });
+
+ it('still treats a client-side index.js as static, not a server', () => {
+ // In a static site an `index.js` is a browser script far more often than a
+ // server entry point, so it must not suppress the classification.
+ expect(classifyNonNodeType(['index.html', 'index.js', 'style.css'])).toBe('static');
+ });
+
+ it('keeps the language markers ahead of a Node entry point', () => {
+ // A Python repo with a stray `app.py`-adjacent `main.js` build script is
+ // still python — the language check runs first.
+ expect(classifyNonNodeType(['requirements.txt', 'main.js'])).toBe('python');
+ });
+
+ it('prefers the language over the packaging when both markers are present', () => {
+ // A Python service that also ships a Dockerfile is a python repo, not a
+ // docker stack — the language is the more useful classification, and both
+ // are refused by the standardize gate either way.
+ expect(classifyNonNodeType(['pyproject.toml', 'Dockerfile', 'index.html'])).toBe('python');
+ expect(classifyNonNodeType(['go.mod', 'docker-compose.yml'])).toBe('go');
+ expect(classifyNonNodeType(['Dockerfile', 'index.html'])).toBe('docker');
+ });
+
+ it('returns null when nothing matches, rather than guessing', () => {
+ expect(classifyNonNodeType(['README.md', 'LICENSE'])).toBeNull();
+ expect(classifyNonNodeType([])).toBeNull();
+ expect(classifyNonNodeType(undefined)).toBeNull();
+ });
+
+ it('emits only types the standardizer refuses', () => {
+ for (const type of NON_NODE_TYPES) {
+ expect(isStandardizable(type)).toBe(false);
+ }
+ });
+});
+
+describe('isStandardizable', () => {
+ it('allows the Node app types', () => {
+ for (const type of ['vite+express', 'vite', 'single-node-server', 'nextjs', 'desktop']) {
+ expect(isStandardizable(type)).toBe(true);
+ }
+ });
+
+ it('stays permissive for unknown — the persisted type of every pre-classification app', () => {
+ expect(isStandardizable('unknown')).toBe(true);
+ expect(isStandardizable(undefined)).toBe(true);
+ expect(isStandardizable(null)).toBe(true);
+ expect(isStandardizable('')).toBe(true);
+ });
+
+ it('stays permissive for legacy/custom persisted types', () => {
+ // `type` is a free-form string on the app record (appSchema accepts any
+ // value) and installs upgrade independently, so records in the wild carry
+ // values this file never emitted. Only a POSITIVELY identified non-Node
+ // type is refused — anything else keeps the pre-change behavior instead of
+ // the button silently vanishing on upgrade.
+ for (const type of ['express', 'node', 'react', 'web', 'anything-a-user-typed']) {
+ expect(isStandardizable(type)).toBe(true);
+ }
+ });
+
+ it("covers appSchema's default type — the most common PERSISTED value", async () => {
+ // `type` lives on the app record, and an app created without one defaults to
+ // this. A positive gate that missed it would silently hide the standardize
+ // button on every such app across every install.
+ const { appSchema } = await import('../lib/validation.js');
+ const defaultType = appSchema.parse({ name: 'Example App', repoPath: '/srv/example-app' }).type;
+ expect(isStandardizable(defaultType)).toBe(true);
+ });
+
+ it('refuses the non-Node runtimes', () => {
+ for (const type of ['python', 'go', 'docker', 'static']) {
+ expect(isStandardizable(type)).toBe(false);
+ }
+ });
+
+ it('refuses the Apple types', () => {
+ for (const type of NON_PM2_TYPES) {
+ expect(isStandardizable(type)).toBe(false);
+ }
+ });
+
+ it('does not widen NON_PM2_TYPES — a Python or Go service can still run under PM2', () => {
+ // NON_PM2_TYPES means "Apple" to its other consumers (appDeployer's deploy
+ // gate, the slashdo workflow filter, the client's Xcode-only UI), so the
+ // standardizer gate is a separate predicate rather than an entry there.
+ for (const type of NON_NODE_TYPES) {
+ expect(NON_PM2_TYPES.has(type)).toBe(false);
+ expect(usesPm2(type)).toBe(true);
+ }
+ });
});