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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

# Host port to publish the app on (pick anything free — this is what you open in
# the browser). Container-internal port is API_PORT below; they can differ.
PORT=3001
API_PORT=3001
# Default 3210 matches the CLI and avoids the busy 3000/3001 band.
PORT=3210
API_PORT=3210

# Optional. 32-byte key that encrypts saved database passwords at rest.
# Leave empty for Docker pull-and-run: the entrypoint auto-generates one into
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ Standard commands live in `CONTRIBUTING.md` and `package.json` scripts (`npm run
Node 22/24 without reinstalling — a Node switch alone does not require `npm install`.

### Running the app
- `npm run dev` runs the Express API (`:3001`) and Vite UI (`:5173`) together; open the
UI at http://localhost:5173. API liveness: `GET http://localhost:3001/api/health`
- `npm run dev` runs the Express API (`:3210`) and Vite UI (`:5173`) together; open the
UI at http://localhost:5173. API liveness: `GET http://localhost:3210/api/health`
→ `{"ok":true}`. Default mode is single-user (no login).
- Vite is configured with `server.host: true`, `server.strictPort: true`, and
`server.allowedHosts: true` so `http://127.0.0.1:5173` works (not only IPv6
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ bash scripts/seed/seed-all.sh all # seed demo_a/demo_b schemas into each
npm run dev # Express API + Vite UI (single-user mode)
```

`npm run dev` serves the UI on **http://localhost:5173** and the API on **:3001**.
`npm run dev` serves the UI on **http://localhost:5173** and the API on **:3210**.
Connection details for the seeded databases are printed by `seed-all.sh` (all use
`foxuser` / `foxpass` except SQL Server/Oracle — see the script output).

Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ RUN mkdir -p /data \
USER fox

ENV NODE_ENV=production \
API_PORT=3001 \
API_PORT=3210 \
STATIC_DIR=/app/apps/web/dist \
APP_DB_ENGINE=sqlite \
APP_DB_PATH=/data/foxschema.db \
Expand All @@ -73,11 +73,11 @@ ENV NODE_ENV=production \
# APP_ENCRYPTION_KEY is optional for pull-and-run: entrypoint generates one into
# /data/.app_encryption_key on first boot. Set -e APP_ENCRYPTION_KEY=… to override.

EXPOSE 3001
EXPOSE 3210
VOLUME ["/data"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.API_PORT||3001)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD node -e "fetch('http://127.0.0.1:'+(process.env.API_PORT||3210)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

# tsx is present in node_modules (a devDependency, kept because we run TS at
# runtime). node:sqlite is flag-free on Node 24.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,12 @@ Cross-dialect demo:

```bash
docker run -d --name foxschema \
-p 3001:3001 \
-p 3210:3210 \
-v foxschema_data:/data \
5nickels/foxschema:latest
```

Open **http://localhost:3001**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
Open **http://localhost:3210**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).

## Supported dialects

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ brew install foxschema

# Docker (linux/amd64, includes Db2)
docker pull 5nickels/foxschema:latest
docker run -d -p 3001:3001 -v foxschema_data:/data 5nickels/foxschema:latest
docker run -d -p 3210:3210 -v foxschema_data:/data 5nickels/foxschema:latest
```

## CLI
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@foxschema/cli",
"version": "0.2.48",
"version": "0.2.49",
"private": true,
"type": "module",
"description": "Fox Schema CLI — schema diff, migrations, and a rich SQL Editor (TypeScript / Node ESM)",
Expand Down
48 changes: 48 additions & 0 deletions apps/cli/src/commands/__tests__/open-stale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,51 @@ describe('probeRunningVersion', () => {
await expect(probeRunningVersion(3210)).resolves.toBe('0.2.10');
});
});

describe('resolveListenPort', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('keeps preferred when free', async () => {
const { resolveListenPort } = await import('../open.js');
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed');
})
);
await expect(resolveListenPort(3210, true)).resolves.toEqual({
port: 3210,
skippedConflict: false,
});
});

it('skips to next free port when preferred is occupied by another app', async () => {
const { resolveListenPort } = await import('../open.js');
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes(':3210/')) {
return new Response('other app', { status: 200 });
}
throw new TypeError('fetch failed');
})
);
await expect(resolveListenPort(3210, true)).resolves.toEqual({
port: 3211,
skippedConflict: true,
});
});

