Skip to content

Commit bc86ebe

Browse files
committed
Add post category subcommand for category management
Add support for managing post categories via CLI: - post category list: list all categories with pagination - post category get: show category details - post category create: create a new category - post category update: update an existing category - post category delete: delete a category The implementation follows the same nested CLI pattern as post tag.
1 parent 4c40201 commit bc86ebe

File tree

4 files changed

+495
-1
lines changed

4 files changed

+495
-1
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import { tryRunPostCommand } from "../index.js";
4+
5+
// Mock the category module
6+
vi.mock("../category.js", () => ({
7+
buildCategoryCli: vi.fn(() => {
8+
const mockCli = {
9+
outputHelp: vi.fn(),
10+
parse: vi.fn(),
11+
matchedCommand: undefined,
12+
args: [],
13+
runMatchedCommand: vi.fn(),
14+
};
15+
return mockCli;
16+
}),
17+
}));
18+
19+
describe("tryRunPostCommand category", () => {
20+
const mockRuntime = {
21+
getClientsForOptions: vi.fn(),
22+
} as unknown as Parameters<typeof tryRunPostCommand>[1];
23+
24+
it("handles category subcommand", async () => {
25+
const result = await tryRunPostCommand(["post", "category", "list"], mockRuntime);
26+
expect(result).toBe(true);
27+
});
28+
29+
it("handles bare category subcommand", async () => {
30+
const result = await tryRunPostCommand(["post", "category"], mockRuntime);
31+
expect(result).toBe(true);
32+
});
33+
});

