@autifyhq/aximo-cli 0.13.0 → 0.15.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.
Files changed (47) hide show
  1. package/README.md +20 -5
  2. package/dist/client.d.ts +5 -0
  3. package/dist/client.js +23 -22
  4. package/dist/client.test.js +28 -0
  5. package/dist/commands/case/archive.d.ts +20 -0
  6. package/dist/commands/case/archive.js +42 -0
  7. package/dist/commands/case/case.test.js +181 -0
  8. package/dist/commands/case/delete.js +4 -1
  9. package/dist/commands/case/index.js +2 -0
  10. package/dist/commands/case/restore.d.ts +20 -0
  11. package/dist/commands/case/restore.js +42 -0
  12. package/dist/commands/case/update.d.ts +22 -0
  13. package/dist/commands/case/update.js +34 -3
  14. package/dist/commands/device/status.d.ts +1 -10
  15. package/dist/commands/mcp/index.js +6 -3
  16. package/dist/commands/mcp/server.test.js +32 -1
  17. package/dist/commands/mcp/tools.js +242 -9
  18. package/dist/commands/mcp/tools.test.js +434 -1
  19. package/dist/commands/mcp/wait.d.ts +20 -0
  20. package/dist/commands/mcp/wait.js +46 -0
  21. package/dist/commands/mcp/wait.test.d.ts +1 -0
  22. package/dist/commands/mcp/wait.test.js +91 -0
  23. package/dist/commands/plan/archive.d.ts +20 -0
  24. package/dist/commands/plan/archive.js +42 -0
  25. package/dist/commands/plan/delete.d.ts +20 -0
  26. package/dist/commands/plan/delete.js +42 -0
  27. package/dist/commands/plan/index.js +3 -0
  28. package/dist/commands/plan/plan.test.js +115 -3
  29. package/dist/commands/plan/restore.d.ts +20 -0
  30. package/dist/commands/plan/restore.js +42 -0
  31. package/dist/commands/session/export.d.ts +30 -0
  32. package/dist/commands/session/export.js +58 -0
  33. package/dist/commands/session/index.js +1 -0
  34. package/dist/commands/session/session.test.js +62 -0
  35. package/dist/commands/upload/upload.test.js +5 -2
  36. package/dist/errors.d.ts +5 -0
  37. package/dist/errors.js +110 -2
  38. package/dist/errors.test.js +106 -3
  39. package/dist/session-export.d.ts +30 -0
  40. package/dist/session-export.js +72 -0
  41. package/dist/session-export.test.d.ts +1 -0
  42. package/dist/session-export.test.js +107 -0
  43. package/dist/types.d.ts +41 -3
  44. package/dist/validate.d.ts +1 -0
  45. package/dist/validate.js +15 -3
  46. package/dist/validate.test.js +18 -1
  47. package/package.json +1 -1