it('throws when explicit port is occupied by another app', async () => {
const { resolveListenPort } = await import('../open.js');
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('other app', { status: 200 }))
);
await expect(resolveListenPort(3210, false)).rejects.toThrow(/in use but does not look like Fox Schema/);
});
});
7 changes: 7 additions & 0 deletions apps/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
const hasKey = process.env.FOXSCHEMA_KEY ? true : !!safe(() => getDek(c.email));
console.log(` bound email ${c.email}`);
console.log(` key reachable ${hasKey ? chalk.green('yes') : chalk.red('no')}`);
} else if (existsSync(LOCAL_KEY_FILE)) {

Check warning on line 57 in apps/cli/src/commands/doctor.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found existsSync from package "node:fs" with non literal argument at index 0
console.log(` local key file ${chalk.green('yes')} ${chalk.dim(LOCAL_KEY_FILE)}`);
}
console.log(
Expand All @@ -63,12 +63,19 @@

let managedPid = '';
try {
managedPid = readFileSync(PID_FILE, 'utf8').trim();

Check warning on line 66 in apps/cli/src/commands/doctor.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found readFileSync from package "node:fs" with non literal argument at index 0
} catch {
managedPid = '';
}
console.log(` ui lock pid ${managedPid || chalk.dim('(none)')}`);
console.log(` ui server ${await uiServerStatus(DEFAULT_UI_PORT)}`);
if (managedPid) {
console.log(
chalk.dim(
` tip look for process “foxschema” (or node · ui-server) · PID ${managedPid}`
)
);
}

const coreModulesOk = typeof CompareModule === 'function' && typeof SqlGeneratorModule === 'function';
let core: string;
Expand Down
58 changes: 52 additions & 6 deletions apps/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@

function readManagedPid(): number | null {
try {
const n = Number(readFileSync(PID_FILE, 'utf8').trim());

Check warning on line 111 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found readFileSync from package "node:fs" with non literal argument at index 0
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
Expand All @@ -127,7 +127,7 @@
function clearLock(): void {
for (const f of [PID_FILE, PORT_FILE]) {
try {
unlinkSync(f);

Check warning on line 130 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found unlinkSync from package "node:fs" with non literal argument at index 0
} catch {
/* ignore */
}
Expand All @@ -135,9 +135,9 @@
}

function writeLock(pid: number, port: number): void {
mkdirSync(RUNTIME_DIR, { recursive: true });

Check warning on line 138 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found mkdirSync from package "node:fs" with non literal argument at index 0
writeFileSync(PID_FILE, String(pid), { mode: 0o600 });

Check warning on line 139 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found writeFileSync from package "node:fs" with non literal argument at index 0
writeFileSync(PORT_FILE, String(port), { mode: 0o600 });

Check warning on line 140 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found writeFileSync from package "node:fs" with non literal argument at index 0
}

async function waitUntilHealthy(port: number, timeoutMs = 30_000): Promise<boolean> {
Expand All @@ -149,6 +149,46 @@
return false;
}

/** True if anything accepts HTTP on the port (Fox Schema or another app). */
export async function isPortOccupied(port: number): Promise<boolean> {
try {
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(500) });
return true;
} catch {
return false;
}
}

/**
* Choose a listen port. Prefer `preferred` when free or already Fox-healthy.
* If another app owns it and `allowFallback` is true, try preferred+1 … +19.
*/
export async function resolveListenPort(
preferred: number,
allowFallback: boolean
): Promise<{ port: number; skippedConflict: boolean }> {
if (await isHealthy(preferred)) {
return { port: preferred, skippedConflict: false };
}
if (!(await isPortOccupied(preferred))) {
return { port: preferred, skippedConflict: false };
}
if (!allowFallback) {
throw new Error(
`Port ${preferred} is in use but does not look like Fox Schema. ` +
`Stop that process, or run \`foxschema open --port <other>\`.`
);
}
const last = preferred + 19;
for (let p = preferred + 1; p <= last; p++) {
if (await isHealthy(p)) return { port: p, skippedConflict: true };
if (!(await isPortOccupied(p))) return { port: p, skippedConflict: true };
}
throw new Error(
`Ports ${preferred}–${last} are all in use. Free one, or pass \`--port <free>\`.`
);
}

async function waitUntilDead(pid: number, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline && isProcessAlive(pid)) {
Expand Down Expand Up @@ -204,7 +244,17 @@
* **and** matches the installed package version / has Query-files routes.
*/
export async function runOpen(opts: OpenOptions = {}): Promise<void> {
const port = opts.port ?? (Number(process.env.FOXSCHEMA_PORT) || DEFAULT_UI_PORT);
const envPortRaw = process.env.FOXSCHEMA_PORT;
const envPort = envPortRaw != null && envPortRaw !== '' ? Number(envPortRaw) : NaN;
const portExplicit = opts.port != null || (Number.isFinite(envPort) && envPort > 0);
const preferred =
opts.port ?? (Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_UI_PORT);
const { port, skippedConflict } = await resolveListenPort(preferred, !portExplicit);
if (skippedConflict) {
console.log(
chalk.yellow(`Port ${preferred} is in use by another app — starting on ${port} instead.`)
);
}
const url = `http://localhost:${port}`;
const installedVersion = readCliPackageVersion();

Expand Down Expand Up @@ -235,15 +285,11 @@
`Run \`foxschema stop\` then \`foxschema open\`, or \`foxschema open --port <other>\`.`
);
}
try {
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(800) });
if (await isPortOccupied(port)) {
throw new Error(
`Port ${port} is in use but does not look like Fox Schema. ` +
`Stop that process, or run \`foxschema open --port <other>\`.`
);
} catch (e) {
if (e instanceof Error && e.message.startsWith('Port ')) throw e;
/* connection refused / timeout — free to bind */
}

const keySource = ensureUiEnv();
Expand Down Expand Up @@ -312,7 +358,7 @@
}

