@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.
- package/README.md +20 -5
- package/dist/client.d.ts +5 -0
- package/dist/client.js +23 -22
- package/dist/client.test.js +28 -0
- package/dist/commands/case/archive.d.ts +20 -0
- package/dist/commands/case/archive.js +42 -0
- package/dist/commands/case/case.test.js +181 -0
- package/dist/commands/case/delete.js +4 -1
- package/dist/commands/case/index.js +2 -0
- package/dist/commands/case/restore.d.ts +20 -0
- package/dist/commands/case/restore.js +42 -0
- package/dist/commands/case/update.d.ts +22 -0
- package/dist/commands/case/update.js +34 -3
- package/dist/commands/device/status.d.ts +1 -10
- package/dist/commands/mcp/index.js +6 -3
- package/dist/commands/mcp/server.test.js +32 -1
- package/dist/commands/mcp/tools.js +242 -9
- package/dist/commands/mcp/tools.test.js +434 -1
- package/dist/commands/mcp/wait.d.ts +20 -0
- package/dist/commands/mcp/wait.js +46 -0
- package/dist/commands/mcp/wait.test.d.ts +1 -0
- package/dist/commands/mcp/wait.test.js +91 -0
- package/dist/commands/plan/archive.d.ts +20 -0
- package/dist/commands/plan/archive.js +42 -0
- package/dist/commands/plan/delete.d.ts +20 -0
- package/dist/commands/plan/delete.js +42 -0
- package/dist/commands/plan/index.js +3 -0
- package/dist/commands/plan/plan.test.js +115 -3
- package/dist/commands/plan/restore.d.ts +20 -0
- package/dist/commands/plan/restore.js +42 -0
- package/dist/commands/session/export.d.ts +30 -0
- package/dist/commands/session/export.js +58 -0
- package/dist/commands/session/index.js +1 -0
- package/dist/commands/session/session.test.js +62 -0
- package/dist/commands/upload/upload.test.js +5 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +110 -2
- package/dist/errors.test.js +106 -3
- package/dist/session-export.d.ts +30 -0
- package/dist/session-export.js +72 -0
- package/dist/session-export.test.d.ts +1 -0
- package/dist/session-export.test.js +107 -0
- package/dist/types.d.ts +41 -3
- package/dist/validate.d.ts +1 -0
- package/dist/validate.js +15 -3
- package/dist/validate.test.js +18 -1
- 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
|
|
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("
|
|
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("
|
|
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,
|
|
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
|
-
|
|
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
|
}
|