@@ -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;
@@ -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 runPlanRestore(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)}/restore`;
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: "restore",
30
+ description: "Restore an archived test plan (returns it to lists; its schedule resumes as configured)",
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 runPlanRestore({
38
+ planId: args.planId,
39
+ dryRun: args["dry-run"],
40
+ }));
41
+ },
42
+ });
@@ -0,0 +1,30 @@
1
+ import type { ClientDeps } from "../../client.js";
2
+ import { type SessionExportResult } from "../../session-export.js";
3
+ import type { Envelope } from "../../types.js";
4
+ export interface SessionExportOptions {
5
+ sessionId?: unknown;
6
+ format?: unknown;
7
+ output?: unknown;
8
+ locale?: unknown;
9
+ }
10
+ export declare function runSessionExport(options: SessionExportOptions, deps?: ClientDeps): Promise<Envelope<SessionExportResult>>;
11
+ declare const _default: import("citty").CommandDef<{
12
+ readonly sessionId: {
13
+ readonly type: "positional";
14
+ readonly description: "Session ID";
15
+ readonly required: true;
16
+ };
17
+ readonly format: {
18
+ readonly type: "string";
19
+ readonly description: "pdf | screenshots (default: pdf)";
20
+ };
21
+ readonly output: {
22
+ readonly type: "string";
23
+ readonly description: "File to write (default: <sessionId>.pdf or <sessionId>-screenshots.zip)";
24
+ };
25
+ readonly locale: {
26
+ readonly type: "string";
27
+ readonly description: "Report language: en | ja | ko (default: en)";
28
+ };
29
+ }>;
30
+ export default _default;
@@ -0,0 +1,58 @@
1
+ import { defineCommand } from "citty";
2
+ import { handleApiError } from "../../errors.js";
3
+ import { formatSuccess, printResult, validationError } from "../../output.js";
4
+ import { defaultExportFileName, exportSession, SESSION_EXPORT_FORMATS, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
5
+ import { optionalString, requireId } from "../../validate.js";
6
+ function parseOneOf(name, allowed, value) {
7
+ const raw = optionalString(name, value);
8
+ if (raw === undefined)
9
+ return undefined;
10
+ if (!allowed.includes(raw)) {
11
+ throw new Error(`${name} must be one of: ${allowed.join(", ")}`);
12
+ }
13
+ return raw;
14
+ }
15
+ export async function runSessionExport(options, deps) {
16
+ let sessionId;
17
+ let format;
18
+ let locale;
19
+ let output;
20
+ try {
21
+ sessionId = requireId("sessionId", options.sessionId);
22
+ format = parseOneOf("format", SESSION_EXPORT_FORMATS, options.format) ?? "pdf";
23
+ locale = parseOneOf("locale", SESSION_EXPORT_LOCALES, options.locale);
24
+ output = optionalString("output", options.output) ?? defaultExportFileName(sessionId, format);
25
+ }
26
+ catch (err) {
27
+ return validationError(err);
28
+ }
29
+ try {
30
+ return formatSuccess(await exportSession({ sessionId, format, outputPath: output, locale }, deps));
31
+ }
32
+ catch (err) {
33
+ return handleApiError(err);
34
+ }
35
+ }
36
+ export default defineCommand({
37
+ meta: {
38
+ name: "export",
39
+ description: "Download a session's result as a PDF report or a ZIP of its screenshots",
40
+ },
41
+ args: {
42
+ sessionId: { type: "positional", description: "Session ID", required: true },
43
+ format: { type: "string", description: "pdf | screenshots (default: pdf)" },
44
+ output: {
45
+ type: "string",
46
+ description: "File to write (default: <sessionId>.pdf or <sessionId>-screenshots.zip)",
47
+ },
48
+ locale: { type: "string", description: "Report language: en | ja | ko (default: en)" },
49
+ },
50
+ async run({ args }) {
51
+ printResult(await runSessionExport({
52
+ sessionId: args.sessionId,
53
+ format: args.format,
54
+ output: args.output,
55
+ locale: args.locale,
56
+ }));
57
+ },
58
+ });
@@ -4,6 +4,7 @@ export default defineCommand({
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
6
  get: () => import("./get.js").then((m) => m.default),
7
+ export: () => import("./export.js").then((m) => m.default),
7
8
  "save-as-case": () => import("./save-as-case.js").then((m) => m.default),
8
9
  },
9
10
  });
@@ -1,6 +1,10 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
1
4
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
5
  import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
3
6
  import { runSessionCreate } from "./create.js";
7
+ import { runSessionExport } from "./export.js";
4
8
  import { runSessionGet } from "./get.js";
5
9
  beforeAll(() => {
6
10
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -126,3 +130,61 @@ describe("runSessionGet", () => {
126
130
  expect(result.error.code).toBe("not_found");
127
131
  });
128
132
  });
133
+ describe("runSessionExport", () => {
134
+ const sessionId = "44444444-4444-4444-9444-444444444444";
135
+ const defaultPdf = resolve(`${sessionId}.pdf`);
136
+ const dir = mkdtempSync(join(tmpdir(), "aximo-session-export-"));
137
+ afterEach(() => rmSync(defaultPdf, { force: true }));
138
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
139
+ it("defaults to a PDF named after the session in the working directory", async () => {
140
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/pdf`, () => new HttpResponse("%PDF-1.3\n%%EOF\n")));
141
+ const result = await runSessionExport({ sessionId });
142
+ expect(result).toEqual({
143
+ ok: true,
144
+ data: { sessionId, format: "pdf", path: defaultPdf, sizeBytes: 15 },
145
+ });
146
+ });
147
+ it("downloads the screenshots archive in the requested locale to --output", async () => {
148
+ let locale = null;
149
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, ({ request }) => {
150
+ locale = new URL(request.url).searchParams.get("locale");
151
+ const eocd = Buffer.alloc(22);
152
+ eocd.writeUInt32LE(0x06054b50, 0);
153
+ return new HttpResponse(eocd);
154
+ }));
155
+ const output = join(dir, "shots.zip");
156
+ const result = await runSessionExport({
157
+ sessionId,
158
+ format: "screenshots",
159
+ output,
160
+ locale: "ja",
161
+ });
162
+ expect(result.ok).toBe(true);
163
+ if (result.ok)
164
+ expect(result.data.path).toBe(output);
165
+ expect(locale).toBe("ja");
166
+ });
167
+ it.each([
168
+ [{ format: "docx" }, /format must be one of: pdf, screenshots/],
169
+ [{ locale: "fr" }, /locale must be one of: en, ja, ko/],
170
+ [{ sessionId: "" }, /sessionId/],
171
+ ])("rejects %o", async (overrides, message) => {
172
+ const result = await runSessionExport({ sessionId, ...overrides });
173
+ expect(result.ok).toBe(false);
174
+ if (!result.ok) {
175
+ expect(result.error.code).toBe("validation_error");
176
+ expect(result.error.message).toMatch(message);
177
+ }
178
+ });
179
+ it("maps the API's 409 to a conflict envelope", async () => {
180
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${sessionId}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
181
+ const result = await runSessionExport({
182
+ sessionId,
183
+ format: "screenshots",
184
+ output: join(dir, "x.zip"),
185
+ });
186
+ expect(result.ok).toBe(false);
187
+ if (!result.ok)
188
+ expect(result.error.code).toBe("conflict");
189
+ });
190
+ });
@@ -131,8 +131,11 @@ describe("runUploadPut", () => {
131
131
  presignedUrl: "https://bucket.s3.test/uploads/u2",
132
132
  });