// Reassure TypeScript / tooling that the lock file path was used.
void existsSync(PID_FILE);

Check warning on line 361 in apps/cli/src/commands/open.ts

View workflow job for this annotation

GitHub Actions / ESLint security

Found existsSync from package "node:fs" with non literal argument at index 0
}

/** Stop the managed UI server started by `foxschema open`. */
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/runtime/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export const PID_FILE = join(RUNTIME_DIR, 'ui-server.pid');
export const PORT_FILE = join(RUNTIME_DIR, 'ui-server.port');
export const LOCAL_KEY_FILE = join(DATA_DIR, '.app_encryption_key');

/** Default port for the browser UI launcher (Docker stays on 3001). */
/** Default port for the browser UI launcher (Docker / API use the same 3210). */
export const DEFAULT_UI_PORT = 3210;
8 changes: 8 additions & 0 deletions apps/cli/src/server/ui-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
*/
import { startUiServer } from '@foxschema/web/serve';

// So Activity Monitor / `ps` / Task Manager “window title” can identify us
// (Image Name on Windows is still node.exe — see docs).
try {
process.title = 'foxschema';
} catch {
/* ignore */
}

const port = Number(process.env.API_PORT || process.env.PORT) || 3210;
const host = process.env.LISTEN_HOST || '127.0.0.1';
const staticDir = process.env.STATIC_DIR;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@foxschema/web",
"private": true,
"version": "0.2.48",
"version": "0.2.49",
"type": "module",
"exports": {
"./package.json": "./package.json",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/backend/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createUserRoutes } from './user.routes';
import { createAdminRoutes } from './admin.routes';
import { createSignupRoutes } from './signup.routes';
import { createFileQueryRoutes } from './file-query.routes';
import { DEFAULT_API_PORT } from '../defaultApiPort';
import { AppSecretsStore } from '../modules/app-secrets.module';
import { resolveAppVersion } from '../modules/updates.module';

Expand Down Expand Up @@ -104,7 +105,7 @@ export function createApp() {
return app;
}

