singularity-forge/src/cli-web-branch.ts
2026-05-05 15:42:10 +02:00

371 lines
10 KiB
TypeScript

import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
} from "node:fs";
import { join, resolve, sep } from "node:path";
import {
agentDir as defaultAgentDir,
sessionsDir as defaultSessionsDir,
webPreferencesPath as defaultWebPreferencesPath,
} from "./app-paths.js";
import { getProjectSessionsDir } from "./project-sessions.js";
import {
launchWebMode,
stopWebMode,
type WebModeLaunchStatus,
type WebModeStopOptions,
type WebModeStopResult,
} from "./web-mode.js";
export interface CliFlags {
mode?: "text" | "json" | "rpc";
print?: boolean;
continue?: boolean;
noSession?: boolean;
worktree?: boolean | string;
model?: string;
listModels?: string | true;
discover?: boolean;
extensions: string[];
appendSystemPrompt?: string;
tools?: string[];
messages: string[];
web?: boolean;
/** Optional project path for web mode: `sf --web <path>` or `sf web start <path>` */
webPath?: string;
/** Custom host to bind web server to: `--host 0.0.0.0` */
webHost?: string;
/** Custom port for web server: `--port 8080` */
webPort?: number;
/** Additional allowed origins for CORS: `--allowed-origins http://192.168.1.10:8080` */
webAllowedOrigins?: string[];
/** Set by `sf sessions` when the user picks a specific session to resume */
_selectedSessionPath?: string;
/** `sf sessions --all` — list sessions across all projects */
allSessions?: boolean;
}
type WritableLike = Pick<typeof process.stderr, "write">;
export interface RunWebCliBranchDeps {
runWebMode?: typeof launchWebMode;
stopWebMode?: (
deps: Parameters<typeof stopWebMode>[0],
options?: WebModeStopOptions,
) => WebModeStopResult;
cwd?: () => string;
stderr?: WritableLike;
baseSessionsDir?: string;
agentDir?: string;
webPreferencesPath?: string;
}
export function parseCliArgs(argv: string[]): CliFlags {
const flags: CliFlags = { extensions: [], messages: [] };
const args = argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--mode" && i + 1 < args.length) {
const mode = args[++i];
if (mode === "text" || mode === "json" || mode === "rpc")
flags.mode = mode;
} else if (arg === "--print" || arg === "-p") {
flags.print = true;
} else if (arg === "--continue" || arg === "-c") {
flags.continue = true;
} else if (arg === "--no-session") {
flags.noSession = true;
} else if (arg === "--all" || arg === "-a") {
flags.allSessions = true;
} else if (arg === "--worktree" || arg === "-w") {
// -w with no value → auto-generate name; -w <name> → use that name
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
flags.worktree = args[++i];
} else {
flags.worktree = true;
}
} else if (arg === "--web") {
flags.web = true;
// Peek at next arg — if it looks like a path (not another flag), capture it
if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
flags.webPath = args[++i];
}
} else if (arg === "--host" && i + 1 < args.length) {
flags.webHost = args[++i];
} else if (arg === "--port" && i + 1 < args.length) {
const portStr = args[++i];
const port = parseInt(portStr, 10);
if (Number.isFinite(port) && port > 0 && port < 65536) {
flags.webPort = port;
}
} else if (arg === "--allowed-origins" && i + 1 < args.length) {
const origins = args[++i]
.split(",")
.map((o) => o.trim())
.filter(Boolean);
flags.webAllowedOrigins = (flags.webAllowedOrigins ?? []).concat(origins);
} else if (arg === "--model" && i + 1 < args.length) {
flags.model = args[++i];
} else if (arg === "--extension" && i + 1 < args.length) {
flags.extensions.push(args[++i]);
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
flags.appendSystemPrompt = args[++i];
} else if (arg === "--tools" && i + 1 < args.length) {
flags.tools = args[++i].split(",");
} else if (arg === "--list-models") {
flags.listModels =
i + 1 < args.length && !args[i + 1].startsWith("-") ? args[++i] : true;
} else if (arg === "--discover") {
flags.discover = true;
} else if (!arg.startsWith("--") && !arg.startsWith("-")) {
flags.messages.push(arg);
}
}
return flags;
}
export { getProjectSessionsDir } from "./project-sessions.js";
export function migrateLegacyFlatSessions(
baseSessionsDir: string,
projectSessionsDir: string,
): void {
if (!existsSync(baseSessionsDir)) return;
try {
const entries = readdirSync(baseSessionsDir);
const flatJsonl = entries.filter((file) => file.endsWith(".jsonl"));
if (flatJsonl.length === 0) return;
mkdirSync(projectSessionsDir, { recursive: true });
for (const file of flatJsonl) {
const src = join(baseSessionsDir, file);
const dst = join(projectSessionsDir, file);
if (!existsSync(dst)) {
renameSync(src, dst);
}
}
} catch {
// Non-fatal — don't block startup if migration fails
}
}
function emitWebModeFailure(
stderr: WritableLike,
status: WebModeLaunchStatus,
): void {
if (status.ok) return;
stderr.write(
`[forge] Web mode launch failed: ${(status as any).failureReason}\n`,
);
}
/**
* Resolve the working directory for context-aware launch detection.
*
* If the user has configured a dev root via onboarding and their cwd is inside
* a project under that dev root, return the one-level-deep project directory.
* Otherwise, return the cwd unchanged (browser picker handles selection).
*
* Edge cases handled:
* - Missing or unreadable prefs file → cwd unchanged
* - No devRoot field in prefs → cwd unchanged
* - devRoot path doesn't exist (stale) → cwd unchanged
* - cwd IS the devRoot → cwd unchanged (picker selects)
* - cwd outside devRoot → cwd unchanged
*/
export function resolveContextAwareCwd(
currentCwd: string,
prefsPath: string,
): string {
// 1. Read preferences file
let prefs: Record<string, unknown>;
try {
const raw = readFileSync(prefsPath, "utf-8");
prefs = JSON.parse(raw);
} catch {
return currentCwd;
}
// 2. Extract devRoot
const devRoot = prefs.devRoot;
if (typeof devRoot !== "string" || !devRoot) {
return currentCwd;
}
// 3. Resolve both paths to absolute
const resolvedCwd = resolve(currentCwd);
const resolvedDevRoot = resolve(devRoot);
// 4. Check devRoot still exists
if (!existsSync(resolvedDevRoot)) {
return currentCwd;
}
// 5. If cwd IS the devRoot → unchanged (picker handles selection)
if (resolvedCwd === resolvedDevRoot) {
return currentCwd;
}
// 6. If cwd is inside devRoot, extract one-level-deep project directory
const prefix = resolvedDevRoot + sep;
if (resolvedCwd.startsWith(prefix)) {
const relative = resolvedCwd.slice(prefix.length);
const firstSegment = relative.split(sep)[0];
if (firstSegment) {
return join(resolvedDevRoot, firstSegment);
}
}
// 7. cwd outside devRoot → unchanged
return currentCwd;
}
export type RunWebCliBranchResult =
| { handled: false }
| {
handled: true;
exitCode: number;
action: "start";
status: WebModeLaunchStatus;
launchInputs: {
cwd: string;
projectSessionsDir: string;
agentDir: string;
};
}
| {
handled: true;
exitCode: number;
action: "stop";
stopResult: WebModeStopResult;
};
export async function runWebCliBranch(
flags: CliFlags,
deps: RunWebCliBranchDeps = {},
): Promise<RunWebCliBranchResult> {
// Handle `sf web stop [path|--all]` subcommand
if (flags.messages[0] === "web" && flags.messages[1] === "stop") {
const stderr = deps.stderr ?? process.stderr;
const stopArg = flags.messages[2];
const isAll = stopArg === "all";
const stopCwd =
stopArg && !isAll
? resolve((deps.cwd ?? (() => process.cwd()))(), stopArg)
: undefined;
const stopResult = (deps.stopWebMode ?? stopWebMode)(
{ stderr },
{
projectCwd: stopCwd,
all: isAll,
},
);
return {
handled: true,
exitCode: stopResult.ok ? 0 : 1,
action: "stop",
stopResult,
};
}
// `sf web [start] [path]` is an alias for `sf --web [path]`
// Matches: `sf web`, `sf web start`, `sf web start <path>`, `sf web <path>`
const isWebSubcommand =
flags.messages[0] === "web" && flags.messages[1] !== "stop";
if (!flags.web && !isWebSubcommand) {
return { handled: false };
}
const stderr = deps.stderr ?? process.stderr;
const defaultCwd = (deps.cwd ?? (() => process.cwd()))();
// Resolve project path from multiple forms:
// sf --web <path> → flags.webPath
// sf web start <path> → messages[2]
// sf web <path> → messages[1] (when not "start")
let webPath = flags.webPath;
if (!webPath && isWebSubcommand) {
if (flags.messages[1] === "start") {
webPath = flags.messages[2];
} else if (flags.messages[1]) {
webPath = flags.messages[1];
}
}
let currentCwd: string;
if (webPath) {
currentCwd = resolve(defaultCwd, webPath);
const checkExists = existsSync;
if (!checkExists(currentCwd)) {
stderr.write(`[forge] Project path does not exist: ${currentCwd}\n`);
return {
handled: true,
exitCode: 1,
action: "start",
status: {
mode: "web",
ok: false,
cwd: currentCwd,
projectSessionsDir: "",
host: "127.0.0.1",
port: null,
url: null,
hostKind: "unresolved",
hostPath: null,
hostRoot: null,
failureReason: `project path does not exist: ${currentCwd}`,
},
launchInputs: {
cwd: currentCwd,
projectSessionsDir: "",
agentDir: deps.agentDir ?? defaultAgentDir,
},
};
}
stderr.write(`[forge] Using project path: ${currentCwd}\n`);
} else {
currentCwd = defaultCwd;
}
// Context-aware launch: if cwd is inside a project under the configured dev root,
// resolve to the project directory so the browser opens directly into it
currentCwd = resolveContextAwareCwd(
currentCwd,
deps.webPreferencesPath ?? defaultWebPreferencesPath,
);
const baseSessionsDir = deps.baseSessionsDir ?? defaultSessionsDir;
const agentDir = deps.agentDir ?? defaultAgentDir;
const projectSessionsDir = getProjectSessionsDir(currentCwd, baseSessionsDir);
migrateLegacyFlatSessions(baseSessionsDir, projectSessionsDir);
const status = await (deps.runWebMode ?? launchWebMode)({
cwd: currentCwd,
projectSessionsDir,
agentDir,
host: flags.webHost,
port: flags.webPort,
allowedOrigins: flags.webAllowedOrigins,
});
if (!status.ok) {
emitWebModeFailure(stderr, status);
}
return {
handled: true,
exitCode: status.ok ? 0 : 1,
action: "start",
status,
launchInputs: {
cwd: currentCwd,
projectSessionsDir,
agentDir,
},
};
}