refactor(cli): extract resolveOrCreateProject for the not-running path (#1621)

Replaces the per-arg-shape dispatch that lived inline in start.ts (URL,
path, project id, no arg) with a single resolveOrCreateProject(arg, deps)
call. The new module dispatches internally to fromUrl/fromPath/fromCwdOrId
helpers and returns a uniform { config, projectId, project, source,
justCreated, parsed? } shape.

Behavior preserved exactly — fromUrl is a near-verbatim move of
handleUrlStart's body (which is now deleted as it had no other callers),
fromPath mirrors the path branch's loadConfig/autoCreate/addProject
cascade, and fromCwdOrId carries over the registerFlatConfig recovery
path with the same semantics.

Dependencies that live in start.ts (addProjectToConfig, autoCreateConfig,
resolveProject, resolveProjectByRepo, registerFlatConfig, cloneRepo) are
passed in via a ResolveDeps object. This keeps the new module decoupled
from start.ts's other concerns (interactive prompts, agent detection,
project type detection) without creating a circular import.

Scope note: the "AO is already running + URL/path arg" branch in start.ts
still has its own inline clone+register block. That block deliberately
diverged from handleUrlStart (it writes a flat local config, not the
legacy wrapped one) — migrating it to share fromUrl is a small follow-up
before PR B.2 collapses the running-vs-not-running fork.

Step 1 of PR B in the start.ts refactor plan
(~/.ao/agent-orchestrator/ao-118/plan.md). Net diff: start.ts shrinks by
~146 lines, new file adds 294. Test count and failure set are identical
to upstream/main.
This commit is contained in:
Harshit Singh Bhandari 2026-05-04 09:44:07 +05:30 committed by GitHub
parent ef8ac42dd4
commit fad75b6323
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 306 additions and 158 deletions

View File