export function startServer(port = Number(process.env.API_PORT) || 3001) {
export function startServer(port = Number(process.env.API_PORT) || DEFAULT_API_PORT) {
const app = createApp();

const server = app.listen(port, () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/backend/defaultApiPort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Default listen port for the Fox Schema API / single-origin UI server.
* Shared by Docker, `npm run dev` API, and CLI (`foxschema open` uses the same).
* 3210 avoids the crowded 3000/3001 band used by many Node apps.
*/
export const DEFAULT_API_PORT = 3210;
5 changes: 4 additions & 1 deletion apps/web/src/backend/modules/updates.module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ describe('updates.module (npm publish channel)', () => {
);
expect(parsed.updateAvailable).toBe(true);
expect(parsed.latest).toBe('0.3.0');
expect(parsed.url).toMatch(/npmjs\.com\/package\/foxschema\/v\/0\.3\.0/);
// “What’s new” lands on the GitHub Release page (notes from docs/RELEASE_*.md).
expect(parsed.url).toBe(
'https://github.com/tedious-code/foxschema/releases/tag/v0.3.0'
);
});

it('parseUpdateFeed understands GitHub releases JSON', () => {
Expand Down
18 changes: 9 additions & 9 deletions apps/web/src/backend/modules/updates.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ function stripV(v: string): string {
return v.replace(/^v/i, '').trim();
}

/** User-facing release notes (in-app “What’s new” opens this page). */
export function githubReleaseUrl(version: string): string {
return `https://github.com/tedious-code/foxschema/releases/tag/v${stripV(version)}`;
}

/**
* Resolve the running app version: APP_VERSION env, else nearest package.json
* (web → repo root → cwd). Falls back to 0.0.0 only if nothing is readable.
Expand Down Expand Up @@ -92,21 +97,16 @@ type FeedJson = {
/** Map npm / GitHub / custom feed JSON into version + link + notes. */
export function parseUpdateFeed(
data: FeedJson,
feedUrl: string,
_feedUrl: string,
current: string
): Pick<UpdateInfo, 'latest' | 'updateAvailable' | 'url' | 'notes'> {
const latest = stripV(data.version || data.tag_name || '') || current;
const fromNpm =
data.name === NPM_PACKAGE || /registry\.npmjs\.org/i.test(feedUrl);
const npmPage = fromNpm
? `https://www.npmjs.com/package/${NPM_PACKAGE}/v/${latest}`
: undefined;
return {
latest,
updateAvailable: !!latest && isNewer(latest, current),
// Prefer explicit release links; for the npm channel use the package page
// (homepage alone is not version-specific).
url: data.url || data.html_url || npmPage || data.homepage,
// Prefer explicit release links; otherwise the GitHub Release page
// (populated from docs/RELEASE_*.md) so “What’s new” shows ship notes.
url: data.url || data.html_url || githubReleaseUrl(latest) || data.homepage,
notes: data.notes || data.body || data.description || undefined,
};
}
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/backend/startUiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import http from 'node:http';
import express from 'express';
import { ConnectionFactory, setupDb2ClientEnv } from '@foxschema/db';
import { createApp } from './api/server';
import { DEFAULT_API_PORT } from './defaultApiPort';

export interface StartUiServerOptions {
/** Listen port. Defaults to API_PORT / PORT / 3001. */
/** Listen port. Defaults to API_PORT / PORT / DEFAULT_API_PORT (3210). */
port?: number;
/** Absolute path to the Vite `dist` directory. Defaults to STATIC_DIR or apps/web/dist. */
staticDir?: string;
Expand Down Expand Up @@ -40,7 +41,7 @@ export function startUiServer(opts: StartUiServerOptions = {}): StartedUiServer
res.sendFile(join(staticDir, 'index.html'));
});

const port = opts.port ?? (Number(process.env.API_PORT || process.env.PORT) || 3001);
const port = opts.port ?? (Number(process.env.API_PORT || process.env.PORT) || DEFAULT_API_PORT);
const host = opts.host ?? process.env.LISTEN_HOST ?? '0.0.0.0';

const server = app.listen(port, host);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/frontend/components/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function ProfileMenu(): React.ReactElement | null {
rel="noreferrer"
onClick={() => setOpen(false)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-semibold text-amber-300 hover:bg-amber-950/20 transition cursor-pointer border-b border-slate-800"
title="Open release notes (What's new)"
>
<ArrowUpCircle className="w-4 h-4" /> Update available · v{update?.latest}
</a>
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/frontend/components/UpdatesSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,25 @@ export const UpdatesSettings: React.FC = () => {
</p>
{info?.updateAvailable ? (
<p className="mt-0.5 inline-flex items-center gap-1 text-amber-300">
<ArrowUpCircle className="w-3.5 h-3.5" /> v{info.latest} available on npm
<ArrowUpCircle className="w-3.5 h-3.5" /> v{info.latest} available
{info.url ? (
<>
{' · '}
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="underline decoration-amber-500/50 hover:text-amber-200"
>
What&apos;s new
</a>
</>
) : null}
</p>
) : info ? (
<p className="mt-0.5 inline-flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{info.configured ? 'Up to date (npm)' : 'Up to date (check disabled)'}
{info.configured ? 'Up to date' : 'Up to date (check disabled)'}
</p>
) : null}
</div>
Expand Down
Loading
Loading