@autifyhq/aximo-cli 0.13.0 → 0.14.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.
@@ -1,4 +1,6 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
2
4
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
5
  import { defineTools } from "./tools.js";
4
6
  beforeAll(() => {
@@ -22,6 +24,9 @@ describe("defineTools", () => {
22
24
  const names = defineTools().map((t) => t.name);
23
25
  expect(names).toEqual([
24
26
  "aximo_list_projects",
27
+ "aximo_list_devices",
28
+ "aximo_list_user_devices",
29
+ "aximo_list_uploads",
25
30
  "aximo_list_sessions",
26
31
  "aximo_get_session",
27
32
  "aximo_get_session_messages",
@@ -33,6 +38,8 @@ describe("defineTools", () => {
33
38
  "aximo_get_case",
34
39
  "aximo_create_case",
35
40
  "aximo_update_case",
41
+ "aximo_archive_case",
42
+ "aximo_restore_case",
36
43
  "aximo_delete_case",
37
44
  "aximo_run_case",
38
45
  "aximo_create_plan",
@@ -41,6 +48,9 @@ describe("defineTools", () => {
41
48
  "aximo_list_plans",
42
49
  "aximo_get_plan",
43
50
  "aximo_update_plan",
51
+ "aximo_archive_plan",
52
+ "aximo_restore_plan",
53
+ "aximo_delete_plan",
44
54
  "aximo_run_plan",
45
55
  "aximo_list_plan_runs",
46
56
  "aximo_get_plan_run",
@@ -180,6 +190,9 @@ describe("aximo_update_case", () => {
180
190
  expect(result.isError).toBeUndefined();
181
191
  expect(parsePayload(result)).toEqual(payload);
182
192
  expect(body).toEqual({ title: "Renamed", scenario: "Given a renamed page" });
193
+ // GEN-2844 regression guard: an omitted description must not be sent at
194
+ // all, or every partial update would clear the field server-side.
195
+ expect(body).not.toHaveProperty("description");
183
196
  });
184
197
  it("rejects an update with no fields instead of issuing an empty PATCH", async () => {
185
198
  let called = false;
@@ -204,6 +217,35 @@ describe("aximo_update_case", () => {
204
217
  expect(result.isError).toBeUndefined();
205
218
  expect(body).toEqual({ startUrl: null });
206
219
  });
220
+ it("passes description: null through as an explicit clear (GEN-2844)", async () => {
221
+ let body = null;
222
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
223
+ body = await req.json();
224
+ return HttpResponse.json({ success: true });
225
+ }));
226
+ const tool = byName(defineTools(), "aximo_update_case");
227
+ const result = await tool.handler({ caseId, description: null });
228
+ expect(result.isError).toBeUndefined();
229
+ expect(body).toEqual({ description: null });
230
+ });
231
+ // The tests above call handler() directly, which bypasses Zod entirely --
232
+ // the MCP SDK is what parses in production. These assertions are what
233
+ // actually pin the clearable fields to their schema.
234
+ it("accepts null for the clearable fields at schema time, and drops omitted keys", () => {
235
+ const tool = byName(defineTools(), "aximo_update_case");
236
+ // biome-ignore lint/suspicious/noExplicitAny: zod runtime check
237
+ const schemas = tool.inputSchema;
238
+ expect(schemas.description.safeParse(null).success).toBe(true);
239
+ expect(schemas.description.safeParse("").success).toBe(true);
240
+ expect(schemas.description.safeParse(5).success).toBe(false);
241
+ expect(schemas.startUrl.safeParse(null).success).toBe(true);
242
+ expect(schemas.startUrl.safeParse("not-a-url").success).toBe(false);
243
+ // The object schema must leave omitted keys absent rather than defaulting
244
+ // them to null, which would clear on every partial update.
245
+ const parsed = z.object(schemas).parse({ caseId, title: "Renamed" });
246
+ expect(parsed).not.toHaveProperty("description");
247
+ expect(parsed).not.toHaveProperty("startUrl");
248
+ });
207
249
  });
208
250
  describe("aximo_run_case", () => {
209
251
  const caseId = "77777777-7777-4777-9777-777777777777";
@@ -704,6 +746,32 @@ describe("write tools", () => {
704
746
  expect(parsePayload(result)).toEqual({ success: true });
705
747
  expect(method).toBe("DELETE");
706
748
  });
749
+ it("aximo_archive_case POSTs the case archive path", async () => {
750
+ let method = null;
751
+ server.use(http.post(`${TEST_BASE_URL}/api/cases/c1/archive`, ({ request: req }) => {
752
+ method = req.method;
753
+ return HttpResponse.json({ archived: true });
754
+ }));
755
+ const tool = byName(defineTools(), "aximo_archive_case");
756
+ const result = await tool.handler({ caseId: "c1" });
757
+ expect(parsePayload(result)).toEqual({ archived: true });
758
+ expect(method).toBe("POST");
759
+ });
760
+ it("aximo_archive_plan POSTs the plan archive path", async () => {
761
+ let method = null;
762
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/archive`, ({ request: req }) => {
763
+ method = req.method;
764
+ return HttpResponse.json({ archived: true });
765
+ }));
766
+ const tool = byName(defineTools(), "aximo_archive_plan");
767
+ const result = await tool.handler({ planId: "pl1" });
768
+ expect(parsePayload(result)).toEqual({ archived: true });
769
+ expect(method).toBe("POST");
770
+ });
771
+ it("aximo_delete_case describes itself as archive-first and permanent", () => {
772
+ const tool = byName(defineTools(), "aximo_delete_case");
773
+ expect(tool.description).toBe("Permanently delete an already-archived test case. Archive it first with aximo_archive_case. Requires an organization admin or owner API key. It is removed from any plans it belongs to, and its rows in past plan runs go with it, so those runs list fewer cases than their stored totals; the agent sessions themselves are kept but detached. This cannot be undone.");
774
+ });
707
775
  it("aximo_delete_case DELETEs the case path", async () => {
708
776
  let method = null;
709
777
  server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, ({ request: req }) => {
@@ -727,3 +795,245 @@ describe("write tools", () => {
727
795
  expect(body).toEqual({ title: "Saved" });
728
796
  });
729
797
  });
798
+ describe("aximo_update_case per-target config", () => {
799
+ const caseId = "55555555-5555-4555-9555-555555555555";
800
+ const userDeviceId = "77777777-7777-4777-9777-777777777777";
801
+ it("forwards the config objects untouched, including keys the CLI does not know about", async () => {
802
+ let body = null;
803
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
804
+ body = await req.json();
805
+ return HttpResponse.json({ success: true });
806
+ }));
807
+ const mobile = {
808
+ deviceArn: "enc-device",
809
+ deviceName: "Pixel 8",
810
+ platform: "ANDROID",
811
+ apps: [{ uploadArn: "enc-upload", uploadType: "own_app", appName: null, bundleId: "com.x" }],
812
+ futureField: "kept",
813
+ };
814
+ const multi = [
815
+ { kind: "web", name: "Buyer", startUrl: "https://shop.example" },
816
+ { kind: "mobile", name: "Seller", mobilePlatform: "ios", userDeviceId },
817
+ ];
818
+ const tool = byName(defineTools(), "aximo_update_case");
819
+ const result = await tool.handler({
820
+ caseId,
821
+ type: "multi_device",
822
+ apiMobileConfig: mobile,
823
+ apiDesktopConfig: { userDeviceId },
824
+ apiMultiDeviceConfig: multi,
825
+ apiWebConfig: { hostnameOverrideIds: ["h1"] },
826
+ });
827
+ expect(result.isError).toBeUndefined();
828
+ expect(body).toEqual({
829
+ type: "multi_device",
830
+ apiMobileConfig: mobile,
831
+ apiDesktopConfig: { userDeviceId },
832
+ apiMultiDeviceConfig: multi,
833
+ apiWebConfig: { hostnameOverrideIds: ["h1"] },
834
+ });
835
+ });
836
+ it("passes null configs (and description) through as explicit clears", async () => {
837
+ let body = null;
838
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, async ({ request: req }) => {
839
+ body = await req.json();
840
+ return HttpResponse.json({ success: true });
841
+ }));
842
+ const tool = byName(defineTools(), "aximo_update_case");
843
+ const result = await tool.handler({
844
+ caseId,
845
+ description: null,
846
+ apiMobileConfig: null,
847
+ apiDesktopConfig: null,
848
+ apiMultiDeviceConfig: null,
849
+ apiWebConfig: null,
850
+ });
851
+ expect(result.isError).toBeUndefined();
852
+ expect(body).toEqual({
853
+ description: null,
854
+ apiMobileConfig: null,
855
+ apiDesktopConfig: null,
856
+ apiMultiDeviceConfig: null,
857
+ apiWebConfig: null,
858
+ });
859
+ });
860
+ it("keeps unknown nested keys at schema time and only pins userDeviceId's shape", () => {
861
+ const schema = z.object(byName(defineTools(), "aximo_update_case").inputSchema);
862
+ const parsed = schema.parse({
863
+ caseId,
864
+ apiMobileConfig: { deviceArn: "x", newKey: 1 },
865
+ apiMultiDeviceConfig: [{ kind: "web", extra: true }],
866
+ apiWebConfig: { hostnameOverrideIds: [], newKey: true },
867
+ });
868
+ expect(parsed["apiMobileConfig"]).toEqual({ deviceArn: "x", newKey: 1 });
869
+ expect(parsed["apiMultiDeviceConfig"]).toEqual([{ kind: "web", extra: true }]);
870
+ expect(parsed["apiWebConfig"]).toEqual({ hostnameOverrideIds: [], newKey: true });
871
+ expect(schema.safeParse({ caseId, apiDesktopConfig: { userDeviceId: "nope" } }).success).toBe(false);
872
+ expect(schema.safeParse({ caseId, apiMultiDeviceConfig: "web" }).success).toBe(false);
873
+ });
874
+ it("names the failing path from the API's error details", async () => {
875
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
876
+ error: "Upload not found",
877
+ details: { path: "apiMultiDeviceConfig[1].apps[0].uploadArn" },
878
+ }, { status: 404 })));
879
+ const tool = byName(defineTools(), "aximo_update_case");
880
+ const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
881
+ expect(result.isError).toBe(true);
882
+ expect(parsePayload(result).error).toBe("Upload not found (at apiMultiDeviceConfig[1].apps[0].uploadArn)");
883
+ });
884
+ it("renders zod issues from an Invalid request body tree", async () => {
885
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
886
+ error: "Invalid request body",
887
+ details: {
888
+ errors: [],
889
+ properties: {
890
+ apiMobileConfig: {
891
+ errors: [],
892
+ properties: { deviceArn: { errors: ["Invalid input: expected string"] } },
893
+ },
894
+ },
895
+ },
896
+ }, { status: 400 })));
897
+ const tool = byName(defineTools(), "aximo_update_case");
898
+ const result = await tool.handler({ caseId, apiMobileConfig: {} });
899
+ expect(result.isError).toBe(true);
900
+ expect(parsePayload(result).error).toBe("HTTP 400: Invalid request body (apiMobileConfig.deviceArn: Invalid input: expected string)");
901
+ });
902
+ it("points at an org admin, not the API key, when a feature gate rejects the config", async () => {
903
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({
904
+ error: "Desktop cases are not enabled for this organization",
905
+ details: { feature: "byodEnabled" },
906
+ }, { status: 403 })));
907
+ const tool = byName(defineTools(), "aximo_update_case");
908
+ const result = await tool.handler({ caseId, apiDesktopConfig: { userDeviceId } });
909
+ expect(result.isError).toBe(true);
910
+ const payload = parsePayload(result);
911
+ expect(payload.error).toBe("Desktop cases are not enabled for this organization (requires feature byodEnabled)");
912
+ expect(payload.hint).toBe("Ask an Aximo organization admin to enable byodEnabled.");
913
+ });
914
+ it("recognises the feature-gate message even without details", async () => {
915
+ server.use(http.patch(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: "Multi-device cases are not enabled for this organization" }, { status: 403 })));
916
+ const tool = byName(defineTools(), "aximo_update_case");
917
+ const result = await tool.handler({ caseId, apiMultiDeviceConfig: [{}, {}] });
918
+ const payload = parsePayload(result);
919
+ expect(payload.error).toBe("Multi-device cases are not enabled for this organization");
920
+ expect(payload.hint).toMatch(/organization admin/);
921
+ expect(payload.hint).not.toMatch(/API key/);
922
+ });
923
+ });
924
+ describe("device and upload read tools", () => {
925
+ it("aximo_list_devices GETs /api/devices with platform and availableOnly", async () => {
926
+ let url = null;
927
+ const payload = {
928
+ devices: [{ arn: "enc-device", name: "Pixel 8", platform: "ANDROID", os: "14" }],
929
+ };
930
+ server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
931
+ url = new URL(req.url);
932
+ return HttpResponse.json(payload);
933
+ }));
934
+ const tool = byName(defineTools(), "aximo_list_devices");
935
+ const result = await tool.handler({ platform: "ANDROID", availableOnly: true });
936
+ expect(result.isError).toBeUndefined();
937
+ expect(parsePayload(result)).toEqual(payload);
938
+ expect(url.searchParams.get("platform")).toBe("ANDROID");
939
+ expect(url.searchParams.get("availableOnly")).toBe("true");
940
+ });
941
+ it("aximo_list_devices sends no filters when none are given", async () => {
942
+ let url = null;
943
+ server.use(http.get(`${TEST_BASE_URL}/api/devices`, ({ request: req }) => {
944
+ url = new URL(req.url);
945
+ return HttpResponse.json({ devices: [] });
946
+ }));
947
+ const tool = byName(defineTools(), "aximo_list_devices");
948
+ await tool.handler({ availableOnly: false });
949
+ expect([...url.searchParams.keys()]).toEqual([]);
950
+ });
951
+ it("aximo_list_user_devices GETs /api/devices/user", async () => {
952
+ const payload = {
953
+ devices: [{ id: "u1", name: "Abdul's Mac", platform: "macos", os: "15.0", status: "online" }],
954
+ };
955
+ let called = false;
956
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => {
957
+ called = true;
958
+ return HttpResponse.json(payload);
959
+ }));
960
+ const tool = byName(defineTools(), "aximo_list_user_devices");
961
+ const result = await tool.handler({});
962
+ expect(called).toBe(true);
963
+ expect(parsePayload(result)).toEqual(payload);
964
+ });
965
+ it("aximo_list_user_devices explains the bare 404 the route returns when BYOD is disabled", async () => {
966
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ error: "Not found" }, { status: 404 })));
967
+ const tool = byName(defineTools(), "aximo_list_user_devices");
968
+ const result = await tool.handler({});
969
+ expect(result.isError).toBe(true);
970
+ const payload = parsePayload(result);
971
+ expect(payload.error).toContain("BYOD is not enabled for this organization");
972
+ expect(payload.hint).toContain("byodEnabled");
973
+ });
974
+ it("aximo_list_uploads GETs /api/uploads with fileType", async () => {
975
+ let url = null;
976
+ const payload = {
977
+ uploads: [
978
+ {
979
+ id: "up1",
980
+ uploadArn: "enc-upload",
981
+ status: "succeeded",
982
+ appName: "Shop",
983
+ identifier: "com.example.shop",
984
+ fileType: "IOS_APP",
985
+ },
986
+ ],
987
+ };
988
+ server.use(http.get(`${TEST_BASE_URL}/api/uploads`, ({ request: req }) => {
989
+ url = new URL(req.url);
990
+ return HttpResponse.json(payload);
991
+ }));
992
+ const tool = byName(defineTools(), "aximo_list_uploads");
993
+ const result = await tool.handler({ fileType: "IOS_APP" });
994
+ expect(result.isError).toBeUndefined();
995
+ expect(parsePayload(result)).toEqual(payload);
996
+ expect(url.searchParams.get("fileType")).toBe("IOS_APP");
997
+ });
998
+ });
999
+ describe("409 handling parity between the CLI and MCP error mappers", () => {
1000
+ const caseId = "11111111-1111-4111-8111-111111111111";
1001
+ const ARCHIVE_409 = "Test case must be archived before it can be permanently deleted. Archive it first with POST /api/cases/{caseId}/archive, then retry the delete.";
1002
+ const OTHER_409 = "Case already in plan";
1003
+ function conflictOnDelete(message) {
1004
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/${caseId}`, () => HttpResponse.json({ error: message }, { status: 409 })));
1005
+ }
1006
+ it("surfaces the archive hint on the MCP surface, not just the CLI", async () => {
1007
+ conflictOnDelete(ARCHIVE_409);
1008
+ const tool = byName(defineTools(), "aximo_delete_case");
1009
+ const result = await tool.handler({ caseId });
1010
+ expect(result.isError).toBe(true);
1011
+ const payload = parsePayload(result);
1012
+ expect(payload.error).toMatch(/must be archived/);
1013
+ // The hint names aximo_archive_case, so the MCP caller it names has to receive
1014
+ // it. Before this branch existed the agent got a bare "HTTP 409: ..." pointing
1015
+ // at a REST endpoint it cannot call.
1016
+ expect(payload.hint).toBe(ARCHIVE_FIRST_HINT);
1017
+ expect(payload.hint).toMatch(/aximo_archive_case/);
1018
+ });
1019
+ it("stays silent on a 409 that archiving would not fix", async () => {
1020
+ conflictOnDelete(OTHER_409);
1021
+ const tool = byName(defineTools(), "aximo_delete_case");
1022
+ const result = await tool.handler({ caseId });
1023
+ expect(result.isError).toBe(true);
1024
+ const payload = parsePayload(result);
1025
+ expect(payload.error).toMatch(/already in plan/);
1026
+ expect(payload.hint).toBeUndefined();
1027
+ });
1028
+ it("agrees with handleApiError on which 409s earn the hint", () => {
1029
+ // Both mappers key off the same predicate and the same constant. If one side
1030
+ // grows a rule the other lacks, this fails rather than silently diverging on
1031
+ // the agent-facing surface.
1032
+ for (const message of [ARCHIVE_409, OTHER_409]) {
1033
+ const cliEnvelope = handleApiError(Object.assign(new Error(message), { status: 409 }));
1034
+ const expectedHint = isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined;
1035
+ expect(cliEnvelope.error.code).toBe("conflict");
1036
+ expect(cliEnvelope.error.hint).toBe(expectedHint);
1037
+ }
1038
+ });
1039
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { ArchivePlanResult, Envelope } from "../../types.js";
4
+ export interface ArchivePlanOptions {
5
+ planId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runPlanArchive(options: ArchivePlanOptions, deps?: ClientDeps): Promise<Envelope<ArchivePlanResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly planId: {
11
+ readonly type: "positional";
12
+ readonly description: "Plan ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runPlanArchive(options, deps) {
8
+ let planId;
9
+ try {
10
+ planId = requireId("planId", options.planId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/plans/${encodeURIComponent(planId)}/archive`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("POST", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "POST" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: {
29
+ name: "archive",
30
+ description: "Archive a test plan (hides it from lists; cases, run history, and its schedule are kept)",
31
+ },
32
+ args: {
33
+ planId: { type: "positional", description: "Plan ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runPlanArchive({
38
+ planId: args.planId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { DeletePlanResult, Envelope } from "../../types.js";
4
+ export interface DeletePlanOptions {
5
+ planId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runPlanDelete(options: DeletePlanOptions, deps?: ClientDeps): Promise<Envelope<DeletePlanResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly planId: {
11
+ readonly type: "positional";
12
+ readonly description: "Plan ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;
@@ -0,0 +1,42 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runPlanDelete(options, deps) {
8
+ let planId;
9
+ try {
10
+ planId = requireId("planId", options.planId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/plans/${encodeURIComponent(planId)}`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("DELETE", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "DELETE" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: {
29
+ name: "delete",
30
+ description: "Permanently delete an archived test plan (archive it first with `aximo plan archive`). Requires an org admin or owner. Member cases and run history are kept; the runs are detached from the plan",
31
+ },
32
+ args: {
33
+ planId: { type: "positional", description: "Plan ID", required: true },
34
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
35
+ },
36
+ async run({ args }) {
37
+ printResult(await runPlanDelete({
38
+ planId: args.planId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -4,6 +4,9 @@ export default defineCommand({
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
6
  update: () => import("./update.js").then((m) => m.default),
7
+ archive: () => import("./archive.js").then((m) => m.default),
8
+ restore: () => import("./restore.js").then((m) => m.default),
9
+ delete: () => import("./delete.js").then((m) => m.default),
7
10
  "add-case": () => import("./add-case.js").then((m) => m.default),
8
11
  "remove-case": () => import("./remove-case.js").then((m) => m.default),
9
12
  },
@@ -4,6 +4,9 @@ import { runPlanAddCase } from "./add-case.js";
4
4
  import { runPlanCreate } from "./create.js";
5
5
  import { runPlanUpdate } from "./update.js";
6
6
  import { runPlanRemoveCase } from "./remove-case.js";
7
+ import { runPlanArchive } from "./archive.js";
8
+ import { runPlanDelete } from "./delete.js";
9
+ import { runPlanRestore } from "./restore.js";
7
10
  beforeAll(() => {
8
11
  process.env["AXIMO_API_KEY"] = "test-key";
9
12
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
@@ -61,12 +64,14 @@ describe("runPlanAddCase", () => {
61
64
  if (!result.ok)
62
65
  expect(result.error.code).toBe("validation_error");
63
66
  });
64
- it("maps 409 to validation_error", async () => {
67
+ it("maps 409 to conflict without an archive hint", async () => {
65
68
  server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/cases`, () => HttpResponse.json({ error: "Case already in plan" }, { status: 409 })));
66
69
  const result = await runPlanAddCase({ planId: "pl1", caseId: "c1" });
67
70
  expect(result.ok).toBe(false);
68
- if (!result.ok)
69
- expect(result.error.code).toBe("validation_error");
71
+ if (!result.ok) {
72
+ expect(result.error.code).toBe("conflict");
73
+ expect(result.error.hint).toBeUndefined();
74
+ }
70
75
  });
71
76
  it("--dry-run returns request without calling API", async () => {
72
77
  const result = await runPlanAddCase({ planId: "pl1", caseId: "c1", dryRun: true });
@@ -103,6 +108,41 @@ describe("runPlanRemoveCase", () => {
103
108
  }
104
109
  });
105
110
  });
111
+ describe("runPlanArchive", () => {
112
+ it("POSTs the archive path and returns success", async () => {
113
+ let called = false;
114
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/archive`, () => {
115
+ called = true;
116
+ return HttpResponse.json({ archived: true });
117
+ }));
118
+ const result = await runPlanArchive({ planId: "pl1" });
119
+ expect(result.ok).toBe(true);
120
+ if (result.ok && !("dryRun" in result.data))
121
+ expect(result.data.archived).toBe(true);
122
+ expect(called).toBe(true);
123
+ });
124
+ it("rejects missing planId", async () => {
125
+ const result = await runPlanArchive({});
126
+ expect(result.ok).toBe(false);
127
+ if (!result.ok)
128
+ expect(result.error.code).toBe("validation_error");
129
+ });
130
+ it("maps 404 to not_found", async () => {
131
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/pl1/archive`, () => HttpResponse.json({ error: "Test plan not found" }, { status: 404 })));
132
+ const result = await runPlanArchive({ planId: "pl1" });
133
+ expect(result.ok).toBe(false);
134
+ if (!result.ok)
135
+ expect(result.error.code).toBe("not_found");
136
+ });
137
+ it("--dry-run returns a POST request without calling API", async () => {
138
+ const result = await runPlanArchive({ planId: "pl1", dryRun: true });
139
+ expect(result.ok).toBe(true);
140
+ if (result.ok && "dryRun" in result.data) {
141
+ expect(result.data.method).toBe("POST");
142
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/plans/pl1/archive`);
143
+ }
144
+ });
145
+ });
106
146
  describe("runPlanUpdate", () => {
107
147
  it("PATCHes the provided fields and returns the updated plan", async () => {
108
148
  let seen;
@@ -163,3 +203,75 @@ describe("runPlanUpdate", () => {
163
203
  }
164
204
  });
165
205
  });
206
+ describe("runPlanRestore", () => {
207
+ it("POSTs the restore path and returns success", async () => {
208
+ let called = false;
209
+ server.use(http.post(`${TEST_BASE_URL}/api/plans/p1/restore`, () => {
210
+ called = true;
211
+ return HttpResponse.json({ restored: true });
212
+ }));
213
+ const result = await runPlanRestore({ planId: "p1" });
214
+ expect(result.ok).toBe(true);
215
+ if (result.ok && !("dryRun" in result.data))
216
+ expect(result.data.restored).toBe(true);
217
+ expect(called).toBe(true);
218
+ });
219
+ it("rejects missing planId", async () => {
220
+ const result = await runPlanRestore({});
221
+ expect(result.ok).toBe(false);
222
+ if (!result.ok)
223
+ expect(result.error.code).toBe("validation_error");
224
+ });
225
+ });
226
+ describe("runPlanDelete", () => {
227
+ it("DELETEs the plan and returns success", async () => {
228
+ let called = false;
229
+ server.use(http.delete(`${TEST_BASE_URL}/api/plans/p1`, () => {
230
+ called = true;
231
+ return HttpResponse.json({ success: true });
232
+ }));
233
+ const result = await runPlanDelete({ planId: "p1" });
234
+ expect(result.ok).toBe(true);
235
+ if (result.ok && !("dryRun" in result.data))
236
+ expect(result.data.success).toBe(true);
237
+ expect(called).toBe(true);
238
+ });
239
+ // Archive is the mandatory first step, and the hint names the command that does it.
240
+ it("maps the archived-first 409 to conflict with an actionable hint", async () => {
241
+ server.use(http.delete(`${TEST_BASE_URL}/api/plans/p1`, () => HttpResponse.json({
242
+ error: "Test plan must be archived before it can be permanently deleted. Archive it first with POST /api/plans/{planId}/archive, then retry the delete.",
243
+ }, { status: 409 })));
244
+ const result = await runPlanDelete({ planId: "p1" });
245
+ expect(result.ok).toBe(false);
246
+ if (!result.ok) {
247
+ expect(result.error.code).toBe("conflict");
248
+ expect(result.error.message).toContain("must be archived");
249
+ }
250
+ });
251
+ it("maps 403 for a non-admin key", async () => {
252
+ server.use(http.delete(`${TEST_BASE_URL}/api/plans/p1`, () => HttpResponse.json({ error: "Only admins and owners can perform this action" }, { status: 403 })));
253
+ const result = await runPlanDelete({ planId: "p1" });
254
+ expect(result.ok).toBe(false);
255
+ if (!result.ok) {
256
+ // Not "unauthorized": the key is valid, the role is not sufficient, and the
257
+ // hint must not tell the user to go reconfigure a working key.
258
+ expect(result.error.code).toBe("forbidden");
259
+ expect(result.error.hint).toContain("admin or owner");
260
+ expect(result.error.hint).not.toContain("aximo configure");
261
+ }
262
+ });
263
+ it("--dry-run returns a DELETE request without calling API", async () => {
264
+ let called = false;
265
+ server.use(http.delete(`${TEST_BASE_URL}/api/plans/p1`, () => {
266
+ called = true;
267
+ return HttpResponse.json({ success: true });
268
+ }));
269
+ const result = await runPlanDelete({ planId: "p1", dryRun: true });
270
+ expect(called).toBe(false);
271
+ expect(result.ok).toBe(true);
272
+ if (result.ok && "dryRun" in result.data) {
273
+ expect(result.data.method).toBe("DELETE");
274
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/plans/p1`);
275
+ }
276
+ });
277
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { RestorePlanResult, Envelope } from "../../types.js";
4
+ export interface RestorePlanOptions {
5
+ planId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runPlanRestore(options: RestorePlanOptions, deps?: ClientDeps): Promise<Envelope<RestorePlanResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly planId: {
11
+ readonly type: "positional";
12
+ readonly description: "Plan ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;