src/commands/post/category.ts

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import type { Category } from "@halo-dev/api-client";
2+
import { CategoryV1alpha1Api } from "@halo-dev/api-client";
3+
import { input } from "@inquirer/prompts";
4+
import cac, { type CAC } from "cac";
5+
6+
import { confirmDangerousAction } from "../../utils/confirmation.js";
7+
import { CliError } from "../../utils/errors.js";
8+
import { isInteractive, parseNumberOption } from "../../utils/options.js";
9+
import { printJson } from "../../utils/output.js";
10+
import { RuntimeContext } from "../../utils/runtime.js";
11+
import { printCategory, printCategoryList } from "./format.js";
12+
import { slugifyTaxonomyDisplayName } from "./input.js";
13+
14+
interface CategoryCommandOptions {
15+
profile?: string;
16+
json?: boolean;
17+
}
18+
19+
interface CategoryListOptions extends CategoryCommandOptions {
20+
page?: string;
21+
size?: string;
22+
keyword?: string;
23+
sort?: string;
24+
}
25+
26+
interface CategoryCreateOptions extends CategoryCommandOptions {
27+
displayName?: string;
28+
slug?: string;
29+
description?: string;
30+
cover?: string;
31+
priority?: string;
32+
}
33+
34+
interface CategoryUpdateOptions extends CategoryCommandOptions {
35+
displayName?: string;
36+
slug?: string;
37+
description?: string;
38+
cover?: string;
39+
priority?: string;
40+
}
41+
42+
interface CategoryDeleteOptions extends CategoryCommandOptions {
43+
force?: boolean;
44+
}
45+
46+
async function resolveCategoryDisplayName(displayName: string | undefined): Promise<string> {
47+
if (displayName?.trim()) {
48+
return displayName.trim();
49+
}
50+
51+
if (!isInteractive()) {
52+
throw new CliError(
53+
"Category display name is required. Use --display-name or run interactively.",
54+
);
55+
}
56+
57+
const value = await input({
58+
message: "Display name",
59+
validate: (value) => (value.trim().length > 0 ? true : "Display name is required."),
60+
});
61+
62+
return value.trim();
63+
}
64+
65+
async function resolveCategorySlug(slug: string | undefined, displayName: string): Promise<string> {
66+
if (slug?.trim()) {
67+
return slug.trim();
68+
}
69+
70+
const generatedSlug = slugifyTaxonomyDisplayName(displayName, "category");
71+
72+
if (!isInteractive()) {
73+
return generatedSlug;
74+
}
75+
76+
const value = await input({
77+
message: "Slug",
78+
default: generatedSlug,
79+
});
80+
81+
return value.trim() || generatedSlug;
82+
}
83+
84+
async function resolveCategoryDescription(
85+
description: string | undefined,
86+
): Promise<string | undefined> {
87+
if (description !== undefined) {
88+
return description.trim() || undefined;
89+
}
90+
91+
if (!isInteractive()) {
92+
return undefined;
93+
}
94+
95+
const value = await input({
96+
message: "Description (optional)",
97+
});
98+
99+
return value.trim() || undefined;
100+
}
101+
102+
async function resolveCategoryCover(cover: string | undefined): Promise<string | undefined> {
103+
if (cover !== undefined) {
104+
return cover.trim() || undefined;
105+
}
106+
107+
if (!isInteractive()) {
108+
return undefined;
109+
}
110+
111+
const value = await input({
112+
message: "Cover URL (optional)",
113+
});
114+
115+
return value.trim() || undefined;
116+
}
117+
118+
async function resolveCategoryPriority(priority: string | undefined): Promise<number | undefined> {
119+
if (priority !== undefined) {
120+
const parsed = parseNumberOption(priority);
121+
return parsed;
122+
}
123+
124+
if (!isInteractive()) {
125+
return undefined;
126+
}
127+
128+
const value = await input({
129+
message: "Priority (optional, number)",
130+
});
131+
132+
const trimmed = value.trim();
133+
if (!trimmed) {
134+
return undefined;
135+
}
136+
137+
const parsed = Number(trimmed);
138+
if (Number.isNaN(parsed)) {
139+
return undefined;
140+
}
141+
142+
return parsed;
143+
}
144+
145+
function buildCategoryRequestPayload(
146+
displayName: string,
147+
slug: string,
148+
description: string | undefined,
149+
cover: string | undefined,
150+
priority: number | undefined,
151+
): Category {
152+
return {
153+
apiVersion: "content.halo.run/v1alpha1",
154+
kind: "Category",
155+
metadata: {
156+
name: "",
157+
generateName: "category-",
158+
},
159+
spec: {
160+
displayName,
161+
slug,
162+
description,
163+
cover,
164+
priority: priority ?? 0,
165+
children: [],
166+
},
167+
};
168+
}
169+
170+
function buildCategoryUpdatePayload(
171+
existingCategory: Category,
172+
displayName: string | undefined,
173+
slug: string | undefined,
174+
description: string | undefined,
175+
cover: string | undefined,
176+
priority: number | undefined,
177+
): Category {
178+
return {
179+
...existingCategory,
180+
spec: {
181+
...existingCategory.spec,
182+
displayName: displayName ?? existingCategory.spec.displayName,
183+
slug: slug ?? existingCategory.spec.slug,
184+
description: description !== undefined ? description : existingCategory.spec.description,
185+
cover: cover !== undefined ? cover : existingCategory.spec.cover,
186+
priority: priority !== undefined ? priority : existingCategory.spec.priority,
187+
},
188+
};
189+
}
190+
191+
export function buildCategoryCli(runtime: RuntimeContext): CAC {
192+
const categoryCli = cac("halo post category");
193+
194+
categoryCli
195+
.command("list", "List categories")
196+
.option("--profile <name>", "Halo profile name")
197+
.option("--json", "Output JSON")
198+
.option("--page <number>", "Page number", { default: 1 })
199+
.option("--size <number>", "Page size", { default: 50 })
200+
.option("--keyword <keyword>", "Filter by keyword")
201+
.option("--sort <sort>", "Sort expression, e.g. metadata.creationTimestamp,desc")
202+
.action(async (options: CategoryListOptions) => {
203+
const { profile, clients } = await runtime.getClientsForOptions(options);
204+
const categoryApi = new CategoryV1alpha1Api(undefined, profile.baseUrl, clients.axios);
205+
206+
const response = await categoryApi.listCategory({
207+
page: parseNumberOption(options.page),
208+
size: parseNumberOption(options.size),
209+
fieldSelector: options.keyword ? [`spec.displayName=${options.keyword}`] : undefined,
210+
sort: options.sort?.trim() ? [options.sort.trim()] : undefined,
211+
});
212+
213+
printCategoryList(response.data, options.json);
214+
});
215+
216+
categoryCli
217+
.command("get <name>", "Show category details")
218+
.option("--profile <name>", "Halo profile name")
219+
.option("--json", "Output JSON")
220+
.action(async (name: string, options: CategoryCommandOptions) => {
221+
const { profile, clients } = await runtime.getClientsForOptions(options);
222+
const categoryApi = new CategoryV1alpha1Api(undefined, profile.baseUrl, clients.axios);
223+
224+
const response = await categoryApi.getCategory({ name });
225+
printCategory(response.data, options.json);
226+
});
227+
228+
categoryCli
229+
.command("create", "Create a category")
230+
.option("--profile <name>", "Halo profile name")
231+
.option("--json", "Output JSON")
232+
.option("--display-name <name>", "Category display name")
233+
.option("--slug <slug>", "Category slug")
234+
.option("--description <text>", "Category description")
235+
.option("--cover <url>", "Category cover URL")
236+
.option("--priority <number>", "Category priority")
237+
.action(async (options: CategoryCreateOptions) => {
238+
const { profile, clients } = await runtime.getClientsForOptions(options);
239+
const categoryApi = new CategoryV1alpha1Api(undefined, profile.baseUrl, clients.axios);
240+
241+
const displayName = await resolveCategoryDisplayName(options.displayName);
242+
const slug = await resolveCategorySlug(options.slug, displayName);
243+
const description = await resolveCategoryDescription(options.description);
244+
const cover = await resolveCategoryCover(options.cover);
245+
const priority = await resolveCategoryPriority(options.priority);
246+
247+
const payload = buildCategoryRequestPayload(displayName, slug, description, cover, priority);
248+
const response = await categoryApi.createCategory({ category: payload });
249+
250+
printCategory(response.data, options.json, "Category created successfully.");
251+
});
252+
253+
categoryCli
254+
.command("update <name>", "Update a category")
255+
.option("--profile <name>", "Halo profile name")
256+
.option("--json", "Output JSON")
257+
.option("--display-name <name>", "Category display name")
258+
.option("--slug <slug>", "Category slug")
259+
.option("--description <text>", "Category description")
260+
.option("--cover <url>", "Category cover URL")
261+
.option("--priority <number>", "Category priority")
262+
.action(async (name: string, options: CategoryUpdateOptions) => {
263+
const { profile, clients } = await runtime.getClientsForOptions(options);
264+
const categoryApi = new CategoryV1alpha1Api(undefined, profile.baseUrl, clients.axios);
265+
266+
// Fetch existing category first
267+
const existingResponse = await categoryApi.getCategory({ name });
268+
const existingCategory = existingResponse.data;
269+
270+
// Resolve new values
271+
const displayName = options.displayName?.trim();
272+
const slug = options.slug?.trim();
273+
const description =
274+
options.description !== undefined ? options.description.trim() || undefined : undefined;
275+
const cover = options.cover !== undefined ? options.cover.trim() || undefined : undefined;
276+
const priority = parseNumberOption(options.priority);
277+
278+
// Interactive prompts for empty fields if in TTY mode
279+
let finalDisplayName = displayName;
280+
let finalSlug = slug;
281+
282+
if (!finalDisplayName && isInteractive()) {
283+
finalDisplayName = await input({
284+
message: "Display name",
285+
default: existingCategory.spec.displayName,
286+
});
287+
}
288+
289+
if (!finalSlug && isInteractive() && finalDisplayName) {
290+
const defaultSlug = slugifyTaxonomyDisplayName(finalDisplayName, "category");
291+
finalSlug = await input({
292+
message: "Slug",
293+
default: defaultSlug,
294+
});
295+
}
296+
297+
const payload = buildCategoryUpdatePayload(
298+
existingCategory,
299+
finalDisplayName,
300+
finalSlug,
301+
description,
302+
cover,
303+
priority,
304+
);
305+
306+
const response = await categoryApi.updateCategory({ name, category: payload });
307+
printCategory(response.data, options.json, "Category updated successfully.");
308+
});
309+
310+
categoryCli
311+
.command("delete <name>", "Delete a category")
312+
.option("--profile <name>", "Halo profile name")
313+
.option("--json", "Output JSON")
314+
.option("--force", "Delete without confirmation")
315+
.action(async (name: string, options: CategoryDeleteOptions) => {
316+
const { profile, clients } = await runtime.getClientsForOptions(options);
317+
const categoryApi = new CategoryV1alpha1Api(undefined, profile.baseUrl, clients.axios);
318+
319+
if (
320+
!(await confirmDangerousAction(
321+
{
322+
commandPath: "halo post category delete",
323+
actionLabel: "Delete",
324+
resourceLabel: "category",
325+
resourceName: name,
326+
cancellationVerb: "deleting",
327+
},
328+
options,
329+
))
330+
) {
331+
return;
332+
}
333+
334+
await categoryApi.deleteCategory({ name });
335+
336+
if (options.json) {
337+
printJson({ deleted: true, name });
338+
return;
339+
}
340+
341+
process.stdout.write(`Deleted category ${name}.\n`);
342+
});
343+
344+
categoryCli.usage("<command> [flags]");
345+
categoryCli.example((bin) => `${bin} list`);
346+
categoryCli.example((bin) => `${bin} get category-abc123`);
347+
categoryCli.example((bin) => `${bin} create --display-name "Technology" --priority 100`);
348+
categoryCli.example((bin) => `${bin} update category-abc123 --display-name "Tech"`);
349+
categoryCli.example((bin) => `${bin} delete category-abc123 --force`);
350+
categoryCli.help();
351+
352+
return categoryCli;
353+
}

0 commit comments

Comments
 (0)