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:
parent
ef8ac42dd4
commit
fad75b6323
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue