-
Notifications
You must be signed in to change notification settings - Fork 75
feat(init): bridge to APM during init for cross-team distribution #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| import { execFile } from "child_process"; | ||
| import path from "path"; | ||
| import { promisify } from "util"; | ||
|
|
||
| import { analyzeRepo } from "@agentrc/core/services/analyzer"; | ||
| import type { | ||
|
|
@@ -19,11 +21,11 @@ import { buildAuthedUrl, cloneRepo, isGitRepo, setRemoteUrl } from "@agentrc/cor | |
| import type { GitHubRepo } from "@agentrc/core/services/github"; | ||
| import { getGitHubToken, listAccessibleRepos } from "@agentrc/core/services/github"; | ||
| import { generateCopilotInstructions } from "@agentrc/core/services/instructions"; | ||
| import { ensureDir, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; | ||
| import { ensureDir, fileExists, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; | ||
| import { prettyPrintSummary } from "@agentrc/core/utils/logger"; | ||
| import type { CommandResult } from "@agentrc/core/utils/output"; | ||
| import { outputResult, outputError, deriveFileStatus, shouldLog } from "@agentrc/core/utils/output"; | ||
| import { checkbox, select } from "@inquirer/prompts"; | ||
| import { checkbox, confirm, select } from "@inquirer/prompts"; | ||
|
|
||
| type InitOptions = { | ||
| github?: boolean; | ||
|
|
@@ -244,6 +246,9 @@ export async function initCommand( | |
| } | ||
| } | ||
|
|
||
| // ── APM bridge: offer to initialize APM for cross-team distribution ── | ||
| await offerApmInit(repoPath, options); | ||
|
|
||
| if (options.json) { | ||
| const { ok, status } = deriveFileStatus(allFiles); | ||
| const result: CommandResult<{ | ||
|
|
@@ -268,3 +273,57 @@ export async function initCommand( | |
| process.stderr.write(" agentrc eval --init Scaffold evaluation test cases\n"); | ||
| } | ||
| } | ||
|
|
||
| const execFileAsync = promisify(execFile); | ||
|
|
||
| const APM_TIMEOUT_MS = 10_000; | ||
|
|
||
| export async function isApmInstalled(): Promise<boolean> { | ||
| try { | ||
| await execFileAsync("apm", ["--version"], { timeout: APM_TIMEOUT_MS }); | ||
| return true; | ||
|
danielmeppiel marked this conversation as resolved.
|
||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export async function offerApmInit(repoPath: string, options: InitOptions): Promise<void> { | ||
| if (options.json) return; | ||
|
|
||
| const hasApmYml = await fileExists(path.join(repoPath, "apm.yml")); | ||
| if (hasApmYml) return; | ||
|
|
||
| const apmAvailable = await isApmInstalled(); | ||
|
|
||
| if (apmAvailable) { | ||
| const accepted = options.yes | ||
| ? true | ||
| : await confirm({ | ||
| message: "Set up APM to install and share agent packages across your team?", | ||
| default: false | ||
| }); | ||
| if (accepted) { | ||
|
Comment on lines
+298
to
+305
|
||
| try { | ||
| await execFileAsync("apm", ["init", "--yes"], { cwd: repoPath, timeout: APM_TIMEOUT_MS }); | ||
| if (shouldLog(options)) { | ||
| process.stderr.write( | ||
| "APM initialized — run `apm install <package>` to add shared agent packages.\n" | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| if (shouldLog(options)) { | ||
| process.stderr.write( | ||
| `Warning: APM init failed; continuing without APM: ${ | ||
| error instanceof Error ? error.message : String(error) | ||
| }\n` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } else if (shouldLog(options)) { | ||
| process.stderr.write( | ||
| "\nTip: use APM to install shared agent packages and distribute your instructions across repos.\n" + | ||
| " https://github.com/microsoft/apm\n" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { execFile } from "child_process"; | ||
| import fs from "fs/promises"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
|
|
||
| import { confirm } from "@inquirer/prompts"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { offerApmInit } from "../../commands/init"; | ||
|
|
||
| vi.mock("@inquirer/prompts", () => ({ | ||
| confirm: vi.fn(), | ||
| checkbox: vi.fn(), | ||
| select: vi.fn() | ||
| })); | ||
|
|
||
| vi.mock("child_process", () => ({ | ||
| execFile: vi.fn() | ||
| })); | ||
|
|
||
| const mockConfirm = vi.mocked(confirm); | ||
| const mockExecFile = vi.mocked(execFile); | ||
|
|
||
| describe("offerApmInit", () => { | ||
| let repoPath: string; | ||
| let stderrSpy: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(async () => { | ||
| repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-apm-init-")); | ||
| stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); | ||
| mockConfirm.mockReset(); | ||
| mockExecFile.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| stderrSpy.mockRestore(); | ||
| await fs.rm(repoPath, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function mockApmNotInstalled(): void { | ||
| mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { | ||
| const callback = typeof _opts === "function" ? _opts : cb; | ||
| if (callback) callback(new Error("not found"), "", ""); | ||
| return {} as ReturnType<typeof execFile>; | ||
| }); | ||
| } | ||
|
|
||
| function mockApmInstalled(): void { | ||
| mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { | ||
| const callback = typeof _opts === "function" ? _opts : cb; | ||
| if (callback) callback(null, "1.0.0", ""); | ||
| return {} as ReturnType<typeof execFile>; | ||
| }); | ||
| } | ||
|
|
||
| it("auto-initializes APM when --yes is set and apm is installed", async () => { | ||
| mockApmInstalled(); | ||
| await offerApmInit(repoPath, { yes: true }); | ||
| expect(mockConfirm).not.toHaveBeenCalled(); | ||
| expect(mockExecFile).toHaveBeenCalledTimes(2); // --version + init --yes | ||
| }); | ||
|
Comment on lines
+56
to
+61
|
||
|
|
||
| it("skips when --yes is set but apm is not installed", async () => { | ||
| mockApmNotInstalled(); | ||
| await offerApmInit(repoPath, { yes: true }); | ||
| expect(mockConfirm).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("skips when --json is set", async () => { | ||
| await offerApmInit(repoPath, { json: true }); | ||
| expect(mockExecFile).not.toHaveBeenCalled(); | ||
| expect(mockConfirm).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("skips when apm.yml already exists", async () => { | ||
| await fs.writeFile(path.join(repoPath, "apm.yml"), "name: test"); | ||
| await offerApmInit(repoPath, {}); | ||
| expect(mockExecFile).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("shows tip when apm is not installed and not quiet", async () => { | ||
| mockApmNotInstalled(); | ||
| await offerApmInit(repoPath, {}); | ||
| const output = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join(""); | ||
| expect(output).toContain("APM"); | ||
| expect(output).toContain("https://github.com/microsoft/apm"); | ||
| }); | ||
|
|
||
| it("suppresses tip when --quiet is set", async () => { | ||
| mockApmNotInstalled(); | ||
| await offerApmInit(repoPath, { quiet: true }); | ||
| const tipCalls = stderrSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes("APM")); | ||
| expect(tipCalls).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("prompts when apm is installed and no apm.yml", async () => { | ||
| mockApmInstalled(); | ||
| mockConfirm.mockResolvedValue(false); | ||
| await offerApmInit(repoPath, {}); | ||
| expect(mockConfirm).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.