From 48a48ab1a68d3f3fa11d5f7741d63ad9dcd8611b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Tue, 21 Jul 2026 15:59:56 -0400 Subject: [PATCH 1/2] Add factory init command --- README.md | 16 +++++ src/cli/fleet.test.ts | 8 +++ src/cli/fleet.ts | 28 ++++++++- src/cli/init.test.ts | 100 +++++++++++++++++++++++++++++++ src/cli/init.ts | 133 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/cli/init.test.ts create mode 100644 src/cli/init.ts diff --git a/README.md b/README.md index aa8f397..77418d8 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,21 @@ From a source checkout instead of an npm install, run ## Quick start +From the repository checkout you want Factory to work in, the quickest setup is: + +```bash +factory init +``` + +It derives the repository from `origin`, checks for `agent-relay` and +`relayfile`, finds the active Relay workspace, starts the local mount, verifies +GitHub access, and creates `factory.config.json` for GitHub-native issues. If +something is missing, it explains what to do and writes no partial config. Use +`factory init owner/repo` when the checkout has no GitHub `origin`, or add +`--workspace ` to choose a workspace explicitly. + +After init, add the `factory` label to an open issue and run a dry run below. + 1. **Connect GitHub to your relay workspace** with push access for the target repositories. Factory uses that workspace connection to publish branches and open pull requests by default. A local `gh` installation and `gh auth login` @@ -116,6 +131,7 @@ From a source checkout instead of an npm install, run | Command | What it does | |---|---| +| `factory init [owner/repo]` | Verify local Relay prerequisites and GitHub access, then configure the current checkout for GitHub-native issue dispatch. | | `factory run-once` | One discover→triage→dispatch cycle, then exit. Honors `--dry-run`. | | `factory loop` | A bounded multi-iteration loop, then exit. | | `factory start --mode live` | Long-lived daemon — the production entrypoint. Runs until you stop it. | diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index 9526ab6..9a7caeb 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -367,6 +367,14 @@ describe('fleet CLI parsing', () => { }) }) + it('parses init with an explicit repository and workspace', () => { + expect(parseFleetCommand(['init', 'Acme/widgets', '--workspace', 'rw_widgets'])).toEqual({ + kind: 'factory-init', + repo: 'Acme/widgets', + workspaceId: 'rw_widgets', + }) + }) + it('defaults factory start to live mode', () => { expect(parseFleetCommand(['start'])).toEqual({ kind: 'factory', diff --git a/src/cli/fleet.ts b/src/cli/fleet.ts index dec7556..fc150b1 100644 --- a/src/cli/fleet.ts +++ b/src/cli/fleet.ts @@ -7,6 +7,7 @@ import { ensureCloudSession, type CloudSession } from '@agent-relay/cloud' import { stringifyLogValue } from '../logging' import { resolveLocalFactoryConfig, type LocalClonePathOptions } from '../config/local-clone-paths' +import { initializeFactory } from './init' import { FileStateStore, RelayfileCloudMountClient, @@ -134,6 +135,7 @@ type ParsedCommand = | { kind: 'factory-babysit'; prNumber: number; repo?: string; url?: string } | { kind: 'factory-close-probe'; prNumber: number; repo: string; issue: string } | { kind: 'featuremap-check'; manifestPath?: string; baseRef?: string } + | { kind: 'factory-init'; repo?: string; workspaceId?: string } export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Promise { const out = deps.stdout ?? process.stdout @@ -163,6 +165,11 @@ export async function runFleetCli(argv: string[], deps: FleetCliDeps = {}): Prom return 0 } + if (command.kind === 'factory-init') { + await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err }) + return 0 + } + if (command.kind === 'factory-close-probe') { // Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification. let githubWrite @@ -948,6 +955,22 @@ function resolveLocalMountFn( function parseFactoryCommand(args: string[]): ParsedCommand { const [action, issueOrPr, ...flags] = args + if (action === 'init') { + const values = [issueOrPr, ...flags].filter((value): value is string => Boolean(value)) + let repo: string | undefined + let workspaceId: string | undefined + for (let index = 0; index < values.length; index += 1) { + const value = values[index] + if (value === '--workspace') { + workspaceId = requireValue(values, ++index, '--workspace') + continue + } + if (value.startsWith('-')) throw new Error(`Unknown factory init option: ${value}`) + if (repo) throw new Error('factory init accepts at most one owner/repo argument') + repo = value + } + return { kind: 'factory-init', repo, workspaceId } + } if (action === 'start') { return { kind: 'factory', action, ...parseFactoryStartFlags([issueOrPr, ...flags]) } } @@ -1707,7 +1730,8 @@ function isCapability(value: string | undefined): value is Capability { } function isFactoryAction(value: string): boolean { - return value === 'start' || + return value === 'init' || + value === 'start' || value === 'run-once' || value === 'loop' || value === 'status' || @@ -1783,6 +1807,7 @@ function helpText(): string { return `${usage()} Commands: + init [owner/repo] Set up this checkout for GitHub-native issue dispatch run-once Run one discovery -> triage -> dispatch cycle start Run the live factory daemon status Print current factory status as JSON @@ -1799,6 +1824,7 @@ Commands: fleet Low-level fleet commands: spawn, roster, release Options: + --workspace Relay workspace to use with init (otherwise active workspace) --config Factory config JSON path (default: ./factory.config.json) --dry-run Discover and triage without writes or agent spawns --backend Fleet backend: internal or relay diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts new file mode 100644 index 0000000..4d50a3a --- /dev/null +++ b/src/cli/init.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { FakeMountClient } from '../testing' +import { initializeFactory } from './init' + +describe('factory init', () => { + it('creates a GitHub-native config for the repository in the checkout', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'factory-init-')) + try { + const output = buffer() + const result = await initializeFactory({ + cwd, + stdout: output, + stderr: buffer(), + gitRemote: async () => 'git@github.com:Acme/widgets.git', + commandExists: async () => true, + resolveWorkspace: async () => ({ workspaceId: 'rw_widgets', cloudWorkspaceId: 'workspace-uuid' }), + ensureLocalMount: async () => {}, + cloudMountFromConfig: async () => new FakeMountClient(), + }) + + expect(result).toEqual({ configPath: join(cwd, 'factory.config.json'), repo: 'Acme/widgets', workspaceId: 'rw_widgets' }) + expect(JSON.parse(await readFile(result.configPath, 'utf8'))).toEqual({ + workspaceId: 'rw_widgets', + issueSource: 'github', + repos: { + org: 'Acme', + names: ['widgets'], + default: 'Acme/widgets', + clonePaths: { 'Acme/widgets': cwd }, + }, + }) + expect(output.text()).toContain('Add the `factory` label') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('does not write a config when a dependency or GitHub connection is missing', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'factory-init-missing-')) + try { + await expect(initializeFactory({ + cwd, + repo: 'Acme/widgets', + commandExists: async (command) => command === 'relayfile', + stderr: buffer(), + })).rejects.toThrow('Missing required dependency') + + await expect(readFile(join(cwd, 'factory.config.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + + await expect(initializeFactory({ + cwd, + repo: 'Acme/widgets', + commandExists: async () => true, + resolveWorkspace: async () => ({ workspaceId: 'rw_widgets' }), + ensureLocalMount: async () => {}, + cloudMountFromConfig: async () => { + const mount = new FakeMountClient() + mount.setSubRoot('/github/repos/Acme/widgets', 'absent') + return mount + }, + stderr: buffer(), + })).rejects.toThrow('GitHub connection') + await expect(readFile(join(cwd, 'factory.config.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('will not replace an existing config', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'factory-init-existing-')) + try { + const path = join(cwd, 'factory.config.json') + await writeFile(path, '{"keep":true}\n') + await expect(initializeFactory({ + cwd, + repo: 'Acme/widgets', + commandExists: async () => true, + resolveWorkspace: async () => ({ workspaceId: 'rw_widgets' }), + })).rejects.toThrow('Refusing to overwrite') + expect(await readFile(path, 'utf8')).toBe('{"keep":true}\n') + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) +}) + +function buffer(): Pick & { text: () => string } { + let contents = '' + return { + write: (chunk: string | Uint8Array) => { + contents += String(chunk) + return true + }, + text: () => contents, + } +} diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 0000000..cfe4371 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,133 @@ +import { access, writeFile } from 'node:fs/promises' +import { constants } from 'node:fs' +import { spawn } from 'node:child_process' +import { join, resolve } from 'node:path' + +import { + RelayfileCloudMountClient, + resolveFactoryWorkspace, + type MountClient, + type ResolvedFactoryWorkspace, +} from '../index' +import { ensureLocalMount } from '../mount/local-mount-preflight' + +export interface FactoryInitOptions { + cwd?: string + repo?: string + workspaceId?: string + stdout?: Pick + stderr?: Pick + commandExists?: (command: string) => Promise + gitRemote?: (cwd: string) => Promise + resolveWorkspace?: () => Promise + cloudMountFromConfig?: (config: { workspaceId: string }) => Promise + ensureLocalMount?: (workspaceId: string, cwd: string, options?: { acceptableWorkspaceIds?: readonly string[] }) => Promise +} + +export interface FactoryInitResult { + configPath: string + repo: string + workspaceId: string +} + +/** + * Make the current checkout dispatch GitHub-native issues. The checks happen + * before writing config so a successful init is immediately runnable. + */ +export async function initializeFactory(options: FactoryInitOptions = {}): Promise { + const cwd = resolve(options.cwd ?? process.cwd()) + const out = options.stdout ?? process.stdout + const err = options.stderr ?? process.stderr + const repo = validateRepo(options.repo ?? repoFromRemote(await (options.gitRemote ?? originRemote)(cwd))) + const commandExists = options.commandExists ?? executableOnPath + + const missing = (await Promise.all(['agent-relay', 'relayfile'].map(async (command) => ( + await commandExists(command) ? undefined : command + )))).filter((command): command is string => command !== undefined) + if (missing.length > 0) { + err.write('Factory needs a couple of local tools before it can run:\n') + for (const command of missing) { + err.write(` - ${command} is not installed or is not on PATH\n`) + } + err.write('Install and sign in to those tools, then run `factory init` again. No files were changed.\n') + throw new Error(`Missing required dependency${missing.length === 1 ? '' : 'ies'}: ${missing.join(', ')}`) + } + + const workspace = options.workspaceId + ? { workspaceId: options.workspaceId } + : await (options.resolveWorkspace ?? resolveFactoryWorkspace)() + const workspaceId = workspace.workspaceId + if (!workspaceId) throw new Error('Could not find an active Relay workspace. Sign in to relayfile, or run `factory init --workspace `.') + + const configPath = join(cwd, 'factory.config.json') + await assertAbsent(configPath) + + out.write(`Checking ${repo} in Relay workspace ${workspaceId}…\n`) + await (options.ensureLocalMount ?? ensureLocalMount)(workspaceId, cwd, { + acceptableWorkspaceIds: 'cloudWorkspaceId' in workspace && workspace.cloudWorkspaceId ? [workspace.cloudWorkspaceId] : undefined, + }) + const mount = await (options.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({ workspaceId }) + const githubStatus = await mount.ensureSubRoot(`/github/repos/${repo}`, { timeoutMs: 90_000 }) + if (githubStatus !== 'ready') { + err.write(`GitHub repository ${repo} is not connected to this Relay workspace.\n`) + err.write('Connect GitHub with push access for this repository in Agent Relay, wait for the repository to sync, then run `factory init` again. No config was written.\n') + throw new Error(`GitHub connection for ${repo} is not ready (${githubStatus})`) + } + + const [owner, name] = repo.split('/') as [string, string] + const config = { + workspaceId, + issueSource: 'github', + repos: { + org: owner, + names: [name], + default: repo, + clonePaths: { [repo]: cwd }, + }, + } + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }) + out.write(`Factory is ready for ${repo}. Created ${configPath}.\n`) + out.write('Add the `factory` label to an open GitHub issue, then run `factory run-once --dry-run`.\n') + return { configPath, repo, workspaceId } +} + +function validateRepo(value: string | undefined): string { + if (!value || !/^[^/\s]+\/[^/\s]+$/u.test(value)) { + throw new Error('Could not determine the GitHub repository. Run `factory init owner/repo` from a Git checkout, or pass an explicit owner/repo.') + } + return value +} + +function repoFromRemote(remote: string | undefined): string | undefined { + if (!remote) return undefined + const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/u.exec(remote) + const https = /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/u.exec(remote) + return ssh?.[1] ?? https?.[1] +} + +async function assertAbsent(path: string): Promise { + try { + await access(path, constants.F_OK) + throw new Error(`Refusing to overwrite existing ${path}. Edit it manually or move it aside before running factory init.`) + } catch (error) { + if (error instanceof Error && error.message.startsWith('Refusing')) throw error + } +} + +function originRemote(cwd: string): Promise { + return new Promise((resolveRemote) => { + const child = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }) + const chunks: Buffer[] = [] + child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk)) + child.on('close', (code) => resolveRemote(code === 0 ? Buffer.concat(chunks).toString('utf8').trim() || undefined : undefined)) + child.on('error', () => resolveRemote(undefined)) + }) +} + +function executableOnPath(command: string): Promise { + return new Promise((resolveExists) => { + const child = spawn(command, ['--version'], { stdio: 'ignore' }) + child.on('close', (code) => resolveExists(code === 0)) + child.on('error', () => resolveExists(false)) + }) +} From 03034a06a25ffa1f31f715abfa43ca6604069aa2 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 22 Jul 2026 10:06:51 -0400 Subject: [PATCH 2/2] Fix factory init mount setup --- src/cli/init.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cli/init.ts b/src/cli/init.ts index cfe4371..3ad55b8 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -9,7 +9,10 @@ import { type MountClient, type ResolvedFactoryWorkspace, } from '../index' -import { ensureLocalMount } from '../mount/local-mount-preflight' + +type FactoryInitMount = MountClient & { + ensureLocalMount?: (startDir: string, options?: { acceptableWorkspaceIds?: readonly string[] }) => Promise +} export interface FactoryInitOptions { cwd?: string @@ -20,7 +23,7 @@ export interface FactoryInitOptions { commandExists?: (command: string) => Promise gitRemote?: (cwd: string) => Promise resolveWorkspace?: () => Promise - cloudMountFromConfig?: (config: { workspaceId: string }) => Promise + cloudMountFromConfig?: (config: { workspaceId: string }) => Promise ensureLocalMount?: (workspaceId: string, cwd: string, options?: { acceptableWorkspaceIds?: readonly string[] }) => Promise } @@ -63,10 +66,17 @@ export async function initializeFactory(options: FactoryInitOptions = {}): Promi await assertAbsent(configPath) out.write(`Checking ${repo} in Relay workspace ${workspaceId}…\n`) - await (options.ensureLocalMount ?? ensureLocalMount)(workspaceId, cwd, { - acceptableWorkspaceIds: 'cloudWorkspaceId' in workspace && workspace.cloudWorkspaceId ? [workspace.cloudWorkspaceId] : undefined, - }) const mount = await (options.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({ workspaceId }) + const mountOptions = { + acceptableWorkspaceIds: 'cloudWorkspaceId' in workspace && workspace.cloudWorkspaceId ? [workspace.cloudWorkspaceId] : undefined, + } + if (options.ensureLocalMount) { + await options.ensureLocalMount(workspaceId, cwd, mountOptions) + } else if (mount.ensureLocalMount) { + await mount.ensureLocalMount(cwd, mountOptions) + } else { + throw new Error('The connected Relay workspace cannot start a local mount. Update @agent-relay/factory and run `factory init` again.') + } const githubStatus = await mount.ensureSubRoot(`/github/repos/${repo}`, { timeoutMs: 90_000 }) if (githubStatus !== 'ready') { err.write(`GitHub repository ${repo} is not connected to this Relay workspace.\n`)