herdctl 1.3.9 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/__tests__/agent.test.d.ts +2 -0
- package/dist/commands/__tests__/agent.test.d.ts.map +1 -0
- package/dist/commands/__tests__/agent.test.js +1461 -0
- package/dist/commands/__tests__/agent.test.js.map +1 -0
- package/dist/commands/__tests__/init-agent.test.d.ts +2 -0
- package/dist/commands/__tests__/init-agent.test.d.ts.map +1 -0
- package/dist/commands/__tests__/init-agent.test.js +363 -0
- package/dist/commands/__tests__/init-agent.test.js.map +1 -0
- package/dist/commands/__tests__/init-fleet.test.d.ts +2 -0
- package/dist/commands/__tests__/init-fleet.test.d.ts.map +1 -0
- package/dist/commands/__tests__/init-fleet.test.js +154 -0
- package/dist/commands/__tests__/init-fleet.test.js.map +1 -0
- package/dist/commands/__tests__/init.test.js +43 -213
- package/dist/commands/__tests__/init.test.js.map +1 -1
- package/dist/commands/agent.d.ts +143 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +845 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/init-agent.d.ts +22 -0
- package/dist/commands/init-agent.d.ts.map +1 -0
- package/dist/commands/init-agent.js +273 -0
- package/dist/commands/init-agent.js.map +1 -0
- package/dist/commands/init-fleet.d.ts +13 -0
- package/dist/commands/init-fleet.d.ts.map +1 -0
- package/dist/commands/init-fleet.js +91 -0
- package/dist/commands/init-fleet.js.map +1 -0
- package/dist/commands/init-utils.d.ts +8 -0
- package/dist/commands/init-utils.d.ts.map +1 -0
- package/dist/commands/init-utils.js +24 -0
- package/dist/commands/init-utils.js.map +1 -0
- package/dist/commands/init.d.ts +9 -9
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +30 -289
- package/dist/commands/init.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -8
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
// Mock @herdctl/core entirely
|
|
6
|
+
vi.mock("@herdctl/core", () => ({
|
|
7
|
+
// Logger - mock that routes to console so tests can capture it
|
|
8
|
+
createLogger: () => ({
|
|
9
|
+
debug: (...args) => console.debug(...args),
|
|
10
|
+
info: (...args) => console.log(...args),
|
|
11
|
+
warn: (...args) => console.warn(...args),
|
|
12
|
+
error: (...args) => console.error(...args),
|
|
13
|
+
}),
|
|
14
|
+
// Source parsing
|
|
15
|
+
parseSourceSpecifier: vi.fn(),
|
|
16
|
+
stringifySourceSpecifier: vi.fn(),
|
|
17
|
+
SourceParseError: class SourceParseError extends Error {
|
|
18
|
+
source;
|
|
19
|
+
constructor(message, source) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "SourceParseError";
|
|
22
|
+
this.source = source;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
isGitHubSource: vi.fn(),
|
|
26
|
+
isLocalSource: vi.fn(),
|
|
27
|
+
// Repository fetching
|
|
28
|
+
fetchRepository: vi.fn(),
|
|
29
|
+
RepositoryFetchError: class RepositoryFetchError extends Error {
|
|
30
|
+
source;
|
|
31
|
+
cause;
|
|
32
|
+
constructor(message, source, cause) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "RepositoryFetchError";
|
|
35
|
+
this.source = source;
|
|
36
|
+
this.cause = cause;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
GitHubCloneAuthError: class GitHubCloneAuthError extends Error {
|
|
40
|
+
source;
|
|
41
|
+
constructor(source, cause) {
|
|
42
|
+
super("Auth failed");
|
|
43
|
+
this.name = "GitHubCloneAuthError";
|
|
44
|
+
this.source = source;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
GitHubRepoNotFoundError: class GitHubRepoNotFoundError extends Error {
|
|
48
|
+
source;
|
|
49
|
+
constructor(source, cause) {
|
|
50
|
+
super("Repo not found");
|
|
51
|
+
this.name = "GitHubRepoNotFoundError";
|
|
52
|
+
this.source = source;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
NetworkError: class NetworkError extends Error {
|
|
56
|
+
source;
|
|
57
|
+
constructor(source, cause) {
|
|
58
|
+
super("Network error");
|
|
59
|
+
this.name = "NetworkError";
|
|
60
|
+
this.source = source;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
LocalPathError: class LocalPathError extends Error {
|
|
64
|
+
source;
|
|
65
|
+
constructor(source, reason, cause) {
|
|
66
|
+
super(reason);
|
|
67
|
+
this.name = "LocalPathError";
|
|
68
|
+
this.source = source;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
// Repository validation
|
|
72
|
+
validateRepository: vi.fn(),
|
|
73
|
+
// File installation
|
|
74
|
+
installAgentFiles: vi.fn(),
|
|
75
|
+
AgentInstallError: class AgentInstallError extends Error {
|
|
76
|
+
code;
|
|
77
|
+
constructor(message, code) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "AgentInstallError";
|
|
80
|
+
this.code = code;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
AGENT_ALREADY_EXISTS: "AGENT_ALREADY_EXISTS",
|
|
84
|
+
// Fleet config update
|
|
85
|
+
addAgentToFleetConfig: vi.fn(),
|
|
86
|
+
FleetConfigError: class FleetConfigError extends Error {
|
|
87
|
+
code;
|
|
88
|
+
constructor(message, code) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = "FleetConfigError";
|
|
91
|
+
this.code = code;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
// Environment variable scanning
|
|
95
|
+
scanEnvVariables: vi.fn(),
|
|
96
|
+
// Agent discovery
|
|
97
|
+
discoverAgents: vi.fn(),
|
|
98
|
+
AgentDiscoveryError: class AgentDiscoveryError extends Error {
|
|
99
|
+
code;
|
|
100
|
+
constructor(message, code) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.name = "AgentDiscoveryError";
|
|
103
|
+
this.code = code;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
// Agent info
|
|
107
|
+
getAgentInfo: vi.fn(),
|
|
108
|
+
// Agent removal
|
|
109
|
+
removeAgent: vi.fn(),
|
|
110
|
+
AgentRemoveError: class AgentRemoveError extends Error {
|
|
111
|
+
code;
|
|
112
|
+
constructor(message, code) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "AgentRemoveError";
|
|
115
|
+
this.code = code;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
|
|
119
|
+
// Config loader (for fleet-of-fleets support)
|
|
120
|
+
loadConfig: vi.fn(),
|
|
121
|
+
ConfigError: class ConfigError extends Error {
|
|
122
|
+
constructor(message) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.name = "ConfigError";
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
ConfigNotFoundError: class ConfigNotFoundError extends Error {
|
|
128
|
+
searchedPaths;
|
|
129
|
+
startDirectory;
|
|
130
|
+
constructor(startDirectory, searchedPaths = []) {
|
|
131
|
+
super(`No herdctl configuration file found from '${startDirectory}'`);
|
|
132
|
+
this.name = "ConfigNotFoundError";
|
|
133
|
+
this.startDirectory = startDirectory;
|
|
134
|
+
this.searchedPaths = searchedPaths;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}));
|
|
138
|
+
import { AGENT_ALREADY_EXISTS, AGENT_NOT_FOUND, AgentDiscoveryError, AgentInstallError, AgentRemoveError, addAgentToFleetConfig, ConfigError, ConfigNotFoundError, discoverAgents, FleetConfigError, fetchRepository, GitHubCloneAuthError, GitHubRepoNotFoundError, getAgentInfo, installAgentFiles, isGitHubSource, isLocalSource, loadConfig, parseSourceSpecifier, RepositoryFetchError, removeAgent, SourceParseError, scanEnvVariables, stringifySourceSpecifier, validateRepository, } from "@herdctl/core";
|
|
139
|
+
import { agentAddCommand, agentInfoCommand, agentListCommand, agentRemoveCommand, buildFleetTree, renderFleetTree, } from "../agent.js";
|
|
140
|
+
const mockedParseSourceSpecifier = vi.mocked(parseSourceSpecifier);
|
|
141
|
+
const mockedStringifySourceSpecifier = vi.mocked(stringifySourceSpecifier);
|
|
142
|
+
const mockedIsGitHubSource = vi.mocked(isGitHubSource);
|
|
143
|
+
const mockedIsLocalSource = vi.mocked(isLocalSource);
|
|
144
|
+
const mockedFetchRepository = vi.mocked(fetchRepository);
|
|
145
|
+
const mockedValidateRepository = vi.mocked(validateRepository);
|
|
146
|
+
const mockedInstallAgentFiles = vi.mocked(installAgentFiles);
|
|
147
|
+
const mockedAddAgentToFleetConfig = vi.mocked(addAgentToFleetConfig);
|
|
148
|
+
const mockedScanEnvVariables = vi.mocked(scanEnvVariables);
|
|
149
|
+
const mockedRemoveAgent = vi.mocked(removeAgent);
|
|
150
|
+
const mockedDiscoverAgents = vi.mocked(discoverAgents);
|
|
151
|
+
const mockedGetAgentInfo = vi.mocked(getAgentInfo);
|
|
152
|
+
const mockedLoadConfig = vi.mocked(loadConfig);
|
|
153
|
+
function createTempDir() {
|
|
154
|
+
const baseDir = path.join(tmpdir(), `herdctl-agent-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
155
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
156
|
+
return fs.realpathSync(baseDir);
|
|
157
|
+
}
|
|
158
|
+
function cleanupTempDir(dir) {
|
|
159
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
/** Create a minimal agent repository structure in a temp directory */
|
|
162
|
+
function createMockAgentRepo(dir, agentName = "test-agent") {
|
|
163
|
+
fs.writeFileSync(path.join(dir, "agent.yaml"), `name: ${agentName}
|
|
164
|
+
permission_mode: default
|
|
165
|
+
runtime: sdk
|
|
166
|
+
`, "utf-8");
|
|
167
|
+
fs.writeFileSync(path.join(dir, "CLAUDE.md"), "# Agent Instructions\n", "utf-8");
|
|
168
|
+
}
|
|
169
|
+
/** Create a minimal herdctl.yaml for tests */
|
|
170
|
+
function createFleetConfig(dir) {
|
|
171
|
+
fs.writeFileSync(path.join(dir, "herdctl.yaml"), `version: 1
|
|
172
|
+
|
|
173
|
+
fleet:
|
|
174
|
+
name: test-fleet
|
|
175
|
+
|
|
176
|
+
agents: []
|
|
177
|
+
`, "utf-8");
|
|
178
|
+
}
|
|
179
|
+
describe("agentAddCommand", () => {
|
|
180
|
+
let tempDir;
|
|
181
|
+
let fetchedRepoDir;
|
|
182
|
+
let originalCwd;
|
|
183
|
+
let consoleLogs;
|
|
184
|
+
let consoleErrors;
|
|
185
|
+
let originalConsoleLog;
|
|
186
|
+
let originalConsoleError;
|
|
187
|
+
let originalProcessExit;
|
|
188
|
+
let exitCode;
|
|
189
|
+
let cleanupCalled;
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
tempDir = createTempDir();
|
|
192
|
+
fetchedRepoDir = createTempDir();
|
|
193
|
+
originalCwd = process.cwd();
|
|
194
|
+
process.chdir(tempDir);
|
|
195
|
+
consoleLogs = [];
|
|
196
|
+
consoleErrors = [];
|
|
197
|
+
originalConsoleLog = console.log;
|
|
198
|
+
originalConsoleError = console.error;
|
|
199
|
+
console.log = (...args) => consoleLogs.push(args.join(" "));
|
|
200
|
+
console.error = (...args) => consoleErrors.push(args.join(" "));
|
|
201
|
+
exitCode = undefined;
|
|
202
|
+
process.exitCode = undefined;
|
|
203
|
+
originalProcessExit = process.exit;
|
|
204
|
+
process.exit = ((code) => {
|
|
205
|
+
exitCode = code ?? 0;
|
|
206
|
+
throw new Error(`process.exit(${code})`);
|
|
207
|
+
});
|
|
208
|
+
cleanupCalled = false;
|
|
209
|
+
vi.clearAllMocks();
|
|
210
|
+
});
|
|
211
|
+
afterEach(() => {
|
|
212
|
+
process.chdir(originalCwd);
|
|
213
|
+
cleanupTempDir(tempDir);
|
|
214
|
+
cleanupTempDir(fetchedRepoDir);
|
|
215
|
+
console.log = originalConsoleLog;
|
|
216
|
+
console.error = originalConsoleError;
|
|
217
|
+
process.exit = originalProcessExit;
|
|
218
|
+
});
|
|
219
|
+
/** Set up default mocks for a successful GitHub installation */
|
|
220
|
+
function setupSuccessfulGitHubMocks(agentName = "test-agent") {
|
|
221
|
+
createFleetConfig(tempDir);
|
|
222
|
+
createMockAgentRepo(fetchedRepoDir, agentName);
|
|
223
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
224
|
+
type: "github",
|
|
225
|
+
owner: "user",
|
|
226
|
+
repo: "repo",
|
|
227
|
+
ref: "v1.0.0",
|
|
228
|
+
});
|
|
229
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/repo@v1.0.0");
|
|
230
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
231
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
232
|
+
mockedFetchRepository.mockResolvedValue({
|
|
233
|
+
path: fetchedRepoDir,
|
|
234
|
+
cleanup: async () => {
|
|
235
|
+
cleanupCalled = true;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
mockedValidateRepository.mockResolvedValue({
|
|
239
|
+
valid: true,
|
|
240
|
+
agentName,
|
|
241
|
+
agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" },
|
|
242
|
+
repoMetadata: null,
|
|
243
|
+
errors: [],
|
|
244
|
+
warnings: [],
|
|
245
|
+
});
|
|
246
|
+
const installPath = path.join(tempDir, "agents", agentName);
|
|
247
|
+
mockedInstallAgentFiles.mockResolvedValue({
|
|
248
|
+
agentName,
|
|
249
|
+
installPath,
|
|
250
|
+
copiedFiles: ["agent.yaml", "CLAUDE.md"],
|
|
251
|
+
});
|
|
252
|
+
// Create the installed files so scanEnvVariables can read them
|
|
253
|
+
fs.mkdirSync(path.join(tempDir, "agents", agentName), { recursive: true });
|
|
254
|
+
fs.copyFileSync(path.join(fetchedRepoDir, "agent.yaml"), path.join(tempDir, "agents", agentName, "agent.yaml"));
|
|
255
|
+
mockedAddAgentToFleetConfig.mockResolvedValue({
|
|
256
|
+
modified: true,
|
|
257
|
+
agentPath: `./agents/${agentName}/agent.yaml`,
|
|
258
|
+
alreadyExists: false,
|
|
259
|
+
});
|
|
260
|
+
mockedScanEnvVariables.mockReturnValue({
|
|
261
|
+
variables: [],
|
|
262
|
+
required: [],
|
|
263
|
+
optional: [],
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/** Set up default mocks for a successful local installation */
|
|
267
|
+
function setupSuccessfulLocalMocks(agentName = "local-agent") {
|
|
268
|
+
createFleetConfig(tempDir);
|
|
269
|
+
createMockAgentRepo(fetchedRepoDir, agentName);
|
|
270
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
271
|
+
type: "local",
|
|
272
|
+
path: fetchedRepoDir,
|
|
273
|
+
});
|
|
274
|
+
mockedStringifySourceSpecifier.mockReturnValue(fetchedRepoDir);
|
|
275
|
+
mockedIsGitHubSource.mockReturnValue(false);
|
|
276
|
+
mockedIsLocalSource.mockReturnValue(true);
|
|
277
|
+
mockedFetchRepository.mockResolvedValue({
|
|
278
|
+
path: fetchedRepoDir,
|
|
279
|
+
cleanup: async () => {
|
|
280
|
+
cleanupCalled = true;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
mockedValidateRepository.mockResolvedValue({
|
|
284
|
+
valid: true,
|
|
285
|
+
agentName,
|
|
286
|
+
agentConfig: { name: agentName, permission_mode: "default", runtime: "sdk" },
|
|
287
|
+
repoMetadata: null,
|
|
288
|
+
errors: [],
|
|
289
|
+
warnings: [],
|
|
290
|
+
});
|
|
291
|
+
const installPath = path.join(tempDir, "agents", agentName);
|
|
292
|
+
mockedInstallAgentFiles.mockResolvedValue({
|
|
293
|
+
agentName,
|
|
294
|
+
installPath,
|
|
295
|
+
copiedFiles: ["agent.yaml", "CLAUDE.md"],
|
|
296
|
+
});
|
|
297
|
+
// Create the installed files so scanEnvVariables can read them
|
|
298
|
+
fs.mkdirSync(path.join(tempDir, "agents", agentName), { recursive: true });
|
|
299
|
+
fs.copyFileSync(path.join(fetchedRepoDir, "agent.yaml"), path.join(tempDir, "agents", agentName, "agent.yaml"));
|
|
300
|
+
mockedAddAgentToFleetConfig.mockResolvedValue({
|
|
301
|
+
modified: true,
|
|
302
|
+
agentPath: `./agents/${agentName}/agent.yaml`,
|
|
303
|
+
alreadyExists: false,
|
|
304
|
+
});
|
|
305
|
+
mockedScanEnvVariables.mockReturnValue({
|
|
306
|
+
variables: [],
|
|
307
|
+
required: [],
|
|
308
|
+
optional: [],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
describe("successful installation from GitHub", () => {
|
|
312
|
+
it("installs an agent from GitHub source", async () => {
|
|
313
|
+
setupSuccessfulGitHubMocks("my-agent");
|
|
314
|
+
await agentAddCommand("github:user/repo@v1.0.0", {});
|
|
315
|
+
expect(mockedParseSourceSpecifier).toHaveBeenCalledWith("github:user/repo@v1.0.0");
|
|
316
|
+
expect(mockedFetchRepository).toHaveBeenCalled();
|
|
317
|
+
expect(mockedValidateRepository).toHaveBeenCalledWith(fetchedRepoDir);
|
|
318
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalled();
|
|
319
|
+
expect(mockedAddAgentToFleetConfig).toHaveBeenCalled();
|
|
320
|
+
expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
it("calls cleanup even after successful installation", async () => {
|
|
323
|
+
setupSuccessfulGitHubMocks();
|
|
324
|
+
await agentAddCommand("github:user/repo", {});
|
|
325
|
+
expect(cleanupCalled).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
describe("successful installation from local path", () => {
|
|
329
|
+
it("installs an agent from local path", async () => {
|
|
330
|
+
setupSuccessfulLocalMocks("local-agent");
|
|
331
|
+
await agentAddCommand("./local/path", {});
|
|
332
|
+
expect(mockedParseSourceSpecifier).toHaveBeenCalledWith("./local/path");
|
|
333
|
+
expect(mockedFetchRepository).toHaveBeenCalled();
|
|
334
|
+
expect(mockedValidateRepository).toHaveBeenCalled();
|
|
335
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalled();
|
|
336
|
+
expect(mockedAddAgentToFleetConfig).toHaveBeenCalled();
|
|
337
|
+
expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
describe("dry run mode", () => {
|
|
341
|
+
it("does not modify files in dry run mode", async () => {
|
|
342
|
+
setupSuccessfulGitHubMocks("dry-run-agent");
|
|
343
|
+
await agentAddCommand("github:user/repo", { dryRun: true });
|
|
344
|
+
// Should parse and fetch
|
|
345
|
+
expect(mockedParseSourceSpecifier).toHaveBeenCalled();
|
|
346
|
+
expect(mockedFetchRepository).toHaveBeenCalled();
|
|
347
|
+
expect(mockedValidateRepository).toHaveBeenCalled();
|
|
348
|
+
// Should NOT install or update config
|
|
349
|
+
expect(mockedInstallAgentFiles).not.toHaveBeenCalled();
|
|
350
|
+
expect(mockedAddAgentToFleetConfig).not.toHaveBeenCalled();
|
|
351
|
+
// Should print dry run message
|
|
352
|
+
expect(consoleLogs.some((log) => log.includes("Dry run mode"))).toBe(true);
|
|
353
|
+
expect(consoleLogs.some((log) => log.includes("Would install"))).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
it("still calls cleanup in dry run mode", async () => {
|
|
356
|
+
setupSuccessfulGitHubMocks();
|
|
357
|
+
await agentAddCommand("github:user/repo", { dryRun: true });
|
|
358
|
+
expect(cleanupCalled).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
describe("error handling", () => {
|
|
362
|
+
it("handles SourceParseError gracefully", async () => {
|
|
363
|
+
mockedParseSourceSpecifier.mockImplementation(() => {
|
|
364
|
+
throw new SourceParseError("Invalid source format", "bad-source");
|
|
365
|
+
});
|
|
366
|
+
await expect(agentAddCommand("bad-source", {})).rejects.toThrow("process.exit");
|
|
367
|
+
expect(exitCode).toBe(1);
|
|
368
|
+
expect(consoleErrors.some((e) => e.includes("Invalid source"))).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
it("handles RepositoryFetchError gracefully", async () => {
|
|
371
|
+
createFleetConfig(tempDir);
|
|
372
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
373
|
+
type: "github",
|
|
374
|
+
owner: "user",
|
|
375
|
+
repo: "repo",
|
|
376
|
+
});
|
|
377
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
|
|
378
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
379
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
380
|
+
mockedFetchRepository.mockRejectedValue(new RepositoryFetchError("Clone failed", { type: "github", owner: "user", repo: "repo" }));
|
|
381
|
+
await expect(agentAddCommand("github:user/repo", {})).rejects.toThrow("process.exit");
|
|
382
|
+
expect(exitCode).toBe(1);
|
|
383
|
+
expect(consoleErrors.some((e) => e.includes("Failed to fetch"))).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
it("handles GitHubCloneAuthError gracefully", async () => {
|
|
386
|
+
createFleetConfig(tempDir);
|
|
387
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
388
|
+
type: "github",
|
|
389
|
+
owner: "user",
|
|
390
|
+
repo: "private-repo",
|
|
391
|
+
});
|
|
392
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/private-repo");
|
|
393
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
394
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
395
|
+
mockedFetchRepository.mockRejectedValue(new GitHubCloneAuthError({ type: "github", owner: "user", repo: "private-repo" }));
|
|
396
|
+
await expect(agentAddCommand("github:user/private-repo", {})).rejects.toThrow("process.exit");
|
|
397
|
+
expect(exitCode).toBe(1);
|
|
398
|
+
expect(consoleErrors.some((e) => e.includes("Authentication failed"))).toBe(true);
|
|
399
|
+
});
|
|
400
|
+
it("handles GitHubRepoNotFoundError gracefully", async () => {
|
|
401
|
+
createFleetConfig(tempDir);
|
|
402
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
403
|
+
type: "github",
|
|
404
|
+
owner: "user",
|
|
405
|
+
repo: "nonexistent",
|
|
406
|
+
});
|
|
407
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/nonexistent");
|
|
408
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
409
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
410
|
+
mockedFetchRepository.mockRejectedValue(new GitHubRepoNotFoundError({ type: "github", owner: "user", repo: "nonexistent" }));
|
|
411
|
+
await expect(agentAddCommand("github:user/nonexistent", {})).rejects.toThrow("process.exit");
|
|
412
|
+
expect(exitCode).toBe(1);
|
|
413
|
+
expect(consoleErrors.some((e) => e.includes("Repository not found"))).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
it("handles AgentInstallError (already exists)", async () => {
|
|
416
|
+
createFleetConfig(tempDir);
|
|
417
|
+
createMockAgentRepo(fetchedRepoDir, "existing-agent");
|
|
418
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
419
|
+
type: "github",
|
|
420
|
+
owner: "user",
|
|
421
|
+
repo: "repo",
|
|
422
|
+
});
|
|
423
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
|
|
424
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
425
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
426
|
+
mockedFetchRepository.mockResolvedValue({
|
|
427
|
+
path: fetchedRepoDir,
|
|
428
|
+
cleanup: async () => {
|
|
429
|
+
cleanupCalled = true;
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
mockedValidateRepository.mockResolvedValue({
|
|
433
|
+
valid: true,
|
|
434
|
+
agentName: "existing-agent",
|
|
435
|
+
agentConfig: { name: "existing-agent", permission_mode: "default", runtime: "sdk" },
|
|
436
|
+
repoMetadata: null,
|
|
437
|
+
errors: [],
|
|
438
|
+
warnings: [],
|
|
439
|
+
});
|
|
440
|
+
mockedInstallAgentFiles.mockRejectedValue(new AgentInstallError("Agent already exists", AGENT_ALREADY_EXISTS));
|
|
441
|
+
await agentAddCommand("github:user/repo", {});
|
|
442
|
+
expect(process.exitCode).toBe(1);
|
|
443
|
+
expect(consoleErrors.some((e) => e.includes("Installation failed"))).toBe(true);
|
|
444
|
+
expect(consoleErrors.some((e) => e.includes("--force"))).toBe(true);
|
|
445
|
+
expect(cleanupCalled).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
it("handles validation errors (stops installation)", async () => {
|
|
448
|
+
createFleetConfig(tempDir);
|
|
449
|
+
createMockAgentRepo(fetchedRepoDir, "invalid-agent");
|
|
450
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
451
|
+
type: "github",
|
|
452
|
+
owner: "user",
|
|
453
|
+
repo: "repo",
|
|
454
|
+
});
|
|
455
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
|
|
456
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
457
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
458
|
+
mockedFetchRepository.mockResolvedValue({
|
|
459
|
+
path: fetchedRepoDir,
|
|
460
|
+
cleanup: async () => {
|
|
461
|
+
cleanupCalled = true;
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
mockedValidateRepository.mockResolvedValue({
|
|
465
|
+
valid: false,
|
|
466
|
+
agentName: null,
|
|
467
|
+
agentConfig: null,
|
|
468
|
+
repoMetadata: null,
|
|
469
|
+
errors: [
|
|
470
|
+
{ code: "MISSING_AGENT_YAML", message: "agent.yaml not found", path: "agent.yaml" },
|
|
471
|
+
],
|
|
472
|
+
warnings: [],
|
|
473
|
+
});
|
|
474
|
+
await agentAddCommand("github:user/repo", {});
|
|
475
|
+
expect(process.exitCode).toBe(1);
|
|
476
|
+
expect(consoleErrors.some((e) => e.includes("Validation failed"))).toBe(true);
|
|
477
|
+
expect(mockedInstallAgentFiles).not.toHaveBeenCalled();
|
|
478
|
+
expect(cleanupCalled).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
it("handles validation warnings (continues installation)", async () => {
|
|
481
|
+
setupSuccessfulGitHubMocks("warning-agent");
|
|
482
|
+
mockedValidateRepository.mockResolvedValue({
|
|
483
|
+
valid: true,
|
|
484
|
+
agentName: "warning-agent",
|
|
485
|
+
agentConfig: { name: "warning-agent", permission_mode: "default", runtime: "sdk" },
|
|
486
|
+
repoMetadata: null,
|
|
487
|
+
errors: [],
|
|
488
|
+
warnings: [
|
|
489
|
+
{ code: "MISSING_README", message: "No README.md found", path: "README.md" },
|
|
490
|
+
{ code: "MISSING_CLAUDE_MD", message: "No CLAUDE.md found", path: "CLAUDE.md" },
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
await agentAddCommand("github:user/repo", {});
|
|
494
|
+
// Should print warnings
|
|
495
|
+
expect(consoleLogs.some((log) => log.includes("Warnings"))).toBe(true);
|
|
496
|
+
// But should still install
|
|
497
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalled();
|
|
498
|
+
expect(consoleLogs.some((log) => log.includes("installed successfully"))).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
it("calls cleanup even on errors", async () => {
|
|
501
|
+
createFleetConfig(tempDir);
|
|
502
|
+
createMockAgentRepo(fetchedRepoDir, "error-agent");
|
|
503
|
+
mockedParseSourceSpecifier.mockReturnValue({
|
|
504
|
+
type: "github",
|
|
505
|
+
owner: "user",
|
|
506
|
+
repo: "repo",
|
|
507
|
+
});
|
|
508
|
+
mockedStringifySourceSpecifier.mockReturnValue("github:user/repo");
|
|
509
|
+
mockedIsGitHubSource.mockReturnValue(true);
|
|
510
|
+
mockedIsLocalSource.mockReturnValue(false);
|
|
511
|
+
mockedFetchRepository.mockResolvedValue({
|
|
512
|
+
path: fetchedRepoDir,
|
|
513
|
+
cleanup: async () => {
|
|
514
|
+
cleanupCalled = true;
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
mockedValidateRepository.mockRejectedValue(new Error("Unexpected error"));
|
|
518
|
+
await expect(agentAddCommand("github:user/repo", {})).rejects.toThrow("Unexpected error");
|
|
519
|
+
expect(cleanupCalled).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
it("handles FleetConfigError gracefully", async () => {
|
|
522
|
+
setupSuccessfulGitHubMocks("config-error-agent");
|
|
523
|
+
mockedAddAgentToFleetConfig.mockRejectedValue(new FleetConfigError("Config not found", "CONFIG_NOT_FOUND"));
|
|
524
|
+
await agentAddCommand("github:user/repo", {});
|
|
525
|
+
expect(process.exitCode).toBe(1);
|
|
526
|
+
expect(consoleErrors.some((e) => e.includes("Config update failed"))).toBe(true);
|
|
527
|
+
expect(cleanupCalled).toBe(true);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
describe("environment variables display", () => {
|
|
531
|
+
it("displays required env vars correctly", async () => {
|
|
532
|
+
setupSuccessfulGitHubMocks("env-agent");
|
|
533
|
+
mockedScanEnvVariables.mockReturnValue({
|
|
534
|
+
variables: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
|
|
535
|
+
required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
|
|
536
|
+
optional: [],
|
|
537
|
+
});
|
|
538
|
+
await agentAddCommand("github:user/repo", {});
|
|
539
|
+
expect(consoleLogs.some((log) => log.includes("Environment variables to configure"))).toBe(true);
|
|
540
|
+
expect(consoleLogs.some((log) => log.includes("Required (no defaults)"))).toBe(true);
|
|
541
|
+
expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
|
|
542
|
+
expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
it("displays optional env vars with defaults", async () => {
|
|
545
|
+
setupSuccessfulGitHubMocks("env-agent");
|
|
546
|
+
mockedScanEnvVariables.mockReturnValue({
|
|
547
|
+
variables: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
|
|
548
|
+
required: [],
|
|
549
|
+
optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
|
|
550
|
+
});
|
|
551
|
+
await agentAddCommand("github:user/repo", {});
|
|
552
|
+
expect(consoleLogs.some((log) => log.includes("Optional (have defaults)"))).toBe(true);
|
|
553
|
+
expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
|
|
554
|
+
expect(consoleLogs.some((log) => log.includes("*/5 * * * *"))).toBe(true);
|
|
555
|
+
});
|
|
556
|
+
it("does not display env section when no variables found", async () => {
|
|
557
|
+
setupSuccessfulGitHubMocks("no-env-agent");
|
|
558
|
+
mockedScanEnvVariables.mockReturnValue({
|
|
559
|
+
variables: [],
|
|
560
|
+
required: [],
|
|
561
|
+
optional: [],
|
|
562
|
+
});
|
|
563
|
+
await agentAddCommand("github:user/repo", {});
|
|
564
|
+
expect(consoleLogs.some((log) => log.includes("Environment variables to configure"))).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
describe("force mode", () => {
|
|
568
|
+
it("passes force option to installAgentFiles", async () => {
|
|
569
|
+
setupSuccessfulGitHubMocks("force-agent");
|
|
570
|
+
await agentAddCommand("github:user/repo", { force: true });
|
|
571
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
|
|
572
|
+
force: true,
|
|
573
|
+
}));
|
|
574
|
+
});
|
|
575
|
+
it("does not pass force when not specified", async () => {
|
|
576
|
+
setupSuccessfulGitHubMocks("no-force-agent");
|
|
577
|
+
await agentAddCommand("github:user/repo", {});
|
|
578
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
|
|
579
|
+
force: undefined,
|
|
580
|
+
}));
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
describe("custom path option", () => {
|
|
584
|
+
it("passes custom path to installAgentFiles", async () => {
|
|
585
|
+
setupSuccessfulGitHubMocks("custom-path-agent");
|
|
586
|
+
const customPath = path.join(tempDir, "custom", "location");
|
|
587
|
+
await agentAddCommand("github:user/repo", { path: customPath });
|
|
588
|
+
expect(mockedInstallAgentFiles).toHaveBeenCalledWith(expect.objectContaining({
|
|
589
|
+
targetPath: customPath,
|
|
590
|
+
}));
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
describe("agentListCommand", () => {
|
|
595
|
+
let tempDir;
|
|
596
|
+
let originalCwd;
|
|
597
|
+
let consoleLogs;
|
|
598
|
+
let consoleErrors;
|
|
599
|
+
let originalConsoleLog;
|
|
600
|
+
let originalConsoleError;
|
|
601
|
+
let originalProcessExit;
|
|
602
|
+
let exitCode;
|
|
603
|
+
beforeEach(() => {
|
|
604
|
+
tempDir = createTempDir();
|
|
605
|
+
originalCwd = process.cwd();
|
|
606
|
+
process.chdir(tempDir);
|
|
607
|
+
consoleLogs = [];
|
|
608
|
+
consoleErrors = [];
|
|
609
|
+
originalConsoleLog = console.log;
|
|
610
|
+
originalConsoleError = console.error;
|
|
611
|
+
console.log = (...args) => consoleLogs.push(args.join(" "));
|
|
612
|
+
console.error = (...args) => consoleErrors.push(args.join(" "));
|
|
613
|
+
exitCode = undefined;
|
|
614
|
+
originalProcessExit = process.exit;
|
|
615
|
+
process.exit = ((code) => {
|
|
616
|
+
exitCode = code ?? 0;
|
|
617
|
+
throw new Error(`process.exit(${code})`);
|
|
618
|
+
});
|
|
619
|
+
vi.clearAllMocks();
|
|
620
|
+
});
|
|
621
|
+
afterEach(() => {
|
|
622
|
+
process.chdir(originalCwd);
|
|
623
|
+
cleanupTempDir(tempDir);
|
|
624
|
+
console.log = originalConsoleLog;
|
|
625
|
+
console.error = originalConsoleError;
|
|
626
|
+
process.exit = originalProcessExit;
|
|
627
|
+
});
|
|
628
|
+
describe("successful listing", () => {
|
|
629
|
+
it("lists agents in a table format", async () => {
|
|
630
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
631
|
+
agents: [
|
|
632
|
+
{
|
|
633
|
+
name: "agent-alpha",
|
|
634
|
+
installed: true,
|
|
635
|
+
path: "/path/to/agents/agent-alpha",
|
|
636
|
+
configPath: "./agents/agent-alpha/agent.yaml",
|
|
637
|
+
version: "1.0.0",
|
|
638
|
+
metadata: {
|
|
639
|
+
source: {
|
|
640
|
+
type: "github",
|
|
641
|
+
url: "https://github.com/user/agent-alpha",
|
|
642
|
+
ref: "v1.0.0",
|
|
643
|
+
version: "1.0.0",
|
|
644
|
+
},
|
|
645
|
+
installed_at: "2024-01-15T10:30:00Z",
|
|
646
|
+
installed_by: "herdctl@0.5.0",
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
name: "agent-beta",
|
|
651
|
+
installed: false,
|
|
652
|
+
path: "/path/to/agents/agent-beta",
|
|
653
|
+
configPath: "./agents/agent-beta/agent.yaml",
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
});
|
|
657
|
+
await agentListCommand({});
|
|
658
|
+
expect(mockedDiscoverAgents).toHaveBeenCalled();
|
|
659
|
+
expect(consoleLogs.some((log) => log.includes("Agents in fleet"))).toBe(true);
|
|
660
|
+
expect(consoleLogs.some((log) => log.includes("agent-alpha"))).toBe(true);
|
|
661
|
+
expect(consoleLogs.some((log) => log.includes("agent-beta"))).toBe(true);
|
|
662
|
+
expect(consoleLogs.some((log) => log.includes("installed"))).toBe(true);
|
|
663
|
+
expect(consoleLogs.some((log) => log.includes("manual"))).toBe(true);
|
|
664
|
+
expect(consoleLogs.some((log) => log.includes("Total: 2 agents"))).toBe(true);
|
|
665
|
+
});
|
|
666
|
+
it("outputs JSON when --json flag is provided", async () => {
|
|
667
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
668
|
+
agents: [
|
|
669
|
+
{
|
|
670
|
+
name: "json-agent",
|
|
671
|
+
installed: true,
|
|
672
|
+
path: "/path/to/agents/json-agent",
|
|
673
|
+
configPath: "./agents/json-agent/agent.yaml",
|
|
674
|
+
version: "2.0.0",
|
|
675
|
+
metadata: {
|
|
676
|
+
source: { type: "github", url: "https://github.com/user/json-agent" },
|
|
677
|
+
installed_at: "2024-02-20T15:00:00Z",
|
|
678
|
+
installed_by: "herdctl@1.0.0",
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
});
|
|
683
|
+
await agentListCommand({ json: true });
|
|
684
|
+
// Parse the output as JSON
|
|
685
|
+
const output = consoleLogs.join("\n");
|
|
686
|
+
const parsed = JSON.parse(output);
|
|
687
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
688
|
+
expect(parsed).toHaveLength(1);
|
|
689
|
+
expect(parsed[0].name).toBe("json-agent");
|
|
690
|
+
});
|
|
691
|
+
it("shows helpful message when no agents found", async () => {
|
|
692
|
+
mockedDiscoverAgents.mockResolvedValue({ agents: [] });
|
|
693
|
+
await agentListCommand({});
|
|
694
|
+
expect(consoleLogs.some((log) => log.includes("No agents found"))).toBe(true);
|
|
695
|
+
expect(consoleLogs.some((log) => log.includes("herdctl agent add"))).toBe(true);
|
|
696
|
+
expect(consoleLogs.some((log) => log.includes("herdctl init agent"))).toBe(true);
|
|
697
|
+
});
|
|
698
|
+
it("shows version from metadata", async () => {
|
|
699
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
700
|
+
agents: [
|
|
701
|
+
{
|
|
702
|
+
name: "versioned-agent",
|
|
703
|
+
installed: true,
|
|
704
|
+
path: "/path/to/agents/versioned-agent",
|
|
705
|
+
configPath: "./agents/versioned-agent/agent.yaml",
|
|
706
|
+
version: "3.2.1",
|
|
707
|
+
metadata: {
|
|
708
|
+
source: { type: "github", version: "3.2.1" },
|
|
709
|
+
installed_at: "2024-03-01T12:00:00Z",
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
],
|
|
713
|
+
});
|
|
714
|
+
await agentListCommand({});
|
|
715
|
+
expect(consoleLogs.some((log) => log.includes("3.2.1"))).toBe(true);
|
|
716
|
+
});
|
|
717
|
+
it("shows dash for missing version", async () => {
|
|
718
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
719
|
+
agents: [
|
|
720
|
+
{
|
|
721
|
+
name: "no-version-agent",
|
|
722
|
+
installed: false,
|
|
723
|
+
path: "/path/to/agents/no-version-agent",
|
|
724
|
+
configPath: "./agents/no-version-agent/agent.yaml",
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
});
|
|
728
|
+
await agentListCommand({});
|
|
729
|
+
// The table should show "-" for missing version
|
|
730
|
+
expect(consoleLogs.some((log) => log.includes("no-version-agent"))).toBe(true);
|
|
731
|
+
});
|
|
732
|
+
it("formats GitHub source correctly", async () => {
|
|
733
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
734
|
+
agents: [
|
|
735
|
+
{
|
|
736
|
+
name: "github-agent",
|
|
737
|
+
installed: true,
|
|
738
|
+
path: "/path/to/agents/github-agent",
|
|
739
|
+
configPath: "./agents/github-agent/agent.yaml",
|
|
740
|
+
metadata: {
|
|
741
|
+
source: {
|
|
742
|
+
type: "github",
|
|
743
|
+
url: "https://github.com/myorg/myrepo",
|
|
744
|
+
ref: "v2.0.0",
|
|
745
|
+
},
|
|
746
|
+
installed_at: "2024-01-01T00:00:00Z",
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
});
|
|
751
|
+
await agentListCommand({});
|
|
752
|
+
expect(consoleLogs.some((log) => log.includes("myorg/myrepo@v2.0.0"))).toBe(true);
|
|
753
|
+
});
|
|
754
|
+
it("shows local source type", async () => {
|
|
755
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
756
|
+
agents: [
|
|
757
|
+
{
|
|
758
|
+
name: "local-agent",
|
|
759
|
+
installed: true,
|
|
760
|
+
path: "/path/to/agents/local-agent",
|
|
761
|
+
configPath: "./agents/local-agent/agent.yaml",
|
|
762
|
+
metadata: {
|
|
763
|
+
source: {
|
|
764
|
+
type: "local",
|
|
765
|
+
url: "/path/to/source",
|
|
766
|
+
},
|
|
767
|
+
installed_at: "2024-01-01T00:00:00Z",
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
});
|
|
772
|
+
await agentListCommand({});
|
|
773
|
+
expect(consoleLogs.some((log) => log.includes("/path/to/source"))).toBe(true);
|
|
774
|
+
});
|
|
775
|
+
it("shows singular 'agent' for one agent", async () => {
|
|
776
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
777
|
+
agents: [
|
|
778
|
+
{
|
|
779
|
+
name: "single-agent",
|
|
780
|
+
installed: false,
|
|
781
|
+
path: "/path/to/agents/single-agent",
|
|
782
|
+
configPath: "./agents/single-agent/agent.yaml",
|
|
783
|
+
},
|
|
784
|
+
],
|
|
785
|
+
});
|
|
786
|
+
await agentListCommand({});
|
|
787
|
+
expect(consoleLogs.some((log) => log.includes("Total: 1 agent"))).toBe(true);
|
|
788
|
+
expect(consoleLogs.some((log) => log.includes("Total: 1 agents"))).toBe(false);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
describe("error handling", () => {
|
|
792
|
+
it("handles AgentDiscoveryError gracefully", async () => {
|
|
793
|
+
mockedDiscoverAgents.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
|
|
794
|
+
await expect(agentListCommand({})).rejects.toThrow("process.exit");
|
|
795
|
+
expect(exitCode).toBe(1);
|
|
796
|
+
expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
|
|
797
|
+
expect(consoleErrors.some((e) => e.includes("Fleet config not found"))).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
it("re-throws unknown errors", async () => {
|
|
800
|
+
mockedDiscoverAgents.mockRejectedValue(new Error("Unknown error"));
|
|
801
|
+
await expect(agentListCommand({})).rejects.toThrow("Unknown error");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
describe("agentInfoCommand", () => {
|
|
806
|
+
let tempDir;
|
|
807
|
+
let originalCwd;
|
|
808
|
+
let consoleLogs;
|
|
809
|
+
let consoleErrors;
|
|
810
|
+
let originalConsoleLog;
|
|
811
|
+
let originalConsoleError;
|
|
812
|
+
let originalProcessExit;
|
|
813
|
+
let exitCode;
|
|
814
|
+
beforeEach(() => {
|
|
815
|
+
tempDir = createTempDir();
|
|
816
|
+
originalCwd = process.cwd();
|
|
817
|
+
process.chdir(tempDir);
|
|
818
|
+
consoleLogs = [];
|
|
819
|
+
consoleErrors = [];
|
|
820
|
+
originalConsoleLog = console.log;
|
|
821
|
+
originalConsoleError = console.error;
|
|
822
|
+
console.log = (...args) => consoleLogs.push(args.join(" "));
|
|
823
|
+
console.error = (...args) => consoleErrors.push(args.join(" "));
|
|
824
|
+
exitCode = undefined;
|
|
825
|
+
originalProcessExit = process.exit;
|
|
826
|
+
process.exit = ((code) => {
|
|
827
|
+
exitCode = code ?? 0;
|
|
828
|
+
throw new Error(`process.exit(${code})`);
|
|
829
|
+
});
|
|
830
|
+
vi.clearAllMocks();
|
|
831
|
+
});
|
|
832
|
+
afterEach(() => {
|
|
833
|
+
process.chdir(originalCwd);
|
|
834
|
+
cleanupTempDir(tempDir);
|
|
835
|
+
console.log = originalConsoleLog;
|
|
836
|
+
console.error = originalConsoleError;
|
|
837
|
+
process.exit = originalProcessExit;
|
|
838
|
+
});
|
|
839
|
+
describe("agent found", () => {
|
|
840
|
+
it("prints formatted info for a complete agent", async () => {
|
|
841
|
+
mockedGetAgentInfo.mockResolvedValue({
|
|
842
|
+
name: "my-agent",
|
|
843
|
+
description: "A helpful agent",
|
|
844
|
+
installed: true,
|
|
845
|
+
metadata: {
|
|
846
|
+
source: {
|
|
847
|
+
type: "github",
|
|
848
|
+
url: "https://github.com/user/repo",
|
|
849
|
+
ref: "v1.0.0",
|
|
850
|
+
version: "1.0.0",
|
|
851
|
+
},
|
|
852
|
+
installed_at: "2024-01-15T10:30:00Z",
|
|
853
|
+
installed_by: "herdctl@0.5.0",
|
|
854
|
+
},
|
|
855
|
+
path: "/path/to/agents/my-agent",
|
|
856
|
+
configPath: "./agents/my-agent/agent.yaml",
|
|
857
|
+
version: "1.0.0",
|
|
858
|
+
repoMetadata: {
|
|
859
|
+
name: "my-agent",
|
|
860
|
+
version: "1.0.0",
|
|
861
|
+
description: "A helpful agent",
|
|
862
|
+
author: "test-author",
|
|
863
|
+
},
|
|
864
|
+
envVariables: {
|
|
865
|
+
variables: [
|
|
866
|
+
{ name: "DISCORD_WEBHOOK_URL" },
|
|
867
|
+
{ name: "WEBSITES" },
|
|
868
|
+
{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" },
|
|
869
|
+
],
|
|
870
|
+
required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
|
|
871
|
+
optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
|
|
872
|
+
},
|
|
873
|
+
schedules: {
|
|
874
|
+
"check-websites": { type: "cron", cron: "*/5 * * * *" },
|
|
875
|
+
},
|
|
876
|
+
hasWorkspace: true,
|
|
877
|
+
files: ["agent.yaml", "CLAUDE.md", "knowledge/guide.md"],
|
|
878
|
+
});
|
|
879
|
+
await agentInfoCommand("my-agent", {});
|
|
880
|
+
expect(mockedGetAgentInfo).toHaveBeenCalledWith({
|
|
881
|
+
name: "my-agent",
|
|
882
|
+
configPath: expect.stringContaining("herdctl.yaml"),
|
|
883
|
+
});
|
|
884
|
+
expect(consoleLogs.some((log) => log.includes("Agent: my-agent"))).toBe(true);
|
|
885
|
+
expect(consoleLogs.some((log) => log.includes("Description: A helpful agent"))).toBe(true);
|
|
886
|
+
expect(consoleLogs.some((log) => log.includes("Installed (via GitHub)"))).toBe(true);
|
|
887
|
+
expect(consoleLogs.some((log) => log.includes("Source: https://github.com/user/repo"))).toBe(true);
|
|
888
|
+
expect(consoleLogs.some((log) => log.includes("Version: 1.0.0"))).toBe(true);
|
|
889
|
+
expect(consoleLogs.some((log) => log.includes("Installed: 2024-01-15T10:30:00Z"))).toBe(true);
|
|
890
|
+
expect(consoleLogs.some((log) => log.includes("Schedules:"))).toBe(true);
|
|
891
|
+
expect(consoleLogs.some((log) => log.includes("check-websites:"))).toBe(true);
|
|
892
|
+
expect(consoleLogs.some((log) => log.includes("Environment Variables:"))).toBe(true);
|
|
893
|
+
expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
|
|
894
|
+
expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
|
|
895
|
+
expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
|
|
896
|
+
expect(consoleLogs.some((log) => log.includes("Files:"))).toBe(true);
|
|
897
|
+
expect(consoleLogs.some((log) => log.includes("agent.yaml"))).toBe(true);
|
|
898
|
+
expect(consoleLogs.some((log) => log.includes("CLAUDE.md"))).toBe(true);
|
|
899
|
+
expect(consoleLogs.some((log) => log.includes("Workspace:"))).toBe(true);
|
|
900
|
+
});
|
|
901
|
+
it("prints info for manual agent without metadata", async () => {
|
|
902
|
+
mockedGetAgentInfo.mockResolvedValue({
|
|
903
|
+
name: "manual-agent",
|
|
904
|
+
description: "A manual agent",
|
|
905
|
+
installed: false,
|
|
906
|
+
path: "/path/to/agents/manual-agent",
|
|
907
|
+
configPath: "./agents/manual-agent/agent.yaml",
|
|
908
|
+
hasWorkspace: false,
|
|
909
|
+
files: ["agent.yaml"],
|
|
910
|
+
});
|
|
911
|
+
await agentInfoCommand("manual-agent", {});
|
|
912
|
+
expect(consoleLogs.some((log) => log.includes("Agent: manual-agent"))).toBe(true);
|
|
913
|
+
expect(consoleLogs.some((log) => log.includes("Manual (not installed via herdctl)"))).toBe(true);
|
|
914
|
+
expect(consoleLogs.some((log) => log.includes("Workspace: (not created)"))).toBe(true);
|
|
915
|
+
});
|
|
916
|
+
it("outputs JSON when --json flag is provided", async () => {
|
|
917
|
+
const agentInfo = {
|
|
918
|
+
name: "json-agent",
|
|
919
|
+
description: "A JSON agent",
|
|
920
|
+
installed: true,
|
|
921
|
+
metadata: {
|
|
922
|
+
source: { type: "github", url: "https://github.com/user/json-agent" },
|
|
923
|
+
installed_at: "2024-02-20T15:00:00Z",
|
|
924
|
+
installed_by: "herdctl@1.0.0",
|
|
925
|
+
},
|
|
926
|
+
path: "/path/to/agents/json-agent",
|
|
927
|
+
configPath: "./agents/json-agent/agent.yaml",
|
|
928
|
+
version: "2.0.0",
|
|
929
|
+
hasWorkspace: true,
|
|
930
|
+
files: ["agent.yaml"],
|
|
931
|
+
};
|
|
932
|
+
mockedGetAgentInfo.mockResolvedValue(agentInfo);
|
|
933
|
+
await agentInfoCommand("json-agent", { json: true });
|
|
934
|
+
// Parse the output as JSON
|
|
935
|
+
const output = consoleLogs.join("\n");
|
|
936
|
+
const parsed = JSON.parse(output);
|
|
937
|
+
expect(parsed.name).toBe("json-agent");
|
|
938
|
+
expect(parsed.installed).toBe(true);
|
|
939
|
+
expect(parsed.version).toBe("2.0.0");
|
|
940
|
+
expect(parsed.hasWorkspace).toBe(true);
|
|
941
|
+
});
|
|
942
|
+
it("prints info for agent without optional fields", async () => {
|
|
943
|
+
mockedGetAgentInfo.mockResolvedValue({
|
|
944
|
+
name: "minimal-agent",
|
|
945
|
+
installed: false,
|
|
946
|
+
path: "/path/to/agents/minimal-agent",
|
|
947
|
+
configPath: "./agents/minimal-agent/agent.yaml",
|
|
948
|
+
hasWorkspace: false,
|
|
949
|
+
files: ["agent.yaml"],
|
|
950
|
+
});
|
|
951
|
+
await agentInfoCommand("minimal-agent", {});
|
|
952
|
+
expect(consoleLogs.some((log) => log.includes("Agent: minimal-agent"))).toBe(true);
|
|
953
|
+
// Should not include these sections
|
|
954
|
+
expect(consoleLogs.some((log) => log.includes("Schedules:"))).toBe(false);
|
|
955
|
+
expect(consoleLogs.some((log) => log.includes("Environment Variables:"))).toBe(false);
|
|
956
|
+
expect(consoleLogs.some((log) => log.includes("Description:"))).toBe(false);
|
|
957
|
+
});
|
|
958
|
+
it("handles local source type correctly", async () => {
|
|
959
|
+
mockedGetAgentInfo.mockResolvedValue({
|
|
960
|
+
name: "local-agent",
|
|
961
|
+
installed: true,
|
|
962
|
+
metadata: {
|
|
963
|
+
source: {
|
|
964
|
+
type: "local",
|
|
965
|
+
url: "/path/to/source",
|
|
966
|
+
},
|
|
967
|
+
installed_at: "2024-01-01T00:00:00Z",
|
|
968
|
+
},
|
|
969
|
+
path: "/path/to/agents/local-agent",
|
|
970
|
+
configPath: "./agents/local-agent/agent.yaml",
|
|
971
|
+
hasWorkspace: false,
|
|
972
|
+
files: ["agent.yaml"],
|
|
973
|
+
});
|
|
974
|
+
await agentInfoCommand("local-agent", {});
|
|
975
|
+
expect(consoleLogs.some((log) => log.includes("Installed (via local path)"))).toBe(true);
|
|
976
|
+
expect(consoleLogs.some((log) => log.includes("Source: /path/to/source"))).toBe(true);
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
describe("agent not found", () => {
|
|
980
|
+
it("prints error and exits when agent is not found", async () => {
|
|
981
|
+
mockedGetAgentInfo.mockResolvedValue(null);
|
|
982
|
+
await expect(agentInfoCommand("nonexistent", {})).rejects.toThrow("process.exit");
|
|
983
|
+
expect(exitCode).toBe(1);
|
|
984
|
+
expect(consoleErrors.some((e) => e.includes("Agent 'nonexistent' not found"))).toBe(true);
|
|
985
|
+
expect(consoleErrors.some((e) => e.includes("herdctl agent list"))).toBe(true);
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
describe("error handling", () => {
|
|
989
|
+
it("handles AgentDiscoveryError gracefully", async () => {
|
|
990
|
+
mockedGetAgentInfo.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
|
|
991
|
+
await expect(agentInfoCommand("any-agent", {})).rejects.toThrow("process.exit");
|
|
992
|
+
expect(exitCode).toBe(1);
|
|
993
|
+
expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
|
|
994
|
+
});
|
|
995
|
+
it("re-throws unknown errors", async () => {
|
|
996
|
+
mockedGetAgentInfo.mockRejectedValue(new Error("Unknown error"));
|
|
997
|
+
await expect(agentInfoCommand("any-agent", {})).rejects.toThrow("Unknown error");
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
describe("agentRemoveCommand", () => {
|
|
1002
|
+
let tempDir;
|
|
1003
|
+
let originalCwd;
|
|
1004
|
+
let consoleLogs;
|
|
1005
|
+
let consoleErrors;
|
|
1006
|
+
let originalConsoleLog;
|
|
1007
|
+
let originalConsoleError;
|
|
1008
|
+
let originalProcessExit;
|
|
1009
|
+
let exitCode;
|
|
1010
|
+
beforeEach(() => {
|
|
1011
|
+
tempDir = createTempDir();
|
|
1012
|
+
originalCwd = process.cwd();
|
|
1013
|
+
process.chdir(tempDir);
|
|
1014
|
+
consoleLogs = [];
|
|
1015
|
+
consoleErrors = [];
|
|
1016
|
+
originalConsoleLog = console.log;
|
|
1017
|
+
originalConsoleError = console.error;
|
|
1018
|
+
console.log = (...args) => consoleLogs.push(args.join(" "));
|
|
1019
|
+
console.error = (...args) => consoleErrors.push(args.join(" "));
|
|
1020
|
+
exitCode = undefined;
|
|
1021
|
+
originalProcessExit = process.exit;
|
|
1022
|
+
process.exit = ((code) => {
|
|
1023
|
+
exitCode = code ?? 0;
|
|
1024
|
+
throw new Error(`process.exit(${code})`);
|
|
1025
|
+
});
|
|
1026
|
+
vi.clearAllMocks();
|
|
1027
|
+
});
|
|
1028
|
+
afterEach(() => {
|
|
1029
|
+
process.chdir(originalCwd);
|
|
1030
|
+
cleanupTempDir(tempDir);
|
|
1031
|
+
console.log = originalConsoleLog;
|
|
1032
|
+
console.error = originalConsoleError;
|
|
1033
|
+
process.exit = originalProcessExit;
|
|
1034
|
+
});
|
|
1035
|
+
describe("successful removal", () => {
|
|
1036
|
+
it("removes agent and prints summary", async () => {
|
|
1037
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1038
|
+
agentName: "my-agent",
|
|
1039
|
+
removedPath: path.join(tempDir, "agents", "my-agent"),
|
|
1040
|
+
filesRemoved: true,
|
|
1041
|
+
configUpdated: true,
|
|
1042
|
+
workspacePreserved: false,
|
|
1043
|
+
});
|
|
1044
|
+
await agentRemoveCommand("my-agent", {});
|
|
1045
|
+
expect(mockedRemoveAgent).toHaveBeenCalledWith({
|
|
1046
|
+
name: "my-agent",
|
|
1047
|
+
configPath: expect.stringContaining("herdctl.yaml"),
|
|
1048
|
+
keepWorkspace: false,
|
|
1049
|
+
});
|
|
1050
|
+
expect(consoleLogs.some((log) => log.includes("Removing agent 'my-agent'"))).toBe(true);
|
|
1051
|
+
expect(consoleLogs.some((log) => log.includes("Deleted"))).toBe(true);
|
|
1052
|
+
expect(consoleLogs.some((log) => log.includes("Updated herdctl.yaml"))).toBe(true);
|
|
1053
|
+
});
|
|
1054
|
+
it("prints env variable summary when agent has env vars", async () => {
|
|
1055
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1056
|
+
agentName: "env-agent",
|
|
1057
|
+
removedPath: path.join(tempDir, "agents", "env-agent"),
|
|
1058
|
+
filesRemoved: true,
|
|
1059
|
+
configUpdated: true,
|
|
1060
|
+
workspacePreserved: false,
|
|
1061
|
+
envVariables: {
|
|
1062
|
+
variables: [
|
|
1063
|
+
{ name: "DISCORD_WEBHOOK_URL" },
|
|
1064
|
+
{ name: "WEBSITES" },
|
|
1065
|
+
{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" },
|
|
1066
|
+
],
|
|
1067
|
+
required: [{ name: "DISCORD_WEBHOOK_URL" }, { name: "WEBSITES" }],
|
|
1068
|
+
optional: [{ name: "CRON_SCHEDULE", defaultValue: "*/5 * * * *" }],
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
await agentRemoveCommand("env-agent", {});
|
|
1072
|
+
expect(consoleLogs.some((log) => log.includes("environment variables"))).toBe(true);
|
|
1073
|
+
expect(consoleLogs.some((log) => log.includes("Required:"))).toBe(true);
|
|
1074
|
+
expect(consoleLogs.some((log) => log.includes("DISCORD_WEBHOOK_URL"))).toBe(true);
|
|
1075
|
+
expect(consoleLogs.some((log) => log.includes("WEBSITES"))).toBe(true);
|
|
1076
|
+
expect(consoleLogs.some((log) => log.includes("Optional:"))).toBe(true);
|
|
1077
|
+
expect(consoleLogs.some((log) => log.includes("CRON_SCHEDULE"))).toBe(true);
|
|
1078
|
+
expect(consoleLogs.some((log) => log.includes("*/5 * * * *"))).toBe(true);
|
|
1079
|
+
expect(consoleLogs.some((log) => log.includes("remove these from your .env file"))).toBe(true);
|
|
1080
|
+
});
|
|
1081
|
+
it("does not print env section when no env variables", async () => {
|
|
1082
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1083
|
+
agentName: "no-env-agent",
|
|
1084
|
+
removedPath: path.join(tempDir, "agents", "no-env-agent"),
|
|
1085
|
+
filesRemoved: true,
|
|
1086
|
+
configUpdated: true,
|
|
1087
|
+
workspacePreserved: false,
|
|
1088
|
+
envVariables: {
|
|
1089
|
+
variables: [],
|
|
1090
|
+
required: [],
|
|
1091
|
+
optional: [],
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
await agentRemoveCommand("no-env-agent", {});
|
|
1095
|
+
expect(consoleLogs.some((log) => log.includes("environment variables"))).toBe(false);
|
|
1096
|
+
});
|
|
1097
|
+
it("shows workspace preserved message when keepWorkspace is true", async () => {
|
|
1098
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1099
|
+
agentName: "workspace-agent",
|
|
1100
|
+
removedPath: path.join(tempDir, "agents", "workspace-agent"),
|
|
1101
|
+
filesRemoved: true,
|
|
1102
|
+
configUpdated: true,
|
|
1103
|
+
workspacePreserved: true,
|
|
1104
|
+
});
|
|
1105
|
+
await agentRemoveCommand("workspace-agent", { keepWorkspace: true });
|
|
1106
|
+
expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
|
|
1107
|
+
keepWorkspace: true,
|
|
1108
|
+
}));
|
|
1109
|
+
expect(consoleLogs.some((log) => log.includes("workspace preserved"))).toBe(true);
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
describe("error handling", () => {
|
|
1113
|
+
it("handles AgentRemoveError (agent not found) gracefully", async () => {
|
|
1114
|
+
mockedRemoveAgent.mockRejectedValue(new AgentRemoveError("Agent 'nonexistent' not found", AGENT_NOT_FOUND));
|
|
1115
|
+
await expect(agentRemoveCommand("nonexistent", {})).rejects.toThrow("process.exit");
|
|
1116
|
+
expect(exitCode).toBe(1);
|
|
1117
|
+
expect(consoleErrors.some((e) => e.includes("Agent 'nonexistent' not found"))).toBe(true);
|
|
1118
|
+
expect(consoleErrors.some((e) => e.includes("herdctl agent list"))).toBe(true);
|
|
1119
|
+
});
|
|
1120
|
+
it("handles AgentRemoveError (other errors) gracefully", async () => {
|
|
1121
|
+
mockedRemoveAgent.mockRejectedValue(new AgentRemoveError("Some other error", "AGENT_REMOVE_ERROR"));
|
|
1122
|
+
await expect(agentRemoveCommand("some-agent", {})).rejects.toThrow("process.exit");
|
|
1123
|
+
expect(exitCode).toBe(1);
|
|
1124
|
+
expect(consoleErrors.some((e) => e.includes("Removal failed"))).toBe(true);
|
|
1125
|
+
});
|
|
1126
|
+
it("handles AgentDiscoveryError gracefully", async () => {
|
|
1127
|
+
mockedRemoveAgent.mockRejectedValue(new AgentDiscoveryError("Fleet config not found", "DISCOVERY_CONFIG_NOT_FOUND"));
|
|
1128
|
+
await expect(agentRemoveCommand("any-agent", {})).rejects.toThrow("process.exit");
|
|
1129
|
+
expect(exitCode).toBe(1);
|
|
1130
|
+
expect(consoleErrors.some((e) => e.includes("Discovery failed"))).toBe(true);
|
|
1131
|
+
});
|
|
1132
|
+
it("re-throws unknown errors", async () => {
|
|
1133
|
+
mockedRemoveAgent.mockRejectedValue(new Error("Unknown error"));
|
|
1134
|
+
await expect(agentRemoveCommand("any-agent", {})).rejects.toThrow("Unknown error");
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
describe("options handling", () => {
|
|
1138
|
+
it("passes keepWorkspace option to removeAgent", async () => {
|
|
1139
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1140
|
+
agentName: "test-agent",
|
|
1141
|
+
removedPath: path.join(tempDir, "agents", "test-agent"),
|
|
1142
|
+
filesRemoved: true,
|
|
1143
|
+
configUpdated: true,
|
|
1144
|
+
workspacePreserved: true,
|
|
1145
|
+
});
|
|
1146
|
+
await agentRemoveCommand("test-agent", { keepWorkspace: true });
|
|
1147
|
+
expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
|
|
1148
|
+
keepWorkspace: true,
|
|
1149
|
+
}));
|
|
1150
|
+
});
|
|
1151
|
+
it("defaults keepWorkspace to false", async () => {
|
|
1152
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1153
|
+
agentName: "test-agent",
|
|
1154
|
+
removedPath: path.join(tempDir, "agents", "test-agent"),
|
|
1155
|
+
filesRemoved: true,
|
|
1156
|
+
configUpdated: true,
|
|
1157
|
+
workspacePreserved: false,
|
|
1158
|
+
});
|
|
1159
|
+
await agentRemoveCommand("test-agent", {});
|
|
1160
|
+
expect(mockedRemoveAgent).toHaveBeenCalledWith(expect.objectContaining({
|
|
1161
|
+
keepWorkspace: false,
|
|
1162
|
+
}));
|
|
1163
|
+
});
|
|
1164
|
+
it("accepts force option (no-op for now)", async () => {
|
|
1165
|
+
mockedRemoveAgent.mockResolvedValue({
|
|
1166
|
+
agentName: "force-agent",
|
|
1167
|
+
removedPath: path.join(tempDir, "agents", "force-agent"),
|
|
1168
|
+
filesRemoved: true,
|
|
1169
|
+
configUpdated: true,
|
|
1170
|
+
workspacePreserved: false,
|
|
1171
|
+
});
|
|
1172
|
+
// force option is accepted but doesn't change behavior
|
|
1173
|
+
await agentRemoveCommand("force-agent", { force: true });
|
|
1174
|
+
expect(mockedRemoveAgent).toHaveBeenCalled();
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
// =============================================================================
|
|
1179
|
+
// Fleet Tree View Tests
|
|
1180
|
+
// =============================================================================
|
|
1181
|
+
describe("buildFleetTree", () => {
|
|
1182
|
+
it("groups root-level agents under root node", () => {
|
|
1183
|
+
const agents = [
|
|
1184
|
+
{ name: "agent-a", fleetPath: [], qualifiedName: "agent-a" },
|
|
1185
|
+
{ name: "agent-b", fleetPath: [], qualifiedName: "agent-b" },
|
|
1186
|
+
];
|
|
1187
|
+
const tree = buildFleetTree(agents, "my-fleet", "My Fleet");
|
|
1188
|
+
expect(tree.name).toBe("my-fleet");
|
|
1189
|
+
expect(tree.description).toBe("My Fleet");
|
|
1190
|
+
expect(tree.agents).toEqual(["agent-a", "agent-b"]);
|
|
1191
|
+
expect(tree.children).toHaveLength(0);
|
|
1192
|
+
});
|
|
1193
|
+
it("creates child nodes for sub-fleet agents", () => {
|
|
1194
|
+
const agents = [
|
|
1195
|
+
{ name: "engineer", fleetPath: ["herdctl"], qualifiedName: "herdctl.engineer" },
|
|
1196
|
+
{ name: "tester", fleetPath: ["herdctl"], qualifiedName: "herdctl.tester" },
|
|
1197
|
+
{ name: "agent-one", fleetPath: ["personal"], qualifiedName: "personal.agent-one" },
|
|
1198
|
+
];
|
|
1199
|
+
const tree = buildFleetTree(agents, "global");
|
|
1200
|
+
expect(tree.agents).toHaveLength(0);
|
|
1201
|
+
expect(tree.children).toHaveLength(2);
|
|
1202
|
+
const herdctl = tree.children.find((c) => c.name === "herdctl");
|
|
1203
|
+
expect(herdctl).toBeDefined();
|
|
1204
|
+
expect(herdctl.agents).toEqual(["engineer", "tester"]);
|
|
1205
|
+
const personal = tree.children.find((c) => c.name === "personal");
|
|
1206
|
+
expect(personal).toBeDefined();
|
|
1207
|
+
expect(personal.agents).toEqual(["agent-one"]);
|
|
1208
|
+
});
|
|
1209
|
+
it("handles nested fleet paths (multi-level)", () => {
|
|
1210
|
+
const agents = [
|
|
1211
|
+
{
|
|
1212
|
+
name: "deep-agent",
|
|
1213
|
+
fleetPath: ["level1", "level2"],
|
|
1214
|
+
qualifiedName: "level1.level2.deep-agent",
|
|
1215
|
+
},
|
|
1216
|
+
];
|
|
1217
|
+
const tree = buildFleetTree(agents, "root");
|
|
1218
|
+
expect(tree.children).toHaveLength(1);
|
|
1219
|
+
expect(tree.children[0].name).toBe("level1");
|
|
1220
|
+
expect(tree.children[0].children).toHaveLength(1);
|
|
1221
|
+
expect(tree.children[0].children[0].name).toBe("level2");
|
|
1222
|
+
expect(tree.children[0].children[0].agents).toEqual(["deep-agent"]);
|
|
1223
|
+
});
|
|
1224
|
+
it("handles mix of root and sub-fleet agents", () => {
|
|
1225
|
+
const agents = [
|
|
1226
|
+
{ name: "root-agent", fleetPath: [], qualifiedName: "root-agent" },
|
|
1227
|
+
{ name: "sub-agent", fleetPath: ["sub"], qualifiedName: "sub.sub-agent" },
|
|
1228
|
+
];
|
|
1229
|
+
const tree = buildFleetTree(agents, "root");
|
|
1230
|
+
expect(tree.agents).toEqual(["root-agent"]);
|
|
1231
|
+
expect(tree.children).toHaveLength(1);
|
|
1232
|
+
expect(tree.children[0].name).toBe("sub");
|
|
1233
|
+
expect(tree.children[0].agents).toEqual(["sub-agent"]);
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
describe("renderFleetTree", () => {
|
|
1237
|
+
it("renders a simple tree with box-drawing characters", () => {
|
|
1238
|
+
const tree = buildFleetTree([
|
|
1239
|
+
{ name: "agent-one", fleetPath: ["personal"], qualifiedName: "personal.agent-one" },
|
|
1240
|
+
{ name: "agent-two", fleetPath: ["personal"], qualifiedName: "personal.agent-two" },
|
|
1241
|
+
{ name: "engineer", fleetPath: ["herdctl"], qualifiedName: "herdctl.engineer" },
|
|
1242
|
+
], "global", "Fleet of Fleets");
|
|
1243
|
+
const lines = renderFleetTree(tree);
|
|
1244
|
+
// Root line
|
|
1245
|
+
expect(lines[0]).toBe("global (Fleet of Fleets)");
|
|
1246
|
+
// Should contain sub-fleet names
|
|
1247
|
+
expect(lines.some((l) => l.includes("personal"))).toBe(true);
|
|
1248
|
+
expect(lines.some((l) => l.includes("herdctl"))).toBe(true);
|
|
1249
|
+
// Should contain agent names
|
|
1250
|
+
expect(lines.some((l) => l.includes("agent-one"))).toBe(true);
|
|
1251
|
+
expect(lines.some((l) => l.includes("agent-two"))).toBe(true);
|
|
1252
|
+
expect(lines.some((l) => l.includes("engineer"))).toBe(true);
|
|
1253
|
+
// Should use box-drawing characters
|
|
1254
|
+
expect(lines.some((l) => l.includes("\u251C\u2500\u2500") || l.includes("\u2514\u2500\u2500"))).toBe(true);
|
|
1255
|
+
});
|
|
1256
|
+
it("uses end connector for last items", () => {
|
|
1257
|
+
const tree = buildFleetTree([
|
|
1258
|
+
{ name: "only-agent", fleetPath: ["sub"], qualifiedName: "sub.only-agent" },
|
|
1259
|
+
], "root");
|
|
1260
|
+
const lines = renderFleetTree(tree);
|
|
1261
|
+
// The sub-fleet should use end connector since it's the last child
|
|
1262
|
+
expect(lines[1]).toContain("\u2514\u2500\u2500 sub");
|
|
1263
|
+
// The only agent should also use end connector
|
|
1264
|
+
expect(lines[2]).toContain("\u2514\u2500\u2500 only-agent");
|
|
1265
|
+
});
|
|
1266
|
+
it("shows agent counts in summary mode", () => {
|
|
1267
|
+
const tree = buildFleetTree([
|
|
1268
|
+
{ name: "a1", fleetPath: ["sub"], qualifiedName: "sub.a1" },
|
|
1269
|
+
{ name: "a2", fleetPath: ["sub"], qualifiedName: "sub.a2" },
|
|
1270
|
+
{ name: "a3", fleetPath: ["sub"], qualifiedName: "sub.a3" },
|
|
1271
|
+
], "root");
|
|
1272
|
+
const lines = renderFleetTree(tree, "", true, true, true);
|
|
1273
|
+
// Should show count instead of individual names
|
|
1274
|
+
expect(lines.some((l) => l.includes("(3 agents)"))).toBe(true);
|
|
1275
|
+
// Should NOT show individual agent names
|
|
1276
|
+
expect(lines.some((l) => l.includes("a1"))).toBe(false);
|
|
1277
|
+
expect(lines.some((l) => l.includes("a2"))).toBe(false);
|
|
1278
|
+
});
|
|
1279
|
+
it("renders root-level agents", () => {
|
|
1280
|
+
const tree = buildFleetTree([
|
|
1281
|
+
{ name: "root-agent", fleetPath: [], qualifiedName: "root-agent" },
|
|
1282
|
+
], "fleet");
|
|
1283
|
+
const lines = renderFleetTree(tree);
|
|
1284
|
+
expect(lines[0]).toBe("fleet");
|
|
1285
|
+
expect(lines[1]).toContain("root-agent");
|
|
1286
|
+
});
|
|
1287
|
+
});
|
|
1288
|
+
describe("agentListCommand with sub-fleets (tree view)", () => {
|
|
1289
|
+
let tempDir;
|
|
1290
|
+
let originalCwd;
|
|
1291
|
+
let consoleLogs;
|
|
1292
|
+
let consoleErrors;
|
|
1293
|
+
let originalConsoleLog;
|
|
1294
|
+
let originalConsoleError;
|
|
1295
|
+
let originalProcessExit;
|
|
1296
|
+
let exitCode;
|
|
1297
|
+
beforeEach(() => {
|
|
1298
|
+
tempDir = createTempDir();
|
|
1299
|
+
originalCwd = process.cwd();
|
|
1300
|
+
process.chdir(tempDir);
|
|
1301
|
+
consoleLogs = [];
|
|
1302
|
+
consoleErrors = [];
|
|
1303
|
+
originalConsoleLog = console.log;
|
|
1304
|
+
originalConsoleError = console.error;
|
|
1305
|
+
console.log = (...args) => consoleLogs.push(args.join(" "));
|
|
1306
|
+
console.error = (...args) => consoleErrors.push(args.join(" "));
|
|
1307
|
+
exitCode = undefined;
|
|
1308
|
+
originalProcessExit = process.exit;
|
|
1309
|
+
process.exit = ((code) => {
|
|
1310
|
+
exitCode = code ?? 0;
|
|
1311
|
+
throw new Error(`process.exit(${code})`);
|
|
1312
|
+
});
|
|
1313
|
+
vi.clearAllMocks();
|
|
1314
|
+
});
|
|
1315
|
+
afterEach(() => {
|
|
1316
|
+
process.chdir(originalCwd);
|
|
1317
|
+
cleanupTempDir(tempDir);
|
|
1318
|
+
console.log = originalConsoleLog;
|
|
1319
|
+
console.error = originalConsoleError;
|
|
1320
|
+
process.exit = originalProcessExit;
|
|
1321
|
+
});
|
|
1322
|
+
/** Create a fleet config with sub-fleets */
|
|
1323
|
+
function createFleetConfigWithSubFleets(dir) {
|
|
1324
|
+
fs.writeFileSync(path.join(dir, "herdctl.yaml"), `version: 1
|
|
1325
|
+
|
|
1326
|
+
fleet:
|
|
1327
|
+
name: global
|
|
1328
|
+
description: Fleet of Fleets
|
|
1329
|
+
|
|
1330
|
+
fleets:
|
|
1331
|
+
- path: ./sub/herdctl.yaml
|
|
1332
|
+
`, "utf-8");
|
|
1333
|
+
}
|
|
1334
|
+
it("renders tree view when sub-fleets exist", async () => {
|
|
1335
|
+
createFleetConfigWithSubFleets(tempDir);
|
|
1336
|
+
mockedLoadConfig.mockResolvedValue({
|
|
1337
|
+
fleet: {
|
|
1338
|
+
version: 1,
|
|
1339
|
+
fleet: { name: "global", description: "Fleet of Fleets" },
|
|
1340
|
+
fleets: [{ path: "./sub/herdctl.yaml" }],
|
|
1341
|
+
agents: [],
|
|
1342
|
+
},
|
|
1343
|
+
agents: [
|
|
1344
|
+
{
|
|
1345
|
+
name: "engineer",
|
|
1346
|
+
fleetPath: ["herdctl"],
|
|
1347
|
+
qualifiedName: "herdctl.engineer",
|
|
1348
|
+
configPath: "/path/to/herdctl/agents/engineer/agent.yaml",
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: "tester",
|
|
1352
|
+
fleetPath: ["herdctl"],
|
|
1353
|
+
qualifiedName: "herdctl.tester",
|
|
1354
|
+
configPath: "/path/to/herdctl/agents/tester/agent.yaml",
|
|
1355
|
+
},
|
|
1356
|
+
],
|
|
1357
|
+
configPath: path.join(tempDir, "herdctl.yaml"),
|
|
1358
|
+
configDir: tempDir,
|
|
1359
|
+
});
|
|
1360
|
+
await agentListCommand({});
|
|
1361
|
+
expect(mockedLoadConfig).toHaveBeenCalled();
|
|
1362
|
+
// Should show tree with fleet name
|
|
1363
|
+
expect(consoleLogs.some((log) => log.includes("global"))).toBe(true);
|
|
1364
|
+
expect(consoleLogs.some((log) => log.includes("Fleet of Fleets"))).toBe(true);
|
|
1365
|
+
// Should show sub-fleet and agents
|
|
1366
|
+
expect(consoleLogs.some((log) => log.includes("herdctl"))).toBe(true);
|
|
1367
|
+
expect(consoleLogs.some((log) => log.includes("engineer"))).toBe(true);
|
|
1368
|
+
expect(consoleLogs.some((log) => log.includes("tester"))).toBe(true);
|
|
1369
|
+
// Should show total
|
|
1370
|
+
expect(consoleLogs.some((log) => log.includes("Total: 2 agents across fleet hierarchy"))).toBe(true);
|
|
1371
|
+
});
|
|
1372
|
+
it("outputs JSON with fleet hierarchy info when --json is used", async () => {
|
|
1373
|
+
createFleetConfigWithSubFleets(tempDir);
|
|
1374
|
+
mockedLoadConfig.mockResolvedValue({
|
|
1375
|
+
fleet: {
|
|
1376
|
+
version: 1,
|
|
1377
|
+
fleet: { name: "global" },
|
|
1378
|
+
fleets: [{ path: "./sub/herdctl.yaml" }],
|
|
1379
|
+
agents: [],
|
|
1380
|
+
},
|
|
1381
|
+
agents: [
|
|
1382
|
+
{
|
|
1383
|
+
name: "engineer",
|
|
1384
|
+
fleetPath: ["herdctl"],
|
|
1385
|
+
qualifiedName: "herdctl.engineer",
|
|
1386
|
+
configPath: "/path/to/engineer/agent.yaml",
|
|
1387
|
+
description: "Engineering agent",
|
|
1388
|
+
},
|
|
1389
|
+
],
|
|
1390
|
+
configPath: path.join(tempDir, "herdctl.yaml"),
|
|
1391
|
+
configDir: tempDir,
|
|
1392
|
+
});
|
|
1393
|
+
await agentListCommand({ json: true });
|
|
1394
|
+
const output = consoleLogs.join("\n");
|
|
1395
|
+
const parsed = JSON.parse(output);
|
|
1396
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
1397
|
+
expect(parsed).toHaveLength(1);
|
|
1398
|
+
expect(parsed[0].name).toBe("engineer");
|
|
1399
|
+
expect(parsed[0].qualifiedName).toBe("herdctl.engineer");
|
|
1400
|
+
expect(parsed[0].fleetPath).toEqual(["herdctl"]);
|
|
1401
|
+
});
|
|
1402
|
+
it("shows helpful message when no agents across hierarchy", async () => {
|
|
1403
|
+
createFleetConfigWithSubFleets(tempDir);
|
|
1404
|
+
mockedLoadConfig.mockResolvedValue({
|
|
1405
|
+
fleet: {
|
|
1406
|
+
version: 1,
|
|
1407
|
+
fleet: { name: "global" },
|
|
1408
|
+
fleets: [{ path: "./sub/herdctl.yaml" }],
|
|
1409
|
+
agents: [],
|
|
1410
|
+
},
|
|
1411
|
+
agents: [],
|
|
1412
|
+
configPath: path.join(tempDir, "herdctl.yaml"),
|
|
1413
|
+
configDir: tempDir,
|
|
1414
|
+
});
|
|
1415
|
+
await agentListCommand({});
|
|
1416
|
+
expect(consoleLogs.some((log) => log.includes("No agents found across fleet hierarchy"))).toBe(true);
|
|
1417
|
+
expect(consoleLogs.some((log) => log.includes("herdctl agent add"))).toBe(true);
|
|
1418
|
+
});
|
|
1419
|
+
it("handles ConfigError gracefully", async () => {
|
|
1420
|
+
createFleetConfigWithSubFleets(tempDir);
|
|
1421
|
+
mockedLoadConfig.mockRejectedValue(new ConfigError("Bad config"));
|
|
1422
|
+
await expect(agentListCommand({})).rejects.toThrow("process.exit");
|
|
1423
|
+
expect(exitCode).toBe(1);
|
|
1424
|
+
expect(consoleErrors.some((e) => e.includes("Config error"))).toBe(true);
|
|
1425
|
+
});
|
|
1426
|
+
it("handles ConfigNotFoundError gracefully", async () => {
|
|
1427
|
+
createFleetConfigWithSubFleets(tempDir);
|
|
1428
|
+
mockedLoadConfig.mockRejectedValue(new ConfigNotFoundError("/some/path", []));
|
|
1429
|
+
await expect(agentListCommand({})).rejects.toThrow("process.exit");
|
|
1430
|
+
expect(exitCode).toBe(1);
|
|
1431
|
+
expect(consoleErrors.some((e) => e.includes("Config error"))).toBe(true);
|
|
1432
|
+
});
|
|
1433
|
+
it("falls back to flat table when no sub-fleets in config", async () => {
|
|
1434
|
+
// Create a config WITHOUT fleets
|
|
1435
|
+
fs.writeFileSync(path.join(tempDir, "herdctl.yaml"), `version: 1
|
|
1436
|
+
|
|
1437
|
+
fleet:
|
|
1438
|
+
name: simple-fleet
|
|
1439
|
+
|
|
1440
|
+
agents: []
|
|
1441
|
+
`, "utf-8");
|
|
1442
|
+
mockedDiscoverAgents.mockResolvedValue({
|
|
1443
|
+
agents: [
|
|
1444
|
+
{
|
|
1445
|
+
name: "simple-agent",
|
|
1446
|
+
installed: false,
|
|
1447
|
+
path: "/path/to/agents/simple-agent",
|
|
1448
|
+
configPath: "./agents/simple-agent/agent.yaml",
|
|
1449
|
+
},
|
|
1450
|
+
],
|
|
1451
|
+
});
|
|
1452
|
+
await agentListCommand({});
|
|
1453
|
+
// Should use discoverAgents, NOT loadConfig
|
|
1454
|
+
expect(mockedDiscoverAgents).toHaveBeenCalled();
|
|
1455
|
+
expect(mockedLoadConfig).not.toHaveBeenCalled();
|
|
1456
|
+
// Should show flat table
|
|
1457
|
+
expect(consoleLogs.some((log) => log.includes("Agents in fleet:"))).toBe(true);
|
|
1458
|
+
expect(consoleLogs.some((log) => log.includes("simple-agent"))).toBe(true);
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
//# sourceMappingURL=agent.test.js.map
|