Files
l1shen b969ec0b4e feat: add cloud project OAuth and remote execution (#627)
## Summary

Add OOMOL cloud OAuth as a provider authorization source. Users can save
an encrypted project API key, select a cloud provider configuration,
authorize named connections, and run their actions and proxy requests
through SaaS. Existing connections retain their source when the provider
default changes, and third-party credentials remain in SaaS.

- Implement project discovery, OAuth request synchronization, remote
execution, and persistent cleanup of disconnected cloud accounts. Add
SQLite, PostgreSQL, and D1 migrations and storage support, with instance
reset and maintenance tooling.
- Add Console project and authorization settings, localized errors,
disconnect notifications, and styled OAuth completion pages that close
after successful authorization. Align local development callback origins
and proxy routes.
- Improve safe request diagnostics and retry transient project
health-check failures once. Execution requests are not automatically
replayed.
- Group storage implementations by database backend, share guarded HTTP
requests with Marketplace, update Zod to 4.6.5, and load provider icons
from the public catalog.
- Document setup, maintenance, and the manual acceptance checklist.

## Validation

- `npm run fix-check` passed.
- `npm run build:web` passed.
- Latest focused regression run: 346 tests passed across 33 files
covering Console, SaaS project/client/routes, OAuth completion, and
guarded HTTP requests.
- Earlier implementation validation included the broader runtime/storage
suites and a real PostgreSQL integration run. Full deployed
action/proxy, cleanup recovery, and D1 maintenance acceptance remain
documented manual checks; they are not claimed as completed by the unit
tests.

## Deployment notes

Apply migrations `0014_saas_project` and `0015_saas_cleanup_runtime`,
configure encryption and an explicit `OOMOL_CONNECT_ORIGIN`, and enable
the relevant cleanup scheduling. The remote SaaS deployment must support
the project OAuth and remote execution APIs described in
`docs/saas-oauth.md`.
2026-09-30 13:25:49 +08:00

96 lines
2.8 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
interface DevProcess {
name: string;
child: ChildProcess;
}
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
runChecked(process.execPath, ["scripts/ensure-generated.ts"]);
const apiPort = process.env.PORT ?? "3000";
process.env.OOMOL_CONNECT_ORIGIN ??= "http://localhost:5173";
const processes: DevProcess[] = [
startProcess("api", process.execPath, ["--watch", "--watch-preserve-output", "src/server/index.ts"]),
startProcess("web", npmCommand, ["run", "dev", "--workspace", "web", "--", "--clearScreen", "false"]),
];
console.log(`API runtime: http://localhost:${apiPort}`);
console.log("Web console: http://localhost:5173");
await waitForProcesses(processes);
function runChecked(command: string, args: string[]): void {
const result = spawnSync(command, args, {
cwd: process.cwd(),
stdio: "inherit",
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function startProcess(name: string, command: string, args: string[]): DevProcess {
return {
name,
child: spawn(command, args, {
cwd: process.cwd(),
stdio: "inherit",
}),
};
}
function waitForProcesses(processes: DevProcess[]): Promise<void> {
let shuttingDown = false;
return new Promise((resolve) => {
const shutdown = (exitCode: number, signal?: NodeJS.Signals): void => {
if (shuttingDown) {
return;
}
shuttingDown = true;
process.exitCode = exitCode;
stopProcesses(processes, signal);
resolve();
};
for (const processInfo of processes) {
processInfo.child.on("error", (error) => {
console.error(`${processInfo.name} failed to start: ${error.message}`);
shutdown(1);
});
processInfo.child.on("close", (code, signal) => {
if (!shuttingDown) {
if (signal && signal !== "SIGINT" && signal !== "SIGTERM") {
console.error(`${processInfo.name} exited with ${signal}.`);
} else if (code != null && code !== 0) {
console.error(`${processInfo.name} exited with code ${code}.`);
}
shutdown(code ?? exitCodeForSignal(signal));
}
});
}
process.once("SIGINT", () => shutdown(0, "SIGINT"));
process.once("SIGTERM", () => shutdown(0, "SIGTERM"));
});
}
function exitCodeForSignal(signal: NodeJS.Signals | null): number {
return signal === "SIGINT" || signal === "SIGTERM" || signal == null ? 0 : 1;
}
function stopProcesses(processes: DevProcess[], signal: NodeJS.Signals = "SIGTERM"): void {
for (const processInfo of processes) {
if (processInfo.child.exitCode == null && !processInfo.child.killed) {
processInfo.child.kill(signal);
}
}
}