源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const [runtimeManifestPath] = process.argv.slice(2);
|
||||
if (!runtimeManifestPath) process.exit(1);
|
||||
|
||||
const { monitorHeadlessRuntime } = await import("../src/headless-web.ts");
|
||||
|
||||
await monitorHeadlessRuntime(runtimeManifestPath);
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
|
||||
if (nodeMajor < 24 || !process.features?.typescript) {
|
||||
console.error("Node 24+ with native TypeScript required — run `nvm use`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { runStandaloneWorldCli } = await import("../src/standalone-cli.ts");
|
||||
|
||||
try {
|
||||
process.exitCode = await runStandaloneWorldCli();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@openwork/world",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"openwork-world": "./bin/world.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openwork/paths": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import { discoverWorlds, displayWorldPath, resolveWorldScript } from "./loader.ts";
|
||||
import { WorldStateStore } from "./store.ts";
|
||||
import {
|
||||
downScriptWorld,
|
||||
isProcessAlive,
|
||||
launchScriptWorld,
|
||||
readScriptWorldSnapshot,
|
||||
scriptWorldSnapshotDirectory,
|
||||
scriptWorldSnapshotPath,
|
||||
} from "./script-world.ts";
|
||||
|
||||
export type WorldCommand =
|
||||
| {
|
||||
kind: "up";
|
||||
source: string;
|
||||
detach?: boolean;
|
||||
timeoutMs?: number;
|
||||
args: string[];
|
||||
}
|
||||
| { kind: "down"; name: string }
|
||||
| { kind: "list" }
|
||||
| { kind: "forget"; name: string }
|
||||
| { kind: "help"; error?: string };
|
||||
|
||||
export interface WorldCliOptions {
|
||||
cwd: string;
|
||||
worldsDirectory: string;
|
||||
print?: (line: string) => void;
|
||||
}
|
||||
|
||||
function helpError(message: string): WorldCommand {
|
||||
return { kind: "help", error: message };
|
||||
}
|
||||
|
||||
export function parseWorldArgs(argv: string[]): WorldCommand {
|
||||
const [command, ...args] = argv;
|
||||
if (!command || command === "help") {
|
||||
return args.length === 0 ? { kind: "help" } : helpError("The help command does not take arguments.");
|
||||
}
|
||||
if (command === "up") {
|
||||
const [source, ...rest] = args;
|
||||
if (!source || source === "--" || source.startsWith("--")) {
|
||||
return helpError("The up command needs a script path or world name.");
|
||||
}
|
||||
const separator = rest.indexOf("--");
|
||||
const options = separator === -1 ? rest : rest.slice(0, separator);
|
||||
const scriptArgs = separator === -1 ? [] : rest.slice(separator + 1);
|
||||
let detach = false;
|
||||
let timeoutMs: number | undefined;
|
||||
for (let index = 0; index < options.length; index += 1) {
|
||||
const option = options[index];
|
||||
if (option === "--detach" && !detach) {
|
||||
detach = true;
|
||||
continue;
|
||||
}
|
||||
if (option === "--timeout" && timeoutMs === undefined) {
|
||||
const value = options[index + 1];
|
||||
const parsed = value === undefined ? Number.NaN : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
return helpError("Use --timeout followed by a positive number of milliseconds.");
|
||||
}
|
||||
timeoutMs = parsed;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return helpError(`Unknown world CLI option ${JSON.stringify(option)}. Pass script arguments after --.`);
|
||||
}
|
||||
if (timeoutMs !== undefined && !detach) return helpError("Use --timeout only with --detach.");
|
||||
return {
|
||||
kind: "up",
|
||||
source,
|
||||
...(detach ? { detach: true } : {}),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
args: scriptArgs,
|
||||
};
|
||||
}
|
||||
if (command === "list") {
|
||||
return args.length === 0 ? { kind: "list" } : helpError("The list command does not take arguments.");
|
||||
}
|
||||
if (command === "down") {
|
||||
return args.length === 1 && args[0]
|
||||
? { kind: "down", name: args[0] }
|
||||
: helpError("The down command needs exactly one world name.");
|
||||
}
|
||||
if (command === "forget") {
|
||||
return args.length === 1 && args[0]
|
||||
? { kind: "forget", name: args[0] }
|
||||
: helpError("The forget command needs exactly one world name.");
|
||||
}
|
||||
return helpError(`Unknown command ${JSON.stringify(command)}. Script worlds support up, down, list, forget, and help.`);
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function helpText(options: WorldCliOptions): Promise<string> {
|
||||
const discovered = await discoverWorlds(options.worldsDirectory);
|
||||
const sources = discovered.map((world) => displayWorldPath(world.path, options.cwd));
|
||||
return `Usage:
|
||||
pnpm world up <script-path-or-name> [--detach] [--timeout <ms>] [-- <script args...>]
|
||||
pnpm world down <name>
|
||||
pnpm world list
|
||||
pnpm world forget <name>
|
||||
pnpm world help
|
||||
|
||||
World scripts run in the foreground by default; use --detach for background lifecycle receipts.
|
||||
Available world scripts: ${sources.join(", ") || "(none)"}`;
|
||||
}
|
||||
|
||||
export async function main(argv: string[], options: WorldCliOptions): Promise<number> {
|
||||
const print = options.print ?? console.log;
|
||||
const command = parseWorldArgs(argv);
|
||||
if (command.kind === "help") {
|
||||
if (command.error) print(command.error);
|
||||
print(await helpText(options));
|
||||
return command.error ? 1 : 0;
|
||||
}
|
||||
if (command.kind === "up") {
|
||||
try {
|
||||
const script = await resolveWorldScript(command.source, options);
|
||||
return await launchScriptWorld({
|
||||
path: script.path,
|
||||
name: script.name,
|
||||
args: command.args,
|
||||
snapshotDirectory: scriptWorldSnapshotDirectory(options.cwd),
|
||||
detach: command.detach === true,
|
||||
...(command.timeoutMs === undefined ? {} : { timeoutMs: command.timeoutMs }),
|
||||
print,
|
||||
});
|
||||
} catch (error) {
|
||||
print(messageText(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (command.kind === "down") {
|
||||
try {
|
||||
const path = scriptWorldSnapshotPath(scriptWorldSnapshotDirectory(options.cwd), command.name);
|
||||
const result = await downScriptWorld(path);
|
||||
if (!result.found) {
|
||||
print(`World receipt ${JSON.stringify(command.name)} does not exist.`);
|
||||
return 1;
|
||||
}
|
||||
if (result.forced) {
|
||||
print(`World ${JSON.stringify(command.name)} teardown was forced (pid ${result.pid}).`);
|
||||
} else {
|
||||
print(`World ${JSON.stringify(command.name)} torn down.`);
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
print(messageText(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (command.kind === "list") {
|
||||
const discovered = await discoverWorlds(options.worldsDirectory);
|
||||
print(`World scripts: ${discovered.map((world) => `${world.name} (${displayWorldPath(world.path, options.cwd)}, script)`).join(", ") || "(none)"}`);
|
||||
let count = 0;
|
||||
const receiptsDirectory = scriptWorldSnapshotDirectory(options.cwd);
|
||||
for (const path of await new WorldStateStore(receiptsDirectory).list()) {
|
||||
try {
|
||||
const receipt = await readScriptWorldSnapshot(path);
|
||||
if (!receipt) continue;
|
||||
print(`${receipt.name} ${receipt.createdAt} script ${isProcessAlive(receipt.pid) ? "alive" : `dead(pid ${receipt.pid})`}`);
|
||||
count += 1;
|
||||
} catch (error) {
|
||||
print(`Warning: skipped ${displayWorldPath(path, options.cwd)}: ${messageText(error)}`);
|
||||
}
|
||||
}
|
||||
if (count === 0) print("No script world receipts.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const store = new WorldStateStore(scriptWorldSnapshotDirectory(options.cwd));
|
||||
if (!await store.forget(command.name)) {
|
||||
print(`World receipt ${JSON.stringify(command.name)} does not exist.`);
|
||||
return 1;
|
||||
}
|
||||
print(`Removed receipt metadata for ${JSON.stringify(command.name)}. The script process was not stopped.`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
print(messageText(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultWorldCliPaths(repoRoot: string): { cwd: string; worldsDirectory: string } {
|
||||
const cwd = resolve(repoRoot);
|
||||
return { cwd, worldsDirectory: join(cwd, "worlds") };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import path from "node:path";
|
||||
|
||||
export type HeadlessServerConfigDocument = Record<string, unknown> & {
|
||||
authorizedRoots: string[];
|
||||
workspaces: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripTrailingSlashes(value: string): string {
|
||||
let end = value.length;
|
||||
while (end > 0 && value[end - 1] === "/") end -= 1;
|
||||
return value.slice(0, end);
|
||||
}
|
||||
|
||||
export type HeadlessRuntimePids = {
|
||||
launcher: number;
|
||||
web: number | null;
|
||||
openworkServer: number | null;
|
||||
};
|
||||
|
||||
export type HeadlessRuntimeManifest = {
|
||||
mode: "local-server";
|
||||
webUrl: string;
|
||||
openworkUrl: string;
|
||||
healthUrl: string;
|
||||
workspace: string;
|
||||
token: string;
|
||||
hostToken: string;
|
||||
serverConfigPath: string;
|
||||
runtimeManifestPath: string;
|
||||
webLogPath: string;
|
||||
headlessLogPath: string;
|
||||
denTarget: string | null;
|
||||
denApiUrl: string | null;
|
||||
notes: string;
|
||||
startedAt: string;
|
||||
pid: number;
|
||||
pids: HeadlessRuntimePids;
|
||||
supervisorPid?: number | null;
|
||||
world?: { name: string; state: HeadlessWebState; launchId?: string };
|
||||
};
|
||||
|
||||
export type HeadlessWebState = "isolated" | "installed-production";
|
||||
|
||||
/** Args forwarded by the legacy detached launcher. */
|
||||
export function buildDetachedRespawnArgs(argv: string[]): string[] {
|
||||
return argv.filter((arg) => arg !== "--detach");
|
||||
}
|
||||
|
||||
export function normalizeDenTarget(value: string | undefined): string {
|
||||
const raw = (value ?? "https://app.openworklabs.com").trim();
|
||||
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
return new URL(withProtocol).origin;
|
||||
}
|
||||
|
||||
export function isHeadlessStackCommand(command: string): boolean {
|
||||
return command.includes("dev-headless-web")
|
||||
|| command.includes("openwork-server")
|
||||
|| command.includes("apps/server/src/cli.ts")
|
||||
|| command.includes("packages/world")
|
||||
|| command.includes("vite");
|
||||
}
|
||||
|
||||
export function buildHeadlessServerLaunch(
|
||||
cwd: string,
|
||||
serverArgs: string[],
|
||||
): { command: string; args: string[] } {
|
||||
return {
|
||||
command: "bun",
|
||||
args: [
|
||||
"--conditions=development",
|
||||
path.join(cwd, "apps/server/src/cli.ts"),
|
||||
...serverArgs,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHeadlessServerConfigPath(
|
||||
cwd: string,
|
||||
override?: string | null,
|
||||
): string {
|
||||
const trimmed = override?.trim();
|
||||
if (trimmed) return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
return path.join(cwd, "tmp", "headless-server.json");
|
||||
}
|
||||
|
||||
export function resolveHeadlessRuntimeManifestPath(cwd: string): string {
|
||||
return path.join(cwd, "tmp", "dev-headless-web.json");
|
||||
}
|
||||
|
||||
export function mergeHeadlessServerConfig(
|
||||
existingRaw: string | null,
|
||||
workspace: string,
|
||||
): HeadlessServerConfigDocument {
|
||||
const workspaceRoot = path.resolve(workspace);
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existingRaw) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(existingRaw);
|
||||
if (isRecord(parsed)) existing = parsed;
|
||||
} catch {
|
||||
// Corrupt config: fall back to a minimal isolated document.
|
||||
}
|
||||
}
|
||||
const existingRoots = Array.isArray(existing.authorizedRoots)
|
||||
? existing.authorizedRoots.filter((root): root is string => typeof root === "string")
|
||||
: [];
|
||||
const authorizedRoots = existingRoots.some((root) => path.resolve(root) === workspaceRoot)
|
||||
? existingRoots
|
||||
: [...existingRoots, workspaceRoot];
|
||||
const existingWorkspaces = Array.isArray(existing.workspaces)
|
||||
? existing.workspaces.filter(isRecord)
|
||||
: [];
|
||||
const hasWorkspace = existingWorkspaces.some(
|
||||
(entry) => typeof entry.path === "string" && path.resolve(entry.path) === workspaceRoot,
|
||||
);
|
||||
return {
|
||||
...existing,
|
||||
authorizedRoots,
|
||||
workspaces: hasWorkspace
|
||||
? existingWorkspaces
|
||||
: [...existingWorkspaces, { path: workspaceRoot }],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHeadlessTokens(input: {
|
||||
envToken: string | undefined;
|
||||
envHostToken: string | undefined;
|
||||
previous: Pick<HeadlessRuntimeManifest, "token" | "hostToken"> | null;
|
||||
generate: () => string;
|
||||
}): { token: string; hostToken: string } {
|
||||
const token = input.envToken?.trim() || input.previous?.token?.trim() || input.generate();
|
||||
const hostToken = input.envHostToken?.trim() || input.previous?.hostToken?.trim() || input.generate();
|
||||
return { token, hostToken };
|
||||
}
|
||||
|
||||
export function buildHeadlessCorsOrigins(input: {
|
||||
webUrl: string;
|
||||
webPort: number;
|
||||
}): string[] {
|
||||
return Array.from(new Set([
|
||||
new URL(input.webUrl).origin,
|
||||
`http://127.0.0.1:${input.webPort}`,
|
||||
`http://localhost:${input.webPort}`,
|
||||
]));
|
||||
}
|
||||
|
||||
export function buildOpenworkServerArgs(input: {
|
||||
host: string;
|
||||
port: number;
|
||||
configPath: string;
|
||||
corsOrigins: string[];
|
||||
}): string[] {
|
||||
return [
|
||||
"--config",
|
||||
input.configPath,
|
||||
"--host",
|
||||
input.host,
|
||||
"--port",
|
||||
String(input.port),
|
||||
"--approval",
|
||||
"auto",
|
||||
"--cors",
|
||||
input.corsOrigins.join(","),
|
||||
"--verbose",
|
||||
];
|
||||
}
|
||||
|
||||
export function buildHeadlessRuntimeManifest(input: {
|
||||
webUrl: string;
|
||||
openworkUrl: string;
|
||||
workspace: string;
|
||||
token: string;
|
||||
hostToken: string;
|
||||
serverConfigPath: string;
|
||||
runtimeManifestPath: string;
|
||||
webLogPath: string;
|
||||
headlessLogPath: string;
|
||||
denTarget?: string | null;
|
||||
pid?: number;
|
||||
webPid?: number | null;
|
||||
openworkServerPid?: number | null;
|
||||
startedAt?: string;
|
||||
supervisorPid?: number | null;
|
||||
world?: { name: string; state: HeadlessWebState; launchId?: string };
|
||||
}): HeadlessRuntimeManifest {
|
||||
const denTarget = input.denTarget ?? null;
|
||||
const launcherPid = input.pid ?? process.pid;
|
||||
return {
|
||||
mode: "local-server",
|
||||
webUrl: input.webUrl,
|
||||
openworkUrl: input.openworkUrl,
|
||||
healthUrl: `${stripTrailingSlashes(input.openworkUrl)}/health`,
|
||||
workspace: path.resolve(input.workspace),
|
||||
token: input.token,
|
||||
hostToken: input.hostToken,
|
||||
serverConfigPath: input.serverConfigPath,
|
||||
runtimeManifestPath: input.runtimeManifestPath,
|
||||
webLogPath: input.webLogPath,
|
||||
headlessLogPath: input.headlessLogPath,
|
||||
denTarget,
|
||||
denApiUrl: denTarget ? `${stripTrailingSlashes(input.webUrl)}/api/den` : null,
|
||||
notes: "Local openwork-server session. Workspace auth uses token/hostToken; the server config and state selection are owned by the selected world. Den/Cloud API calls go same-origin through denApiUrl (Vite proxies them to denTarget; the app is pinned there via VITE_DEN_API_BASE_URL).",
|
||||
startedAt: input.startedAt ?? new Date().toISOString(),
|
||||
pid: launcherPid,
|
||||
pids: {
|
||||
launcher: launcherPid,
|
||||
web: input.webPid ?? null,
|
||||
openworkServer: input.openworkServerPid ?? null,
|
||||
},
|
||||
...(input.supervisorPid === undefined ? {} : { supervisorPid: input.supervisorPid }),
|
||||
...(input.world ? { world: input.world } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,905 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import {
|
||||
desktopBootstrapPath,
|
||||
globalOpencodeConfigDir,
|
||||
opencodeDbCandidates,
|
||||
openworkEnvStorePath,
|
||||
openworkServerConfigPath,
|
||||
openworkServerDataDir,
|
||||
} from "@openwork/paths";
|
||||
import {
|
||||
buildHeadlessCorsOrigins,
|
||||
buildHeadlessRuntimeManifest,
|
||||
buildHeadlessServerLaunch,
|
||||
buildOpenworkServerArgs,
|
||||
mergeHeadlessServerConfig,
|
||||
normalizeDenTarget,
|
||||
resolveHeadlessRuntimeManifestPath,
|
||||
resolveHeadlessServerConfigPath,
|
||||
resolveHeadlessTokens,
|
||||
} from "./headless-web-helpers.ts";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { HeadlessRuntimeManifest, HeadlessWebState } from "./headless-web-helpers.ts";
|
||||
import { assertWorldName } from "./store.ts";
|
||||
|
||||
const DEFAULT_WEB_PORT = "5178";
|
||||
const DEFAULT_SERVER_PORT = "8778";
|
||||
const DEFAULT_DEN_TARGET = "https://app.openworklabs.com";
|
||||
|
||||
export interface HeadlessWebLaunchOptions {
|
||||
repoRoot: string;
|
||||
name: string;
|
||||
state: HeadlessWebState;
|
||||
workspace?: string;
|
||||
allowSharedState?: boolean;
|
||||
replace?: boolean;
|
||||
keepTokens?: boolean;
|
||||
rotateTokens?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface HeadlessWebHandle {
|
||||
manifest: HeadlessRuntimeManifest;
|
||||
reused: boolean;
|
||||
waitForExit(): Promise<string>;
|
||||
detach(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HeadlessWorldRuntimePaths {
|
||||
directory: string;
|
||||
runtimeManifestPath: string;
|
||||
serverConfigPath: string;
|
||||
webLogPath: string;
|
||||
headlessLogPath: string;
|
||||
}
|
||||
|
||||
export interface InstalledProductionHeadlessState {
|
||||
bootstrapPath: string;
|
||||
dataDir: string;
|
||||
envStorePath: string;
|
||||
homeDir: string;
|
||||
opencodeDb: string;
|
||||
opencodeConfigDir: string;
|
||||
serverConfigPath: string;
|
||||
serverStatePath: string;
|
||||
serverTokenStorePath: string;
|
||||
workspaceStatePath: string;
|
||||
token: string;
|
||||
hostToken: string;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface HeadlessClientConnection {
|
||||
url: string;
|
||||
port: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readBool(value: string | undefined): boolean {
|
||||
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function isPortFree(port: number, host: string): Promise<boolean> {
|
||||
return new Promise((done) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => done(false));
|
||||
server.listen(port, host, () => server.close(() => done(true)));
|
||||
});
|
||||
}
|
||||
|
||||
async function getFreePort(host: string): Promise<number> {
|
||||
return new Promise((done, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, host, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to resolve free port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => done(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePort(value: string | undefined, host: string): Promise<number> {
|
||||
if (value) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535 && await isPortFree(parsed, host)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return getFreePort(host);
|
||||
}
|
||||
|
||||
async function probeOk(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1_500) });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeStack(manifest: HeadlessRuntimeManifest): Promise<boolean> {
|
||||
return await probeOk(manifest.healthUrl) && await probeOk(manifest.webUrl);
|
||||
}
|
||||
|
||||
export function headlessRuntimeIsHealthy(manifest: HeadlessRuntimeManifest): Promise<boolean> {
|
||||
return headlessRuntimeProcessesAreOwned(manifest)
|
||||
? probeStack(manifest)
|
||||
: Promise.resolve(false);
|
||||
}
|
||||
|
||||
function processCommand(pid: number): string | null {
|
||||
try {
|
||||
const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).trim();
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function commandHasArgument(command: string, value: string): boolean {
|
||||
return command.includes(value) || command.includes(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function runtimePort(value: string): string {
|
||||
return new URL(value).port;
|
||||
}
|
||||
|
||||
function isOwnedRuntimeProcess(
|
||||
manifest: HeadlessRuntimeManifest,
|
||||
pid: number,
|
||||
kind: "web" | "server" | "supervisor",
|
||||
): boolean {
|
||||
const command = processCommand(pid);
|
||||
if (!command) return false;
|
||||
if (kind === "web") {
|
||||
return command.includes("vite")
|
||||
&& commandHasArgument(command, "@openwork/app")
|
||||
&& commandHasArgument(command, runtimePort(manifest.webUrl));
|
||||
}
|
||||
if (kind === "server") {
|
||||
return command.includes("apps/server/src/cli.ts")
|
||||
&& commandHasArgument(command, manifest.serverConfigPath)
|
||||
&& commandHasArgument(command, runtimePort(manifest.openworkUrl));
|
||||
}
|
||||
return command.includes("headless-monitor.mjs")
|
||||
&& commandHasArgument(command, manifest.runtimeManifestPath);
|
||||
}
|
||||
|
||||
async function killOwnedRuntimeProcess(
|
||||
manifest: HeadlessRuntimeManifest,
|
||||
pid: number | null | undefined,
|
||||
kind: "web" | "server" | "supervisor",
|
||||
): Promise<void> {
|
||||
if (!pid || pid <= 0 || pid === process.pid || !isOwnedRuntimeProcess(manifest, pid, kind)) return;
|
||||
const signal = (value: NodeJS.Signals): void => {
|
||||
try {
|
||||
process.kill(-pid, value);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, value);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
signal("SIGTERM");
|
||||
await delay(500);
|
||||
if (isOwnedRuntimeProcess(manifest, pid, kind)) signal("SIGKILL");
|
||||
const deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline && isOwnedRuntimeProcess(manifest, pid, kind)) {
|
||||
await delay(50);
|
||||
}
|
||||
if (isOwnedRuntimeProcess(manifest, pid, kind)) {
|
||||
throw new Error(`Owned headless ${kind} process ${pid} did not stop.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsedWorld(value: unknown): HeadlessRuntimeManifest["world"] {
|
||||
if (!isRecord(value) || typeof value.name !== "string") return undefined;
|
||||
if (value.state !== "isolated" && value.state !== "installed-production") return undefined;
|
||||
const launchId = typeof value.launchId === "string" ? value.launchId : undefined;
|
||||
return {
|
||||
name: value.name,
|
||||
state: value.state,
|
||||
...(launchId === undefined ? {} : { launchId }),
|
||||
};
|
||||
}
|
||||
|
||||
function parsedPid(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseRuntimeManifest(value: unknown): HeadlessRuntimeManifest | null {
|
||||
if (!isRecord(value) || value.mode !== "local-server" || !isRecord(value.pids)) return null;
|
||||
const requiredStrings = [
|
||||
"webUrl",
|
||||
"openworkUrl",
|
||||
"healthUrl",
|
||||
"workspace",
|
||||
"token",
|
||||
"hostToken",
|
||||
"serverConfigPath",
|
||||
"runtimeManifestPath",
|
||||
"webLogPath",
|
||||
"headlessLogPath",
|
||||
"notes",
|
||||
"startedAt",
|
||||
];
|
||||
if (requiredStrings.some((key) => typeof value[key] !== "string")) return null;
|
||||
const launcher = parsedPid(value.pids.launcher) ?? parsedPid(value.pid);
|
||||
if (!launcher) return null;
|
||||
const denTarget = value.denTarget === null || typeof value.denTarget === "string" ? value.denTarget : null;
|
||||
const denApiUrl = value.denApiUrl === null || typeof value.denApiUrl === "string" ? value.denApiUrl : null;
|
||||
return {
|
||||
mode: "local-server",
|
||||
webUrl: String(value.webUrl),
|
||||
openworkUrl: String(value.openworkUrl),
|
||||
healthUrl: String(value.healthUrl),
|
||||
workspace: String(value.workspace),
|
||||
token: String(value.token),
|
||||
hostToken: String(value.hostToken),
|
||||
serverConfigPath: String(value.serverConfigPath),
|
||||
runtimeManifestPath: String(value.runtimeManifestPath),
|
||||
webLogPath: String(value.webLogPath),
|
||||
headlessLogPath: String(value.headlessLogPath),
|
||||
denTarget,
|
||||
denApiUrl,
|
||||
notes: String(value.notes),
|
||||
startedAt: String(value.startedAt),
|
||||
pid: parsedPid(value.pid) ?? launcher,
|
||||
pids: {
|
||||
launcher,
|
||||
web: parsedPid(value.pids.web),
|
||||
openworkServer: parsedPid(value.pids.openworkServer),
|
||||
},
|
||||
...(parsedPid(value.supervisorPid) === null ? {} : { supervisorPid: parsedPid(value.supervisorPid) }),
|
||||
...(parsedWorld(value.world) ? { world: parsedWorld(value.world) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHeadlessWorldRuntimePaths(
|
||||
repoRootInput: string,
|
||||
name: string,
|
||||
): HeadlessWorldRuntimePaths {
|
||||
assertWorldName(name);
|
||||
const repoRoot = resolve(repoRootInput);
|
||||
if (name === "dev-headless") {
|
||||
const directory = join(repoRoot, "tmp");
|
||||
return {
|
||||
directory,
|
||||
runtimeManifestPath: resolveHeadlessRuntimeManifestPath(repoRoot),
|
||||
serverConfigPath: resolveHeadlessServerConfigPath(repoRoot),
|
||||
webLogPath: join(directory, "dev-web.log"),
|
||||
headlessLogPath: join(directory, "dev-headless.log"),
|
||||
};
|
||||
}
|
||||
const directory = join(repoRoot, "tmp", "worlds", "runtime", name);
|
||||
return {
|
||||
directory,
|
||||
runtimeManifestPath: join(directory, "runtime.json"),
|
||||
serverConfigPath: join(directory, "server.json"),
|
||||
webLogPath: join(directory, "web.log"),
|
||||
headlessLogPath: join(directory, "server.log"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readHeadlessRuntimeManifest(path: string): Promise<HeadlessRuntimeManifest | null> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
||||
return parseRuntimeManifest(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function spawnLogged(
|
||||
command: string,
|
||||
args: string[],
|
||||
logPath: string,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): ChildProcess {
|
||||
const logFd = openSync(logPath, "w");
|
||||
try {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
detached: true,
|
||||
});
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForSpawn(child: ChildProcess, label: string): Promise<void> {
|
||||
return new Promise((done, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
child.off("spawn", onSpawn);
|
||||
reject(new Error(`${label} failed to spawn: ${error.message}`));
|
||||
};
|
||||
const onSpawn = (): void => {
|
||||
child.off("error", onError);
|
||||
done();
|
||||
};
|
||||
child.once("error", onError);
|
||||
child.once("spawn", onSpawn);
|
||||
});
|
||||
}
|
||||
|
||||
async function requirePath(path: string, kind: "directory" | "file", label: string): Promise<void> {
|
||||
const metadata = await stat(path).catch(() => null);
|
||||
const valid = kind === "directory" ? metadata?.isDirectory() : metadata?.isFile();
|
||||
if (!valid) {
|
||||
throw new Error(`${label} is unavailable at ${path}. Start the installed production OpenWork desktop once and confirm its local state exists.`);
|
||||
}
|
||||
}
|
||||
|
||||
function credentials(value: unknown): { token: string; hostToken: string } | null {
|
||||
if (!isRecord(value) || !isRecord(value.credentials)) return null;
|
||||
const token = value.credentials.clientToken;
|
||||
const hostToken = value.credentials.hostToken;
|
||||
return typeof token === "string" && token.length > 0 && typeof hostToken === "string" && hostToken.length > 0
|
||||
? { token, hostToken }
|
||||
: null;
|
||||
}
|
||||
|
||||
function firstWorkspace(value: unknown): string | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const candidates = [value.workspaces, value.workspacePaths, value.items];
|
||||
for (const candidate of candidates) {
|
||||
if (!Array.isArray(candidate)) continue;
|
||||
for (const entry of candidate) {
|
||||
if (typeof entry === "string" && entry.trim()) return entry;
|
||||
if (isRecord(entry) && typeof entry.path === "string" && entry.path.trim()) return entry.path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveInstalledProductionHeadlessState(options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fallbackWorkspace: string;
|
||||
homeDir?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<InstalledProductionHeadlessState> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(`Live shared installed-production state is currently supported only on macOS; received ${platform}.`);
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const userDataDir = join(homeDir, "Library", "Application Support", "com.differentai.openwork");
|
||||
const pathOptions = { env, homeDir, platform };
|
||||
const dataDir = openworkServerDataDir(pathOptions);
|
||||
const serverConfigPath = openworkServerConfigPath(pathOptions);
|
||||
const envStorePath = openworkEnvStorePath(pathOptions);
|
||||
const bootstrapPath = desktopBootstrapPath(pathOptions);
|
||||
const opencodeConfigDir = globalOpencodeConfigDir(pathOptions);
|
||||
const serverTokenStorePath = join(userDataDir, "openwork-server-tokens.json");
|
||||
const serverStatePath = join(userDataDir, "openwork-server-state.json");
|
||||
const workspaceStatePath = join(userDataDir, "openwork-workspaces.json");
|
||||
const dbCandidates = opencodeDbCandidates({ ...pathOptions, defaultChannel: "latest" });
|
||||
let opencodeDb: string | null = null;
|
||||
for (const candidate of dbCandidates) {
|
||||
if ((await stat(candidate).catch(() => null))?.isFile()) {
|
||||
opencodeDb = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [path, kind, label] of [
|
||||
[dataDir, "directory", "Installed production OpenWork data directory"],
|
||||
[opencodeConfigDir, "directory", "Installed production OpenCode config directory"],
|
||||
[workspaceStatePath, "file", "Installed production workspace state"],
|
||||
[serverTokenStorePath, "file", "Installed production server token store"],
|
||||
[serverStatePath, "file", "Installed production server state"],
|
||||
[serverConfigPath, "file", "Installed production server config"],
|
||||
[envStorePath, "file", "Installed production environment store"],
|
||||
[bootstrapPath, "file", "Installed production desktop bootstrap"],
|
||||
] satisfies Array<[string, "directory" | "file", string]>) {
|
||||
await requirePath(path, kind, label);
|
||||
}
|
||||
if (!opencodeDb) {
|
||||
throw new Error(`Installed production OpenCode database is unavailable. Checked channel-aware candidates: ${dbCandidates.join(", ")}.`);
|
||||
}
|
||||
const tokenJson: unknown = JSON.parse(await readFile(serverTokenStorePath, "utf8"));
|
||||
const resolvedCredentials = credentials(tokenJson);
|
||||
if (!resolvedCredentials) throw new Error(`Installed production server credentials are unavailable in ${serverTokenStorePath}.`);
|
||||
const workspaceJson: unknown = JSON.parse(await readFile(workspaceStatePath, "utf8"));
|
||||
return {
|
||||
bootstrapPath,
|
||||
dataDir,
|
||||
envStorePath,
|
||||
homeDir,
|
||||
opencodeDb,
|
||||
opencodeConfigDir,
|
||||
serverConfigPath,
|
||||
serverStatePath,
|
||||
serverTokenStorePath,
|
||||
workspaceStatePath,
|
||||
...resolvedCredentials,
|
||||
workspace: firstWorkspace(workspaceJson) ?? options.fallbackWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function installedProductionHeadlessEnv(state: InstalledProductionHeadlessState): NodeJS.ProcessEnv {
|
||||
return {
|
||||
HOME: state.homeDir,
|
||||
USERPROFILE: state.homeDir,
|
||||
XDG_CONFIG_HOME: join(state.homeDir, ".config"),
|
||||
XDG_DATA_HOME: join(state.homeDir, ".local", "share"),
|
||||
XDG_CACHE_HOME: join(state.homeDir, ".cache"),
|
||||
XDG_STATE_HOME: join(state.homeDir, ".local", "state"),
|
||||
OPENWORK_DATA_DIR: state.dataDir,
|
||||
OPENWORK_DESKTOP_BOOTSTRAP_PATH: state.bootstrapPath,
|
||||
OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY: "0",
|
||||
OPENWORK_DESKTOP_WORKSPACE_STATE_PATH: state.workspaceStatePath,
|
||||
OPENWORK_DEV_SHARED_STATE: "1",
|
||||
OPENWORK_ENV_STORE: state.envStorePath,
|
||||
OPENWORK_SERVER_CONFIG: state.serverConfigPath,
|
||||
OPENWORK_SERVER_STATE_PATH: state.serverStatePath,
|
||||
OPENWORK_SERVER_TOKEN_STORE_PATH: state.serverTokenStorePath,
|
||||
OPENCODE_DB: state.opencodeDb,
|
||||
OPENCODE_CONFIG_DIR: state.opencodeConfigDir,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(value: string | undefined): boolean {
|
||||
if (!value) return true;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "127.0.0.1"
|
||||
|| normalized === "localhost"
|
||||
|| normalized === "::1"
|
||||
|| normalized === "[::1]";
|
||||
}
|
||||
|
||||
export function assertHeadlessLaunchSafety(
|
||||
state: HeadlessWebState,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): void {
|
||||
if (state !== "installed-production") return;
|
||||
if (
|
||||
readBool(env.OPENWORK_REMOTE_ACCESS)
|
||||
|| Boolean(env.OPENWORK_PUBLIC_HOST?.trim())
|
||||
|| !isLoopbackHost(env.VITE_HOST)
|
||||
|| !isLoopbackHost(env.HOST)
|
||||
) {
|
||||
throw new Error(
|
||||
"LIVE SHARED PRODUCTION STATE headless worlds require loopback-only access; OPENWORK_REMOTE_ACCESS, OPENWORK_PUBLIC_HOST, and non-loopback HOST/VITE_HOST values are refused.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveHeadlessClientConnection(input: {
|
||||
state: HeadlessWebState;
|
||||
env: NodeJS.ProcessEnv;
|
||||
openworkUrl: string;
|
||||
openworkPort: number;
|
||||
token: string;
|
||||
}): HeadlessClientConnection {
|
||||
if (input.state === "installed-production") {
|
||||
return {
|
||||
url: input.openworkUrl,
|
||||
port: String(input.openworkPort),
|
||||
token: input.token,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: input.env.VITE_OPENWORK_URL ?? input.openworkUrl,
|
||||
port: input.env.VITE_OPENWORK_PORT ?? String(input.openworkPort),
|
||||
token: input.env.VITE_OPENWORK_TOKEN ?? input.token,
|
||||
};
|
||||
}
|
||||
|
||||
function killChild(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (child.exitCode !== null) return;
|
||||
const pid = child.pid;
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to direct kill.
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function childProcessGroupIsAlive(child: ChildProcess): boolean {
|
||||
const pid = child.pid;
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(-pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAcquiredChild(child: ChildProcess): Promise<void> {
|
||||
if (!childProcessGroupIsAlive(child)) return;
|
||||
killChild(child, "SIGTERM");
|
||||
let deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline && childProcessGroupIsAlive(child)) await delay(50);
|
||||
if (!childProcessGroupIsAlive(child)) return;
|
||||
killChild(child, "SIGKILL");
|
||||
deadline = Date.now() + 2_000;
|
||||
while (Date.now() < deadline && childProcessGroupIsAlive(child)) await delay(50);
|
||||
if (childProcessGroupIsAlive(child)) {
|
||||
throw new Error(`Acquired headless process group ${child.pid ?? "unknown"} did not stop.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function headlessRuntimeProcessesAreOwned(manifest: HeadlessRuntimeManifest): boolean {
|
||||
const webPid = manifest.pids.web;
|
||||
const serverPid = manifest.pids.openworkServer;
|
||||
return webPid !== null
|
||||
&& serverPid !== null
|
||||
&& isOwnedRuntimeProcess(manifest, webPid, "web")
|
||||
&& isOwnedRuntimeProcess(manifest, serverPid, "server");
|
||||
}
|
||||
|
||||
async function removeRuntimeManifest(manifest: HeadlessRuntimeManifest): Promise<void> {
|
||||
await unlink(manifest.runtimeManifestPath).catch((error: unknown) => {
|
||||
if (
|
||||
typeof error !== "object"
|
||||
|| error === null
|
||||
|| !("code" in error)
|
||||
|| error.code !== "ENOENT"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManifest(manifest: HeadlessRuntimeManifest): Promise<void> {
|
||||
await killOwnedRuntimeProcess(manifest, manifest.pids.web, "web");
|
||||
await killOwnedRuntimeProcess(manifest, manifest.pids.openworkServer, "server");
|
||||
await killOwnedRuntimeProcess(manifest, manifest.supervisorPid, "supervisor");
|
||||
await removeRuntimeManifest(manifest);
|
||||
}
|
||||
|
||||
export function stopHeadlessRuntime(manifest: HeadlessRuntimeManifest): Promise<void> {
|
||||
return stopManifest(manifest);
|
||||
}
|
||||
|
||||
async function waitForHealthy(manifest: HeadlessRuntimeManifest): Promise<void> {
|
||||
const deadline = Date.now() + 45_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await probeStack(manifest)) return;
|
||||
await delay(500);
|
||||
}
|
||||
throw new Error(`Headless web did not become healthy; inspect ${manifest.webLogPath} and ${manifest.headlessLogPath}.`);
|
||||
}
|
||||
|
||||
async function waitForOwnedRuntimeExit(manifest: HeadlessRuntimeManifest): Promise<string> {
|
||||
while (headlessRuntimeProcessesAreOwned(manifest)) await delay(500);
|
||||
const webRunning = manifest.pids.web !== null
|
||||
&& isOwnedRuntimeProcess(manifest, manifest.pids.web, "web");
|
||||
const serverRunning = manifest.pids.openworkServer !== null
|
||||
&& isOwnedRuntimeProcess(manifest, manifest.pids.openworkServer, "server");
|
||||
return `headless process exited (web=${webRunning ? "running" : "stopped"}, server=${serverRunning ? "running" : "stopped"})`;
|
||||
}
|
||||
|
||||
export async function monitorHeadlessRuntime(runtimeManifestPath: string): Promise<void> {
|
||||
const manifest = await readHeadlessRuntimeManifest(runtimeManifestPath);
|
||||
if (!manifest || manifest.runtimeManifestPath !== runtimeManifestPath) return;
|
||||
await waitForOwnedRuntimeExit(manifest);
|
||||
await killOwnedRuntimeProcess(manifest, manifest.pids.web, "web");
|
||||
await killOwnedRuntimeProcess(manifest, manifest.pids.openworkServer, "server");
|
||||
await removeRuntimeManifest(manifest);
|
||||
}
|
||||
|
||||
async function writeRuntimeManifest(manifest: HeadlessRuntimeManifest): Promise<void> {
|
||||
const temporaryPath = `${manifest.runtimeManifestPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await chmod(temporaryPath, 0o600);
|
||||
await rename(temporaryPath, manifest.runtimeManifestPath);
|
||||
} catch (error) {
|
||||
await unlink(temporaryPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startRuntimeSupervisor(
|
||||
manifest: HeadlessRuntimeManifest,
|
||||
repoRoot: string,
|
||||
): ChildProcess {
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[join(repoRoot, "packages", "world", "bin", "headless-monitor.mjs"), manifest.runtimeManifestPath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureRuntimeSupervisor(
|
||||
manifest: HeadlessRuntimeManifest,
|
||||
repoRoot: string,
|
||||
): Promise<{ manifest: HeadlessRuntimeManifest; supervisor: ChildProcess | null }> {
|
||||
if (
|
||||
manifest.supervisorPid
|
||||
&& isOwnedRuntimeProcess(manifest, manifest.supervisorPid, "supervisor")
|
||||
) {
|
||||
return { manifest, supervisor: null };
|
||||
}
|
||||
const supervisor = startRuntimeSupervisor(manifest, repoRoot);
|
||||
try {
|
||||
await waitForSpawn(supervisor, "headless supervisor");
|
||||
const supervised: HeadlessRuntimeManifest = {
|
||||
...manifest,
|
||||
supervisorPid: supervisor.pid ?? null,
|
||||
};
|
||||
await writeRuntimeManifest(supervised);
|
||||
return { manifest: supervised, supervisor };
|
||||
} catch (error) {
|
||||
killChild(supervisor, "SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchHeadlessWeb(options: HeadlessWebLaunchOptions): Promise<HeadlessWebHandle> {
|
||||
if (options.state === "installed-production" && options.allowSharedState !== true) {
|
||||
throw new Error("Refusing LIVE SHARED PRODUCTION STATE launch without explicit --allow-shared-state opt-in.");
|
||||
}
|
||||
const repoRoot = resolve(options.repoRoot);
|
||||
const env = options.env ?? process.env;
|
||||
assertWorldName(options.name);
|
||||
assertHeadlessLaunchSafety(options.state, env);
|
||||
const runtimePaths = resolveHeadlessWorldRuntimePaths(repoRoot, options.name);
|
||||
await mkdir(runtimePaths.directory, { recursive: true });
|
||||
const runtimeManifestPath = runtimePaths.runtimeManifestPath;
|
||||
const existing = await readHeadlessRuntimeManifest(runtimeManifestPath);
|
||||
const existingHealthy = existing ? await headlessRuntimeIsHealthy(existing) : false;
|
||||
const existingState = existing?.world?.state ?? "isolated";
|
||||
const existingName = existing?.world?.name ?? (options.name === "dev-headless" ? "dev-headless" : null);
|
||||
if (
|
||||
existing
|
||||
&& existingHealthy
|
||||
&& existingState === options.state
|
||||
&& existingName === options.name
|
||||
&& options.replace !== true
|
||||
) {
|
||||
const adopted: HeadlessRuntimeManifest = existing.world?.launchId
|
||||
? existing
|
||||
: {
|
||||
...existing,
|
||||
world: {
|
||||
name: options.name,
|
||||
state: options.state,
|
||||
launchId: randomUUID(),
|
||||
},
|
||||
};
|
||||
await writeRuntimeManifest(adopted);
|
||||
const supervised = await ensureRuntimeSupervisor(adopted, repoRoot);
|
||||
supervised.supervisor?.unref();
|
||||
return {
|
||||
manifest: supervised.manifest,
|
||||
reused: true,
|
||||
waitForExit: () => waitForOwnedRuntimeExit(supervised.manifest),
|
||||
async detach() {},
|
||||
async stop() { await stopManifest(supervised.manifest); },
|
||||
};
|
||||
}
|
||||
if (existing) await stopManifest(existing);
|
||||
|
||||
const remoteAccessEnabled = readBool(env.OPENWORK_REMOTE_ACCESS);
|
||||
const host = remoteAccessEnabled ? "0.0.0.0" : "127.0.0.1";
|
||||
const viteHost = env.VITE_HOST ?? env.HOST ?? host;
|
||||
const publicHost = env.OPENWORK_PUBLIC_HOST ?? null;
|
||||
const clientHost = publicHost ?? (host === "0.0.0.0" ? "127.0.0.1" : host);
|
||||
const declaredWorkspace = resolve(options.workspace ?? env.OPENWORK_WORKSPACE ?? repoRoot);
|
||||
const productionState = options.state === "installed-production"
|
||||
? await resolveInstalledProductionHeadlessState({ env, fallbackWorkspace: declaredWorkspace })
|
||||
: null;
|
||||
const workspace = productionState?.workspace ?? declaredWorkspace;
|
||||
const openworkPort = await resolvePort(env.OPENWORK_PORT ?? DEFAULT_SERVER_PORT, "127.0.0.1");
|
||||
const webPort = await resolvePort(env.OPENWORK_WEB_PORT ?? DEFAULT_WEB_PORT, "127.0.0.1");
|
||||
const rotateTokens = options.rotateTokens === true || (options.replace === true && options.keepTokens !== true);
|
||||
const resolvedTokens = productionState ?? resolveHeadlessTokens({
|
||||
envToken: env.OPENWORK_TOKEN,
|
||||
envHostToken: env.OPENWORK_HOST_TOKEN,
|
||||
previous: rotateTokens ? null : existing,
|
||||
generate: randomUUID,
|
||||
});
|
||||
const serverConfigPath = productionState?.serverConfigPath
|
||||
?? (options.name === "dev-headless" && env.OPENWORK_DEV_HEADLESS_WEB_CONFIG
|
||||
? resolveHeadlessServerConfigPath(repoRoot, env.OPENWORK_DEV_HEADLESS_WEB_CONFIG)
|
||||
: runtimePaths.serverConfigPath);
|
||||
if (!productionState) {
|
||||
const existingConfig = await readFile(serverConfigPath, "utf8").catch(() => null);
|
||||
await writeFile(
|
||||
serverConfigPath,
|
||||
`${JSON.stringify(mergeHeadlessServerConfig(existingConfig, workspace), null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const webLogPath = runtimePaths.webLogPath;
|
||||
const headlessLogPath = runtimePaths.headlessLogPath;
|
||||
const openworkUrl = `http://${clientHost}:${openworkPort}`;
|
||||
const webUrl = `http://${clientHost}:${webPort}`;
|
||||
const denProxyEnabled = env.OPENWORK_DEV_HEADLESS_WEB_DEN_PROXY === undefined
|
||||
? true
|
||||
: readBool(env.OPENWORK_DEV_HEADLESS_WEB_DEN_PROXY);
|
||||
const denTarget = denProxyEnabled ? normalizeDenTarget(env.OPENWORK_DEV_DEN_PROXY_TARGET) : null;
|
||||
const denApiUrl = denTarget ? `${webUrl}/api/den` : null;
|
||||
const clientConnection = resolveHeadlessClientConnection({
|
||||
state: options.state,
|
||||
env,
|
||||
openworkUrl,
|
||||
openworkPort,
|
||||
token: resolvedTokens.token,
|
||||
});
|
||||
const viteEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
OPENWORK_DEV_MODE: "1",
|
||||
HOST: viteHost,
|
||||
PORT: String(webPort),
|
||||
VITE_OPENWORK_URL: clientConnection.url,
|
||||
VITE_OPENWORK_PORT: clientConnection.port,
|
||||
VITE_OPENWORK_TOKEN: clientConnection.token,
|
||||
VITE_OPENWORK_FORCE_ENV_SETTINGS: "1",
|
||||
VITE_OPENWORK_DEPLOYMENT: env.VITE_OPENWORK_DEPLOYMENT ?? "web",
|
||||
...(denTarget && denApiUrl ? {
|
||||
OPENWORK_DEV_HEADLESS_DEN_TARGET: denTarget,
|
||||
VITE_DEN_API_BASE_URL: env.VITE_DEN_API_BASE_URL ?? denApiUrl,
|
||||
...(denTarget === DEFAULT_DEN_TARGET ? {} : { VITE_DEN_BASE_URL: env.VITE_DEN_BASE_URL ?? denTarget }),
|
||||
} : {}),
|
||||
};
|
||||
const headlessEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
OPENWORK_DEV_MODE: "1",
|
||||
...(productionState ? installedProductionHeadlessEnv(productionState) : {}),
|
||||
...(productionState ? {} : { OPENWORK_WORKSPACE: workspace }),
|
||||
OPENWORK_HOST: host,
|
||||
OPENWORK_REMOTE_ACCESS: remoteAccessEnabled ? "1" : "0",
|
||||
OPENWORK_PORT: String(openworkPort),
|
||||
OPENWORK_TOKEN: resolvedTokens.token,
|
||||
OPENWORK_HOST_TOKEN: resolvedTokens.hostToken,
|
||||
OPENWORK_SERVER_CONFIG: serverConfigPath,
|
||||
OPENWORK_MANAGE_OPENCODE: "1",
|
||||
OPENWORK_OPENCODE_BIN: env.OPENWORK_OPENCODE_BIN ?? "opencode",
|
||||
};
|
||||
const pnpmExecPath = env.npm_execpath?.trim();
|
||||
const pnpmCommand = pnpmExecPath ? env.npm_node_execpath?.trim() || "node" : "pnpm";
|
||||
const pnpmArgs = pnpmExecPath ? [pnpmExecPath] : [];
|
||||
const acquiredChildren: ChildProcess[] = [];
|
||||
let acquiredManifest: HeadlessRuntimeManifest | null = null;
|
||||
let acquiredSupervisor: ChildProcess | null = null;
|
||||
const launchId = randomUUID();
|
||||
const provisionalManifest = (
|
||||
webPid: number | null,
|
||||
openworkServerPid: number | null,
|
||||
): HeadlessRuntimeManifest => buildHeadlessRuntimeManifest({
|
||||
webUrl,
|
||||
openworkUrl,
|
||||
workspace,
|
||||
token: resolvedTokens.token,
|
||||
hostToken: resolvedTokens.hostToken,
|
||||
serverConfigPath,
|
||||
runtimeManifestPath,
|
||||
webLogPath,
|
||||
headlessLogPath,
|
||||
denTarget,
|
||||
webPid,
|
||||
openworkServerPid,
|
||||
world: { name: options.name, state: options.state, launchId },
|
||||
});
|
||||
try {
|
||||
const webProcess = spawnLogged(pnpmCommand, [...pnpmArgs,
|
||||
"--filter",
|
||||
"@openwork/app",
|
||||
"exec",
|
||||
"vite",
|
||||
"--host",
|
||||
viteHost,
|
||||
"--port",
|
||||
String(webPort),
|
||||
"--strictPort",
|
||||
], webLogPath, repoRoot, viteEnv);
|
||||
acquiredChildren.push(webProcess);
|
||||
acquiredManifest = provisionalManifest(webProcess.pid ?? null, null);
|
||||
const serverLaunch = buildHeadlessServerLaunch(repoRoot, buildOpenworkServerArgs({
|
||||
host,
|
||||
port: openworkPort,
|
||||
configPath: serverConfigPath,
|
||||
corsOrigins: buildHeadlessCorsOrigins({ webUrl, webPort }),
|
||||
}));
|
||||
const serverProcess = spawnLogged(serverLaunch.command, serverLaunch.args, headlessLogPath, repoRoot, headlessEnv);
|
||||
acquiredChildren.push(serverProcess);
|
||||
let manifest = provisionalManifest(
|
||||
webProcess.pid ?? null,
|
||||
serverProcess.pid ?? null,
|
||||
);
|
||||
acquiredManifest = manifest;
|
||||
await Promise.all([
|
||||
waitForSpawn(webProcess, "headless web"),
|
||||
waitForSpawn(serverProcess, "openwork-server"),
|
||||
]);
|
||||
await writeRuntimeManifest(manifest);
|
||||
await waitForHealthy(manifest);
|
||||
const supervised = await ensureRuntimeSupervisor(manifest, repoRoot);
|
||||
manifest = supervised.manifest;
|
||||
acquiredManifest = manifest;
|
||||
acquiredSupervisor = supervised.supervisor;
|
||||
const children = [...acquiredChildren];
|
||||
const supervisor = acquiredSupervisor;
|
||||
let stopped = false;
|
||||
return {
|
||||
manifest,
|
||||
reused: false,
|
||||
waitForExit: () => waitForOwnedRuntimeExit(manifest),
|
||||
async detach() {
|
||||
for (const child of children) child.unref();
|
||||
supervisor?.unref();
|
||||
},
|
||||
async stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await Promise.all(children.map(stopAcquiredChild));
|
||||
if (supervisor) await stopAcquiredChild(supervisor);
|
||||
await stopManifest(manifest);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const cleanupErrors: string[] = [];
|
||||
for (const child of acquiredChildren) {
|
||||
await stopAcquiredChild(child).catch((cleanupError: unknown) => {
|
||||
cleanupErrors.push(messageText(cleanupError));
|
||||
});
|
||||
}
|
||||
if (acquiredSupervisor) {
|
||||
await stopAcquiredChild(acquiredSupervisor).catch((cleanupError: unknown) => {
|
||||
cleanupErrors.push(messageText(cleanupError));
|
||||
});
|
||||
}
|
||||
const failedManifest = acquiredManifest;
|
||||
if (cleanupErrors.length > 0 && failedManifest) {
|
||||
await writeRuntimeManifest(failedManifest).catch((manifestError: unknown) => {
|
||||
cleanupErrors.push(`ownership manifest publish failed: ${messageText(manifestError)}`);
|
||||
});
|
||||
} else if (failedManifest) {
|
||||
await removeRuntimeManifest(failedManifest);
|
||||
} else if (cleanupErrors.length === 0) {
|
||||
await unlink(runtimeManifestPath).catch(() => {});
|
||||
}
|
||||
const cleanupDetail = cleanupErrors.length === 0
|
||||
? ""
|
||||
: ` Cleanup could not confirm ownership release: ${cleanupErrors.join("; ")}.`;
|
||||
throw new Error(`${messageText(error)} Headless server: ${openworkUrl}; web: ${webUrl}.${cleanupDetail}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const SAFE_WORLD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
export interface ScriptWorldSnapshot {
|
||||
version: 1;
|
||||
kind: "script";
|
||||
name: string;
|
||||
createdAt: string;
|
||||
pid: number;
|
||||
sourcePath: string;
|
||||
outputs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HoldOptions {
|
||||
name?: string;
|
||||
outputs?: Record<string, string>;
|
||||
snapshotDir?: string;
|
||||
}
|
||||
|
||||
function recordedPid(text: string): number | undefined {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (typeof value !== "object" || value === null || !("pid" in value)) return undefined;
|
||||
return typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0
|
||||
? value.pid
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function aliveSnapshotPid(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
const pid = recordedPid(await readFile(path, "utf8"));
|
||||
return pid !== undefined && isAlive(pid) ? pid : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a script world alive until SIGINT or SIGTERM, then release its resources. */
|
||||
export async function hold(options: HoldOptions = {}): Promise<void> {
|
||||
const sourcePath = process.argv[1];
|
||||
const name = options.name ?? basename(sourcePath, extname(sourcePath));
|
||||
if (!SAFE_WORLD_NAME.test(name)) {
|
||||
throw new Error("World names must use only letters, numbers, dots, underscores, and hyphens.");
|
||||
}
|
||||
|
||||
const snapshotDirectory = resolve(
|
||||
options.snapshotDir
|
||||
?? process.env.OPENWORK_WORLD_SNAPSHOT_DIR
|
||||
?? join(REPO_ROOT, "evals", "results", ".worlds", "scripts"),
|
||||
);
|
||||
const snapshotPath = join(snapshotDirectory, `${name}.json`);
|
||||
const existingPid = await aliveSnapshotPid(snapshotPath);
|
||||
if (existingPid !== undefined) {
|
||||
throw new Error(
|
||||
`Script world ${JSON.stringify(name)} is already running (pid ${existingPid}); run \`pnpm world down ${name}\` first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const outputs = options.outputs ?? {};
|
||||
const snapshot: ScriptWorldSnapshot = {
|
||||
version: 1,
|
||||
kind: "script",
|
||||
name,
|
||||
createdAt: new Date().toISOString(),
|
||||
pid: process.pid,
|
||||
sourcePath,
|
||||
outputs,
|
||||
};
|
||||
await mkdir(dirname(snapshotPath), { recursive: true });
|
||||
await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
await chmod(snapshotPath, 0o600);
|
||||
|
||||
for (const [key, value] of Object.entries(outputs)) console.log(`${key} ${value}`);
|
||||
console.log(`World ${JSON.stringify(name)} is up. Ctrl-C (or pnpm world down ${name}) tears it down.`);
|
||||
|
||||
await new Promise<void>((done) => {
|
||||
let stopping = false;
|
||||
const stop = (): void => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
void unlink(snapshotPath).then(done, (error: unknown) => {
|
||||
console.error(`Could not remove script world snapshot ${snapshotPath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
done();
|
||||
});
|
||||
};
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./loader.ts";
|
||||
export * from "./store.ts";
|
||||
export * from "./hold.ts";
|
||||
export * from "./script-world.ts";
|
||||
export * from "./cli.ts";
|
||||
export * from "./headless-web-helpers.ts";
|
||||
export * from "./headless-web.ts";
|
||||
export * from "./standalone-cli.ts";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface ScriptWorld {
|
||||
kind: "script";
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function worldScriptName(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
|
||||
async function isFile(path: string): Promise<boolean> {
|
||||
return stat(path).then((entry) => entry.isFile(), () => false);
|
||||
}
|
||||
|
||||
export async function discoverWorlds(directory: string): Promise<ScriptWorld[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(directory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
|
||||
.sort()
|
||||
.map((name) => ({ kind: "script", name: worldScriptName(name), path: join(directory, name) }));
|
||||
}
|
||||
|
||||
export async function resolveWorldScript(
|
||||
source: string,
|
||||
options: { cwd: string; worldsDirectory: string },
|
||||
): Promise<ScriptWorld> {
|
||||
const pathLike = isAbsolute(source) || source.includes("/") || source.includes("\\");
|
||||
const requestedPath = pathLike
|
||||
? resolve(options.cwd, source)
|
||||
: join(options.worldsDirectory, source.endsWith(".ts") ? source : `${source}.ts`);
|
||||
if (!requestedPath.endsWith(".ts")) {
|
||||
throw new Error(`World script ${JSON.stringify(source)} must be a TypeScript file.`);
|
||||
}
|
||||
if (await isFile(requestedPath)) {
|
||||
return { kind: "script", name: worldScriptName(requestedPath), path: requestedPath };
|
||||
}
|
||||
|
||||
const available = (await discoverWorlds(options.worldsDirectory)).map((world) => world.name);
|
||||
throw new Error(`Unknown world script ${JSON.stringify(source)}. Available: ${available.join(", ") || "(none)"}.`);
|
||||
}
|
||||
|
||||
export function displayWorldPath(path: string, cwd: string): string {
|
||||
const displayed = relative(cwd, path);
|
||||
return displayed.startsWith("..") ? path : displayed;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, chmod, mkdir, open, readFile, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { assertWorldName } from "./store.ts";
|
||||
import type { ScriptWorldSnapshot } from "./hold.ts";
|
||||
|
||||
const DEFAULT_START_TIMEOUT_MS = 10 * 60 * 1_000;
|
||||
const DOWN_TIMEOUT_MS = 60 * 1_000;
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function recordedPid(text: string): number | undefined {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (!isRecord(value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value.pid;
|
||||
}
|
||||
|
||||
export function parseScriptWorldSnapshot(text: string): ScriptWorldSnapshot {
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (
|
||||
!isRecord(value)
|
||||
|| value.version !== 1
|
||||
|| value.kind !== "script"
|
||||
|| typeof value.name !== "string"
|
||||
|| typeof value.createdAt !== "string"
|
||||
|| typeof value.pid !== "number"
|
||||
|| !Number.isInteger(value.pid)
|
||||
|| value.pid <= 0
|
||||
|| typeof value.sourcePath !== "string"
|
||||
|| !isStringRecord(value.outputs)
|
||||
) {
|
||||
throw new Error("The file is not a valid script world snapshot.");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
kind: "script",
|
||||
name: value.name,
|
||||
createdAt: value.createdAt,
|
||||
pid: value.pid,
|
||||
sourcePath: value.sourcePath,
|
||||
outputs: value.outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function scriptWorldSnapshotDirectory(repoRoot: string): string {
|
||||
return resolve(
|
||||
process.env.OPENWORK_WORLD_SNAPSHOT_DIR
|
||||
?? join(repoRoot, "evals", "results", ".worlds", "scripts"),
|
||||
);
|
||||
}
|
||||
|
||||
export function scriptWorldSnapshotPath(directory: string, name: string): string {
|
||||
assertWorldName(name);
|
||||
return join(directory, `${name}.json`);
|
||||
}
|
||||
|
||||
export function scriptWorldLogPath(directory: string, name: string): string {
|
||||
assertWorldName(name);
|
||||
return join(directory, `${name}.log`);
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return isRecord(error) && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
return access(path).then(() => true, () => false);
|
||||
}
|
||||
|
||||
export async function readScriptWorldSnapshot(path: string): Promise<ScriptWorldSnapshot | undefined> {
|
||||
try {
|
||||
return parseScriptWorldSnapshot(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDeadSnapshot(path: string): Promise<void> {
|
||||
try {
|
||||
const snapshot = await readScriptWorldSnapshot(path);
|
||||
if (snapshot && !isProcessAlive(snapshot.pid)) await rm(path, { force: true });
|
||||
} catch {
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoRunningSnapshot(path: string, name: string): Promise<void> {
|
||||
let pid: number | undefined;
|
||||
try {
|
||||
pid = recordedPid(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return;
|
||||
await rm(path, { force: true });
|
||||
return;
|
||||
}
|
||||
if (pid !== undefined && isProcessAlive(pid)) {
|
||||
throw new Error(
|
||||
`Script world ${JSON.stringify(name)} is already running (pid ${pid}); run \`pnpm world down ${name}\` first.`,
|
||||
);
|
||||
}
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
|
||||
async function waitForChild(path: string, args: readonly string[], env: NodeJS.ProcessEnv): Promise<number> {
|
||||
const ignoreSigint = (): void => {};
|
||||
process.on("SIGINT", ignoreSigint);
|
||||
try {
|
||||
const child = spawn(process.execPath, [path, ...args], { env, stdio: "inherit" });
|
||||
return await new Promise<number>((done, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => done(code ?? 1));
|
||||
});
|
||||
} finally {
|
||||
process.off("SIGINT", ignoreSigint);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastLogLines(path: string): Promise<string[]> {
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
return text.trimEnd().split(/\r?\n/).slice(-40);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface LaunchScriptWorldOptions {
|
||||
path: string;
|
||||
name: string;
|
||||
args: readonly string[];
|
||||
snapshotDirectory: string;
|
||||
detach: boolean;
|
||||
timeoutMs?: number;
|
||||
print: (line: string) => void;
|
||||
}
|
||||
|
||||
export async function launchScriptWorld(options: LaunchScriptWorldOptions): Promise<number> {
|
||||
const path = resolve(options.path);
|
||||
const snapshotPath = scriptWorldSnapshotPath(options.snapshotDirectory, options.name);
|
||||
const env = { ...process.env, OPENWORK_WORLD_SNAPSHOT_DIR: options.snapshotDirectory };
|
||||
await assertNoRunningSnapshot(snapshotPath, options.name);
|
||||
|
||||
if (!options.detach) return waitForChild(path, options.args, env);
|
||||
|
||||
const logPath = scriptWorldLogPath(options.snapshotDirectory, options.name);
|
||||
await mkdir(options.snapshotDirectory, { recursive: true });
|
||||
const log = await open(logPath, "w", 0o600);
|
||||
await chmod(logPath, 0o600);
|
||||
const child = await (async () => {
|
||||
try {
|
||||
return spawn(process.execPath, [path, ...options.args], {
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", log.fd, log.fd],
|
||||
});
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
})();
|
||||
let spawnError: Error | undefined;
|
||||
child.once("error", (error) => { spawnError = error; });
|
||||
child.unref();
|
||||
|
||||
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_START_TIMEOUT_MS);
|
||||
while (Date.now() < deadline) {
|
||||
if (spawnError) throw spawnError;
|
||||
const snapshot = await readScriptWorldSnapshot(snapshotPath);
|
||||
if (snapshot) {
|
||||
for (const [key, value] of Object.entries(snapshot.outputs)) options.print(`${key} ${value}`);
|
||||
options.print(`snapshot ${snapshotPath}`);
|
||||
options.print(`log ${logPath}`);
|
||||
return 0;
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
await removeDeadSnapshot(snapshotPath);
|
||||
options.print(`Script world ${JSON.stringify(options.name)} exited before creating its snapshot.`);
|
||||
for (const line of await lastLogLines(logPath)) options.print(line);
|
||||
return 1;
|
||||
}
|
||||
await delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
|
||||
if (child.pid !== undefined) {
|
||||
try { process.kill(-child.pid, "SIGTERM"); } catch {}
|
||||
try { process.kill(child.pid, "SIGTERM"); } catch {}
|
||||
}
|
||||
await removeDeadSnapshot(snapshotPath);
|
||||
options.print(`Timed out after ${options.timeoutMs ?? DEFAULT_START_TIMEOUT_MS}ms waiting for script world ${JSON.stringify(options.name)}.`);
|
||||
for (const line of await lastLogLines(logPath)) options.print(line);
|
||||
return 1;
|
||||
}
|
||||
|
||||
export interface DownScriptWorldResult {
|
||||
found: boolean;
|
||||
forced: boolean;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export async function downScriptWorld(path: string): Promise<DownScriptWorldResult> {
|
||||
const snapshot = await readScriptWorldSnapshot(path);
|
||||
if (!snapshot) return { found: false, forced: false };
|
||||
|
||||
if (!isProcessAlive(snapshot.pid)) {
|
||||
await rm(path, { force: true });
|
||||
return { found: true, forced: false, pid: snapshot.pid };
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(snapshot.pid, "SIGTERM");
|
||||
} catch {}
|
||||
const deadline = Date.now() + DOWN_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (!await fileExists(path) && !isProcessAlive(snapshot.pid)) {
|
||||
return { found: true, forced: false, pid: snapshot.pid };
|
||||
}
|
||||
await delay(Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now())));
|
||||
}
|
||||
|
||||
try { process.kill(-snapshot.pid, "SIGKILL"); } catch {}
|
||||
try { process.kill(snapshot.pid, "SIGKILL"); } catch {}
|
||||
await rm(path, { force: true });
|
||||
return { found: true, forced: true, pid: snapshot.pid };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defaultWorldCliPaths, main } from "./cli.ts";
|
||||
|
||||
/** Standalone script-world CLI. */
|
||||
export function runStandaloneWorldCli(
|
||||
argv: string[] = process.argv.slice(2),
|
||||
cwdInput: string = process.cwd(),
|
||||
): Promise<number> {
|
||||
return main(argv, defaultWorldCliPaths(cwdInput));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { chmod, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
|
||||
const SAFE_WORLD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
export function assertWorldName(name: string): void {
|
||||
if (!SAFE_WORLD_NAME.test(name)) {
|
||||
throw new Error("World names must use only letters, numbers, dots, underscores, and hyphens.");
|
||||
}
|
||||
}
|
||||
|
||||
function worldFileName(name: string): string {
|
||||
assertWorldName(name);
|
||||
return `${name}.json`;
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "code" in error
|
||||
&& error.code === "ENOENT";
|
||||
}
|
||||
|
||||
export class WorldStateStore {
|
||||
readonly directory: string;
|
||||
|
||||
constructor(directory: string) {
|
||||
if (!isAbsolute(directory)) throw new Error("World state directories must be absolute paths.");
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
path(name: string): string {
|
||||
return join(this.directory, worldFileName(name));
|
||||
}
|
||||
|
||||
resolve(nameOrPath: string): string {
|
||||
return nameOrPath.endsWith(".json") || nameOrPath.includes("/") || nameOrPath.includes("\\")
|
||||
? nameOrPath
|
||||
: this.path(nameOrPath);
|
||||
}
|
||||
|
||||
async save(name: string, jsonText: string): Promise<string> {
|
||||
const path = this.path(name);
|
||||
await mkdir(this.directory, { recursive: true });
|
||||
await writeFile(path, jsonText.endsWith("\n") ? jsonText : `${jsonText}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await chmod(path, 0o600);
|
||||
return path;
|
||||
}
|
||||
|
||||
read(nameOrPath: string): Promise<string> {
|
||||
return readFile(this.resolve(nameOrPath), "utf8");
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
try {
|
||||
return (await readdir(this.directory))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort()
|
||||
.map((name) => join(this.directory, name));
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async forget(name: string): Promise<boolean> {
|
||||
try {
|
||||
await unlink(this.path(name));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { main, parseWorldArgs } from "../src/cli.ts";
|
||||
import { discoverWorlds, resolveWorldScript } from "../src/loader.ts";
|
||||
|
||||
test("world arguments expose only script lifecycle flags and forward arguments after --", () => {
|
||||
assert.deepEqual(
|
||||
parseWorldArgs([
|
||||
"up",
|
||||
"./worlds/dev-headless.ts",
|
||||
"--detach",
|
||||
"--timeout",
|
||||
"5000",
|
||||
"--",
|
||||
"--replace",
|
||||
"value",
|
||||
]),
|
||||
{
|
||||
kind: "up",
|
||||
source: "./worlds/dev-headless.ts",
|
||||
detach: true,
|
||||
timeoutMs: 5000,
|
||||
args: ["--replace", "value"],
|
||||
},
|
||||
);
|
||||
|
||||
const foregroundTimeout = parseWorldArgs(["up", "dev-headless", "--timeout", "5000"]);
|
||||
assert.equal(foregroundTimeout.kind, "help");
|
||||
if (foregroundTimeout.kind !== "help") throw new Error("expected help");
|
||||
assert.match(foregroundTimeout.error ?? "", /only with --detach/);
|
||||
|
||||
const oldFlag = parseWorldArgs(["up", "dev-headless", "--keep"]);
|
||||
assert.equal(oldFlag.kind, "help");
|
||||
if (oldFlag.kind !== "help") throw new Error("expected help");
|
||||
assert.match(oldFlag.error ?? "", /Unknown world CLI option "--keep"/);
|
||||
|
||||
const oldCommand = parseWorldArgs(["resume", "dev-headless"]);
|
||||
assert.equal(oldCommand.kind, "help");
|
||||
if (oldCommand.kind !== "help") throw new Error("expected help");
|
||||
assert.match(oldCommand.error ?? "", /Unknown command "resume"/);
|
||||
});
|
||||
|
||||
test("discovery, resolution, list, and help classify scripts without importing them", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "openwork-world-cli-discovery-"));
|
||||
try {
|
||||
const worldsDirectory = join(root, "worlds");
|
||||
const fixturePath = join(worldsDirectory, "throwing.ts");
|
||||
await mkdir(worldsDirectory);
|
||||
await writeFile(fixturePath, 'throw new Error("must not import");\n', "utf8");
|
||||
|
||||
assert.deepEqual(await discoverWorlds(worldsDirectory), [{
|
||||
kind: "script",
|
||||
name: "throwing",
|
||||
path: fixturePath,
|
||||
}]);
|
||||
for (const source of ["throwing", "throwing.ts", fixturePath]) {
|
||||
assert.deepEqual(await resolveWorldScript(source, { cwd: root, worldsDirectory }), {
|
||||
kind: "script",
|
||||
name: "throwing",
|
||||
path: fixturePath,
|
||||
});
|
||||
}
|
||||
|
||||
for (const command of [["list"], ["help"]]) {
|
||||
const lines: string[] = [];
|
||||
assert.equal(await main(command, {
|
||||
cwd: root,
|
||||
worldsDirectory,
|
||||
print: (line) => lines.push(line),
|
||||
}), 0);
|
||||
assert.match(lines.join("\n"), /world scripts/i);
|
||||
assert.match(lines.join("\n"), /throwing/);
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("foreground scripts receive argv after -- and mirror their exit code", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "openwork-world-cli-argv-"));
|
||||
try {
|
||||
const worldsDirectory = join(root, "worlds");
|
||||
const outputPath = join(root, "argv.json");
|
||||
await mkdir(worldsDirectory);
|
||||
await writeFile(join(worldsDirectory, "argv.ts"), `
|
||||
import { writeFile } from "node:fs/promises";
|
||||
await writeFile(process.argv[2], JSON.stringify(process.argv.slice(3)), "utf8");
|
||||
process.exitCode = 7;
|
||||
`, "utf8");
|
||||
|
||||
const code = await main(["up", "argv", "--", outputPath, "--detach", "plain"], {
|
||||
cwd: root,
|
||||
worldsDirectory,
|
||||
print: () => {},
|
||||
});
|
||||
assert.equal(code, 7);
|
||||
const recordedArgs: unknown = JSON.parse(await readFile(outputPath, "utf8"));
|
||||
assert.deepEqual(recordedArgs, ["--detach", "plain"]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
assertHeadlessLaunchSafety,
|
||||
installedProductionHeadlessEnv,
|
||||
resolveHeadlessClientConnection,
|
||||
resolveInstalledProductionHeadlessState,
|
||||
resolveHeadlessWorldRuntimePaths,
|
||||
} from "../src/headless-web.ts";
|
||||
|
||||
test("headless production state resolves installed stores and credentials without copying them", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "openwork-headless-production-"));
|
||||
try {
|
||||
const dataDir = join(root, ".openwork", "openwork-server");
|
||||
const configDir = join(root, ".config", "openwork");
|
||||
const opencodeConfigDir = join(root, ".config", "opencode");
|
||||
const userDataDir = join(root, "Library", "Application Support", "com.differentai.openwork");
|
||||
const opencodeDb = join(root, "Library", "Application Support", "opencode", "opencode.db");
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await mkdir(configDir, { recursive: true });
|
||||
await mkdir(opencodeConfigDir, { recursive: true });
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
await mkdir(dirname(opencodeDb), { recursive: true });
|
||||
await writeFile(opencodeDb, "fixture", "utf8");
|
||||
for (const name of ["desktop-bootstrap.json", "env.json", "server.json"]) {
|
||||
await writeFile(join(configDir, name), "{}\n", "utf8");
|
||||
}
|
||||
await writeFile(join(userDataDir, "openwork-server-state.json"), "{}\n", "utf8");
|
||||
await writeFile(join(userDataDir, "openwork-workspaces.json"), JSON.stringify({
|
||||
workspaces: [{ path: "/Users/me/production-workspace" }],
|
||||
}), "utf8");
|
||||
await writeFile(join(userDataDir, "openwork-server-tokens.json"), JSON.stringify({
|
||||
version: 2,
|
||||
credentials: { clientToken: "client-token", hostToken: "host-token" },
|
||||
}), "utf8");
|
||||
|
||||
const state = await resolveInstalledProductionHeadlessState({
|
||||
env: {},
|
||||
fallbackWorkspace: "/fallback",
|
||||
homeDir: root,
|
||||
platform: "darwin",
|
||||
});
|
||||
assert.equal(state.workspace, "/Users/me/production-workspace");
|
||||
assert.equal(state.token, "client-token");
|
||||
assert.equal(state.hostToken, "host-token");
|
||||
assert.equal(state.opencodeDb, opencodeDb);
|
||||
const env = installedProductionHeadlessEnv(state);
|
||||
assert.equal(env.OPENWORK_DATA_DIR, dataDir);
|
||||
assert.equal(env.OPENWORK_SERVER_CONFIG, join(configDir, "server.json"));
|
||||
assert.equal(env.OPENCODE_DB, opencodeDb);
|
||||
assert.equal(env.OPENWORK_DEV_SHARED_STATE, "1");
|
||||
assert.equal(env.OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY, "0");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("headless production state remains macOS-only", async () => {
|
||||
await assert.rejects(
|
||||
() => resolveInstalledProductionHeadlessState({
|
||||
env: {},
|
||||
fallbackWorkspace: "/tmp",
|
||||
homeDir: "/tmp",
|
||||
platform: "linux",
|
||||
}),
|
||||
/supported only on macOS/,
|
||||
);
|
||||
});
|
||||
|
||||
test("headless production state refuses every remote exposure setting", () => {
|
||||
for (const env of [
|
||||
{ OPENWORK_REMOTE_ACCESS: "1" },
|
||||
{ OPENWORK_PUBLIC_HOST: "dev.example.com" },
|
||||
{ HOST: "0.0.0.0" },
|
||||
{ VITE_HOST: "192.0.2.10" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertHeadlessLaunchSafety("installed-production", env),
|
||||
/require loopback-only access/,
|
||||
);
|
||||
}
|
||||
assert.doesNotThrow(() => assertHeadlessLaunchSafety("installed-production", {
|
||||
HOST: "127.0.0.1",
|
||||
VITE_HOST: "localhost",
|
||||
}));
|
||||
assert.doesNotThrow(() => assertHeadlessLaunchSafety("isolated", {
|
||||
OPENWORK_REMOTE_ACCESS: "1",
|
||||
}));
|
||||
});
|
||||
|
||||
test("headless production state pins browser credentials to its generated loopback server", () => {
|
||||
const production = resolveHeadlessClientConnection({
|
||||
state: "installed-production",
|
||||
env: {
|
||||
VITE_OPENWORK_URL: "https://attacker.example",
|
||||
VITE_OPENWORK_PORT: "443",
|
||||
VITE_OPENWORK_TOKEN: "attacker-selected-token",
|
||||
},
|
||||
openworkUrl: "http://127.0.0.1:8778",
|
||||
openworkPort: 8778,
|
||||
token: "installed-production-token",
|
||||
});
|
||||
assert.deepEqual(production, {
|
||||
url: "http://127.0.0.1:8778",
|
||||
port: "8778",
|
||||
token: "installed-production-token",
|
||||
});
|
||||
|
||||
const isolated = resolveHeadlessClientConnection({
|
||||
state: "isolated",
|
||||
env: { VITE_OPENWORK_URL: "http://127.0.0.1:9999" },
|
||||
openworkUrl: "http://127.0.0.1:8778",
|
||||
openworkPort: 8778,
|
||||
token: "isolated-token",
|
||||
});
|
||||
assert.equal(isolated.url, "http://127.0.0.1:9999");
|
||||
});
|
||||
|
||||
test("named headless worlds own separate runtime paths while the compatibility world keeps legacy paths", () => {
|
||||
const repoRoot = "/repo/openwork";
|
||||
const compatibility = resolveHeadlessWorldRuntimePaths(repoRoot, "dev-headless");
|
||||
const production = resolveHeadlessWorldRuntimePaths(repoRoot, "headless-prod-live");
|
||||
|
||||
assert.equal(compatibility.runtimeManifestPath, join(repoRoot, "tmp", "dev-headless-web.json"));
|
||||
assert.equal(production.runtimeManifestPath, join(
|
||||
repoRoot,
|
||||
"tmp",
|
||||
"worlds",
|
||||
"runtime",
|
||||
"headless-prod-live",
|
||||
"runtime.json",
|
||||
));
|
||||
assert.notEqual(production.serverConfigPath, compatibility.serverConfigPath);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { WorldStateStore } from "../src/store.ts";
|
||||
|
||||
test("local world state is owner-only and addressable by world name", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "openwork-world-store-"));
|
||||
try {
|
||||
const store = new WorldStateStore(join(root, "worlds"));
|
||||
const path = await store.save("demo", '{"name":"demo"}');
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal(await store.read("demo"), '{"name":"demo"}\n');
|
||||
assert.deepEqual(await store.list(), [path]);
|
||||
assert.equal(await store.forget("demo"), true);
|
||||
assert.equal(await store.forget("demo"), false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "ESNext.Disposable"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user