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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ backend/
| 命令 | 说明 |
| --- | --- |
| `localapp server [start]` | 注册系统集成并启动当前用户 daemon |
| `localapp server run` | 以前台模式运行同一 Server,适合容器和服务管理器;Windows 上使用包内 native helper |
| `localapp server run` | 以前台模式运行同一 Server,默认监听 `127.0.0.1:50524`(`--port` 覆盖);适合容器、服务管理器和需要固定地址的机器 |
| `localapp server stop/restart/status/logs/uninstall` | 管理当前用户 daemon |
| `localapp init <name>` | 从 npm 包内置模板创建应用 |
| `localapp build --package` | 构建并生成不含本地数据的 `.localapp` |
Expand Down
5 changes: 4 additions & 1 deletion docs/local-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ LocalApp 只有一个 Server 实现。它可以作为开发机上的本地 Serve

Windows 上的 daemon 是当前用户的计划任务:登录时启动,并以该用户自身的普通权限运行,不继承提权令牌。因此它需要有交互登录会话——只通过 SSH 使用、没有桌面会话的机器请改用 `localapp server run` 前台模式。首次注册计划任务可能需要管理员终端,之后 `status`、`stop`、`dev` 等命令在普通终端即可操作 daemon。

前台模式同样是一种正式用法,适合需要**稳定监听地址**的机器(已保存的 profile、反向代理、容器端口映射,或本机无法注册计划任务):它监听固定的 **50524**,可用 `--port` 覆盖(`--port 0` 才表示临时端口),由启动它的终端显式维持进程,不涉及计划任务与 daemon。

```bash
npm install --global @patodo/localapp
localapp server # 等同于 localapp server start,注册并启动用户 daemon
localapp server status
localapp server run # 容器/前台运行
localapp server run # 容器/前台运行,默认监听 127.0.0.1:50524
localapp server run --port 55441 # 固定到指定端口
```

不进行全局安装时,可使用 `npx --package @patodo/localapp localapp --version` 验证当前
Expand Down
3 changes: 2 additions & 1 deletion packages/localapp/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ Usage:
Options:
--data-dir <path> Server database, files, and configuration directory
--host <address> Listen address (default: 127.0.0.1)
--port <number> Listen port, 0 selects an available port (default: 0)
--port <number> Listen port, 0 selects an available port (default: 50524)
-h, --help Show this help

Examples:
localapp server run
localapp server run --port 55441
localapp server run --data-dir ./localapp-data --port 3000
localapp server run --host 0.0.0.0 --port 3000
`],
Expand Down
10 changes: 9 additions & 1 deletion packages/localapp/src/commands/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { createNativeAdapter, type NativeAdapter, type NativeAdapterOptions } fr

export type ServerCommandAction = "start" | "stop" | "restart" | "status" | "logs" | "uninstall";
export interface RunServerCommandOptions { action: ServerCommandAction; }

/**
* A foreground Server keeps one address across restarts unless the operator
* asks for another one: a saved profile, a reverse proxy, or a container port
* mapping must not have to follow a port the Server picked for itself. Pass
* `--port 0` to ask for any available port instead.
*/
export const DEFAULT_FOREGROUND_PORT = 50524;
export interface ServerCommandDependencies {
layout?: RuntimeLayout;
artifactDirectory?: string;
Expand Down Expand Up @@ -79,7 +87,7 @@ export async function runServerForeground(options: { dataDir?: string; host?: st
if (typeof manifest.serverEntrypoint !== "string") throw lifecycleError("canonical_server_unavailable", "The packed canonical LocalApp Server runtime is unavailable");
const entrypoint = path.join(artifact, ...manifest.serverEntrypoint.split("/"));
const child = (dependencies.spawnOwnedProcess ?? spawnOwnedProcess)(process.execPath, [entrypoint, "start", "--data-dir", options.dataDir ?? layout.dataDir,
"--host", options.host ?? "127.0.0.1", "--port", String(options.port ?? 0)], { stdio: "inherit" });
"--host", options.host ?? "127.0.0.1", "--port", String(options.port ?? DEFAULT_FOREGROUND_PORT)], { stdio: "inherit" });
return await new Promise<number>((resolve, reject) => {
let termination: Promise<void> | undefined;
let settled = false;
Expand Down
28 changes: 27 additions & 1 deletion packages/localapp/tests/server-foreground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { runServerForeground } from "../src/commands/server.js";
import { buildLocalAppPackage } from "../scripts/build-package.mjs";
import type { OwnedProcess } from "../src/process/process-tree.js";

const root = path.resolve(process.cwd(), "../..");
const testRoot = path.join(root, "tmp/task-7b-foreground-tests");
const packageArtifact = path.join(testRoot, "package-artifact");
Expand Down Expand Up @@ -37,4 +36,31 @@ describe("server run foreground ownership", () => {
releaseTerminate();
await expect(result).resolves.toBe(0);
});

it("keeps one stable default port unless the operator asks for another", async () => {
// Break caught: the foreground Server picked an ephemeral port on every
// start, so a saved profile, a proxy target, or a published container port
// had to follow a port the Server chose for itself.
const invocations: string[][] = [];
const ownedStub = () => {
const child = new EventEmitter() as unknown as OwnedProcess["child"];
return {
child,
pid: 42,
exited: Promise.resolve({ code: 0, signal: null }),
terminate: async () => undefined,
} as unknown as OwnedProcess;
};
const run = (options: { port?: number }) => runServerForeground(options, {
artifactDirectory: packageArtifact,
spawnOwnedProcess: (_command, args) => { invocations.push([...args]); return ownedStub(); },
});

await expect(run({})).resolves.toBe(0);
await expect(run({ port: 55441 })).resolves.toBe(0);
await expect(run({ port: 0 })).resolves.toBe(0);

const portOf = (args: string[]) => args[args.indexOf("--port") + 1];
expect(invocations.map(portOf)).toEqual(["50524", "55441", "0"]);
});
});
Loading