Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-showers-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@multiverse-io/cari": patch
---

Gracefully handle user not entering any repo URLs during init
25 changes: 24 additions & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("init command", () => {
},
]);
await init();
expect(pathExists(`${homeDir}/.cari`)).resolves.toBe(true);
await expect(pathExists(`${homeDir}/.cari`)).resolves.toBe(true);
expect(gitMock.clone).toHaveBeenCalledWith(
repoUrl,
`${homeDir}/.cari/my-org/ai-rules`
Expand Down Expand Up @@ -183,4 +183,27 @@ describe("init command", () => {
"No rules were selected to include. Please select at least one rule to include with <space> or press Ctrl-c to exit."
);
});

it("should exit with an error when no repositories are provided", async () => {
mockDirs(populatedAriHomeDir, emptyProjectDir);

// Mock empty input to simulate no repos being added
inputMock.mockResolvedValueOnce("");

// Suppress console.error messages because this test will throw an (expected) error
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});

const mockExit = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`Process exited with code ${code}`);
});

// Expect the init function to throw the error from our mocked process.exit
await expect(init()).rejects.toThrow("Process exited with code 1");

expect(errorMessageMock).toHaveBeenCalledWith(
"No repositories were added. Please try again and enter at least one repository URL."
);
});
});
6 changes: 5 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ import { RepoRules, SelectedRules } from "../rules/types.js";
export const init = async (): Promise<void> => {
try {
const repoUrls = await askUserToSelectRepos();
if (!repoUrls.ok) {
errorMessage(repoUrls.error.message);
process.exit(1);
}
await createAriHomeDirIfNotExists();
const allRepoDetails = repoUrls.map((repoUrl) =>
const allRepoDetails = repoUrls.value.map((repoUrl) =>
extractRepoDetails(repoUrl)
);
for (const repoDetails of allRepoDetails) {
Expand Down
49 changes: 47 additions & 2 deletions src/prompting/init-prompts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { directoryChoice, fileChoice, repoChoice } from "./common.js";
import { getSelectRuleChoices } from "./init-prompts.js";
import { askUserToSelectRepos, getSelectRuleChoices } from "./init-prompts.js";
import { input } from "@inquirer/prompts";
import { ok, error, userInputError } from "../utils/result.js";

// Mock the input prompt
vi.mock("@inquirer/prompts", () => ({
input: vi.fn(),
checkbox: vi.fn(),
}));

describe("getSelectRuleChoices", () => {
it("should return a list of choices", () => {
Expand Down Expand Up @@ -95,3 +103,40 @@ describe("getSelectRuleChoices", () => {
]);
});
});

describe("askUserToSelectRepos", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should return a success result with repository URLs when valid repositories are provided", async () => {
const repoUrl1 = "git@github.com:my-org/my-repo.git";
const repoUrl2 = "git@github.com:my-other-org/my-other-repo.git";

// Mock input to first return two repo URLs and then an empty string to finish
vi.mocked(input).mockResolvedValueOnce(repoUrl1);
vi.mocked(input).mockResolvedValueOnce(repoUrl2);
vi.mocked(input).mockResolvedValueOnce("");

const result = await askUserToSelectRepos();

expect(result).toEqual(ok([repoUrl1, repoUrl2]));
expect(input).toHaveBeenCalledTimes(3);
});

it("should return an error result when no repositories are provided", async () => {
// Mock input to immediately return an empty string (user doesn't add any repos)
vi.mocked(input).mockResolvedValueOnce("");

const result = await askUserToSelectRepos();

expect(result).toEqual(
error(
userInputError(
"No repositories were added. Please try again and enter at least one repository URL."
)
)
);
expect(input).toHaveBeenCalledTimes(1);
});
});
20 changes: 18 additions & 2 deletions src/prompting/init-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ import {
normaliseSelectedRules,
} from "../rules/rule-flattening.js";
import { RepoRules, RuleFilePath, SelectedRules } from "../rules/types.js";
import {
error,
ok,
Result,
userInputError,
UserInputError,
} from "../utils/result.js";

export const askUserToSelectRepos = async (): Promise<string[]> => {
export const askUserToSelectRepos = async (): Promise<
Result<string[], UserInputError>
> => {
const repos: string[] = [];
while (true) {
const repoToAdd = await input({
Expand All @@ -24,7 +33,14 @@ export const askUserToSelectRepos = async (): Promise<string[]> => {
}
repos.push(repoToAdd);
}
return repos;
if (repos.length === 0) {
return error(
userInputError(
"No repositories were added. Please try again and enter at least one repository URL."
)
);
}
return ok(repos);
};

export const askUserToSelectRules = async (
Expand Down
37 changes: 37 additions & 0 deletions src/utils/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type Ok<T> = {
ok: true;
value: T;
};

export function ok<T>(value: T): Ok<T> {
return {
ok: true,
value,
};
}

export type Error<E> = {
ok: false;
error: E;
};

export function error<E>(error: E): Error<E> {
return {
ok: false,
error,
};
}

export type Result<T, E> = Ok<T> | Error<E>;

export type UserInputError = {
type: "user-input";
message: string;
};

export function userInputError(message: string): UserInputError {
return {
type: "user-input",
message,
};
}