@ -21,16 +21,13 @@ import {
generateOrchestratorPrompt,
generateSessionPrefix,
getOrchestratorSessionId,
findConfigFile,
isRepoUrl,
parseRepoUrl,
resolveCloneTarget,
isRepoAlreadyCloned,
generateConfigFromUrl,
configToYaml,
isCanonicalGlobalConfigPath,
isTerminalSession,
ConfigNotFoundError,
loadLocalProjectConfigDetailed,
registerProjectInGlobalConfig,
detectScmPlatform,
@ -100,6 +97,7 @@ import {
} from "../lib/install-helpers.js";
import { ensureGit, runtimePreflight } from "../lib/startup-preflight.js";
import { installShutdownHandlers } from "../lib/shutdown.js";
import { resolveOrCreateProject } from "../lib/resolve-project.js";
import { DEFAULT_PORT } from "../lib/constants.js";
import { projectSessionUrl } from "../lib/routes.js";
@ -490,76 +488,6 @@ async function cloneRepo(parsed: ParsedRepoUrl, targetDir: string, cwd: string):
});
}
/**
* Handle `ao start <url>` clone repo, generate config, return loaded config.
* Also returns the parsed URL so the caller can match by repo when the config
* contains multiple projects.
*/
async function handleUrlStart(
url: string,
): Promise<{ config: OrchestratorConfig; parsed: ParsedRepoUrl; autoGenerated: boolean }> {
const spinner = ora();
// 1. Parse URL
spinner.start("Parsing repository URL");
const parsed = parseRepoUrl(url);
spinner.succeed(`Repository: ${chalk.cyan(parsed.ownerRepo)} (${parsed.host})`);
await ensureGit("repository cloning");
// 2. Determine target directory
const cwd = process.cwd();
const targetDir = resolveCloneTarget(parsed, cwd);
const alreadyCloned = isRepoAlreadyCloned(targetDir, parsed.cloneUrl);
// 3. Clone or reuse
if (alreadyCloned) {
console.log(chalk.green(` Reusing existing clone at ${targetDir}`));
} else {
spinner.start(`Cloning ${parsed.ownerRepo}`);
try {
spinner.stop(); // Clear spinner before interactive command
await cloneRepo(parsed, targetDir, cwd);
spinner.succeed(`Cloned to ${targetDir}`);
} catch (err) {
spinner.fail("Clone failed");
throw new Error(
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
// 4. Check for existing config
const configPath = resolve(targetDir, "agent-orchestrator.yaml");
const configPathAlt = resolve(targetDir, "agent-orchestrator.yml");
if (existsSync(configPath)) {
console.log(chalk.green(` Using existing config: ${configPath}`));
return { config: loadConfig(configPath), parsed, autoGenerated: false };
}
if (existsSync(configPathAlt)) {
console.log(chalk.green(` Using existing config: ${configPathAlt}`));
return { config: loadConfig(configPathAlt), parsed, autoGenerated: false };
}
// 5. Auto-generate config with a free port
spinner.start("Generating config");
const freePort = await findFreePort(DEFAULT_PORT);
const rawConfig = generateConfigFromUrl({
parsed,
repoPath: targetDir,
port: freePort ?? DEFAULT_PORT,
});
const yamlContent = configToYaml(rawConfig);
writeFileSync(configPath, yamlContent);
spinner.succeed(`Config generated: ${configPath}`);
return { config: loadConfig(configPath), parsed, autoGenerated: true };
}
/**
* Auto-create agent-orchestrator.yaml when no config exists.
* Detects environment, project type, and generates config with smart defaults.
@ -1770,91 +1698,17 @@ export function registerStart(program: Command): void {
}
}
if (projectArg && isRepoUrl(projectArg)) {
// ── URL argument: clone + auto-config + start ──
console.log(chalk.bold.cyan("\n Agent Orchestrator — Quick Start\n"));
const result = await handleUrlStart(projectArg);
config = result.config;
({ projectId, project, config } = await resolveProjectByRepo(config, result.parsed));
} else if (projectArg && isLocalPath(projectArg)) {
// ── Path argument: add project if new, then start ──
const resolvedPath = resolve(projectArg.replace(/^~/, process.env["HOME"] || ""));
// Try to load existing config
let configPath: string | undefined;
try {
configPath = findConfigFile() ?? undefined;
} catch {
// No config found — create one first
}
if (!configPath) {
if (resolve(cwd()) !== resolvedPath) {
// Target path differs from cwd — create config at the target repo
config = await autoCreateConfig(resolvedPath);
} else {
// cwd is the target — auto-create config here
config = await autoCreateConfig(cwd());
}
({ projectId, project, config } = await resolveProject(config));
} else {
config = loadConfig(configPath);
// Check if project is already in config (match by path)
const existingEntry = Object.entries(config.projects).find(
([, p]) =>
resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath,
);
if (existingEntry) {
// Already in config — just start it
projectId = existingEntry[0];
project = existingEntry[1];
} else {
// New project — add it to config
const addedId = await addProjectToConfig(config, resolvedPath);
config = loadConfig(config.configPath);
projectId = addedId;
project = config.projects[projectId];
}
}
} else {
// ── No arg or project ID: load config or auto-create ──
let loadedConfig: OrchestratorConfig | null = null;
try {
loadedConfig = loadConfig();
} catch (err) {
if (err instanceof ConfigNotFoundError) {
// First run — auto-create config
loadedConfig = await autoCreateConfig(cwd());
} else {
// A config file exists but failed to load — likely a flat local
// config whose project isn't registered in the global config yet.
// Register it and retry.
const foundConfig = findConfigFile() ?? undefined;
if (foundConfig) {
const addedId = await registerFlatConfig(foundConfig);
if (addedId) {
loadedConfig = loadConfig(foundConfig);
} else {
throw err;
}
} else {
throw err;
}
}
}
config = loadedConfig;
// If the user targets a project not in the local config, fall back
// to the global config which has all registered projects.
if (projectArg && !config.projects[projectArg]) {
const globalPath = getGlobalConfigPath();
if (existsSync(globalPath)) {
config = loadConfig(globalPath);
}
}
({ projectId, project, config } = await resolveProject(config, projectArg));
}
// Unified project resolution. See lib/resolve-project.ts for the
// per-arg-shape dispatch (URL / path / project id / cwd).
const resolvedProject = await resolveOrCreateProject(projectArg, {
addProjectToConfig,
autoCreateConfig,
resolveProject,
resolveProjectByRepo,
registerFlatConfig,
cloneRepo,
});
({ config, projectId, project } = resolvedProject);
// ── Handle "new orchestrator" choice (deferred from already-running check) ──
if (startNewOrchestrator) {

View File

@ -0,0 +1,294 @@
/**
* Unified project resolution for `ao start`.
*
* Replaces the per-arg-shape dispatch that lived inline in start.ts (URL
* handleUrlStart, path addProjectToConfig, none loadConfig/autoCreate +
* registerFlatConfig recovery, project-id resolveProject). Each shape
* still has its own helper here, but they all return the same shape so
* the caller can treat them uniformly.
*
* Today this module only covers the not-running path. The "AO is already
* running" branch in start.ts still has its own inline clone+register
* block (it intentionally avoids handleUrlStart's legacy wrapped local
* config); migrating it to use resolveOrCreateProject is a follow-up
* before PR B.2's daemon unification.
*/
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { cwd } from "node:process";
import {
ConfigNotFoundError,
findConfigFile,
generateConfigFromUrl,
configToYaml,
isRepoUrl,
loadConfig,
parseRepoUrl,
resolveCloneTarget,
isRepoAlreadyCloned,
getGlobalConfigPath,
type OrchestratorConfig,
type ParsedRepoUrl,
type ProjectConfig,
} from "@aoagents/ao-core";
import chalk from "chalk";
import ora from "ora";
import { findFreePort } from "./web-dir.js";
import { DEFAULT_PORT } from "./constants.js";
import { ensureGit } from "./startup-preflight.js";
export type ProjectSource = "url" | "path" | "cwd" | "existing-id";
export interface Resolved {
config: OrchestratorConfig;
projectId: string;
project: ProjectConfig;
source: ProjectSource;
/** True when this resolve call wrote new state to disk (cloned a repo,
* generated a config, or registered a project). False when the project
* was already known. Useful for dashboard cache invalidation hints. */
justCreated: boolean;
/** Populated only when source === "url". */
parsed?: ParsedRepoUrl;
}
/**
* Dependencies the resolver needs to delegate to existing start.ts helpers.
* Passed in rather than imported to keep this module decoupled from
* start.ts's other concerns (interactive prompts, agent detection, etc.).
*/
export interface ResolveDeps {
/**
* Add an unregistered local path as a new project. The signature matches
* start.ts's `addProjectToConfig`. Returns the registered project id.
*/
addProjectToConfig: (config: OrchestratorConfig, path: string) => Promise<string>;
/**
* Auto-create a config when none exists at `workingDir`. Matches
* start.ts's `autoCreateConfig`. Returns the loaded config.
*/
autoCreateConfig: (workingDir: string) => Promise<OrchestratorConfig>;
/**
* Resolve an existing project from a loaded config (handles single-
* project, explicit arg, multi-project prompt). Matches start.ts's
* `resolveProject`.
*/
resolveProject: (
config: OrchestratorConfig,
projectArg?: string,
) => Promise<{ projectId: string; project: ProjectConfig; config: OrchestratorConfig }>;
/**
* Resolve a project from a loaded config by matching the URL's
* `ownerRepo`. Matches start.ts's `resolveProjectByRepo`.
*/
resolveProjectByRepo: (
config: OrchestratorConfig,
parsed: ParsedRepoUrl,
) => Promise<{ projectId: string; project: ProjectConfig; config: OrchestratorConfig }>;
/**
* Recover a flat local config that exists but isn't registered globally.
* Matches start.ts's `registerFlatConfig`. Returns the registered id, or
* null if recovery is not possible.
*/
registerFlatConfig: (configPath: string) => Promise<string | null>;
/**
* Clone a repo into the target dir. Matches start.ts's `cloneRepo`.
*/
cloneRepo: (parsed: ParsedRepoUrl, targetDir: string, cwd: string) => Promise<void>;
}
/**
* Decide whether `arg` looks like a path (rather than a project id).
* Matches start.ts's `isLocalPath`.
*/
function isLocalPath(arg: string): boolean {
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..");
}
async function fromUrl(arg: string, deps: ResolveDeps): Promise<Resolved> {
console.log(chalk.bold.cyan("\n Agent Orchestrator — Quick Start\n"));
const spinner = ora();
spinner.start("Parsing repository URL");
const parsed = parseRepoUrl(arg);
spinner.succeed(`Repository: ${chalk.cyan(parsed.ownerRepo)} (${parsed.host})`);
await ensureGit("repository cloning");
const cwdDir = process.cwd();
const targetDir = resolveCloneTarget(parsed, cwdDir);
const alreadyCloned = isRepoAlreadyCloned(targetDir, parsed.cloneUrl);
if (alreadyCloned) {
console.log(chalk.green(` Reusing existing clone at ${targetDir}`));
} else {
spinner.start(`Cloning ${parsed.ownerRepo}`);
try {
spinner.stop();
await deps.cloneRepo(parsed, targetDir, cwdDir);
spinner.succeed(`Cloned to ${targetDir}`);
} catch (err) {
spinner.fail("Clone failed");
throw new Error(
`Failed to clone ${parsed.ownerRepo}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
const configPath = resolve(targetDir, "agent-orchestrator.yaml");
const configPathAlt = resolve(targetDir, "agent-orchestrator.yml");
let config: OrchestratorConfig;
let justCreated: boolean;
if (existsSync(configPath)) {
console.log(chalk.green(` Using existing config: ${configPath}`));
config = loadConfig(configPath);
justCreated = false;
} else if (existsSync(configPathAlt)) {
console.log(chalk.green(` Using existing config: ${configPathAlt}`));
config = loadConfig(configPathAlt);
justCreated = false;
} else {
spinner.start("Generating config");
const freePort = await findFreePort(DEFAULT_PORT);
const rawConfig = generateConfigFromUrl({
parsed,
repoPath: targetDir,
port: freePort ?? DEFAULT_PORT,
});
const yamlContent = configToYaml(rawConfig);
writeFileSync(configPath, yamlContent);
spinner.succeed(`Config generated: ${configPath}`);
config = loadConfig(configPath);
justCreated = true;
}
const resolved = await deps.resolveProjectByRepo(config, parsed);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: "url",
justCreated,
parsed,
};
}
async function fromPath(arg: string, deps: ResolveDeps): Promise<Resolved> {
const resolvedPath = resolve(arg.replace(/^~/, process.env["HOME"] || ""));
let configPath: string | undefined;
try {
configPath = findConfigFile() ?? undefined;
} catch {
// No config — fall through to autoCreate.
}
if (!configPath) {
// No config anywhere — auto-create at the target path (or cwd if they match).
const targetDir = resolve(cwd()) === resolvedPath ? cwd() : resolvedPath;
const config = await deps.autoCreateConfig(targetDir);
const resolved = await deps.resolveProject(config);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: "path",
justCreated: true,
};
}
// Config exists — check if the path is already registered.
const config = loadConfig(configPath);
const existingEntry = Object.entries(config.projects).find(
([, p]) => resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath,
);
if (existingEntry) {
return {
config,
projectId: existingEntry[0],
project: existingEntry[1],
source: "path",
justCreated: false,
};
}
// Path is new — register it.
const addedId = await deps.addProjectToConfig(config, resolvedPath);
const reloaded = loadConfig(config.configPath);
return {
config: reloaded,
projectId: addedId,
project: reloaded.projects[addedId],
source: "path",
justCreated: true,
};
}
async function fromCwdOrId(arg: string | undefined, deps: ResolveDeps): Promise<Resolved> {
let config: OrchestratorConfig;
let recovered = false;
try {
config = loadConfig();
} catch (err) {
if (err instanceof ConfigNotFoundError) {
// First run — auto-create config in cwd.
config = await deps.autoCreateConfig(cwd());
recovered = true;
} else {
// A config file exists but failed to load — likely a flat local
// config whose project isn't in the global registry yet. Recover
// by registering it, then retry the load.
const foundConfig = findConfigFile() ?? undefined;
if (!foundConfig) throw err;
const addedId = await deps.registerFlatConfig(foundConfig);
if (!addedId) throw err;
config = loadConfig(foundConfig);
recovered = true;
}
}
// If the user named a project that isn't in the local config, fall back
// to the global registry (which has all registered projects).
if (arg && !config.projects[arg]) {
const globalPath = getGlobalConfigPath();
if (existsSync(globalPath)) {
config = loadConfig(globalPath);
}
}
const resolved = await deps.resolveProject(config, arg);
return {
config: resolved.config,
projectId: resolved.projectId,
project: resolved.project,
source: arg ? "existing-id" : "cwd",
justCreated: recovered,
};
}
/**
* Resolve (and create if necessary) the project a given `ao start [arg]`
* invocation refers to.
*
* Dispatches by arg shape:
* - `arg` is a URL clone (or reuse), load/generate config, match by repo
* - `arg` is a local path load existing or addProject or autoCreate
* - `arg` is a project id load config, fall back to global if needed
* - `arg` is undefined load cwd config, autoCreate on first run, register
* flat configs that exist but aren't globally known
*
* The same `Resolved` shape comes back regardless of source; callers use
* `source` and `justCreated` for hints (e.g. dashboard cache invalidation).
*/
export async function resolveOrCreateProject(
arg: string | undefined,
deps: ResolveDeps,
): Promise<Resolved> {
if (arg && isRepoUrl(arg)) return fromUrl(arg, deps);
if (arg && isLocalPath(arg)) return fromPath(arg, deps);
return fromCwdOrId(arg, deps);
}