diff --git a/README.md b/README.md index b666fe1..f2dba8b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,31 @@ an invalid config fails fast with a field-level error. See and [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json) for a worked example (including offline fixture mode). +`cloneRoot` and every `clonePaths` value accept `~` or a leading `~/`, expanded +against the current user's home directory. Named-user forms such as `~alice` +are rejected because Node cannot expand them portably. + +For a local, single-repository run, the checkout mapping can be omitted: + +```json +{ + "repos": { + "org": "your-org", + "names": ["your-repo"], + "default": "your-org/your-repo" + } +} +``` + +Run Factory from that repository (or one of its subdirectories). When exactly +one `repos.names` entry is configured and no `cloneRoot` or `clonePaths` field is +supplied, Factory resolves the checkout's git top-level and uses it only if a +GitHub remote matches the resolved `org/name`. The inference is logged. Missing, +unparseable, or mismatched remotes fail with a config-oriented error instead of +silently dispatching in the wrong directory. Explicit local clone paths are also +preflighted before commands that can dispatch through the internal backend; +relay-backend paths are left for their worker nodes to validate. + ## Notes - The daemon is headless by design; tools like Pear can consume this package and diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 2c707c6..f5c34b1 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -399,6 +399,156 @@ describe('fleet CLI runtime', () => { expect(mount.writes).toEqual([]) }) + it('infers clonePath from cwd for internal dispatch and logs the checkout root', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-infer-')) + try { + const configPath = await writeConfig(root, { + repos: { + org: 'AgentWorkforce', + names: ['pear'], + default: 'AgentWorkforce/pear', + }, + }) + const git = vi.fn(async (_cwd: string, args: string[]) => { + if (args[0] === 'rev-parse') return '/checkout/pear\n' + if (args[0] === 'remote' && args.length === 1) return 'origin\n' + if (args[0] === 'remote' && args[1] === 'get-url') return 'git@github.com:AgentWorkforce/pear.git\n' + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) + const output = buffer() + const errors = buffer() + + const code = await runFleetCli([ + 'dispatch', + 'AR-77', + '--dry-run', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient({ [issuePath]: issueFile }), + localClonePathOptions: { cwd: '/checkout/pear/src', git }, + stdout: output, + stderr: errors, + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toMatchObject({ + issue: { key: 'AR-77' }, + dryRun: true, + }) + expect(errors.text()).toContain('[factory] clonePath inferred from cwd: /checkout/pear') + expect(git).toHaveBeenCalledWith('/checkout/pear/src', ['rev-parse', '--show-toplevel']) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('fails fast for an invalid explicit clonePath before internal dispatch', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-invalid-clone-')) + try { + const missing = join(root, 'missing-checkout') + const configPath = await writeConfig(root, { + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': missing }, + default: 'AgentWorkforce/pear', + }, + }) + const output = buffer() + const errors = buffer() + + const code = await runFleetCli([ + 'dispatch', + 'AR-77', + '--dry-run', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient({ [issuePath]: issueFile }), + localClonePathOptions: { validateConfiguredCheckouts: true }, + stdout: output, + stderr: errors, + }) + + expect(code).toBe(1) + expect(errors.text()).toContain(`[factory] clonePath for AgentWorkforce/pear does not exist: ${missing}`) + expect(output.text()).toBe('') + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not inspect orchestrator-local checkouts for relay dispatch', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-relay-clone-')) + try { + const configPath = await writeConfig(root, { + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': join(root, 'worker-only-checkout') }, + default: 'AgentWorkforce/pear', + }, + }) + const git = vi.fn(async () => { + throw new Error('relay dispatch must not inspect local git state') + }) + + const code = await runFleetCli([ + 'dispatch', + 'AR-77', + '--dry-run', + '--backend', + 'relay', + '--config', + configPath, + ], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient({ [issuePath]: issueFile }), + localClonePathOptions: { git, validateConfiguredCheckouts: true }, + stdout: buffer(), + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(git).not.toHaveBeenCalled() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('does not infer or preflight clone paths for a maintenance command', async () => { + const root = await mkdtemp(join(tmpdir(), 'fleet-cli-maintenance-clone-')) + try { + const configPath = await writeConfig(root, { + repos: { org: 'AgentWorkforce', names: ['pear'] }, + }) + const git = vi.fn(async () => { + throw new Error('status must not inspect local git state') + }) + const factoryStatus = { inFlight: [], queued: [], counters: { pulled: 0 } } + const factory = { + status: vi.fn(() => factoryStatus), + } as unknown as Factory + const output = buffer() + + const code = await runFleetCli(['status', '--config', configPath], { + fleet: new FakeFleetClient(), + mount: new FakeMountClient(), + createFactory: () => factory, + localClonePathOptions: { cwd: '/not/a/checkout', git, validateConfiguredCheckouts: true }, + stdout: output, + stderr: buffer(), + }) + + expect(code).toBe(0) + expect(JSON.parse(output.text())).toEqual(factoryStatus) + expect(git).not.toHaveBeenCalled() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it('auto-detects GitHub-only workspaces without resolving Linear states', async () => { const root = await mkdtemp(join(tmpdir(), 'fleet-cli-github-only-')) try { @@ -705,6 +855,9 @@ describe('fleet CLI runtime', () => { it('runs manual close-probe through the injectable probe closer', async () => { const output = buffer() const calls: unknown[] = [] + const git = vi.fn(async () => { + throw new Error('close-probe must not inspect local git state') + }) const code = await runFleetCli([ 'close-probe', '42', @@ -715,6 +868,7 @@ describe('fleet CLI runtime', () => { ], { stdout: output, stderr: buffer(), + localClonePathOptions: { git, validateConfiguredCheckouts: true }, probeCloser: async (input: Pick) => { calls.push(input) return { repo: input.repo, prNumber: input.prNumber, state: 'CLOSED' } @@ -722,6 +876,7 @@ describe('fleet CLI runtime', () => { }) expect(code).toBe(0) + expect(git).not.toHaveBeenCalled() expect(calls).toEqual([{ repo: 'AgentWorkforce/pear', prNumber: 42, expectedIssueKey: 'AR-77' }]) expect(JSON.parse(output.text())).toEqual({ repo: 'AgentWorkforce/pear', prNumber: 42, state: 'CLOSED' }) }) diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index 81620ad..44badb9 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { ensureLocalMount } from '../mount/local-mount-preflight' +import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths' import { - FactoryConfigSchema, FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, @@ -72,6 +72,8 @@ interface FleetCliDeps { stopSignalProcessLike?: Pick daemonExit?: (code: number) => void flushDaemonOutput?: () => Promise + /** Hermetic local-checkout probes for CLI integration tests. */ + localClonePathOptions?: LocalClonePathOptions } interface GlobalOptions { @@ -130,7 +132,17 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom return 0 } - const loaded = command.kind.startsWith('factory') ? await loadConfig(globals.config) : undefined + const resolvesLocalClonePaths = globals.backend === 'internal' && commandUsesLocalCheckout(command) + const loaded = command.kind.startsWith('factory') + ? await loadConfig(globals.config, { + ...deps.localClonePathOptions, + inferFromCwd: resolvesLocalClonePaths, + logger: streamLogger(err), + validateConfiguredCheckouts: resolvesLocalClonePaths && ( + deps.localClonePathOptions?.validateConfiguredCheckouts ?? !hasInjectedFactoryRuntime(deps) + ), + }) + : undefined fleet = await buildFleet(globals, loaded, deps) switch (command.kind) { @@ -718,16 +730,39 @@ function parseFactoryStartFlags(args: Array): { mode: 'live' return { mode } } -async function loadConfig(path?: string): Promise { +async function loadConfig(path?: string, options: LocalClonePathOptions = {}): Promise { const configPath = path ?? resolve(process.cwd(), 'factory.config.json') const raw = JSON.parse(await readFile(configPath, 'utf8')) as unknown const record = asRecord(raw) return { - config: FactoryConfigSchema.parse(record.factoryConfig ?? record), + config: await resolveLocalFactoryConfig(raw, { + ...options, + // fixtureFiles describe a hermetic in-memory run; their checkout paths + // are intentionally synthetic and must not be preflighted on the host. + validateConfiguredCheckouts: options.validateConfiguredCheckouts && !record.fixtureFiles, + }), fixtureFiles: record.fixtureFiles ? asRecord(record.fixtureFiles) : undefined, } } +function commandUsesLocalCheckout(command: ParsedCommand): boolean { + switch (command.kind) { + case 'factory': + return command.action === 'run-once' || command.action === 'loop' || command.action === 'start' + case 'factory-canary': + case 'factory-triage': + case 'factory-dispatch': + case 'factory-babysit': + return true + default: + return false + } +} + +function hasInjectedFactoryRuntime(deps: FleetCliDeps): boolean { + return Boolean(deps.fleet || deps.mount || deps.createFactory || deps.createFleet || deps.cloudMountFromConfig) +} + async function buildFleet( globals: GlobalOptions, loaded: LoadedConfig | undefined, diff --git a/src/config/local-clone-paths.test.ts b/src/config/local-clone-paths.test.ts new file mode 100644 index 0000000..dd1ae7f --- /dev/null +++ b/src/config/local-clone-paths.test.ts @@ -0,0 +1,256 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { githubRepoFromRemoteUrl, resolveLocalFactoryConfig } from './local-clone-paths' + +const execFileAsync = promisify(execFile) +const tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('resolveLocalFactoryConfig', () => { + it('infers the git top-level from a nested cwd when the resolved route remote matches', async () => { + const root = await gitRepo('git@github.com:AgentWorkforce/factory.git') + const nested = join(root, 'src', 'nested') + await mkdir(nested, { recursive: true }) + const logger = { info: vi.fn() } + + const config = await resolveLocalFactoryConfig({ + repos: { + org: 'WrongOrg', + names: ['factory'], + overrides: { factory: 'OverrideOrg/factory' }, + byLabel: { factory: 'AgentWorkforce/factory' }, + }, + }, { cwd: nested, logger }) + + const checkoutRoot = await realpath(root) + expect(config.clonePaths).toEqual({ 'AgentWorkforce/factory': checkoutRoot }) + expect(config.repos.clonePaths).toEqual(config.clonePaths) + expect(logger.info).toHaveBeenCalledOnce() + expect(logger.info).toHaveBeenCalledWith(`[factory] clonePath inferred from cwd: ${checkoutRoot}`) + }) + + it('infers from a split config only when neither half supplies clone path config', async () => { + const root = await gitRepo('https://github.com/AgentWorkforce/factory') + + const config = await resolveLocalFactoryConfig({ + workspaceConfig: { + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, + nodeConfig: { capabilities: ['spawn:codex'] }, + }, { cwd: root }) + + expect(config.clonePaths).toEqual({ 'AgentWorkforce/factory': await realpath(root) }) + }) + + it.each([ + 'https://github.com/AgentWorkforce/factory.git', + 'https://github.com/agentworkforce/FACTORY/', + 'git@github.com:AgentWorkforce/factory.git', + 'ssh://git@github.com/AgentWorkforce/factory.git', + 'git://github.com/AgentWorkforce/factory.git', + ])('accepts a matching GitHub remote URL: %s', async (remote) => { + const root = await gitRepo(remote) + + const config = await resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: root }) + + expect(config.clonePaths['AgentWorkforce/factory']).toBe(await realpath(root)) + }) + + it('rejects cwd inference when cwd is not a git checkout', async () => { + const root = await tempDir('factory-not-git-') + + await expect(resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: root })).rejects.toThrow( + `cwd is not inside a git checkout: ${resolve(root)}; configure cloneRoot or clonePaths explicitly`, + ) + }) + + it('rejects cwd inference when the checkout has no remote', async () => { + const root = await gitRepo() + + await expect(resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: root })).rejects.toThrow('has no remotes; add a matching GitHub remote') + }) + + it('rejects cwd inference when no GitHub remote matches the resolved repo', async () => { + const root = await gitRepo('https://github.com/OtherOrg/other.git') + + await expect(resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: root })).rejects.toThrow( + 'cannot infer clonePath for AgentWorkforce/factory: found GitHub remote for OtherOrg/other', + ) + }) + + it('rejects cwd inference when remotes are not parseable GitHub repositories', async () => { + const root = await gitRepo('https://gitlab.com/AgentWorkforce/factory.git') + + await expect(resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: root })).rejects.toThrow('found no parseable GitHub remote URLs') + }) + + it.each([ + { + name: 'repos.names is absent', + input: { repos: { byLabel: { factory: 'AgentWorkforce/factory' } } }, + }, + { + name: 'repos.names has multiple entries', + input: { repos: { org: 'AgentWorkforce', names: ['factory', 'cloud'] } }, + }, + { + name: 'top-level cloneRoot is supplied', + input: { cloneRoot: '/work', repos: { org: 'AgentWorkforce', names: ['factory'] } }, + }, + { + name: 'top-level clonePaths is supplied', + input: { clonePaths: {}, repos: { org: 'AgentWorkforce', names: ['factory'] } }, + }, + { + name: 'legacy cloneRoot is supplied', + input: { repos: { org: 'AgentWorkforce', names: ['factory'], cloneRoot: '/work' } }, + }, + { + name: 'legacy clonePaths is supplied', + input: { repos: { org: 'AgentWorkforce', names: ['factory'], clonePaths: {} } }, + }, + ])('does not inspect cwd when $name', async ({ input }) => { + const git = vi.fn(async () => { + throw new Error('git must not run') + }) + + await resolveLocalFactoryConfig(input, { cwd: '/not/a/repo', git }) + + expect(git).not.toHaveBeenCalled() + }) + + it('does not infer when environment-dependent inference is disabled', async () => { + const git = vi.fn(async () => { + throw new Error('git must not run') + }) + + const config = await resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: '/not/a/repo', git, inferFromCwd: false }) + + expect(config.clonePaths).toEqual({}) + expect(git).not.toHaveBeenCalled() + }) + + it('does not infer from a split config when node-local path config is supplied', async () => { + const git = vi.fn(async () => { + throw new Error('git must not run') + }) + + const config = await resolveLocalFactoryConfig({ + workspaceConfig: { + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, + nodeConfig: { clonePaths: {} }, + }, { cwd: '/not/a/repo', git }) + + expect(config.clonePaths).toEqual({}) + expect(git).not.toHaveBeenCalled() + }) + + it('validates configured paths through git and accepts linked worktrees', async () => { + const source = await gitRepo('https://github.com/AgentWorkforce/factory.git', true) + const worktree = await tempDir('factory-worktree-') + await rm(worktree, { recursive: true, force: true }) + await git(source, ['worktree', 'add', '-b', 'test-worktree', worktree]) + + const inferred = await resolveLocalFactoryConfig({ + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { cwd: worktree }) + expect(inferred.clonePaths['AgentWorkforce/factory']).toBe(await realpath(worktree)) + + const config = await resolveLocalFactoryConfig({ + clonePaths: { 'AgentWorkforce/factory': worktree }, + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, { validateConfiguredCheckouts: true }) + + expect(config.clonePaths['AgentWorkforce/factory']).toBe(worktree) + }) + + it('fails fast for a configured path that is not a checkout root', async () => { + const root = await gitRepo('https://github.com/AgentWorkforce/factory.git') + const nested = join(root, 'nested') + await mkdir(nested) + + await expect(resolveLocalFactoryConfig({ + clonePaths: { 'AgentWorkforce/factory': nested }, + repos: { byLabel: { factory: 'AgentWorkforce/factory' } }, + }, { validateConfiguredCheckouts: true })).rejects.toThrow('points inside a git checkout') + }) + + it('fails fast for missing and non-git configured paths', async () => { + const root = await tempDir('factory-invalid-checkout-') + + await expect(resolveLocalFactoryConfig({ + clonePaths: { 'AgentWorkforce/factory': join(root, 'missing') }, + repos: {}, + }, { validateConfiguredCheckouts: true })).rejects.toThrow('does not exist') + + await expect(resolveLocalFactoryConfig({ + clonePaths: { 'AgentWorkforce/factory': root }, + repos: {}, + }, { validateConfiguredCheckouts: true })).rejects.toThrow('is not a git checkout') + }) +}) + +describe('githubRepoFromRemoteUrl', () => { + it.each([ + ['https://github.com/AgentWorkforce/factory.git', 'AgentWorkforce/factory'], + ['git@github.com:AgentWorkforce/factory.git', 'AgentWorkforce/factory'], + ['ssh://git@github.com/AgentWorkforce/factory/', 'AgentWorkforce/factory'], + ['github.com/AgentWorkforce/factory', 'AgentWorkforce/factory'], + ])('normalizes %s', (remote, expected) => { + expect(githubRepoFromRemoteUrl(remote)).toBe(expected) + }) + + it.each([ + 'https://gitlab.com/AgentWorkforce/factory.git', + 'git@github.com:AgentWorkforce/factory/extra.git', + 'not-a-remote', + ])('rejects %s', (remote) => { + expect(githubRepoFromRemoteUrl(remote)).toBeUndefined() + }) +}) + +async function gitRepo(remote?: string, commit = false): Promise { + const root = await tempDir('factory-git-') + await git(root, ['init']) + if (remote) await git(root, ['remote', 'add', 'origin', remote]) + if (commit) { + await git(root, [ + '-c', 'user.name=Factory Test', + '-c', 'user.email=factory@example.com', + 'commit', '--allow-empty', '-m', 'initial', + ]) + } + return root +} + +async function tempDir(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + tempRoots.push(root) + return root +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync('git', ['-C', cwd, ...args]) +} diff --git a/src/config/local-clone-paths.ts b/src/config/local-clone-paths.ts new file mode 100644 index 0000000..3723e12 --- /dev/null +++ b/src/config/local-clone-paths.ts @@ -0,0 +1,227 @@ +import { execFile } from 'node:child_process' +import { realpath, stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { promisify } from 'node:util' + +import { loadFactoryConfig, type FactoryConfig } from './schema' + +const execFileAsync = promisify(execFile) + +export type GitRunner = (cwd: string, args: string[]) => Promise + +export interface LocalClonePathOptions { + cwd?: string + git?: GitRunner + inferFromCwd?: boolean + logger?: { info?(message: string): void } + validateConfiguredCheckouts?: boolean +} + +/** + * Parse a factory config, then apply the environment-dependent conveniences + * that are appropriate only for a local CLI run. + */ +export async function resolveLocalFactoryConfig( + input: unknown, + options: LocalClonePathOptions = {}, +): Promise { + const config = loadFactoryConfig(input).factoryConfig + const git = options.git ?? runGit + let resolved = config + + if (options.inferFromCwd !== false) { + const repoName = cwdInferenceRepoName(input) + if (repoName !== undefined) { + const repo = config.repos.byLabel[repoName] + if (!repo || !isQualifiedRepo(repo)) { + throw new Error( + `[factory] cannot infer clonePath from cwd: resolved route for repos.names[0] must be owner/name (received ${JSON.stringify(repo ?? repoName)})`, + ) + } + + const checkoutRoot = await gitCheckoutRoot(options.cwd ?? process.cwd(), git, repo) + await assertMatchingGithubRemote(checkoutRoot, repo, git) + const clonePaths = { ...config.clonePaths, [repo]: checkoutRoot } + resolved = { + ...config, + clonePaths, + repos: { ...config.repos, clonePaths }, + } + options.logger?.info?.(`[factory] clonePath inferred from cwd: ${checkoutRoot}`) + } + } + + if (options.validateConfiguredCheckouts) { + await validateClonePaths(resolved.clonePaths, git) + } + + return resolved +} + +async function gitCheckoutRoot(cwd: string, git: GitRunner, repo: string): Promise { + let root: string + try { + root = (await git(cwd, ['rev-parse', '--show-toplevel'])).trim() + } catch { + throw new Error( + `[factory] cannot infer clonePath for ${repo}: cwd is not inside a git checkout: ${resolve(cwd)}; configure cloneRoot or clonePaths explicitly`, + ) + } + if (!root) { + throw new Error( + `[factory] cannot infer clonePath for ${repo}: git returned no checkout root for cwd ${resolve(cwd)}; configure cloneRoot or clonePaths explicitly`, + ) + } + return resolve(root) +} + +async function assertMatchingGithubRemote(checkoutRoot: string, expectedRepo: string, git: GitRunner): Promise { + const remoteNames = (await git(checkoutRoot, ['remote'])) + .split(/\r?\n/u) + .map((name) => name.trim()) + .filter(Boolean) + + if (remoteNames.length === 0) { + throw new Error( + `[factory] cannot infer clonePath for ${expectedRepo}: git checkout ${checkoutRoot} has no remotes; add a matching GitHub remote or configure cloneRoot/clonePaths explicitly`, + ) + } + + const remoteUrls: string[] = [] + for (const remote of remoteNames) { + try { + remoteUrls.push(...(await git(checkoutRoot, ['remote', 'get-url', '--all', remote])) + .split(/\r?\n/u) + .map((url) => url.trim()) + .filter(Boolean)) + } catch { + // A concurrently removed/broken remote is treated like an unparseable + // remote below, yielding one actionable config error. + } + } + + const parsedRepos = remoteUrls + .map(githubRepoFromRemoteUrl) + .filter((repo): repo is string => repo !== undefined) + if (parsedRepos.some((repo) => repo.toLowerCase() === expectedRepo.toLowerCase())) return + + const found = [...new Set(parsedRepos)] + const detail = found.length > 0 + ? `found GitHub remote${found.length === 1 ? '' : 's'} for ${found.join(', ')}` + : 'found no parseable GitHub remote URLs' + throw new Error( + `[factory] cannot infer clonePath for ${expectedRepo}: ${detail} in ${checkoutRoot}; add a matching remote or configure cloneRoot/clonePaths explicitly`, + ) +} + +export function githubRepoFromRemoteUrl(remoteUrl: string): string | undefined { + const value = remoteUrl.trim().replace(/^git\+/u, '') + let host: string + let pathname: string + + try { + const url = new URL(value) + host = url.hostname + pathname = url.pathname + } catch { + const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/u.exec(value) + if (scp) { + host = scp[1] + pathname = scp[2] + } else { + const hostPath = /^([^/\s]+)\/(.+)$/u.exec(value) + if (!hostPath) return undefined + host = hostPath[1] + pathname = hostPath[2] + } + } + + if (!['github.com', 'www.github.com'].includes(host.toLowerCase())) return undefined + const parts = pathname + .replace(/^\/+|\/+$/gu, '') + .replace(/\.git$/iu, '') + .split('/') + .filter(Boolean) + if (parts.length !== 2) return undefined + return `${parts[0]}/${parts[1]}` +} + +async function validateClonePaths(clonePaths: Record, git: GitRunner): Promise { + for (const [repo, clonePath] of Object.entries(clonePaths)) { + const path = resolve(clonePath) + let pathStat + try { + pathStat = await stat(path) + } catch { + throw new Error(`[factory] clonePath for ${repo} does not exist: ${path}`) + } + if (!pathStat.isDirectory()) { + throw new Error(`[factory] clonePath for ${repo} is not a directory: ${path}`) + } + + let checkoutRoot: string + try { + checkoutRoot = (await git(path, ['rev-parse', '--show-toplevel'])).trim() + } catch { + throw new Error(`[factory] clonePath for ${repo} is not a git checkout: ${path}`) + } + if (!checkoutRoot) { + throw new Error(`[factory] clonePath for ${repo} is not a git checkout: ${path}`) + } + + const [realPath, realRoot] = await Promise.all([realpath(path), realpath(resolve(checkoutRoot))]) + if (realPath !== realRoot) { + throw new Error( + `[factory] clonePath for ${repo} points inside a git checkout (${path}); configure the checkout root ${resolve(checkoutRoot)}`, + ) + } + } +} + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }) + return stdout +} + +function cwdInferenceRepoName(input: unknown): string | undefined { + const inputRecord = record(input) + const factoryInput = record(inputRecord.factoryConfig ?? input) + if (hasOwn(factoryInput, 'workspaceConfig') || hasOwn(factoryInput, 'nodeConfig')) { + const workspace = record(factoryInput.workspaceConfig) + const node = record(factoryInput.nodeConfig) + const repos = record(workspace.repos) + if (hasClonePathConfig(workspace) || hasClonePathConfig(node) || hasClonePathConfig(repos)) return undefined + return singleRepoName(repos.names) + } + + const repos = record(factoryInput.repos) + if (hasClonePathConfig(factoryInput) || hasClonePathConfig(repos)) return undefined + return singleRepoName(repos.names) +} + +function hasClonePathConfig(value: Record): boolean { + return hasOwn(value, 'cloneRoot') || hasOwn(value, 'clonePaths') +} + +function singleRepoName(value: unknown): string | undefined { + return Array.isArray(value) && value.length === 1 && typeof value[0] === 'string' + ? value[0] + : undefined +} + +function isQualifiedRepo(repo: string): boolean { + return /^[^/\s]+\/[^/\s]+$/u.test(repo) +} + +function record(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key) +} diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4592fe8..9e9a4b6 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import { homedir } from 'node:os' +import { join } from 'node:path' -import { FactoryConfigSchema } from './schema' +import { FactoryConfigSchema, NodeConfigSchema, loadFactoryConfig } from './schema' describe('FactoryConfigSchema', () => { it('parses a valid config and applies defaults', () => { @@ -185,6 +187,111 @@ describe('FactoryConfigSchema', () => { expect(parsed.subscription.labels).toEqual(['pear']) }) + it('expands exact ~ and ~/ in cloneRoot and explicit clonePaths while preserving precedence', () => { + const parsed = FactoryConfigSchema.parse({ + cloneRoot: '~/top-level', + clonePaths: { 'AgentWorkforce/pear': '~/top-level-explicit' }, + repos: { + org: 'AgentWorkforce', + names: ['pear', 'cloud'], + cloneRoot: '~/legacy-root', + clonePaths: { + 'AgentWorkforce/pear': '~/legacy-explicit', + 'AgentWorkforce/cloud': '~', + }, + }, + }) + + expect(parsed.cloneRoot).toBe(join(homedir(), 'top-level')) + expect(parsed.clonePaths).toEqual({ + 'AgentWorkforce/pear': join(homedir(), 'top-level-explicit'), + 'AgentWorkforce/cloud': homedir(), + }) + expect(parsed.repos.clonePaths).toEqual(parsed.clonePaths) + }) + + it('derives clone paths from an exact ~ legacy cloneRoot', () => { + const parsed = FactoryConfigSchema.parse({ + repos: { + org: 'AgentWorkforce', + names: ['factory'], + cloneRoot: '~', + }, + }) + + expect(parsed.cloneRoot).toBe(homedir()) + expect(parsed.clonePaths).toEqual({ + 'AgentWorkforce/factory': join(homedir(), 'factory'), + }) + }) + + it('expands node-only and split clone paths consistently', () => { + expect(NodeConfigSchema.parse({ + cloneRoot: '~', + clonePaths: { 'AgentWorkforce/factory': '~/Projects/factory' }, + })).toMatchObject({ + cloneRoot: homedir(), + clonePaths: { 'AgentWorkforce/factory': join(homedir(), 'Projects/factory') }, + }) + + const loaded = loadFactoryConfig({ + workspaceConfig: { + repos: { org: 'AgentWorkforce', names: ['factory'] }, + }, + nodeConfig: { + cloneRoot: '~/Projects/AgentWorkforce', + clonePaths: { 'AgentWorkforce/factory': '~' }, + }, + }) + expect(loaded.factoryConfig.cloneRoot).toBe(join(homedir(), 'Projects/AgentWorkforce')) + expect(loaded.factoryConfig.clonePaths).toEqual({ 'AgentWorkforce/factory': homedir() }) + expect(loaded.factoryConfig.repos.clonePaths).toEqual(loaded.factoryConfig.clonePaths) + expect(loaded.nodeConfig.cloneRoot).toBe(join(homedir(), 'Projects/AgentWorkforce')) + expect(loaded.nodeConfig.clonePaths).toEqual(loaded.factoryConfig.clonePaths) + }) + + it('does not rewrite embedded tildes', () => { + const parsed = FactoryConfigSchema.parse({ + cloneRoot: '/work/~shared', + repos: { + byLabel: { pear: 'AgentWorkforce/pear' }, + clonePaths: { 'AgentWorkforce/pear': '/work/~shared/pear' }, + }, + }) + + expect(parsed.cloneRoot).toBe('/work/~shared') + expect(parsed.clonePaths['AgentWorkforce/pear']).toBe('/work/~shared/pear') + }) + + it.each([ + { input: { cloneRoot: '~other', repos: {} }, field: 'cloneRoot' }, + { input: { repos: { cloneRoot: '~other/projects' } }, field: 'cloneRoot' }, + { + input: { repos: { clonePaths: { 'AgentWorkforce/pear': '~other/pear' } } }, + field: 'repos.clonePaths["AgentWorkforce/pear"]', + }, + ])('rejects unsupported ~user syntax in $field', ({ input, field }) => { + expect(() => FactoryConfigSchema.parse(input)).toThrow(`${field} does not support ~user expansion`) + }) + + it('rejects ~user syntax even when a higher-precedence value overrides it', () => { + expect(() => FactoryConfigSchema.parse({ + cloneRoot: '/top-level', + clonePaths: { 'AgentWorkforce/pear': '/top-level/pear' }, + repos: { + cloneRoot: '~other/legacy', + clonePaths: { 'AgentWorkforce/pear': '~other/pear' }, + }, + })).toThrow('repos.cloneRoot does not support ~user expansion') + + expect(() => loadFactoryConfig({ + workspaceConfig: { + repos: { cloneRoot: '~other/legacy' }, + }, + nodeConfig: { cloneRoot: '/node-root' }, + })).toThrow('workspaceConfig.repos.cloneRoot does not support ~user expansion') + }) + it('still accepts the legacy explicit-only repos form', () => { const parsed = FactoryConfigSchema.parse({ repos: { diff --git a/src/config/schema.ts b/src/config/schema.ts index 9af82e9..9f20f92 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,3 +1,6 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + import { z } from 'zod' // The five workflow-state roles the factory drives an issue through. Each is @@ -219,18 +222,30 @@ function normalizeWorkspaceConfig(cfg: z.infer) { + const cloneRoot = cfg.cloneRoot === undefined + ? undefined + : expandLeadingTilde(cfg.cloneRoot, 'cloneRoot') + return { ...cfg, + cloneRoot, + clonePaths: expandClonePaths(cfg.clonePaths), factoryLoopHeartbeatPath: cfg.factoryLoopHeartbeatPath, factoryLoopRegistryPath: cfg.factoryLoopRegistryPath, } } function normalizeFactoryConfig(cfg: z.infer) { - const cloneRoot = cfg.cloneRoot ?? cfg.repos.cloneRoot + const topLevelCloneRoot = cfg.cloneRoot === undefined + ? undefined + : expandLeadingTilde(cfg.cloneRoot, 'cloneRoot') + const legacyCloneRoot = cfg.repos.cloneRoot === undefined + ? undefined + : expandLeadingTilde(cfg.repos.cloneRoot, 'repos.cloneRoot') + const cloneRoot = topLevelCloneRoot ?? legacyCloneRoot const explicitClonePaths = { - ...cfg.repos.clonePaths, - ...cfg.clonePaths, + ...expandClonePaths(cfg.repos.clonePaths, 'repos.clonePaths'), + ...expandClonePaths(cfg.clonePaths), } const resolved = resolveRepos(cfg.repos, cloneRoot, explicitClonePaths) const labels = resolveSubscriptionLabels(cfg.subscription.labels, cfg.repos.names ?? []) @@ -281,13 +296,16 @@ function resolveRepos( // Explicit clonePaths entries win. const derivedClonePaths: Record = {} if (cloneRoot) { - const root = cloneRoot.replace(/\/+$/u, '') + const root = expandLeadingTilde(cloneRoot, 'cloneRoot').replace(/\/+$/u, '') for (const repo of Object.values(resolvedByLabel)) { const repoName = repo.includes('/') ? repo.slice(repo.lastIndexOf('/') + 1) : repo derivedClonePaths[repo] = `${root}/${repoName}` } } - const resolvedClonePaths = { ...derivedClonePaths, ...explicitClonePaths } + const resolvedClonePaths = { + ...derivedClonePaths, + ...expandClonePaths(explicitClonePaths), + } return { byLabel: resolvedByLabel, @@ -298,6 +316,26 @@ function resolveRepos( } } +function expandClonePaths( + clonePaths: Record, + field = 'clonePaths', +): Record { + return Object.fromEntries(Object.entries(clonePaths).map(([repo, clonePath]) => [ + repo, + expandLeadingTilde(clonePath, `${field}[${JSON.stringify(repo)}]`), + ])) +} + +/** Expand the shell-like home shorthand that Node's filesystem APIs do not. */ +export function expandLeadingTilde(value: string, field = 'path'): string { + if (value === '~') return homedir() + if (value.startsWith('~/')) return join(homedir(), value.slice(2)) + if (value.startsWith('~')) { + throw new Error(`${field} does not support ~user expansion; use ~ or ~/ instead`) + } + return value +} + function resolveSubscriptionLabels(labels: string[], repoNames: string[]): string[] { return labels.length > 0 ? labels : repoNames } @@ -348,6 +386,11 @@ function combineSplitConfigInput(workspaceInput: unknown, nodeInput: unknown): R const workspaceRepos = asOptionalConfigRecord(workspace.repos) assertCompatibleWorkspaceIds(workspace.workspaceId, node.workspaceId) + // Validate tilde syntax in both split halves before node-local values take + // precedence, so an overridden ~user path is never silently accepted. + validateClonePathSyntax(workspaceRepos, 'workspaceConfig.repos') + validateClonePathSyntax(node, 'nodeConfig') + return { ...workspace, ...node, @@ -359,6 +402,16 @@ function combineSplitConfigInput(workspaceInput: unknown, nodeInput: unknown): R } } +function validateClonePathSyntax(input: Record, field: string): void { + if (typeof input.cloneRoot === 'string') expandLeadingTilde(input.cloneRoot, `${field}.cloneRoot`) + const clonePaths = asOptionalConfigRecord(input.clonePaths) + for (const [repo, clonePath] of Object.entries(clonePaths)) { + if (typeof clonePath === 'string') { + expandLeadingTilde(clonePath, `${field}.clonePaths[${JSON.stringify(repo)}]`) + } + } +} + function assertCompatibleWorkspaceIds(workspaceId: unknown, nodeWorkspaceId: unknown): void { if ( typeof workspaceId === 'string' &&