@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
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { handleApiError } from "./errors.js";
2
+ import { describeDetails, featureHint, handleApiError, withDetails } from "./errors.js";
3
3
  function httpErr(status, message = `status ${status}`, retryAfter) {
4
4
  const err = Object.assign(new Error(message), { status });
5
5
  if (retryAfter !== undefined)
@@ -15,8 +15,17 @@ describe("handleApiError", () => {
15
15
  expect(r.error.hint).toContain("AXIMO_API_KEY");
16
16
  expect(r.error.hint).toContain("--env-file");
17
17
  });
18
- it("maps 403 to unauthorized", () => {
19
- expect(handleApiError(httpErr(403)).error.code).toBe("unauthorized");
18
+ // 403 is its own code as of GEN-2793: it means the key is valid but the role is
19
+ // not sufficient, which is a different remedy from a missing or bad key.
20
+ it("maps 403 to forbidden, with no credentials hint", () => {
21
+ const r = handleApiError(httpErr(403));
22
+ expect(r.error.code).toBe("forbidden");
23
+ expect(r.error.hint).toBeUndefined();
24
+ });
25
+ it("adds the admin-role hint only when the server names the role", () => {
26
+ const r = handleApiError(httpErr(403, "Only admins and owners can perform this action"));
27
+ expect(r.error.code).toBe("forbidden");
28
+ expect(r.error.hint).toContain("admin or owner");
20
29
  });
21
30
  it("maps 404 to not_found with list hint", () => {
22
31
  const r = handleApiError(httpErr(404));
@@ -86,3 +95,97 @@ describe("handleApiError", () => {
86
95
  expect(handleApiError("weird").error.message).toBe("weird");
87
96
  });
88
97
  });
98
+ function httpErrWithDetails(status, message, details) {
99
+ return Object.assign(new Error(message), { status, details });
100
+ }
101
+ describe("describeDetails", () => {
102
+ it("renders path, feature, ids, unknownFields and hint", () => {
103
+ expect(describeDetails({ path: "apiWebConfig.hostnameOverrideIds", ids: ["a", "b"] })).toBe("at apiWebConfig.hostnameOverrideIds; ids: a, b");
104
+ expect(describeDetails({ feature: "multiDeviceEnabled" })).toBe("requires feature multiDeviceEnabled");
105
+ expect(describeDetails({ unknownFields: ["userDeviceId"], hint: "Put it inside apiDesktopConfig." })).toBe("unknown fields: userDeviceId; Put it inside apiDesktopConfig.");
106
+ });
107
+ it("walks a zod treeifyError tree into path: message lines", () => {
108
+ const tree = {
109
+ errors: ["Unrecognized key"],
110
+ properties: {
111
+ apiMobileConfig: {
112
+ errors: [],
113
+ properties: {
114
+ deviceArn: { errors: ["Required"] },
115
+ apps: {
116
+ errors: [],
117
+ items: [null, { errors: [], properties: { uploadArn: { errors: ["Required"] } } }],
118
+ },
119
+ },
120
+ },
121
+ },
122
+ };
123
+ expect(describeDetails(tree)).toBe("Unrecognized key; apiMobileConfig.deviceArn: Required; apiMobileConfig.apps[1].uploadArn: Required");
124
+ });
125
+ it("walks a zod flattenError fieldErrors map", () => {
126
+ expect(describeDetails({
127
+ formErrors: [],
128
+ fieldErrors: { platform: ["Invalid enum value"], limit: ["Too big"] },
129
+ })).toBe("platform: Invalid enum value; limit: Too big");
130
+ });
131
+ it("caps zod issues at five lines", () => {
132
+ const fieldErrors = {};
133
+ for (let i = 0; i < 8; i++)
134
+ fieldErrors[`f${i}`] = ["bad"];
135
+ expect(describeDetails({ fieldErrors })?.split("; ")).toHaveLength(5);
136
+ });
137
+ it("returns undefined for non-object or empty details", () => {
138
+ expect(describeDetails(undefined)).toBeUndefined();
139
+ expect(describeDetails("x")).toBeUndefined();
140
+ expect(describeDetails(["x"])).toBeUndefined();
141
+ expect(describeDetails({})).toBeUndefined();
142
+ expect(describeDetails({ errors: [], properties: { a: { errors: 5 } } })).toBeUndefined();
143
+ });
144
+ });
145
+ describe("withDetails / featureHint", () => {
146
+ it("appends rendered details in parentheses", () => {
147
+ expect(withDetails("Upload not found", { path: "apiMobileConfig.apps[0].uploadArn" })).toBe("Upload not found (at apiMobileConfig.apps[0].uploadArn)");
148
+ expect(withDetails("Upload not found", undefined)).toBe("Upload not found");
149
+ });
150
+ it("names the feature from details, or recognises the not-enabled message", () => {
151
+ expect(featureHint("Forbidden", { feature: "byodEnabled" })).toBe("Ask an Aximo organization admin to enable byodEnabled.");
152
+ expect(featureHint("Multi-device cases are not enabled for this organization", undefined)).toBe("Ask an Aximo organization admin to enable this feature.");
153
+ expect(featureHint("Forbidden", { feature: 3 })).toBeUndefined();
154
+ });
155
+ });
156
+ describe("handleApiError with details", () => {
157
+ it("names the failing path on a 404", () => {
158
+ const r = handleApiError(httpErrWithDetails(404, "Upload not found", {
159
+ path: "apiMultiDeviceConfig[1].apps[0].uploadArn",
160
+ }));
161
+ expect(r.error.code).toBe("not_found");
162
+ expect(r.error.message).toBe("Upload not found (at apiMultiDeviceConfig[1].apps[0].uploadArn)");
163
+ });
164
+ it("swaps the credentials hint for an admin hint on a feature-gated 403", () => {
165
+ const r = handleApiError(httpErrWithDetails(403, "Desktop cases are not enabled for this organization", {
166
+ feature: "byodEnabled",
167
+ }));
168
+ expect(r.error.code).toBe("forbidden");
169
+ expect(r.error.message).toContain("requires feature byodEnabled");
170
+ expect(r.error.hint).toBe("Ask an Aximo organization admin to enable byodEnabled.");
171
+ });
172
+ it("renders zod issues on a 400", () => {
173
+ const r = handleApiError(httpErrWithDetails(400, "Invalid request body", {
174
+ errors: [],
175
+ properties: { apiWebConfig: { errors: ["Expected object, received string"] } },
176
+ }));
177
+ expect(r.error.code).toBe("validation_error");
178
+ expect(r.error.message).toBe("Invalid request body (apiWebConfig: Expected object, received string)");
179
+ });
180
+ });
181
+ describe("collectIssues cap", () => {
182
+ it("caps issue lines even within a single errors array", () => {
183
+ const details = {
184
+ properties: {
185
+ scenario: { errors: Array.from({ length: 12 }, (_, i) => `msg ${i}`) },
186
+ },
187
+ };
188
+ const text = describeDetails(details);
189
+ expect(text?.split("; ")).toHaveLength(5);
190
+ });
191
+ });
@@ -0,0 +1,30 @@
1
+ import { type ClientDeps } from "./client.js";
2
+ export declare const SESSION_EXPORT_FORMATS: readonly ["pdf", "screenshots"];
3
+ export type SessionExportFormat = (typeof SESSION_EXPORT_FORMATS)[number];
4
+ export declare const SESSION_EXPORT_LOCALES: readonly ["en", "ja", "ko"];
5
+ export type SessionExportLocale = (typeof SESSION_EXPORT_LOCALES)[number];
6
+ export interface SessionExportInput {
7
+ sessionId: string;
8
+ format: SessionExportFormat;
9
+ outputPath: string;
10
+ locale?: SessionExportLocale | undefined;
11
+ }
12
+ export interface SessionExportResult {
13
+ sessionId: string;
14
+ format: SessionExportFormat;
15
+ path: string;
16
+ sizeBytes: number;
17
+ }
18
+ export declare function defaultExportFileName(sessionId: string, format: SessionExportFormat): string;
19
+ /**
20
+ * Both exports stream, so a server failure after the first bytes are flushed
21
+ * reaches the client as HTTP 200 and a truncated body. Each format's trailer is
22
+ * written last, so its presence is what proves the download is whole.
23
+ */
24
+ export declare function isCompleteExport(path: string, format: SessionExportFormat): Promise<boolean>;
25
+ /**
26
+ * Downloads a session export to `outputPath`. The bytes land in a sibling
27
+ * `.part` file that is renamed into place only once verified complete, so a
28
+ * failed export neither leaves a corrupt file behind nor replaces an existing one.
29
+ */
30
+ export declare function exportSession(input: SessionExportInput, deps?: ClientDeps): Promise<SessionExportResult>;
@@ -0,0 +1,72 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, rename, rm, stat } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { requestToFile } from "./client.js";
5
+ export const SESSION_EXPORT_FORMATS = ["pdf", "screenshots"];
6
+ export const SESSION_EXPORT_LOCALES = ["en", "ja", "ko"];
7
+ export function defaultExportFileName(sessionId, format) {
8
+ return format === "pdf" ? `${sessionId}.pdf` : `${sessionId}-screenshots.zip`;
9
+ }
10
+ const EOCD_SIGNATURE = 0x06054b50;
11
+ const EOCD_MIN_LENGTH = 22;
12
+ const EOCD_MAX_LENGTH = EOCD_MIN_LENGTH + 0xffff;
13
+ // PDF readers look for %%EOF within the last 1024 bytes.
14
+ const PDF_TRAILER_WINDOW = 1024;
15
+ async function readTail(path, maxBytes) {
16
+ const handle = await open(path, "r");
17
+ try {
18
+ const { size } = await handle.stat();
19
+ const length = Math.min(size, maxBytes);
20
+ const buffer = Buffer.alloc(length);
21
+ await handle.read(buffer, 0, length, size - length);
22
+ return buffer;
23
+ }
24
+ finally {
25
+ await handle.close();
26
+ }
27
+ }
28
+ /**
29
+ * Both exports stream, so a server failure after the first bytes are flushed
30
+ * reaches the client as HTTP 200 and a truncated body. Each format's trailer is
31
+ * written last, so its presence is what proves the download is whole.
32
+ */
33
+ export async function isCompleteExport(path, format) {
34
+ if (format === "pdf") {
35
+ return (await readTail(path, PDF_TRAILER_WINDOW)).includes("%%EOF");
36
+ }
37
+ // The end-of-central-directory record must end the file exactly: its comment
38
+ // length has to account for every byte after it, which a signature that
39
+ // happens to appear inside compressed image data will not.
40
+ const tail = await readTail(path, EOCD_MAX_LENGTH);
41
+ for (let at = tail.length - EOCD_MIN_LENGTH; at >= 0; at--) {
42
+ if (tail.readUInt32LE(at) === EOCD_SIGNATURE &&
43
+ tail.readUInt16LE(at + 20) === tail.length - at - EOCD_MIN_LENGTH) {
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ /**
50
+ * Downloads a session export to `outputPath`. The bytes land in a sibling
51
+ * `.part` file that is renamed into place only once verified complete, so a
52
+ * failed export neither leaves a corrupt file behind nor replaces an existing one.
53
+ */
54
+ export async function exportSession(input, deps = {}) {
55
+ const path = resolve(input.outputPath);
56
+ await mkdir(dirname(path), { recursive: true });
57
+ const partialPath = `${path}.${randomUUID().slice(0, 8)}.part`;
58
+ const query = input.locale ? `?locale=${input.locale}` : "";
59
+ try {
60
+ await requestToFile(`/api/sessions/${encodeURIComponent(input.sessionId)}/export/${input.format}${query}`, partialPath, deps);
61
+ if (!(await isCompleteExport(partialPath, input.format))) {
62
+ throw new Error(`The ${input.format === "pdf" ? "PDF" : "screenshot archive"} download was truncated before it finished. Retry the export.`);
63
+ }
64
+ await rename(partialPath, path);
65
+ }
66
+ catch (error) {
67
+ await rm(partialPath, { force: true });
68
+ throw error;
69
+ }
70
+ const { size } = await stat(path);
71
+ return { sessionId: input.sessionId, format: input.format, path, sizeBytes: size };
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5
+ import { exportSession, isCompleteExport } from "./session-export.js";
6
+ import { HttpResponse, http, server, TEST_BASE_URL } from "./test-utils.js";
7
+ const SESSION_ID = "11111111-1111-4111-9111-111111111111";
8
+ function eocd(comment = "") {
9
+ const record = Buffer.alloc(22);
10
+ record.writeUInt32LE(0x06054b50, 0);
11
+ record.writeUInt16LE(comment.length, 20);
12
+ return Buffer.concat([record, Buffer.from(comment)]);
13
+ }
14
+ let dir;
15
+ beforeAll(() => {
16
+ process.env["AXIMO_API_KEY"] = "test-key";
17
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
18
+ server.listen();
19
+ });
20
+ beforeEach(() => {
21
+ dir = mkdtempSync(join(tmpdir(), "aximo-export-"));
22
+ });
23
+ afterEach(() => {
24
+ server.resetHandlers();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+ afterAll(() => server.close());
28
+ function fileWith(bytes) {
29
+ const path = join(dir, "file");
30
+ writeFileSync(path, bytes);
31
+ return path;
32
+ }
33
+ describe("isCompleteExport", () => {
34
+ it("accepts a ZIP ending in its end-of-central-directory record", async () => {
35
+ const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd()]);
36
+ expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(true);
37
+ });
38
+ it("accepts a ZIP whose record carries a comment", async () => {
39
+ const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd("made by aximo")]);
40
+ expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(true);
41
+ });
42
+ it("rejects a ZIP cut off before its record", async () => {
43
+ const body = Buffer.concat([Buffer.from("PK\x03\x04 entries"), eocd()]).subarray(0, -1);
44
+ expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(false);
45
+ });
46
+ it("rejects a signature inside entry data that does not end the file", async () => {
47
+ const body = Buffer.concat([
48
+ Buffer.from("PK\x03\x04"),
49
+ eocd(),
50
+ Buffer.from("more entry bytes"),
51
+ ]);
52
+ expect(await isCompleteExport(fileWith(body), "screenshots")).toBe(false);
53
+ });
54
+ it("rejects a file that starts with the signature but is shorter than the record", async () => {
55
+ expect(await isCompleteExport(fileWith(eocd().subarray(0, -1)), "screenshots")).toBe(false);
56
+ });
57
+ it("rejects an empty file", async () => {
58
+ expect(await isCompleteExport(fileWith(Buffer.alloc(0)), "screenshots")).toBe(false);
59
+ expect(await isCompleteExport(fileWith(Buffer.alloc(0)), "pdf")).toBe(false);
60
+ });
61
+ it("checks a PDF for its %%EOF trailer", async () => {
62
+ expect(await isCompleteExport(fileWith(Buffer.from("%PDF-1.3\n...\n%%EOF\n")), "pdf")).toBe(true);
63
+ expect(await isCompleteExport(fileWith(Buffer.from("%PDF-1.3\n...stream")), "pdf")).toBe(false);
64
+ });
65
+ });
66
+ describe("exportSession", () => {
67
+ it("streams the export to the path, creating parent directories", async () => {
68
+ let url;
69
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/pdf`, ({ request }) => {
70
+ url = new URL(request.url);
71
+ return new HttpResponse("%PDF-1.3\n%%EOF\n", {
72
+ headers: { "Content-Type": "application/pdf" },
73
+ });
74
+ }));
75
+ const outputPath = join(dir, "nested", "report.pdf");
76
+ const result = await exportSession({
77
+ sessionId: SESSION_ID,
78
+ format: "pdf",
79
+ outputPath,
80
+ locale: "ja",
81
+ });
82
+ expect(result).toEqual({
83
+ sessionId: SESSION_ID,
84
+ format: "pdf",
85
+ path: outputPath,
86
+ sizeBytes: 15,
87
+ });
88
+ expect(url?.searchParams.get("locale")).toBe("ja");
89
+ expect(readdirSync(join(dir, "nested"))).toEqual(["report.pdf"]);
90
+ });
91
+ it("deletes a truncated download and keeps the file it would have replaced", async () => {
92
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/screenshots`, () => new HttpResponse(Buffer.from("PK\x03\x04 half an archive"))));
93
+ const outputPath = join(dir, "shots.zip");
94
+ writeFileSync(outputPath, "previous export");
95
+ await expect(exportSession({ sessionId: SESSION_ID, format: "screenshots", outputPath })).rejects.toThrow(/truncated/);
96
+ expect(readdirSync(dir)).toEqual(["shots.zip"]);
97
+ });
98
+ it("leaves nothing behind when the API refuses the export", async () => {
99
+ server.use(http.get(`${TEST_BASE_URL}/api/sessions/${SESSION_ID}/export/screenshots`, () => HttpResponse.json({ error: "Session has no screenshots to export" }, { status: 409 })));
100
+ await expect(exportSession({
101
+ sessionId: SESSION_ID,
102
+ format: "screenshots",
103
+ outputPath: join(dir, "s.zip"),
104
+ })).rejects.toMatchObject({ status: 409, message: "Session has no screenshots to export" });
105
+ expect(readdirSync(dir)).toEqual([]);
106
+ });
107
+ });
package/dist/types.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface SuccessEnvelope<T> {
6
6
  data: T;
7
7
  meta?: SuccessMeta | undefined;
8
8
  }
9
- export type ErrorCode = "unauthorized" | "rate_limited" | "not_found" | "validation_error" | "api_error";
9
+ export type ErrorCode = "unauthorized" | "forbidden" | "rate_limited" | "not_found" | "validation_error" | "conflict" | "api_error";
10
10
  export interface ErrorEnvelope {
11
11
  ok: false;
12
12
  error: {
@@ -186,6 +186,17 @@ export interface Device {
186
186
  arn?: string | undefined;
187
187
  name?: string | undefined;
188
188
  platform?: Platform | undefined;
189
+ os?: string | undefined;
190
+ [key: string]: unknown;
191
+ }
192
+ export interface UserDevice {
193
+ id: string;
194
+ name: string;
195
+ platform: string;
196
+ os?: string | null;
197
+ status: string;
198
+ lastSeenAt?: string | null;
199
+ createdAt: string;
189
200
  [key: string]: unknown;
190
201
  }
191
202
  export interface CreateUploadInput {
@@ -202,6 +213,10 @@ export interface Upload {
202
213
  id?: string | undefined;
203
214
  fileName?: string | undefined;
204
215
  fileType?: FileType | undefined;
216
+ uploadArn?: string | undefined;
217
+ status?: string | undefined;
218
+ appName?: string | null | undefined;
219
+ identifier?: string | null | undefined;
205
220
  [key: string]: unknown;
206
221
  }
207
222
  export interface UploadPutResult {
@@ -232,11 +247,15 @@ export interface CreateCaseResult {
232
247
  }
233
248
  export interface UpdateCaseInput {
234
249
  title?: string | undefined;
235
- description?: string | undefined;
250
+ description?: string | null | undefined;
236
251
  scenario?: string | undefined;
237
252
  type?: CaseType | undefined;
238
253
  format?: CaseFormat | undefined;
239
- startUrl?: string | undefined;
254
+ startUrl?: string | null | undefined;
255
+ apiMobileConfig?: CaseMobileConfig | null | undefined;
256
+ apiDesktopConfig?: Pick<CaseDesktopConfig, "userDeviceId"> | null | undefined;
257
+ apiMultiDeviceConfig?: CaseMultiDeviceDevice[] | null | undefined;
258
+ apiWebConfig?: CaseWebConfig | null | undefined;
240
259
  }
241
260
  export interface UpdateCaseResult {
242
261
  success: boolean;
@@ -250,11 +269,30 @@ export interface UpdateCaseResult {
250
269
  format: CaseFormat;
251
270
  startUrl: string | null;
252
271
  updatedAt: string;
272
+ apiMobileConfig: CaseMobileConfig | null;
273
+ apiWebConfig: CaseWebConfig | null;
274
+ apiDesktopConfig: CaseDesktopConfig | null;
275
+ apiMultiDeviceConfig: CaseMultiDeviceDevice[] | null;
253
276
  };
254
277
  }
255
278
  export interface DeleteCaseResult {
256
279
  success: boolean;
257
280
  }
281
+ export interface ArchiveCaseResult {
282
+ archived: boolean;
283
+ }
284
+ export interface ArchivePlanResult {
285
+ archived: boolean;
286
+ }
287
+ export interface RestoreCaseResult {
288
+ restored: boolean;
289
+ }
290
+ export interface RestorePlanResult {
291
+ restored: boolean;
292
+ }
293
+ export interface DeletePlanResult {
294
+ success: boolean;
295
+ }
258
296
  export interface CreatePlanInput {
259
297
  projectId: string;
260
298
  title: string;
@@ -17,3 +17,4 @@ export declare class JsonBodyError extends Error {
17
17
  }
18
18
  export declare function parseJsonBody(value: unknown): Record<string, unknown>;
19
19
  export declare function parseJsonObject(name: string, value: unknown): Record<string, unknown> | undefined;
20
+ export declare function parseJsonArray(name: string, value: unknown): unknown[] | undefined;
package/dist/validate.js CHANGED
@@ -130,20 +130,32 @@ export function parseJsonBody(value) {
130
130
  }
131
131
  return parsed;
132
132
  }
133
- export function parseJsonObject(name, value) {
133
+ function parseJsonFlag(name, value) {
134
134
  if (value === undefined || value === "")
135
135
  return undefined;
136
136
  if (typeof value !== "string")
137
137
  throw new Error(`${name} must be a JSON string`);
138
- let parsed;
139
138
  try {
140
- parsed = JSON.parse(value);
139
+ return JSON.parse(value);
141
140
  }
142
141
  catch (err) {
143
142
  throw new Error(`${name} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
144
143
  }
144
+ }
145
+ export function parseJsonObject(name, value) {
146
+ const parsed = parseJsonFlag(name, value);
147
+ if (parsed === undefined)
148
+ return undefined;
145
149
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
146
150
  throw new Error(`${name} must be a JSON object`);
147
151
  }
148
152
  return parsed;
149
153
  }
154
+ export function parseJsonArray(name, value) {
155
+ const parsed = parseJsonFlag(name, value);
156
+ if (parsed === undefined)
157
+ return undefined;
158
+ if (!Array.isArray(parsed))
159
+ throw new Error(`${name} must be a JSON array`);
160
+ return parsed;
161
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { requireString, requireId, parseCaseFormat, parseCaseType, parsePlatform, parseSaveAsCaseType, parseFileType, parseTags, parseJsonObject, parseJsonBody, JsonBodyError, } from "./validate.js";
2
+ import { requireString, requireId, parseCaseFormat, parseCaseType, parsePlatform, parseSaveAsCaseType, parseFileType, parseTags, parseJsonObject, parseJsonArray, parseJsonBody, JsonBodyError, } from "./validate.js";
3
3
  describe("requireId", () => {
4
4
  it("accepts plain IDs", () => {
5
5
  expect(requireId("sessionId", "abc123_-")).toBe("abc123_-");
@@ -132,3 +132,20 @@ describe("parseCaseFormat", () => {
132
132
  expect(() => parseCaseFormat("klingon")).toThrow(/must be gherkin, markdown, or plain_text/);
133
133
  });
134
134
  });
135
+ describe("parseJsonArray", () => {
136
+ it("parses a JSON array", () => {
137
+ expect(parseJsonArray("multiDeviceConfig", '[{"kind":"web"}]')).toEqual([{ kind: "web" }]);
138
+ });
139
+ it("rejects objects", () => {
140
+ expect(() => parseJsonArray("x", "{}")).toThrow("must be a JSON array");
141
+ });
142
+ it("rejects invalid JSON and non-strings", () => {
143
+ expect(() => parseJsonArray("x", "[bad")).toThrow("not valid JSON");
144
+ expect(() => parseJsonArray("x", 5)).toThrow("must be a JSON string");
145
+ expect(() => parseJsonObject("x", 5)).toThrow("must be a JSON string");
146
+ });
147
+ it("returns undefined for empty input", () => {
148
+ expect(parseJsonArray("x", "")).toBeUndefined();
149
+ expect(parseJsonArray("x", undefined)).toBeUndefined();
150
+ });
151
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",