feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SERVER_PORT } from "@prismshadow/penguin-core";
|
||||
import { DEFAULT_SERVER_PORT, resolveRoot } from "@prismshadow/penguin-core";
|
||||
import { liveServerLock } from "@prismshadow/penguin-server/lock";
|
||||
import type { Command } from "commander";
|
||||
import type { Messages, WebProbeFailureKind } from "../i18n.js";
|
||||
|
||||
@@ -84,6 +85,19 @@ export function cliEntryFor(argv1: string | undefined): string | null {
|
||||
* so the values written here are the ones that take effect (options take priority over
|
||||
* .env and any pre-existing env vars).
|
||||
*/
|
||||
/**
|
||||
* Pre-start lock check: the App URL of a live server already owning the data root this
|
||||
* process would use (same resolution as the server: PENGUIN_HOME or the default root),
|
||||
* or null. The server itself re-checks on startup (the in-process backstop); checking
|
||||
* here keeps the friendly path — `penguin server` refuses with the URL, `penguin web`
|
||||
* simply opens the existing instance. Locks live per data root, so a second server on a
|
||||
* DIFFERENT root is untouched. See @prismshadow/penguin-server/lock.
|
||||
*/
|
||||
async function existingInstanceUrl(): Promise<string | null> {
|
||||
const lock = await liveServerLock(process.env.PENGUIN_HOME ?? resolveRoot());
|
||||
return lock === null ? null : `http://localhost:${lock.port}/`;
|
||||
}
|
||||
|
||||
async function startServer(opts: {
|
||||
port?: string;
|
||||
host?: string;
|
||||
@@ -243,6 +257,12 @@ export function registerServeCommands(program: Command, t: Messages): void {
|
||||
.option("--port <port>", t.serve.port)
|
||||
.option("--host <host>", t.serve.host)
|
||||
.action(async (opts: { port?: string; host?: string }) => {
|
||||
const existing = await existingInstanceUrl();
|
||||
if (existing !== null) {
|
||||
process.stderr.write(t.serverAlreadyRunning(existing) + "\n");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await startServer(opts);
|
||||
});
|
||||
|
||||
@@ -253,6 +273,12 @@ export function registerServeCommands(program: Command, t: Messages): void {
|
||||
.option("--host <host>", t.serve.host)
|
||||
.option("--no-open", t.serve.noOpen)
|
||||
.action(async (opts: { port?: string; host?: string; open: boolean }) => {
|
||||
const existing = await existingInstanceUrl();
|
||||
if (existing !== null) {
|
||||
process.stdout.write(t.webAlreadyRunning(existing) + "\n");
|
||||
if (opts.open) openBrowser(existing);
|
||||
return;
|
||||
}
|
||||
const { host, port } = await startServer(opts);
|
||||
const url = browserUrl(host, port);
|
||||
const readiness = await waitForReady(url);
|
||||
|
||||
@@ -223,6 +223,10 @@ export interface Messages {
|
||||
vaultListEmpty(): string;
|
||||
/** URL prompt once the `penguin web` service is ready. */
|
||||
webReady(url: string): string;
|
||||
/** Refusal when `penguin server` finds a live server on the same data root. */
|
||||
serverAlreadyRunning(url: string): string;
|
||||
/** Notice when `penguin web` finds a live server on the same data root (it opens that instance instead). */
|
||||
webAlreadyRunning(url: string): string;
|
||||
/** Diagnostic shown after the `penguin web` ready-poll times out (15s). */
|
||||
webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string;
|
||||
}
|
||||
@@ -455,6 +459,11 @@ const en: Messages = {
|
||||
vaultListTitle: () => "Vault environment variables (values masked):",
|
||||
vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.",
|
||||
webReady: (url) => `Web UI ready: ${url}`,
|
||||
serverAlreadyRunning: (url) =>
|
||||
`A PenguinHarness server is already running on this data root: ${url}\n` +
|
||||
`Stop it first, or point PENGUIN_HOME at a separate data root.`,
|
||||
webAlreadyRunning: (url) =>
|
||||
`Already running on this data root — opening the existing instance: ${url}`,
|
||||
webProbeFailed: (url, detail, kind, port) => {
|
||||
const hint = {
|
||||
timeout:
|
||||
@@ -664,6 +673,9 @@ const zh: Messages = {
|
||||
vaultListTitle: () => "vault 环境变量(值已掩码):",
|
||||
vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。",
|
||||
webReady: (url) => `Web 界面已就绪:${url}`,
|
||||
serverAlreadyRunning: (url) =>
|
||||
`该数据根目录已有 PenguinHarness 服务在运行:${url}\n请先停止它,或用 PENGUIN_HOME 指定另一个数据根目录。`,
|
||||
webAlreadyRunning: (url) => `该数据根目录已有服务在运行,打开既有实例:${url}`,
|
||||
webProbeFailed: (url, detail, kind, port) => {
|
||||
const hint = {
|
||||
timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@prismshadow/penguin-desktop",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "PenguinHarness desktop app: Electron shell running @prismshadow/penguin-server as a utilityProcess, window on http://localhost (same-origin HTTP/SSE, no private IPC).",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"start": "electron ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@prismshadow/penguin-core": "workspace:*",
|
||||
"@prismshadow/penguin-server": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"electron": "^43.2.0",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Desktop shell main process (design § "桌面端原型").
|
||||
*
|
||||
* One window over the embedded server: fork penguin-server as a utilityProcess on the
|
||||
* shared data root (PENGUIN_HOME or ~/.penguin/data), learn its ephemeral port, and load
|
||||
* `http://localhost:<port>/api/auth/desktop-login?token=…` — the one-shot token lands
|
||||
* the window signed in as admin. The window is a plain browser environment (no preload,
|
||||
* no node integration); every capability flows through the server's HTTP API.
|
||||
*
|
||||
* Attach mode: when a live server (e.g. `penguin web`) already owns the data root, the
|
||||
* window loads that instance instead — normal login page, deliberate degradation.
|
||||
*
|
||||
* Smoke hook (PENGUIN_DESKTOP_SMOKE=1): after the first load settles, print a
|
||||
* `DESKTOP-SMOKE-RESULT {json}` line (+ screenshot when PENGUIN_DESKTOP_SMOKE_SHOT is
|
||||
* set) and quit through the regular quit path, exercising the graceful server stop.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, dialog, shell } from "electron";
|
||||
import { resolveRoot } from "@prismshadow/penguin-core";
|
||||
import { liveServerLock } from "@prismshadow/penguin-server/lock";
|
||||
import { startEmbeddedServer, stopEmbeddedServer } from "./server-process.js";
|
||||
import type { EmbeddedServer } from "./server-process.js";
|
||||
import { desktopLoginUrl, isAppUrl, MAX_SERVER_RESTARTS, restartDelayMs } from "./util.js";
|
||||
|
||||
app.setName("PenguinHarness");
|
||||
|
||||
let win: BrowserWindow | null = null;
|
||||
let server: EmbeddedServer | null = null;
|
||||
/** App origin (embedded or attached); null until boot resolves. */
|
||||
let appOrigin: string | null = null;
|
||||
let quitting = false;
|
||||
let stopPromise: Promise<void> | null = null;
|
||||
let restartAttempts = 0;
|
||||
|
||||
function fatal(context: string, err: unknown): void {
|
||||
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
dialog.showErrorBox("PenguinHarness", `${context}\n\n${detail}`);
|
||||
app.exit(1);
|
||||
}
|
||||
|
||||
function createWindow(url: string): void {
|
||||
win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 860,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
// The window is a plain browser: no Node, no preload — the minimal attack surface.
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
win.once("ready-to-show", () => win?.show());
|
||||
win.on("closed", () => {
|
||||
win = null;
|
||||
});
|
||||
// Everything off the app origin (external links, Workspace previews on the 127.0.0.1
|
||||
// counterpart host) opens in the system browser; the window never leaves the app.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
if (!isAppUrl(target, appOrigin)) void shell.openExternal(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (event, target) => {
|
||||
if (!isAppUrl(target, appOrigin)) {
|
||||
event.preventDefault();
|
||||
void shell.openExternal(target);
|
||||
}
|
||||
});
|
||||
win.webContents.on("render-process-gone", () => win?.webContents.reload());
|
||||
armSmokeProbe(win);
|
||||
void win.loadURL(url);
|
||||
}
|
||||
|
||||
/** Starts (or restarts) the embedded server and points the window at desktop-login. */
|
||||
async function startServerAndWindow(dataRoot: string): Promise<void> {
|
||||
const started = await startEmbeddedServer({
|
||||
dataRoot,
|
||||
portFile: path.join(app.getPath("userData"), "server-port"),
|
||||
log: (chunk) => process.stdout.write(`[server] ${chunk}`),
|
||||
});
|
||||
server = started;
|
||||
appOrigin = started.origin;
|
||||
// A run that stays up for a minute is healthy: reset the restart budget so a crash
|
||||
// days later starts a fresh 1s/2s/4s ladder instead of hitting the cap immediately.
|
||||
const healthyTimer = setTimeout(() => {
|
||||
restartAttempts = 0;
|
||||
}, 60_000);
|
||||
started.child.on("exit", (code) => {
|
||||
clearTimeout(healthyTimer);
|
||||
void handleServerExit(dataRoot, code);
|
||||
});
|
||||
const url = desktopLoginUrl(started.origin, started.token);
|
||||
if (win === null) createWindow(url);
|
||||
else void win.loadURL(url);
|
||||
}
|
||||
|
||||
/** Unexpected server death: restart with backoff; give up with an error dialog at the cap. */
|
||||
async function handleServerExit(dataRoot: string, code: number): Promise<void> {
|
||||
if (quitting) return;
|
||||
server = null;
|
||||
if (restartAttempts >= MAX_SERVER_RESTARTS) {
|
||||
fatal(`The embedded server keeps exiting (last exit code ${code}).`, "Giving up.");
|
||||
return;
|
||||
}
|
||||
const wait = restartDelayMs(restartAttempts);
|
||||
restartAttempts += 1;
|
||||
process.stdout.write(`[shell] server exited (code ${code}); restarting in ${wait}ms\n`);
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
if (quitting) return;
|
||||
try {
|
||||
await startServerAndWindow(dataRoot);
|
||||
} catch (err) {
|
||||
fatal("The embedded server could not be restarted.", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
const dataRoot = process.env.PENGUIN_HOME ?? resolveRoot();
|
||||
const existing = await liveServerLock(dataRoot);
|
||||
if (existing !== null) {
|
||||
// Attach mode: the one-shot token only works against a server this shell spawned,
|
||||
// so the window goes through the normal login page of the existing instance.
|
||||
appOrigin = `http://localhost:${existing.port}`;
|
||||
process.stdout.write(`[shell] attaching to the running server at ${appOrigin}\n`);
|
||||
createWindow(`${appOrigin}/`);
|
||||
return;
|
||||
}
|
||||
await startServerAndWindow(dataRoot);
|
||||
}
|
||||
|
||||
// --- app lifecycle ---------------------------------------------------------
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (win !== null) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
// macOS keeps the app alive in the Dock; elsewhere closing the window quits.
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (win === null && appOrigin !== null) createWindow(`${appOrigin}/`);
|
||||
});
|
||||
|
||||
// Quit path: stop the embedded server gracefully first (shutdown endpoint → kill),
|
||||
// then let the quit proceed. Attach mode has no child to stop.
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
if (server !== null && stopPromise === null) {
|
||||
event.preventDefault();
|
||||
const running = server;
|
||||
server = null;
|
||||
stopPromise = stopEmbeddedServer(running).finally(() => app.quit());
|
||||
}
|
||||
});
|
||||
|
||||
void app
|
||||
.whenReady()
|
||||
.then(() => boot().catch((err) => fatal("PenguinHarness failed to start.", err)));
|
||||
}
|
||||
|
||||
// --- smoke hook ------------------------------------------------------------
|
||||
|
||||
/** Render-settle delay before sampling the page in smoke mode. */
|
||||
const SMOKE_SETTLE_MS = 2500;
|
||||
|
||||
function armSmokeProbe(target: BrowserWindow): void {
|
||||
if (process.env.PENGUIN_DESKTOP_SMOKE !== "1") return;
|
||||
target.webContents.once("did-finish-load", () => {
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = {
|
||||
title: target.webContents.getTitle(),
|
||||
url: target.webContents.getURL(),
|
||||
origin: appOrigin,
|
||||
embedded: server !== null,
|
||||
};
|
||||
const shot = process.env.PENGUIN_DESKTOP_SMOKE_SHOT;
|
||||
if (shot) {
|
||||
const image = await target.webContents.capturePage();
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(shot, image.toPNG());
|
||||
}
|
||||
process.stdout.write(`DESKTOP-SMOKE-RESULT ${JSON.stringify(result)}\n`);
|
||||
} catch (err) {
|
||||
process.stdout.write(`DESKTOP-SMOKE-RESULT ${JSON.stringify({ error: String(err) })}\n`);
|
||||
} finally {
|
||||
app.quit();
|
||||
}
|
||||
})();
|
||||
}, SMOKE_SETTLE_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Embedded server lifecycle: forks @prismshadow/penguin-server as an Electron
|
||||
* utilityProcess (same Node runtime, isolated from the main process), learns the actual
|
||||
* port from the PENGUIN_PORT_FILE announcement, probes HTTP readiness, and stops the
|
||||
* server gracefully — shutdown endpoint first (the only graceful path on Windows, where
|
||||
* kill() is a hard TerminateProcess), then SIGTERM-equivalent kill as fallback.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { utilityProcess } from "electron";
|
||||
import type { UtilityProcess } from "electron";
|
||||
import { appOriginFor, parsePortFile } from "./util.js";
|
||||
|
||||
export interface EmbeddedServer {
|
||||
child: UtilityProcess;
|
||||
/** App origin, e.g. `http://localhost:53187` (always localhost — 127.0.0.1 is the preview host). */
|
||||
origin: string;
|
||||
/** This launch's PENGUIN_DESKTOP_TOKEN: one-shot for desktop-login, reusable for the shutdown endpoint. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** How long the server gets to announce its port / answer HTTP before startup fails. */
|
||||
const PORT_FILE_TIMEOUT_MS = 30_000;
|
||||
const HTTP_READY_TIMEOUT_MS = 10_000;
|
||||
/** Grace period after the shutdown request (matches the server's own ≤5s wrap-up). */
|
||||
const SHUTDOWN_GRACE_MS = 6_000;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** The server package's entry file — forked by path, resolved through node_modules. */
|
||||
function serverEntryPath(): string {
|
||||
return fileURLToPath(import.meta.resolve("@prismshadow/penguin-server"));
|
||||
}
|
||||
|
||||
async function waitForPortFile(file: string, exited: () => boolean): Promise<number> {
|
||||
const deadline = Date.now() + PORT_FILE_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
if (exited()) throw new Error("The embedded server exited before announcing its port.");
|
||||
try {
|
||||
const port = parsePortFile(fs.readFileSync(file, "utf8"));
|
||||
if (port !== null) return port;
|
||||
} catch {
|
||||
// Not written yet.
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error("Timed out waiting for the embedded server's port announcement.");
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHttp(origin: string, exited: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + HTTP_READY_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
if (exited()) throw new Error("The embedded server exited during startup.");
|
||||
try {
|
||||
// Any HTTP answer counts (the root may 302 on the preview host); manual redirect
|
||||
// keeps the probe from chasing hosts.
|
||||
const res = await fetch(`${origin}/`, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
void res.body?.cancel();
|
||||
return;
|
||||
} catch {
|
||||
// Not accepting yet.
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the embedded server.");
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the embedded server on the given data root with an ephemeral port (PORT=0) and
|
||||
* a fresh one-shot token. Resolves once HTTP answers. The caller attaches its own
|
||||
* `child.on("exit", …)` restart policy after this resolves.
|
||||
*/
|
||||
export async function startEmbeddedServer(opts: {
|
||||
dataRoot: string;
|
||||
portFile: string;
|
||||
log: (chunk: string) => void;
|
||||
}): Promise<EmbeddedServer> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
fs.rmSync(opts.portFile, { force: true });
|
||||
const child = utilityProcess.fork(serverEntryPath(), [], {
|
||||
serviceName: "penguin-server",
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
PENGUIN_HOME: opts.dataRoot,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: "0",
|
||||
PENGUIN_DESKTOP_TOKEN: token,
|
||||
PENGUIN_PORT_FILE: opts.portFile,
|
||||
},
|
||||
});
|
||||
child.stdout?.on("data", (chunk: Buffer) => opts.log(String(chunk)));
|
||||
child.stderr?.on("data", (chunk: Buffer) => opts.log(String(chunk)));
|
||||
let exited = false;
|
||||
child.on("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const port = await waitForPortFile(opts.portFile, () => exited);
|
||||
const origin = appOriginFor(port);
|
||||
await waitForHttp(origin, () => exited);
|
||||
return { child, origin, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful stop: POST /api/desktop/shutdown with the shell's Bearer token, wait out the
|
||||
* server's wrap-up, then kill as a last resort. Safe to call when the child already died.
|
||||
*/
|
||||
export async function stopEmbeddedServer(server: EmbeddedServer): Promise<void> {
|
||||
let exited = false;
|
||||
const exit = new Promise<void>((resolve) =>
|
||||
server.child.once("exit", () => {
|
||||
exited = true;
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await fetch(`${server.origin}/api/desktop/shutdown`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${server.token}` },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch {
|
||||
// Server unreachable (already dead or wedged): fall through to kill.
|
||||
}
|
||||
await Promise.race([exit, delay(SHUTDOWN_GRACE_MS)]);
|
||||
if (!exited) {
|
||||
server.child.kill();
|
||||
await Promise.race([exit, delay(2000)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Pure helpers for the desktop shell — no Electron imports, so they unit-test under
|
||||
* plain vitest.
|
||||
*/
|
||||
|
||||
/** Parses the server's port-announcement file: a decimal port on the first line. */
|
||||
export function parsePortFile(content: string): number | null {
|
||||
const m = /^(\d{1,5})\s*$/.exec(content.trim());
|
||||
if (!m) return null;
|
||||
const port = Number(m[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The App origin for a port. Always `localhost`: on loopback the App is canonicalized
|
||||
* onto localhost and `127.0.0.1` is reserved as the preview host, which rejects /api
|
||||
* (see design § "桌面端原型 · 进程模型").
|
||||
*/
|
||||
export function appOriginFor(port: number): string {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
/** The window's first navigation: redeems the shell's one-shot token for a cookie session. */
|
||||
export function desktopLoginUrl(origin: string, token: string): string {
|
||||
return `${origin}/api/auth/desktop-login?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a navigation target stays inside the app window. Only the app origin itself
|
||||
* qualifies; everything else (external sites, and Workspace previews on the 127.0.0.1
|
||||
* counterpart host) opens in the system browser.
|
||||
*/
|
||||
export function isAppUrl(url: string, origin: string | null): boolean {
|
||||
if (origin === null) return false;
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Max automatic server restarts before giving up with an error dialog. */
|
||||
export const MAX_SERVER_RESTARTS = 3;
|
||||
|
||||
/** Restart backoff: 1s, 2s, 4s (attempt is 0-based). */
|
||||
export function restartDelayMs(attempt: number): number {
|
||||
return Math.min(1000 * 2 ** attempt, 8000);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appOriginFor,
|
||||
desktopLoginUrl,
|
||||
isAppUrl,
|
||||
parsePortFile,
|
||||
restartDelayMs,
|
||||
} from "../src/util.js";
|
||||
|
||||
describe("parsePortFile", () => {
|
||||
it("accepts a port with surrounding whitespace", () => {
|
||||
expect(parsePortFile("17365\n")).toBe(17365);
|
||||
expect(parsePortFile(" 80 ")).toBe(80);
|
||||
});
|
||||
it("rejects garbage, empty, zero, and out-of-range values", () => {
|
||||
expect(parsePortFile("")).toBeNull();
|
||||
expect(parsePortFile("abc")).toBeNull();
|
||||
expect(parsePortFile("0")).toBeNull();
|
||||
expect(parsePortFile("65536")).toBeNull();
|
||||
expect(parsePortFile("12 34")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("app origin and login URL", () => {
|
||||
it("builds the localhost origin and the one-shot login URL", () => {
|
||||
expect(appOriginFor(7364)).toBe("http://localhost:7364");
|
||||
expect(desktopLoginUrl("http://localhost:7364", "a b/c")).toBe(
|
||||
"http://localhost:7364/api/auth/desktop-login?token=a%20b%2Fc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAppUrl", () => {
|
||||
const origin = "http://localhost:7364";
|
||||
it("accepts only the app origin", () => {
|
||||
expect(isAppUrl("http://localhost:7364/chat", origin)).toBe(true);
|
||||
expect(isAppUrl("http://localhost:7365/", origin)).toBe(false);
|
||||
expect(isAppUrl("http://127.0.0.1:7364/preview/x", origin)).toBe(false);
|
||||
expect(isAppUrl("https://example.com", origin)).toBe(false);
|
||||
expect(isAppUrl("not a url", origin)).toBe(false);
|
||||
expect(isAppUrl("http://localhost:7364/", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restartDelayMs", () => {
|
||||
it("doubles from 1s and caps at 8s", () => {
|
||||
expect([0, 1, 2, 3, 4].map(restartDelayMs)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/main.ts"],
|
||||
format: ["esm"],
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
// `electron` is a runtime builtin inside the Electron main process; the workspace
|
||||
// packages stay external so the server entry keeps its own file identity (the shell
|
||||
// forks it as a child by path) and lock.js resolves from node_modules.
|
||||
external: ["electron", "@prismshadow/penguin-server", "@prismshadow/penguin-core"],
|
||||
});
|
||||
@@ -17,6 +17,10 @@
|
||||
"./api": {
|
||||
"types": "./dist/api/types.d.ts",
|
||||
"import": "./dist/api/types.js"
|
||||
},
|
||||
"./lock": {
|
||||
"types": "./dist/lock.d.ts",
|
||||
"import": "./dist/lock.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -72,10 +72,25 @@ export interface MeResponse {
|
||||
* request, since it depends on the host the caller is using.
|
||||
*/
|
||||
previewIsolated: boolean;
|
||||
/**
|
||||
* Whether this server runs in desktop mode (spawned by the desktop shell with
|
||||
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
|
||||
* initial-password banner and the self-update entry, and omits the old-password
|
||||
* field when changing the password. See design § "桌面端原型".
|
||||
*/
|
||||
desktopMode: boolean;
|
||||
/**
|
||||
* How THIS session was established. Distinct from desktopMode: a browser signed into a
|
||||
* desktop-mode server holds a "password" session and must still provide the old
|
||||
* password when changing it — only "desktop" sessions (opened by the shell's one-shot
|
||||
* token) may omit it.
|
||||
*/
|
||||
sessionVia: "password" | "desktop";
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
oldPassword: string;
|
||||
/** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
|
||||
oldPassword?: string;
|
||||
/** At least 8 characters. */
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ import { TitleGenerator } from "./runtime/title-generator.js";
|
||||
import type { TitleNotifier } from "./runtime/title-generator.js";
|
||||
import { UsageRecorder } from "./runtime/usage-recorder.js";
|
||||
import { AdminService } from "./services/admin-service.js";
|
||||
import { DesktopService } from "./services/desktop-service.js";
|
||||
import { desktopRoutes } from "./http/routes/desktop.js";
|
||||
import { AgentConfigService } from "./services/agent-config-service.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { BenchmarkService } from "./services/benchmark-service.js";
|
||||
@@ -114,6 +116,8 @@ export interface AppDeps {
|
||||
sessionSources: SessionSources;
|
||||
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
|
||||
errors: ErrorRecorder;
|
||||
/** Desktop mode (PENGUIN_DESKTOP_TOKEN): one-shot login + shutdown token holder; null outside desktop mode. */
|
||||
desktop: DesktopService | null;
|
||||
/** Request log output (minimal one-liner); tests inject a noop. */
|
||||
log: (line: string) => void;
|
||||
}
|
||||
@@ -276,6 +280,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
||||
manager,
|
||||
sessionSources,
|
||||
errors,
|
||||
desktop: config.desktopToken !== null ? new DesktopService(config.desktopToken) : null,
|
||||
log,
|
||||
};
|
||||
}
|
||||
@@ -355,6 +360,11 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
|
||||
|
||||
// Public routes (no login required).
|
||||
app.route("/api/auth", authRoutes(deps));
|
||||
// Desktop shutdown authenticates with the shell's Bearer token, not the cookie
|
||||
// session, so it mounts outside authMiddleware (and only in desktop mode).
|
||||
if (deps.desktop) {
|
||||
app.route("/api/desktop", desktopRoutes(deps));
|
||||
}
|
||||
|
||||
// Protected routes: cookie -> auth_session -> user.
|
||||
const auth = authMiddleware(deps.authService);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import type { UserRow } from "../db/repos/users.js";
|
||||
import type { AuthService } from "./service.js";
|
||||
import type { AuthService, SessionVia } from "./service.js";
|
||||
|
||||
/** Session cookie name. */
|
||||
export const SESSION_COOKIE = "penguin_session";
|
||||
@@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session";
|
||||
export type AppEnv = {
|
||||
Variables: {
|
||||
user: UserRow;
|
||||
/** How the current session was established ("password" | "desktop"); legacy rows read as "password". */
|
||||
sessionVia: SessionVia;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,11 +33,12 @@ export function currentUser(c: { var: { user: UserRow } }): UserRow {
|
||||
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
|
||||
return async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
const user = token ? auth.authenticate(token) : null;
|
||||
if (!user) {
|
||||
const authed = token ? auth.authenticateWithMeta(token) : null;
|
||||
if (!authed) {
|
||||
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
|
||||
}
|
||||
c.set("user", user);
|
||||
c.set("user", authed.user);
|
||||
c.set("sessionVia", authed.via);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* How a session was established: "password" via the login form, "desktop" via the
|
||||
* desktop shell's one-shot token (see design § "桌面端原型 · 桌面登录"). Persisted per
|
||||
* session so desktop-specific allowances (password change without the old password)
|
||||
* apply only to sessions the shell itself opened. Legacy rows (NULL) read as "password".
|
||||
*/
|
||||
export type SessionVia = "password" | "desktop";
|
||||
|
||||
export function toUserInfo(row: UserRow): UserInfo {
|
||||
return {
|
||||
userId: row.userId,
|
||||
@@ -159,7 +167,22 @@ export class AuthService {
|
||||
}
|
||||
this.loginFailures.delete(userId);
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
|
||||
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
|
||||
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
|
||||
* after startup seeding, so this only trips on a broken deployment).
|
||||
*/
|
||||
loginDesktop(): { user: UserInfo; token: string } {
|
||||
const row = this.deps.users.findById(ADMIN_USER_ID);
|
||||
if (!row) {
|
||||
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
|
||||
}
|
||||
this.deps.authSessions.deleteExpired(this.now().toISOString());
|
||||
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
|
||||
}
|
||||
|
||||
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
|
||||
@@ -174,12 +197,25 @@ export class AuthService {
|
||||
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-session password set: no old-password check. Only reachable for sessions
|
||||
* established via desktop-login (the me route gates on sessionVia) — the seed password
|
||||
* of a desktop-created root is random and never shown, so its holder has nothing to
|
||||
* type into an old-password field; the shell's token already proved machine ownership.
|
||||
*/
|
||||
async setPasswordDesktop(userId: string, newPassword: string): Promise<void> {
|
||||
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
|
||||
}
|
||||
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
|
||||
}
|
||||
|
||||
logout(token: string): void {
|
||||
this.deps.authSessions.delete(sha256Hex(token));
|
||||
}
|
||||
|
||||
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
|
||||
authenticate(token: string): UserRow | null {
|
||||
authenticateWithMeta(token: string): { user: UserRow; via: SessionVia } | null {
|
||||
const tokenHash = sha256Hex(token);
|
||||
const session = this.deps.authSessions.findByTokenHash(tokenHash);
|
||||
if (!session) return null;
|
||||
@@ -195,10 +231,12 @@ export class AuthService {
|
||||
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
);
|
||||
}
|
||||
return this.deps.users.findById(session.userId);
|
||||
const user = this.deps.users.findById(session.userId);
|
||||
if (!user) return null;
|
||||
return { user, via: session.via === "desktop" ? "desktop" : "password" };
|
||||
}
|
||||
|
||||
private issueSession(userId: string): string {
|
||||
private issueSession(userId: string, via: SessionVia): string {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const now = this.now();
|
||||
this.deps.authSessions.insert({
|
||||
@@ -206,6 +244,7 @@ export class AuthService {
|
||||
userId,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
|
||||
via,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* detected to exist.
|
||||
* Docs: /docs/configuration § "Environment variables".
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -35,13 +36,29 @@ export interface ServerConfig {
|
||||
/**
|
||||
* Fixed initial password for the seeded built-in admin (PENGUIN_SEED_ADMIN_PASSWORD),
|
||||
* used by automated tests and e2e; null (the norm) makes the seed generate a random
|
||||
* `penguin-<4 digits>` password, printed once to the server console.
|
||||
* `penguin-<4 digits>` password, printed once to the server console. In desktop mode
|
||||
* an unpinned value resolves to a FULLY random password instead (never printed):
|
||||
* sign-in there goes through the shell's one-shot token, so nobody needs to read the
|
||||
* seed. See design § "桌面端原型 · 桌面登录".
|
||||
*/
|
||||
seedAdminPassword: string | null;
|
||||
/** Login session validity period (7 days). */
|
||||
authSessionTtlMs: number;
|
||||
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
|
||||
authSessionRenewMs: number;
|
||||
/**
|
||||
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the per-launch token minted by the desktop
|
||||
* shell. Non-null enables the one-shot desktop-login and Bearer-token shutdown
|
||||
* endpoints and requires a loopback HOST — desktop mode passes the token through a
|
||||
* URL, which must never leave the machine. See design § "桌面端原型".
|
||||
*/
|
||||
desktopToken: string | null;
|
||||
/**
|
||||
* Port announcement file (PENGUIN_PORT_FILE): after the listener is up, the actual
|
||||
* bound port is written here — the supervising process's way to learn the port when
|
||||
* it starts the server with PORT=0.
|
||||
*/
|
||||
portFile: string | null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -79,7 +96,7 @@ function normalizePreviewOrigin(raw: string | undefined): string | null {
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD). */
|
||||
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB / PENGUIN_PREVIEW_ORIGIN / PENGUIN_SEED_ADMIN_PASSWORD / PENGUIN_DESKTOP_TOKEN / PENGUIN_PORT_FILE). */
|
||||
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
|
||||
const root = env.PENGUIN_HOME ?? resolveRoot();
|
||||
// An empty PORT string is treated as unset (the common `.env` case of an empty
|
||||
@@ -89,16 +106,29 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`Invalid port configuration PORT=${env.PORT}`);
|
||||
}
|
||||
const host = env.HOST ?? "127.0.0.1";
|
||||
const desktopToken = env.PENGUIN_DESKTOP_TOKEN?.trim() || null;
|
||||
// Desktop mode redeems its token through a URL: never allow it off loopback.
|
||||
if (desktopToken !== null && host !== "127.0.0.1" && host !== "localhost") {
|
||||
throw new Error(`Desktop mode requires a loopback HOST (got HOST=${host})`);
|
||||
}
|
||||
return {
|
||||
root,
|
||||
host: env.HOST ?? "127.0.0.1",
|
||||
host,
|
||||
port,
|
||||
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
|
||||
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
|
||||
previewOrigin: normalizePreviewOrigin(env.PENGUIN_PREVIEW_ORIGIN),
|
||||
// An empty/whitespace value is treated as unset (→ random seed password).
|
||||
seedAdminPassword: env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() || null,
|
||||
// An empty/whitespace value is treated as unset (→ random seed password). Desktop
|
||||
// mode without a pinned value seeds a FULLY random password rather than the
|
||||
// printable penguin-<4 digits>: desktop sign-in goes through the shell's token, so
|
||||
// the seed never needs to be read — and index.ts deliberately does not print it.
|
||||
seedAdminPassword:
|
||||
env.PENGUIN_SEED_ADMIN_PASSWORD?.trim() ||
|
||||
(desktopToken !== null ? randomBytes(24).toString("base64url") : null),
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
desktopToken,
|
||||
portFile: env.PENGUIN_PORT_FILE?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export function openDatabase(dbPath: string): DatabaseSync {
|
||||
// schema.ts; drop entries only in a release allowed to break existing web.db files.
|
||||
ensureColumn(db, "sessions", "client", "TEXT");
|
||||
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "auth_sessions", "via", "TEXT");
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface AuthSessionRow {
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
/** How the session was established ("password" | "desktop"); null on rows formed before the column existed (treated as "password"). */
|
||||
via: string | null;
|
||||
}
|
||||
|
||||
export class AuthSessionsRepo {
|
||||
@@ -18,9 +20,9 @@ export class AuthSessionsRepo {
|
||||
insert(row: AuthSessionRow): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt);
|
||||
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
|
||||
}
|
||||
|
||||
findByTokenHash(tokenHash: string): AuthSessionRow | null {
|
||||
@@ -31,6 +33,7 @@ export class AuthSessionsRepo {
|
||||
userId: r.user_id as string,
|
||||
createdAt: r.created_at as string,
|
||||
expiresAt: r.expires_at as string,
|
||||
via: (r.via as string | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
|
||||
expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
|
||||
via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Auth routes: POST /api/auth/login | logout.
|
||||
* Auth routes: POST /api/auth/login | logout, GET /api/auth/desktop-login (desktop mode).
|
||||
* No self-registration: users are created by an admin in the user backend (/api/admin/users).
|
||||
* Login issues a cookie session; logout deletes the server-side session and clears the cookie.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import type { AuthResponse } from "../../api/types.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { SESSION_COOKIE } from "../../auth/middleware.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { readJson, requireString } from "../validate.js";
|
||||
@@ -42,5 +43,22 @@ export function authRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
// Desktop-mode sign-in: the window's FIRST navigation redeems the shell's one-shot
|
||||
// token for a standard admin cookie session and lands on the app — the desktop user
|
||||
// never sees the login page. 404 outside desktop mode (the route "doesn't exist");
|
||||
// a wrong or already-used token is a plain 401 with no distinction, so a leaked URL
|
||||
// reveals nothing and cannot be replayed. See design § "桌面端原型 · 桌面登录".
|
||||
app.get("/desktop-login", (c) => {
|
||||
const desktop = deps.desktop;
|
||||
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
|
||||
const token = c.req.query("token") ?? "";
|
||||
if (token === "" || !desktop.redeemLoginToken(token)) {
|
||||
throw new HttpError(401, "unauthorized", "Invalid or already-used desktop token.");
|
||||
}
|
||||
const { token: session } = deps.authService.loginDesktop();
|
||||
setCookie(c, SESSION_COOKIE, session, cookieOptions(c));
|
||||
return c.redirect("/", 302);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Desktop-mode routes: POST /api/desktop/shutdown.
|
||||
*
|
||||
* Authenticated by the shell's Bearer token, not the cookie session (the shell holds no
|
||||
* cookie), so this mounts OUTSIDE authMiddleware and only when desktop mode is enabled.
|
||||
* Responds 202 first, then triggers the graceful shutdown a beat later so the response
|
||||
* isn't cut off by the closing listener.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
/** Delay between answering 202 and starting shutdown: lets the response flush. */
|
||||
const SHUTDOWN_DELAY_MS = 50;
|
||||
|
||||
export function desktopRoutes(deps: AppDeps): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/shutdown", (c) => {
|
||||
const desktop = deps.desktop;
|
||||
if (!desktop) throw new HttpError(404, "not_found", "Desktop mode is not enabled.");
|
||||
const header = c.req.header("authorization") ?? "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
|
||||
if (token === "" || !desktop.verifyToken(token)) {
|
||||
throw new HttpError(401, "unauthorized", "Invalid desktop token.");
|
||||
}
|
||||
setTimeout(() => desktop.requestShutdown(), SHUTDOWN_DELAY_MS).unref();
|
||||
return c.body(null, 202);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -28,15 +28,25 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
return c.json({
|
||||
user: toUserInfo(c.var.user),
|
||||
previewIsolated: target !== null,
|
||||
desktopMode: deps.desktop !== null,
|
||||
sessionVia: c.var.sessionVia,
|
||||
} satisfies MeResponse);
|
||||
});
|
||||
|
||||
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
|
||||
// Desktop sessions may omit oldPassword: the seed password of a desktop-created root is
|
||||
// random and never shown, so its holder has nothing to type — the shell's redeemed
|
||||
// token already proved machine ownership (see design § "桌面端原型 · 桌面登录").
|
||||
app.put("/password", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
|
||||
const newPassword = requireString(body, "newPassword", { label: "newPassword" });
|
||||
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
|
||||
const desktopSession = deps.desktop !== null && c.var.sessionVia === "desktop";
|
||||
if (desktopSession && body.oldPassword === undefined) {
|
||||
await deps.authService.setPasswordDesktop(c.var.user.userId, newPassword);
|
||||
} else {
|
||||
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
|
||||
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,15 +9,35 @@
|
||||
* persist + log, with the fatal one still shutting down per existing semantics (see the
|
||||
* comment below).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { buildAppDeps, createApp } from "./app.js";
|
||||
import { resolveServerConfig } from "./config.js";
|
||||
import { loopbackHostRoles } from "./services/preview-token.js";
|
||||
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
|
||||
|
||||
loadDotenv({ quiet: true });
|
||||
|
||||
/** Exit code for "another server already owns this data root" (see lock.ts). */
|
||||
const EXIT_ALREADY_RUNNING = 3;
|
||||
|
||||
const config = resolveServerConfig();
|
||||
|
||||
// Single instance per data root: web.db is single-writer and the scheduler must not run
|
||||
// twice, so refuse to start when a live server already owns this root — BEFORE opening
|
||||
// the database. The CLI and the desktop shell pre-check the same lock for a friendlier
|
||||
// path (open / attach to the existing instance); this is the in-process backstop.
|
||||
const existingLock = await liveServerLock(config.root);
|
||||
if (existingLock) {
|
||||
console.error(
|
||||
`Another PenguinHarness server is already running on this data root (pid ${existingLock.pid}).`,
|
||||
);
|
||||
console.error(`Existing instance: http://localhost:${existingLock.port}/`);
|
||||
process.exit(EXIT_ALREADY_RUNNING);
|
||||
}
|
||||
|
||||
const deps = buildAppDeps(config);
|
||||
const app = createApp(deps);
|
||||
|
||||
@@ -25,7 +45,10 @@ const app = createApp(deps);
|
||||
// users table is empty. The returned initial password (random unless pinned via
|
||||
// PENGUIN_SEED_ADMIN_PASSWORD) is printed here once — the only place it is ever shown.
|
||||
const seededAdminPassword = await deps.authService.seedAdmin();
|
||||
if (seededAdminPassword !== null) {
|
||||
// Never printed in desktop mode: the seed there is fully random by design (config.ts)
|
||||
// and sign-in goes through the shell's one-shot token, so showing it would only leak a
|
||||
// credential into a log nobody needs.
|
||||
if (seededAdminPassword !== null && config.desktopToken === null) {
|
||||
console.log(
|
||||
`Seeded built-in admin "admin" — initial password: ${seededAdminPassword} (change it after first sign-in)`,
|
||||
);
|
||||
@@ -44,11 +67,6 @@ deps.goalsRepo.abortOrphanedActive();
|
||||
// counterpart is reserved for previews, so advertise the canonical name — the other one
|
||||
// only 302s back here for App routes (see the canonical-host guard in app.ts).
|
||||
const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
||||
console.log(`Data root: ${config.root}`);
|
||||
console.log(`SQLite: ${config.dbPath}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second loopback listener so the preview origin is actually reachable.
|
||||
@@ -58,17 +76,56 @@ const server = serve({ fetch: app.fetch, hostname: config.host, port: config.por
|
||||
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
|
||||
* would leave every preview URL refusing connections. Binding `::1` as well closes that
|
||||
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
|
||||
*
|
||||
* Created inside the main listener's callback so it reuses the ACTUAL bound port: with
|
||||
* PORT=0 both listeners resolving 0 independently would land on two different ports and
|
||||
* every preview URL (same port, counterpart host) would refuse connections.
|
||||
*/
|
||||
const ipv6Loopback =
|
||||
config.host === "127.0.0.1" || config.host === "localhost"
|
||||
? serve({ fetch: app.fetch, hostname: "::1", port: config.port })
|
||||
: null;
|
||||
ipv6Loopback?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(
|
||||
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
|
||||
);
|
||||
let ipv6Loopback: ReturnType<typeof serve> | null = null;
|
||||
|
||||
/** Port announcement (PENGUIN_PORT_FILE): tmp + rename, so a polling reader never sees a partial write. */
|
||||
function writePortFile(file: string, port: number): void {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, `${port}\n`);
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
|
||||
console.log(`penguin-server started: http://${appHost}:${info.port}`);
|
||||
console.log(`Data root: ${config.root}`);
|
||||
console.log(`SQLite: ${config.dbPath}`);
|
||||
if (config.desktopToken !== null) console.log("Desktop mode: enabled");
|
||||
// The root exists by now (openDatabase created it), and the pre-start check found no
|
||||
// live owner — record ourselves as this root's server.
|
||||
acquireServerLock(config.root, {
|
||||
pid: process.pid,
|
||||
port: info.port,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
if (config.portFile !== null) writePortFile(config.portFile, info.port);
|
||||
if (config.host === "127.0.0.1" || config.host === "localhost") {
|
||||
ipv6Loopback = serve({ fetch: app.fetch, hostname: "::1", port: info.port });
|
||||
ipv6Loopback.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.warn(
|
||||
`[server] IPv6 loopback listener unavailable (${err.code ?? err.message}); previews via localhost may not resolve.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Removes the instance lock and port file (best-effort; runs on both exit paths). */
|
||||
function cleanupInstanceFiles(): void {
|
||||
releaseServerLock(config.root);
|
||||
if (config.portFile !== null) {
|
||||
try {
|
||||
fs.rmSync(config.portFile, { force: true });
|
||||
} catch {
|
||||
// Best-effort: a stale port file is rewritten by the next server.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
@@ -80,15 +137,24 @@ async function shutdown(signal: string, exitCode = 0): Promise<void> {
|
||||
ipv6Loopback?.close();
|
||||
server.close(() => {
|
||||
deps.db.close();
|
||||
cleanupInstanceFiles();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
// Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s.
|
||||
setTimeout(() => process.exit(exitCode), 1000).unref();
|
||||
setTimeout(() => {
|
||||
cleanupInstanceFiles();
|
||||
process.exit(exitCode);
|
||||
}, 1000).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Desktop shell quit path: POST /api/desktop/shutdown lands here — the same graceful
|
||||
// shutdown as the signals, reachable over HTTP because a Windows child kill is a hard
|
||||
// TerminateProcess with no signal delivery.
|
||||
deps.desktop?.onShutdownRequest(() => void shutdown("desktop-shutdown"));
|
||||
|
||||
// Process-level error fallback: once a background
|
||||
// fire-and-forget promise (title generation, Session drive, etc.) throws, the error
|
||||
// reaches the process without passing through any catch — persist it first for a
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Root-level server instance lock (`<root>/server.lock`).
|
||||
*
|
||||
* web.db is single-process / single-writer (see db/database.ts), and two servers on one
|
||||
* data root would also double-run the schedule scheduler — so a data root admits one
|
||||
* server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the
|
||||
* pid to be alive AND the recorded port to accept a TCP connection, because either signal
|
||||
* alone false-positives (pids get recycled, ports get taken by unrelated processes). A
|
||||
* lock that fails the liveness check is stale and is simply overwritten by the next
|
||||
* server.
|
||||
*
|
||||
* Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the
|
||||
* desktop shell can pre-check a root without importing the package entry, which starts
|
||||
* listening. Docs: design § "桌面端原型 · 数据根与实例互斥".
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface ServerLock {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/** TCP probe budget: loopback either connects immediately or the port is dead. */
|
||||
const PROBE_TIMEOUT_MS = 500;
|
||||
|
||||
export function serverLockPath(root: string): string {
|
||||
return path.join(root, "server.lock");
|
||||
}
|
||||
|
||||
/** Reads the lock file; a missing or malformed file reads as "no lock". */
|
||||
export function readServerLock(root: string): ServerLock | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(serverLockPath(root), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ServerLock>;
|
||||
if (
|
||||
typeof parsed.pid !== "number" ||
|
||||
!Number.isInteger(parsed.pid) ||
|
||||
typeof parsed.port !== "number" ||
|
||||
!Number.isInteger(parsed.port)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM = the process exists but belongs to another user — still alive.
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function portAccepts(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App
|
||||
// request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.
|
||||
const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
|
||||
const done = (ok: boolean) => {
|
||||
socket.destroy();
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("timeout", () => done(false));
|
||||
socket.once("error", () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
/** True when the lock's process is alive AND its port accepts connections. */
|
||||
export async function isServerLockAlive(lock: ServerLock): Promise<boolean> {
|
||||
return pidAlive(lock.pid) && (await portAccepts(lock.port));
|
||||
}
|
||||
|
||||
/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */
|
||||
export async function liveServerLock(root: string): Promise<ServerLock | null> {
|
||||
const lock = readServerLock(root);
|
||||
if (!lock) return null;
|
||||
return (await isServerLockAlive(lock)) ? lock : null;
|
||||
}
|
||||
|
||||
/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */
|
||||
export function acquireServerLock(root: string, lock: ServerLock): void {
|
||||
const target = serverLockPath(root);
|
||||
const tmp = `${target}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */
|
||||
export function releaseServerLock(root: string): void {
|
||||
try {
|
||||
const lock = readServerLock(root);
|
||||
if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
|
||||
} catch {
|
||||
// Best-effort: a stale leftover is overwritten by the next server anyway.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Desktop mode (PENGUIN_DESKTOP_TOKEN): the shell that spawned this server proves itself
|
||||
* with a per-launch random token, which backs two endpoints with different consumption
|
||||
* rules:
|
||||
*
|
||||
* - `GET /api/auth/desktop-login?token=…` — ONE-SHOT: the window's first navigation
|
||||
* redeems the token for a standard admin cookie session; every later attempt fails,
|
||||
* so a leaked URL cannot be replayed.
|
||||
* - `POST /api/desktop/shutdown` (Authorization: Bearer <token>) — REUSABLE for the
|
||||
* process lifetime: the token here identifies the supervising shell, which may need
|
||||
* the endpoint at any point (POSIX quit, and the only graceful path on Windows,
|
||||
* where killing a child is a hard TerminateProcess).
|
||||
*
|
||||
* Comparisons hash both sides first so timingSafeEqual gets equal-length buffers.
|
||||
* Docs: design § "桌面端原型 · 桌面登录".
|
||||
*/
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export class DesktopService {
|
||||
private readonly tokenDigest: Buffer;
|
||||
private loginConsumed = false;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
|
||||
constructor(token: string) {
|
||||
this.tokenDigest = digest(token);
|
||||
}
|
||||
|
||||
/** Constant-time token check (no consumption). */
|
||||
verifyToken(candidate: string): boolean {
|
||||
return timingSafeEqual(digest(candidate), this.tokenDigest);
|
||||
}
|
||||
|
||||
/** One-shot login redemption: true exactly once, for the correct token. */
|
||||
redeemLoginToken(candidate: string): boolean {
|
||||
if (this.loginConsumed || !this.verifyToken(candidate)) return false;
|
||||
this.loginConsumed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** index.ts registers the actual graceful-shutdown trigger after assembly. */
|
||||
onShutdownRequest(handler: () => void): void {
|
||||
this.shutdownHandler = handler;
|
||||
}
|
||||
|
||||
/** Invoked by the shutdown route; false when no handler is registered (tests). */
|
||||
requestShutdown(): boolean {
|
||||
if (!this.shutdownHandler) return false;
|
||||
this.shutdownHandler();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,32 @@ describe("resolveServerConfig: PORT parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerConfig: desktop-mode seed password", () => {
|
||||
it("desktop mode without a pinned value generates a fully random seed password", () => {
|
||||
const a = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
|
||||
const b = resolveServerConfig({ ...base, PENGUIN_DESKTOP_TOKEN: "tok" }).seedAdminPassword;
|
||||
expect(a).not.toBeNull();
|
||||
// base64url of 24 random bytes: far beyond the printable penguin-<4 digits> space.
|
||||
expect(a!.length).toBeGreaterThanOrEqual(24);
|
||||
expect(a).not.toMatch(/^penguin-\d{4}$/);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("an explicit PENGUIN_SEED_ADMIN_PASSWORD still wins in desktop mode", () => {
|
||||
expect(
|
||||
resolveServerConfig({
|
||||
...base,
|
||||
PENGUIN_DESKTOP_TOKEN: "tok",
|
||||
PENGUIN_SEED_ADMIN_PASSWORD: "penguin-2026",
|
||||
}).seedAdminPassword,
|
||||
).toBe("penguin-2026");
|
||||
});
|
||||
|
||||
it("outside desktop mode the unpinned value stays null (random penguin-<4 digits> at seed time)", () => {
|
||||
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerConfig: PENGUIN_SEED_ADMIN_PASSWORD parsing", () => {
|
||||
it("unset/empty/whitespace → null; a value is kept trimmed", () => {
|
||||
expect(resolveServerConfig({ ...base }).seedAdminPassword).toBeNull();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Desktop mode: one-shot desktop-login, Bearer-token shutdown, desktopMode in /api/me,
|
||||
* and the desktop-session password change without oldPassword.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiClient, createTestApp, loginAdmin } from "./helpers.js";
|
||||
import type { MeResponse } from "../src/api/types.js";
|
||||
|
||||
const TOKEN = "test-desktop-token";
|
||||
|
||||
function desktopApp() {
|
||||
return createTestApp({ config: { desktopToken: TOKEN } });
|
||||
}
|
||||
|
||||
describe("desktop-login", () => {
|
||||
it("redeems the token once: cookie session, redirect to /, second attempt 401", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe("/");
|
||||
const cookie = res.headers.get("set-cookie");
|
||||
expect(cookie).toContain("penguin_session=");
|
||||
|
||||
const me = await t.app.request("/api/me", {
|
||||
headers: { cookie: cookie!.split(";")[0]! },
|
||||
});
|
||||
expect(me.status).toBe(200);
|
||||
const body = (await me.json()) as MeResponse;
|
||||
expect(body.user.userId).toBe("admin");
|
||||
expect(body.desktopMode).toBe(true);
|
||||
|
||||
const replay = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
expect(replay.status).toBe(401);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a wrong or missing token without consuming the real one", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
expect((await t.app.request("/api/auth/desktop-login?token=wrong")).status).toBe(401);
|
||||
expect((await t.app.request("/api/auth/desktop-login")).status).toBe(401);
|
||||
// The real token still works after failed attempts.
|
||||
expect((await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`)).status).toBe(302);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("is 404 outside desktop mode, and /api/me reports desktopMode false", async () => {
|
||||
const t = await createTestApp();
|
||||
try {
|
||||
expect((await t.app.request("/api/auth/desktop-login?token=x")).status).toBe(404);
|
||||
const admin = await loginAdmin(t.app);
|
||||
const me = await apiClient(t.app, admin.cookie).get("/api/me");
|
||||
expect(((await me.json()) as MeResponse).desktopMode).toBe(false);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop shutdown endpoint", () => {
|
||||
it("accepts the Bearer token repeatedly and triggers the registered handler", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
let requested = 0;
|
||||
t.deps.desktop!.onShutdownRequest(() => {
|
||||
requested += 1;
|
||||
});
|
||||
const res = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(202);
|
||||
// The route defers the trigger so the 202 can flush first.
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
expect(requested).toBe(1);
|
||||
|
||||
// Unlike the login token, the shutdown credential is NOT one-shot.
|
||||
const again = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(again.status).toBe(202);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong or missing tokens, and does not exist outside desktop mode", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const wrong = await t.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer nope" },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
const missing = await t.app.request("/api/desktop/shutdown", { method: "POST" });
|
||||
expect(missing.status).toBe(401);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
|
||||
const plain = await createTestApp();
|
||||
try {
|
||||
// Outside desktop mode the route is not mounted; the request falls through to the
|
||||
// cookie auth middleware, which rejects the cookieless caller with 401.
|
||||
const res = await plain.app.request("/api/desktop/shutdown", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${TOKEN}` },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
} finally {
|
||||
await plain.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop-session password change", () => {
|
||||
async function desktopCookie(t: Awaited<ReturnType<typeof desktopApp>>): Promise<string> {
|
||||
const res = await t.app.request(`/api/auth/desktop-login?token=${TOKEN}`);
|
||||
return res.headers.get("set-cookie")!.split(";")[0]!;
|
||||
}
|
||||
|
||||
it("allows omitting oldPassword for a desktop session and clears the initial flag", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const cookie = await desktopCookie(t);
|
||||
const res = await apiClient(t.app, cookie).put("/api/me/password", {
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
const me = (await (await apiClient(t.app, cookie).get("/api/me")).json()) as MeResponse;
|
||||
expect(me.user.passwordIsInitial).toBe(false);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("still validates oldPassword when it is provided by a desktop session", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
const cookie = await desktopCookie(t);
|
||||
const res = await apiClient(t.app, cookie).put("/api/me/password", {
|
||||
oldPassword: "wrong-password",
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps requiring oldPassword for password-established sessions in desktop mode", async () => {
|
||||
const t = await desktopApp();
|
||||
try {
|
||||
// Sign in via the regular login form against the same desktop-mode server.
|
||||
const admin = await loginAdmin(t.app);
|
||||
const res = await apiClient(t.app, admin.cookie).put("/api/me/password", {
|
||||
newPassword: "brand-new-password",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
} finally {
|
||||
await t.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@ export function testConfig(root: string): ServerConfig {
|
||||
seedAdminPassword: TEST_ADMIN_PASSWORD,
|
||||
authSessionTtlMs: 7 * DAY_MS,
|
||||
authSessionRenewMs: 6 * DAY_MS,
|
||||
desktopToken: null,
|
||||
portFile: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Server instance lock: read/acquire/release round-trip, stale detection (dead pid,
|
||||
* dead port), and the live path against a real loopback listener.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
acquireServerLock,
|
||||
isServerLockAlive,
|
||||
liveServerLock,
|
||||
readServerLock,
|
||||
releaseServerLock,
|
||||
serverLockPath,
|
||||
} from "../src/lock.js";
|
||||
import { makeTempRoot } from "./helpers.js";
|
||||
|
||||
/** A pid that is guaranteed dead: a just-exited child of ours. */
|
||||
function deadPid(): number {
|
||||
const child = spawnSync(process.execPath, ["-e", ""]);
|
||||
return child.pid ?? 2 ** 21;
|
||||
}
|
||||
|
||||
function listen(): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const port = (srv.address() as net.AddressInfo).port;
|
||||
resolve({
|
||||
port,
|
||||
close: () => new Promise((r) => srv.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("server lock", () => {
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const root = await makeTempRoot();
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reads null on a missing or malformed file, and round-trips acquire/read", async () => {
|
||||
const root = await tempRoot();
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
fs.writeFileSync(serverLockPath(root), "not json");
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
fs.writeFileSync(serverLockPath(root), JSON.stringify({ pid: "x", port: 1 }));
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
|
||||
acquireServerLock(root, { pid: process.pid, port: 12345, startedAt: "2026-01-01T00:00:00Z" });
|
||||
expect(readServerLock(root)).toEqual({
|
||||
pid: process.pid,
|
||||
port: 12345,
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a dead pid as stale even if the port is live", async () => {
|
||||
const { port, close } = await listen();
|
||||
try {
|
||||
expect(await isServerLockAlive({ pid: deadPid(), port, startedAt: "" })).toBe(false);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a dead port as stale even if the pid is live", async () => {
|
||||
const { port, close } = await listen();
|
||||
await close(); // the port is now free — nothing accepts connections
|
||||
expect(await isServerLockAlive({ pid: process.pid, port, startedAt: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("reports alive when pid and port both check out, and liveServerLock surfaces it", async () => {
|
||||
const root = await tempRoot();
|
||||
const { port, close } = await listen();
|
||||
try {
|
||||
const lock = { pid: process.pid, port, startedAt: "now" };
|
||||
expect(await isServerLockAlive(lock)).toBe(true);
|
||||
acquireServerLock(root, lock);
|
||||
expect(await liveServerLock(root)).toEqual(lock);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
expect(await liveServerLock(root)).toBeNull(); // stale once the listener is gone
|
||||
});
|
||||
|
||||
it("release removes only a lock owned by this process", async () => {
|
||||
const root = await tempRoot();
|
||||
acquireServerLock(root, { pid: deadPid(), port: 1, startedAt: "" });
|
||||
releaseServerLock(root); // foreign pid: left in place
|
||||
expect(readServerLock(root)).not.toBeNull();
|
||||
|
||||
acquireServerLock(root, { pid: process.pid, port: 1, startedAt: "" });
|
||||
releaseServerLock(root);
|
||||
expect(readServerLock(root)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
// Explicitly name entries to preserve the dist/api/types.js subpath (exports "./api" points to it).
|
||||
entry: { index: "src/index.ts", "api/types": "src/api/types.ts" },
|
||||
// Explicitly name entries to preserve the dist/api/types.js and dist/lock.js subpaths
|
||||
// (exports "./api" and "./lock" point to them; lock is side-effect-free for pre-checks).
|
||||
entry: { index: "src/index.ts", "api/types": "src/api/types.ts", lock: "src/lock.ts" },
|
||||
format: ["esm"],
|
||||
target: "node22",
|
||||
dts: true,
|
||||
|
||||
@@ -14,7 +14,12 @@ import { PasswordInput } from "../ui/password-input";
|
||||
import { Modal } from "../ui/modal";
|
||||
|
||||
export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { refresh } = useAuth();
|
||||
const { refresh, sessionVia } = useAuth();
|
||||
// Desktop-established sessions set the password without the old one: the desktop seed
|
||||
// password is random and never shown, so there is nothing to type. Keyed on the
|
||||
// session's origin, not desktopMode — a browser signed into the same server holds a
|
||||
// password session and must still prove the current password.
|
||||
const desktopSession = sessionVia === "desktop";
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
@@ -32,7 +37,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
|
||||
const submit = async () => {
|
||||
const next: { old?: string; new?: string; confirm?: string } = {};
|
||||
if (!oldPassword) next.old = S.common.requiredField;
|
||||
if (!desktopSession && !oldPassword) next.old = S.common.requiredField;
|
||||
if (!newPassword) next.new = S.common.requiredField;
|
||||
if (!confirmPassword) next.confirm = S.common.requiredField;
|
||||
if (!next.confirm && newPassword !== confirmPassword) next.confirm = S.account.passwordMismatch;
|
||||
@@ -43,7 +48,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
setBusy(true);
|
||||
setErrors({});
|
||||
try {
|
||||
await api.changePassword({ oldPassword, newPassword });
|
||||
await api.changePassword(desktopSession ? { newPassword } : { oldPassword, newPassword });
|
||||
await refresh();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -76,20 +81,22 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PasswordInput
|
||||
label={S.account.oldPassword}
|
||||
required
|
||||
size="sm"
|
||||
value={oldPassword}
|
||||
onChange={(e) => {
|
||||
setOldPassword(e.target.value);
|
||||
clearErrors();
|
||||
}}
|
||||
error={errors.old}
|
||||
autoComplete="current-password"
|
||||
hint={S.account.oldPasswordHint}
|
||||
autoFocus
|
||||
/>
|
||||
{!desktopSession && (
|
||||
<PasswordInput
|
||||
label={S.account.oldPassword}
|
||||
required
|
||||
size="sm"
|
||||
value={oldPassword}
|
||||
onChange={(e) => {
|
||||
setOldPassword(e.target.value);
|
||||
clearErrors();
|
||||
}}
|
||||
error={errors.old}
|
||||
autoComplete="current-password"
|
||||
hint={S.account.oldPasswordHint}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<PasswordInput
|
||||
label={S.account.newPassword}
|
||||
required
|
||||
@@ -102,6 +109,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
|
||||
error={errors.new}
|
||||
autoComplete="new-password"
|
||||
hint={S.auth.passwordHint}
|
||||
autoFocus={desktopSession}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={S.account.confirmPassword}
|
||||
|
||||
@@ -141,7 +141,7 @@ function CollapsedRail({ onExpand }: { onExpand: () => void }) {
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { user } = useAuth();
|
||||
const { user, desktopMode } = useAuth();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
// Desktop sidebar collapse (persisted): collapsed state leaves a narrow rail to expand from.
|
||||
@@ -195,8 +195,9 @@ export function AppLayout() {
|
||||
<span className="text-sm font-semibold">{S.appName}</span>
|
||||
</header>
|
||||
|
||||
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change */}
|
||||
{user?.passwordIsInitial && (
|
||||
{/* Initial-password notice banner (seed/admin-set password): disappears once passwordIsInitial clears after a successful change.
|
||||
Hidden in desktop mode — the seed password there is random and never shown, so "change it" is meaningless nagging. */}
|
||||
{user?.passwordIsInitial && !desktopMode && (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
<span>{S.account.initialPasswordBanner}</span>
|
||||
<button
|
||||
|
||||
@@ -192,7 +192,7 @@ export function Sidebar({
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { user, logout, desktopMode } = useAuth();
|
||||
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
|
||||
useTheme();
|
||||
const { lang, locale, setLang } = useLocale();
|
||||
@@ -1115,49 +1115,54 @@ export function Sidebar({
|
||||
While checking, the label swaps to the busy text and the version stays put.
|
||||
Nothing is fetched until the menu first opens; the version span appears once
|
||||
/api/version resolves. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateChecking}
|
||||
onClick={() => {
|
||||
if (newVersion !== null) {
|
||||
setUserOpen(false);
|
||||
setUpdateDialogOpen(true);
|
||||
} else {
|
||||
void runUpdateCheck();
|
||||
}
|
||||
}}
|
||||
{...(versionDate !== null
|
||||
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
|
||||
: {})}
|
||||
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{updateChecking && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
|
||||
/>
|
||||
)}
|
||||
{!updateChecking && newVersion !== null && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{updateChecking
|
||||
? S.update.checking
|
||||
: newVersion !== null
|
||||
? S.update.newVersion(newVersion)
|
||||
: S.update.checkNow}
|
||||
{/* Hidden in desktop mode: updates are the desktop app's job (electron-updater),
|
||||
and the dialog's admin self-update re-runs the CLI entry, which does not
|
||||
exist under the desktop shell. */}
|
||||
{!desktopMode && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateChecking}
|
||||
onClick={() => {
|
||||
if (newVersion !== null) {
|
||||
setUserOpen(false);
|
||||
setUpdateDialogOpen(true);
|
||||
} else {
|
||||
void runUpdateCheck();
|
||||
}
|
||||
}}
|
||||
{...(versionDate !== null
|
||||
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
|
||||
: {})}
|
||||
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{updateChecking && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
|
||||
/>
|
||||
)}
|
||||
{!updateChecking && newVersion !== null && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{updateChecking
|
||||
? S.update.checking
|
||||
: newVersion !== null
|
||||
? S.update.newVersion(newVersion)
|
||||
: S.update.checkNow}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{version !== null && (
|
||||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||
{`v${version.version}`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{version !== null && (
|
||||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||||
{`v${version.version}`}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
@@ -1171,16 +1176,20 @@ export function Sidebar({
|
||||
{S.admin.users}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
void logout().then(() => navigate("/login"));
|
||||
}}
|
||||
>
|
||||
{S.auth.logout}
|
||||
</button>
|
||||
{/* Hidden in desktop mode: the window IS the session — logging out would
|
||||
strand the user on a login page whose password was never shown. */}
|
||||
{!desktopMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
void logout().then(() => navigate("/login"));
|
||||
}}
|
||||
>
|
||||
{S.auth.logout}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,18 @@ interface AuthContextValue {
|
||||
* /api/me because it depends on the host the browser is using.
|
||||
*/
|
||||
previewIsolated: boolean;
|
||||
/**
|
||||
* Whether the server runs in desktop mode (spawned by the desktop shell). The UI then
|
||||
* hides the logout entry, the initial-password banner and the self-update entry — the
|
||||
* desktop app manages sign-in and updates itself.
|
||||
*/
|
||||
desktopMode: boolean;
|
||||
/**
|
||||
* How THIS session was established. A browser signed into a desktop-mode server holds
|
||||
* a "password" session; only "desktop" sessions (the shell's window) may change the
|
||||
* password without the old one.
|
||||
*/
|
||||
sessionVia: "password" | "desktop";
|
||||
login: (userId: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
/** Refetch /api/me (e.g. to refresh the passwordIsInitial flag after a password change). */
|
||||
@@ -33,6 +45,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Assume isolated until told otherwise: the warning is the exceptional state, and
|
||||
// flashing it during initialization would be noise.
|
||||
const [previewIsolated, setPreviewIsolated] = useState(true);
|
||||
const [desktopMode, setDesktopMode] = useState(false);
|
||||
const [sessionVia, setSessionVia] = useState<"password" | "desktop">("password");
|
||||
|
||||
// Any API returning 401 (session expired / database rebuilt) clears the current user, and
|
||||
// RequireAuth redirects back to the login page.
|
||||
@@ -51,6 +65,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (cancelled) return;
|
||||
setUser(res.user);
|
||||
setPreviewIsolated(res.previewIsolated);
|
||||
setDesktopMode(res.desktopMode);
|
||||
setSessionVia(res.sessionVia);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
@@ -76,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const me = await api.getMe();
|
||||
setUser(me.user);
|
||||
setPreviewIsolated(me.previewIsolated);
|
||||
setDesktopMode(me.desktopMode);
|
||||
setSessionVia(me.sessionVia);
|
||||
} catch {
|
||||
// Login itself succeeded; keep the optimistic default.
|
||||
}
|
||||
@@ -93,10 +111,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const res = await api.getMe();
|
||||
setUser(res.user);
|
||||
setPreviewIsolated(res.previewIsolated);
|
||||
setDesktopMode(res.desktopMode);
|
||||
setSessionVia(res.sessionVia);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, previewIsolated, login, logout, refresh }}>
|
||||
<AuthContext.Provider
|
||||
value={{ user, previewIsolated, desktopMode, sessionVia, login, logout, refresh }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
Generated
+97
@@ -112,6 +112,31 @@ importers:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.22.4)(yaml@2.9.0)
|
||||
|
||||
packages/desktop:
|
||||
dependencies:
|
||||
'@prismshadow/penguin-core':
|
||||
specifier: workspace:*
|
||||
version: file:packages/core(supports-color@10.2.2)(ws@8.21.0)
|
||||
'@prismshadow/penguin-server':
|
||||
specifier: workspace:*
|
||||
version: link:../server
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.13.3
|
||||
electron:
|
||||
specifier: ^43.2.0
|
||||
version: 43.2.0(supports-color@10.2.2)
|
||||
tsup:
|
||||
specifier: ^8.3.0
|
||||
version: 8.5.1(jiti@2.7.0)(postcss@8.5.23)(supports-color@10.2.2)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0)
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@10.2.2)(tsx@4.22.4)(yaml@2.9.0)
|
||||
|
||||
packages/docs:
|
||||
dependencies:
|
||||
react:
|
||||
@@ -555,6 +580,14 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@electron-internal/extract-zip@1.0.5':
|
||||
resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@electron/get@5.1.0':
|
||||
resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1554,6 +1587,11 @@ packages:
|
||||
electron-to-chromium@1.5.387:
|
||||
resolution: {integrity: sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==}
|
||||
|
||||
electron@43.2.0:
|
||||
resolution: {integrity: sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
@@ -1569,6 +1607,10 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
env-paths@3.0.0:
|
||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2243,6 +2285,10 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
@@ -2352,6 +2398,11 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@0.19.2:
|
||||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2443,6 +2494,10 @@ packages:
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
sumchecker@3.0.1:
|
||||
resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==}
|
||||
engines: {node: '>= 8.0'}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2552,6 +2607,10 @@ packages:
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
undici@7.29.0:
|
||||
resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -3157,6 +3216,21 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@electron-internal/extract-zip@1.0.5': {}
|
||||
|
||||
'@electron/get@5.1.0(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
env-paths: 3.0.0
|
||||
graceful-fs: 4.2.11
|
||||
progress: 2.0.3
|
||||
semver: 7.8.5
|
||||
sumchecker: 3.0.1(supports-color@10.2.2)
|
||||
optionalDependencies:
|
||||
undici: 7.29.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
@@ -4100,6 +4174,14 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.387: {}
|
||||
|
||||
electron@43.2.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0(supports-color@10.2.2)
|
||||
'@types/node': 24.13.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
@@ -4111,6 +4193,8 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
env-paths@3.0.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
@@ -5039,6 +5123,8 @@ snapshots:
|
||||
|
||||
prettier@3.9.4: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
protobufjs@7.6.5:
|
||||
@@ -5206,6 +5292,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
send@0.19.2(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 2.6.9(supports-color@10.2.2)
|
||||
@@ -5333,6 +5421,12 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
sumchecker@3.0.1(supports-color@10.2.2):
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tailwindcss@4.3.2: {}
|
||||
@@ -5431,6 +5525,9 @@ snapshots:
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
undici@7.29.0:
|
||||
optional: true
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -13,3 +13,6 @@ allowBuilds:
|
||||
"@google/genai": true
|
||||
esbuild: true
|
||||
protobufjs: true
|
||||
# electron's postinstall downloads the platform runtime binary; without this allow
|
||||
# entry pnpm skips it and `electron .` has nothing to launch.
|
||||
electron: true
|
||||
|
||||
Reference in New Issue
Block a user