133
133
  expect(result.ok).toBe(false);
134
- if (!result.ok)
135
- expect(result.error.code).toBe("unauthorized");
134
+ if (!result.ok) {
135
+ expect(result.error.code).toBe("forbidden");
136
+ // A signature failure is not a role problem, so no admin hint.
137
+ expect(result.error.hint).toBeUndefined();
138
+ }
136
139
  });
137
140
  it("PUTs via the authenticated API when --url is relative", async () => {
138
141
  let seenApiKey = null;
package/dist/errors.d.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  import type { ErrorEnvelope } from "./types.js";
2
+ export declare function describeDetails(details: unknown): string | undefined;
3
+ export declare function withDetails(message: string, details: unknown): string;
4
+ export declare function featureHint(message: string, details: unknown): string | undefined;
5
+ export declare const ARCHIVE_FIRST_HINT = "Archive the resource first, then retry: `aximo case archive <caseId>` / `aximo plan archive <planId>` (MCP: aximo_archive_case / aximo_archive_plan).";
6
+ export declare function isArchiveFirstConflict(message: string): boolean;
2
7
  export declare function handleApiError(error: unknown): ErrorEnvelope;
package/dist/errors.js CHANGED
@@ -5,8 +5,106 @@ function isHttpError(error) {
5
5
  const e = error;
6
6
  return typeof e["status"] === "number" && typeof e["message"] === "string";
7
7
  }
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ const MAX_ISSUE_LINES = 5;
12
+ // Walks zod treeifyError / flattenError output into "path: message" lines.
13
+ function collectIssues(node, path, out) {
14
+ if (out.length >= MAX_ISSUE_LINES)
15
+ return;
16
+ if (Array.isArray(node)) {
17
+ node.forEach((item, i) => collectIssues(item, `${path}[${i}]`, out));
18
+ return;
19
+ }
20
+ if (!isRecord(node))
21
+ return;
22
+ for (const [key, value] of Object.entries(node)) {
23
+ if (out.length >= MAX_ISSUE_LINES)
24
+ return;
25
+ if (key === "errors" || key === "_errors" || key === "formErrors") {
26
+ if (!Array.isArray(value))
27
+ continue;
28
+ for (const msg of value) {
29
+ if (out.length >= MAX_ISSUE_LINES)
30
+ return;
31
+ if (typeof msg === "string")
32
+ out.push(path ? `${path}: ${msg}` : msg);
33
+ }
34
+ }
35
+ else if (key === "properties" || key === "fieldErrors" || key === "items") {
36
+ collectIssues(value, path, out);
37
+ }
38
+ else {
39
+ const childPath = path ? `${path}.${key}` : key;
40
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
41
+ for (const msg of value) {
42
+ if (out.length >= MAX_ISSUE_LINES)
43
+ return;
44
+ out.push(`${childPath}: ${msg}`);
45
+ }
46
+ }
47
+ else {
48
+ collectIssues(value, childPath, out);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ export function describeDetails(details) {
54
+ if (!isRecord(details))
55
+ return undefined;
56
+ const parts = [];
57
+ if (typeof details["path"] === "string")
58
+ parts.push(`at ${details["path"]}`);
59
+ if (typeof details["feature"] === "string") {
60
+ parts.push(`requires feature ${details["feature"]}`);
61
+ }
62
+ if (Array.isArray(details["ids"]))
63
+ parts.push(`ids: ${details["ids"].map(String).join(", ")}`);
64
+ if (Array.isArray(details["unknownFields"])) {
65
+ parts.push(`unknown fields: ${details["unknownFields"].map(String).join(", ")}`);
66
+ }
67
+ if (typeof details["hint"] === "string")
68
+ parts.push(details["hint"]);
69
+ if (parts.length === 0)
70
+ collectIssues(details, "", parts);
71
+ return parts.length > 0 ? parts.join("; ") : undefined;
72
+ }
73
+ export function withDetails(message, details) {
74
+ const extra = describeDetails(details);
75
+ return extra ? `${message} (${extra})` : message;
76
+ }
77
+ const FEATURE_DISABLED_RE = /not enabled for this organization/i;
78
+ export function featureHint(message, details) {
79
+ const feature = isRecord(details) ? details["feature"] : undefined;
80
+ if (typeof feature === "string") {
81
+ return `Ask an Aximo organization admin to enable ${feature}.`;
82
+ }
83
+ if (FEATURE_DISABLED_RE.test(message)) {
84
+ return "Ask an Aximo organization admin to enable this feature.";
85
+ }
86
+ return undefined;
87
+ }
8
88
  const NOT_FOUND_HINT = "Check the ID exists and you have access. List resources: aximo project list / aximo device list / aximo upload list";
9
89
  const AUTH_HINT = "Run `aximo configure` to save credentials, or set AXIMO_API_KEY via `export AXIMO_API_KEY=your-key` or `node --env-file=.env`.";
90
+ // 403 is a permission outcome, not a credentials problem, so it no longer routes
91
+ // to AUTH_HINT: telling someone to `aximo configure` would have them replace a key
92
+ // that works. The admin wording is attached only when the server says the role is
93
+ // what is missing, following ARCHIVE_FIRST_HINT below; a presigned-upload 403
94
+ // (SignatureDoesNotMatch) is also a 403 and has nothing to do with roles.
95
+ const ADMIN_ROLE_HINT = "This action requires an organization admin or owner API key. Ask an admin to run it, or use a key belonging to an admin or owner.";
96
+ // The archived-first delete gate answers with 409 and a message naming the REST archive
97
+ // endpoint; this hint is what translates it into CLI and MCP terms. Only add it when the
98
+ // server says archiving is what is missing: other 409s (e.g. "Case already in plan") are
99
+ // not fixed by archiving anything.
100
+ // Exported so the MCP error mapper in commands/mcp/tools.ts uses the same string.
101
+ // The hint names both surfaces, so both surfaces have to be able to emit it.
102
+ export const ARCHIVE_FIRST_HINT = "Archive the resource first, then retry: `aximo case archive <caseId>` / `aximo plan archive <planId>` (MCP: aximo_archive_case / aximo_archive_plan).";
103
+ // The server wording is load-bearing for this test: it is the only signal either
104
+ // mapper has for telling an archive-first 409 apart from any other 409.
105
+ export function isArchiveFirstConflict(message) {
106
+ return /archiv/i.test(message);
107
+ }
10
108
  // Node's net layer surfaces transient transport failures via these system error codes.
11
109
  const RETRYABLE_NET_CODES = new Set([
12
110
  "ECONNRESET",
@@ -34,12 +132,18 @@ function isTransientNetworkError(error) {
34
132
  }
35
133
  export function handleApiError(error) {
36
134
  if (isHttpError(error)) {
37
- const { status, message, retryAfter } = error;
135
+ const { status, retryAfter, details } = error;
136
+ const message = withDetails(error.message, details);
38
137
  if (status === 401) {
39
138
  return formatError("unauthorized", message, false, AUTH_HINT);
40
139
  }
41
140
  if (status === 403) {
42
- return formatError("unauthorized", message, false, AUTH_HINT);
141
+ // A feature gate names its own remedy; otherwise fall back to the role hint
142
+ // when the server says which role is missing. Never the credentials hint: a
143
+ // 403 means the key is valid.
144
+ const hint = featureHint(error.message, details) ??
145
+ (/admin|owner/i.test(message) ? ADMIN_ROLE_HINT : undefined);
146
+ return formatError("forbidden", message, false, hint);
43
147
  }
44
148
  if (status === 404) {
45
149
  return formatError("not_found", message, false, NOT_FOUND_HINT);
@@ -52,6 +156,10 @@ export function handleApiError(error) {
52
156
  if (status >= 500) {
53
157
  return formatError("api_error", message, true);
54
158
  }
159
+ if (status === 409) {
160
+ const hint = isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined;
161
+ return formatError("conflict", message, false, hint);
162
+ }
55
163
  if (status >= 400) {
56
164
  return formatError("validation_error", message, false);
57
165
  }