@autifyhq/aximo-cli 0.7.0 → 0.9.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 (75) hide show
  1. package/README.md +28 -13
  2. package/dist/client.d.ts +7 -1
  3. package/dist/client.js +21 -2
  4. package/dist/client.test.js +36 -0
  5. package/dist/commands/case/case.test.js +62 -0
  6. package/dist/commands/case/create.d.ts +1 -1
  7. package/dist/commands/case/create.js +4 -4
  8. package/dist/commands/case/index.js +1 -0
  9. package/dist/commands/case/update.d.ts +50 -0
  10. package/dist/commands/case/update.js +78 -0
  11. package/dist/commands/health/check.js +15 -3
  12. package/dist/commands/health/check.test.js +27 -3
  13. package/dist/commands/mobile/connect-core.d.ts +3 -1
  14. package/dist/commands/mobile/connect-core.js +34 -14
  15. package/dist/commands/mobile/connect.d.ts +5 -0
  16. package/dist/commands/mobile/connect.js +10 -4
  17. package/dist/commands/mobile/connect.test.js +7 -1
  18. package/dist/commands/mobile/doctor.d.ts +1 -0
  19. package/dist/commands/mobile/doctor.js +18 -2
  20. package/dist/commands/mobile/doctor.test.js +15 -1
  21. package/dist/commands/mobile/install.d.ts +1 -0
  22. package/dist/commands/mobile/install.js +14 -3
  23. package/dist/commands/mobile/list.d.ts +1 -0
  24. package/dist/commands/mobile/list.js +23 -3
  25. package/dist/commands/mobile/list.test.d.ts +1 -0
  26. package/dist/commands/mobile/list.test.js +24 -0
  27. package/dist/commands/mobile/output.d.ts +13 -0
  28. package/dist/commands/mobile/output.js +21 -0
  29. package/dist/commands/mobile/output.test.d.ts +1 -0
  30. package/dist/commands/mobile/output.test.js +54 -0
  31. package/dist/commands/mobile/register.d.ts +1 -0
  32. package/dist/commands/mobile/register.js +11 -3
  33. package/dist/commands/mobile/setup.d.ts +1 -0
  34. package/dist/commands/mobile/setup.js +7 -2
  35. package/dist/commands/mobile/signing-consent.d.ts +18 -0
  36. package/dist/commands/mobile/signing-consent.js +28 -0
  37. package/dist/commands/mobile/signing-consent.test.d.ts +1 -0
  38. package/dist/commands/mobile/signing-consent.test.js +50 -0
  39. package/dist/commands/mobile/unregister.d.ts +7 -3
  40. package/dist/commands/mobile/unregister.js +10 -4
  41. package/dist/commands/plan/index.js +1 -0
  42. package/dist/commands/plan/plan.test.js +61 -0
  43. package/dist/commands/plan/update.d.ts +42 -0
  44. package/dist/commands/plan/update.js +78 -0
  45. package/dist/commands/schema.js +1 -1
  46. package/dist/commands/session/save-as-case.js +3 -3
  47. package/dist/commands/trigger/plan/run.d.ts +6 -1
  48. package/dist/commands/trigger/plan/run.js +9 -3
  49. package/dist/commands/trigger/trigger.test.js +22 -3
  50. package/dist/commands/upload/put.d.ts +5 -0
  51. package/dist/commands/upload/put.js +44 -11
  52. package/dist/commands/upload/upload.test.js +82 -0
  53. package/dist/mobile-control/appium-controller.d.ts +4 -0
  54. package/dist/mobile-control/appium-controller.js +90 -2
  55. package/dist/mobile-control/appium-controller.test.js +156 -8
  56. package/dist/mobile-control/bundle-id-map.js +3 -0
  57. package/dist/mobile-control/controller.d.ts +3 -0
  58. package/dist/mobile-control/exec.d.ts +5 -1
  59. package/dist/mobile-control/exec.js +2 -2
  60. package/dist/mobile-control/ios-tunnel.d.ts +5 -2
  61. package/dist/mobile-control/ios-tunnel.js +25 -5
  62. package/dist/mobile-control/ios-tunnel.test.js +8 -3
  63. package/dist/mobile-control/ios-wda-preinstall.js +6 -3
  64. package/dist/mobile-control/ios-wda-preinstall.test.js +12 -8
  65. package/dist/mobile-control/tunnel.js +57 -1
  66. package/dist/mobile-control/tunnel.test.js +157 -0
  67. package/dist/mobile-control/webdriver.d.ts +7 -1
  68. package/dist/mobile-control/webdriver.js +12 -6
  69. package/dist/mutate.d.ts +1 -1
  70. package/dist/mutate.js +4 -2
  71. package/dist/types.d.ts +49 -4
  72. package/dist/validate.d.ts +6 -1
  73. package/dist/validate.js +46 -0
  74. package/dist/validate.test.js +39 -1
  75. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { confirmIosSigning } from "./signing-consent.js";
3
+ const SIGNING = {
4
+ teamId: "TEAM123456",
5
+ signingId: "Apple Development",
6
+ wdaBundleId: "com.aximo.wda.TEAM123456",
7
+ };
8
+ describe("confirmIosSigning", () => {
9
+ it("prints a notice naming the team and its account-facing effects", async () => {
10
+ const log = vi.fn();
11
+ await confirmIosSigning({ signing: SIGNING, assumeYes: true, log });
12
+ const notice = log.mock.calls.map((c) => c[0]).join("\n");
13
+ expect(notice).toContain("TEAM123456");
14
+ expect(notice).toContain("Apple Developer account");
15
+ expect(notice).toContain("AXIMO_IOS_TEAM_ID");
16
+ });
17
+ it("skips the prompt with --yes", async () => {
18
+ const ask = vi.fn();
19
+ await expect(confirmIosSigning({
20
+ signing: SIGNING,
21
+ assumeYes: true,
22
+ interactive: true,
23
+ ask,
24
+ log: vi.fn(),
25
+ })).resolves.toBe(true);
26
+ expect(ask).not.toHaveBeenCalled();
27
+ });
28
+ it("proceeds without prompting when not interactive (daemonized connect)", async () => {
29
+ const ask = vi.fn();
30
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: false, ask, log: vi.fn() })).resolves.toBe(true);
31
+ expect(ask).not.toHaveBeenCalled();
32
+ });
33
+ it("defaults to yes on Enter", async () => {
34
+ const ask = vi.fn(async () => "");
35
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
36
+ expect(ask).toHaveBeenCalledWith("Proceed with this signing identity? [Y/n] ");
37
+ });
38
+ it("accepts y/yes and arbitrary input as yes", async () => {
39
+ for (const answer of ["y", "Y", "yes", " sure "]) {
40
+ const ask = vi.fn(async () => answer);
41
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(true);
42
+ }
43
+ });
44
+ it("declines only on an explicit n/no", async () => {
45
+ for (const answer of ["n", "N", "no", " NO "]) {
46
+ const ask = vi.fn(async () => answer);
47
+ await expect(confirmIosSigning({ signing: SIGNING, interactive: true, ask, log: vi.fn() })).resolves.toBe(false);
48
+ }
49
+ });
50
+ });
@@ -1,10 +1,14 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
2
  import type { Envelope } from "../../types.js";
3
+ export interface MobileUnregisterResult {
4
+ success: boolean;
5
+ deviceId: string;
6
+ name: string;
7
+ }
3
8
  export declare function runMobileUnregister(options: {
4
9
  deviceId?: string;
5
- }, deps?: ClientDeps): Promise<Envelope<{
6
- success: boolean;
7
- }>>;
10
+ }, deps?: ClientDeps): Promise<Envelope<MobileUnregisterResult>>;
11
+ export declare function renderUnregisterResult(data: MobileUnregisterResult): string;
8
12
  declare const _default: import("citty").CommandDef<{
9
13
  readonly "device-id": {
10
14
  readonly type: "string";
@@ -2,14 +2,15 @@ import { defineCommand } from "citty";
2
2
  import { existsSync, unlinkSync } from "node:fs";
3
3
  import { request } from "../../client.js";
4
4
  import { handleApiError } from "../../errors.js";
5
- import { formatSuccess, printResult } from "../../output.js";
5
+ import { formatSuccess } from "../../output.js";
6
6
  import { resolveDeviceConfig } from "./connect-core.js";
7
+ import { printMobileResult } from "./output.js";
7
8
  import { getMobileDeviceConfigPath } from "./paths.js";
8
9
  export async function runMobileUnregister(options, deps) {
9
10
  const resolved = resolveDeviceConfig(options.deviceId);
10
11
  if (!resolved.ok)
11
12
  return resolved;
12
- const { deviceId } = resolved.config;
13
+ const { deviceId, name } = resolved.config;
13
14
  try {
14
15
  await request(`/api/devices/user/${deviceId}`, { method: "DELETE" }, deps ?? {});
15
16
  }
@@ -25,7 +26,10 @@ export async function runMobileUnregister(options, deps) {
25
26
  if (existsSync(configPath)) {
26
27
  unlinkSync(configPath);
27
28
  }
28
- return formatSuccess({ success: true });
29
+ return formatSuccess({ success: true, deviceId, name });
30
+ }
31
+ export function renderUnregisterResult(data) {
32
+ return `Unregistered "${data.name}" (device ${data.deviceId}).`;
29
33
  }
30
34
  export default defineCommand({
31
35
  meta: { name: "unregister", description: "Unregister a local mobile device" },
@@ -37,6 +41,8 @@ export default defineCommand({
37
41
  },
38
42
  async run({ args }) {
39
43
  const deviceId = args["device-id"];
40
- printResult(await runMobileUnregister(deviceId ? { deviceId } : {}));
44
+ printMobileResult(await runMobileUnregister(deviceId ? { deviceId } : {}), {
45
+ render: renderUnregisterResult,
46
+ });
41
47
  },
42
48
  });
@@ -3,6 +3,7 @@ export default defineCommand({
3
3
  meta: { name: "plan", description: "Test plan commands" },
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
+ update: () => import("./update.js").then((m) => m.default),
6
7
  "add-case": () => import("./add-case.js").then((m) => m.default),
7
8
  "remove-case": () => import("./remove-case.js").then((m) => m.default),
8
9
  },
@@ -2,6 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
2
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { runPlanAddCase } from "./add-case.js";
4
4
  import { runPlanCreate } from "./create.js";
5
+ import { runPlanUpdate } from "./update.js";
5
6
  import { runPlanRemoveCase } from "./remove-case.js";
6
7
  beforeAll(() => {
7
8
  process.env["AXIMO_API_KEY"] = "test-key";
@@ -102,3 +103,63 @@ describe("runPlanRemoveCase", () => {
102
103
  }
103
104
  });
104
105
  });
106
+ describe("runPlanUpdate", () => {
107
+ it("PATCHes the provided fields and returns the updated plan", async () => {
108
+ let seen;
109
+ server.use(http.patch(`${TEST_BASE_URL}/api/plans/pl1`, async ({ request: req }) => {
110
+ seen = await req.json();
111
+ return HttpResponse.json({
112
+ success: true,
113
+ plan: {
114
+ id: "pl1",
115
+ title: "Renamed",
116
+ description: null,
117
+ executionMode: "sequential",
118
+ stopOnFailure: true,
119
+ updatedAt: "2026-07-16T00:00:00Z",
120
+ },
121
+ });
122
+ }));
123
+ const result = await runPlanUpdate({
124
+ planId: "pl1",
125
+ title: "Renamed",
126
+ executionMode: "sequential",
127
+ stopOnFailure: "true",
128
+ });
129
+ expect(result.ok).toBe(true);
130
+ if (result.ok && !("dryRun" in result.data)) {
131
+ expect(result.data.plan.executionMode).toBe("sequential");
132
+ }
133
+ expect(seen).toMatchObject({
134
+ title: "Renamed",
135
+ executionMode: "sequential",
136
+ stopOnFailure: true,
137
+ });
138
+ });
139
+ it("rejects when no fields are provided", async () => {
140
+ const result = await runPlanUpdate({ planId: "pl1" });
141
+ expect(result.ok).toBe(false);
142
+ if (!result.ok)
143
+ expect(result.error.code).toBe("validation_error");
144
+ });
145
+ it("rejects an invalid executionMode", async () => {
146
+ const result = await runPlanUpdate({ planId: "pl1", executionMode: "fastest" });
147
+ expect(result.ok).toBe(false);
148
+ if (!result.ok)
149
+ expect(result.error.code).toBe("validation_error");
150
+ });
151
+ it("rejects a non-boolean stop-on-failure", async () => {
152
+ const result = await runPlanUpdate({ planId: "pl1", stopOnFailure: "yes" });
153
+ expect(result.ok).toBe(false);
154
+ if (!result.ok)
155
+ expect(result.error.code).toBe("validation_error");
156
+ });
157
+ it("--dry-run returns the PATCH request without calling the API", async () => {
158
+ const result = await runPlanUpdate({ planId: "pl1", title: "t", dryRun: true });
159
+ expect(result.ok).toBe(true);
160
+ if (result.ok && "dryRun" in result.data) {
161
+ expect(result.data.method).toBe("PATCH");
162
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/plans/pl1`);
163
+ }
164
+ });
165
+ });
@@ -0,0 +1,42 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { Envelope, UpdatePlanResult } from "../../types.js";
4
+ export interface UpdatePlanOptions {
5
+ planId?: unknown;
6
+ title?: unknown;
7
+ description?: unknown;
8
+ executionMode?: unknown;
9
+ stopOnFailure?: unknown;
10
+ json?: unknown;
11
+ dryRun?: unknown;
12
+ }
13
+ export declare function runPlanUpdate(options: UpdatePlanOptions, deps?: ClientDeps): Promise<Envelope<UpdatePlanResult | DryRunResult>>;
14
+ declare const _default: import("citty").CommandDef<{
15
+ readonly planId: {
16
+ readonly type: "positional";
17
+ readonly required: true;
18
+ };
19
+ readonly title: {
20
+ readonly type: "string";
21
+ };
22
+ readonly description: {
23
+ readonly type: "string";
24
+ };
25
+ readonly "execution-mode": {
26
+ readonly type: "string";
27
+ readonly description: "parallel or sequential";
28
+ };
29
+ readonly "stop-on-failure": {
30
+ readonly type: "string";
31
+ readonly description: "true or false; sequential runs only";
32
+ };
33
+ readonly json: {
34
+ readonly type: "string";
35
+ readonly description: "Raw JSON request body (replaces individual flags)";
36
+ };
37
+ readonly "dry-run": {
38
+ readonly type: "boolean";
39
+ readonly description: "Validate and show request without calling API";
40
+ };
41
+ }>;
42
+ export default _default;
@@ -0,0 +1,78 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { optionalString, parseExecutionMode, parseOptionalBoolean, requireId, } from "../../validate.js";
7
+ export async function runPlanUpdate(options, deps) {
8
+ let planId;
9
+ let body;
10
+ const override = resolveJsonOverride(options.json);
11
+ if (override && "error" in override)
12
+ return override.error;
13
+ try {
14
+ planId = requireId("planId", options.planId);
15
+ }
16
+ catch (err) {
17
+ return validationError(err);
18
+ }
19
+ if (override) {
20
+ body = override.body;
21
+ }
22
+ else {
23
+ try {
24
+ body = {
25
+ title: optionalString("title", options.title),
26
+ description: optionalString("description", options.description),
27
+ executionMode: parseExecutionMode(options.executionMode),
28
+ stopOnFailure: parseOptionalBoolean("stopOnFailure", options.stopOnFailure),
29
+ };
30
+ }
31
+ catch (err) {
32
+ return validationError(err);
33
+ }
34
+ if (Object.values(body).every((v) => v === undefined)) {
35
+ return validationError(new Error("at least one field to update is required"));
36
+ }
37
+ }
38
+ const path = `/api/plans/${encodeURIComponent(planId)}`;
39
+ if (options.dryRun) {
40
+ return dryRunEnvelope("PATCH", path, body);
41
+ }
42
+ try {
43
+ const res = await request(path, { method: "PATCH", body }, deps ?? {});
44
+ return formatSuccess(res);
45
+ }
46
+ catch (err) {
47
+ return handleApiError(err);
48
+ }
49
+ }
50
+ export default defineCommand({
51
+ meta: { name: "update", description: "Update a test plan" },
52
+ args: {
53
+ planId: { type: "positional", required: true },
54
+ title: { type: "string" },
55
+ description: { type: "string" },
56
+ "execution-mode": {
57
+ type: "string",
58
+ description: "parallel or sequential",
59
+ },
60
+ "stop-on-failure": {
61
+ type: "string",
62
+ description: "true or false; sequential runs only",
63
+ },
64
+ json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
65
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
66
+ },
67
+ async run({ args }) {
68
+ printResult(await runPlanUpdate({
69
+ planId: args.planId,
70
+ title: args.title,
71
+ description: args.description,
72
+ executionMode: args["execution-mode"],
73
+ stopOnFailure: args["stop-on-failure"],
74
+ json: args.json,
75
+ dryRun: args["dry-run"],
76
+ }));
77
+ },
78
+ });
@@ -19,7 +19,7 @@ export function getSchema() {
19
19
  name: "aximo",
20
20
  version: pkg.version,
21
21
  commands: [
22
- cmd("health", "Health commands", [], [cmd("check", "GET /api/health")]),
22
+ cmd("health", "Health commands", [], [cmd("check", "GET /api/health + authenticated API-key probe")]),
23
23
  cmd("session", "Session commands", [], [
24
24
  cmd("create", "POST /api/sessions", [
25
25
  arg("title", "string", "Session title"),
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, requireId } from "../../validate.js";
6
+ import { optionalString, requireId, parseCaseFormat, parseSaveAsCaseType } from "../../validate.js";
7
7
  export async function runSessionSaveAsCase(options, deps) {
8
8
  let sessionId;
9
9
  let body;
@@ -23,9 +23,9 @@ export async function runSessionSaveAsCase(options, deps) {
23
23
  try {
24
24
  body = {
25
25
  title: optionalString("title", options.title),
26
- type: optionalString("type", options.type),
26
+ type: parseSaveAsCaseType(options.type),
27
27
  scenario: optionalString("scenario", options.scenario),
28
- format: optionalString("format", options.format),
28
+ format: parseCaseFormat(options.format),
29
29
  };
30
30
  }
31
31
  catch (err) {
@@ -1,10 +1,11 @@
1
- import type { Envelope, PlanRunResult } from "../../../types.js";
2
1
  import { type ClientDeps } from "../../../client.js";
3
2
  import { type DryRunResult } from "../../../mutate.js";
3
+ import type { Envelope, PlanRunResult } from "../../../types.js";
4
4
  export interface TriggerPlanRunOptions {
5
5
  planId?: unknown;
6
6
  model?: unknown;
7
7
  mobileConfig?: unknown;
8
+ executionMode?: unknown;
8
9
  json?: unknown;
9
10
  dryRun?: unknown;
10
11
  }
@@ -21,6 +22,10 @@ declare const _default: import("citty").CommandDef<{
21
22
  readonly type: "string";
22
23
  readonly description: "JSON object string";
23
24
  };
25
+ readonly "execution-mode": {
26
+ readonly type: "string";
27
+ readonly description: "parallel or sequential (defaults to the plan's stored setting)";
28
+ };
24
29
  readonly json: {
25
30
  readonly type: "string";
26
31
  readonly description: "Raw JSON request body";
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from "citty";
2
- import { formatSuccess, printResult, validationError } from "../../../output.js";
3
- import { handleApiError } from "../../../errors.js";
4
2
  import { request } from "../../../client.js";
5
- import { requireId, optionalString, parseJsonObject } from "../../../validate.js";
3
+ import { handleApiError } from "../../../errors.js";
6
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../../output.js";
6
+ import { optionalString, parseExecutionMode, parseJsonObject, requireId, } from "../../../validate.js";
7
7
  export async function runTriggerPlanRun(options, deps) {
8
8
  let planId;
9
9
  let body;
@@ -24,6 +24,7 @@ export async function runTriggerPlanRun(options, deps) {
24
24
  body = {
25
25
  model: optionalString("model", options.model),
26
26
  mobileConfig: parseJsonObject("mobileConfig", options.mobileConfig),
27
+ executionMode: parseExecutionMode(options.executionMode),
27
28
  };
28
29
  }
29
30
  catch (err) {
@@ -48,6 +49,10 @@ export default defineCommand({
48
49
  planId: { type: "positional", required: true },
49
50
  model: { type: "string" },
50
51
  "mobile-config": { type: "string", description: "JSON object string" },
52
+ "execution-mode": {
53
+ type: "string",
54
+ description: "parallel or sequential (defaults to the plan's stored setting)",
55
+ },
51
56
  json: { type: "string", description: "Raw JSON request body" },
52
57
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
53
58
  },
@@ -56,6 +61,7 @@ export default defineCommand({
56
61
  planId: args.planId,
57
62
  model: args.model,
58
63
  mobileConfig: args["mobile-config"],
64
+ executionMode: args["execution-mode"],
59
65
  json: args.json,
60
66
  dryRun: args["dry-run"],
61
67
  }));
@@ -1,9 +1,9 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
- import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
3
- import { runTriggerCaseRun } from "./case/run.js";
2
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
4
3
  import { runTriggerCaseGet } from "./case/get.js";
5
- import { runTriggerPlanRun } from "./plan/run.js";
4
+ import { runTriggerCaseRun } from "./case/run.js";
6
5
  import { runTriggerPlanGet } from "./plan/get.js";
6
+ import { runTriggerPlanRun } from "./plan/run.js";
7
7
  beforeAll(() => {
8
8
  process.env["AXIMO_API_KEY"] = "test-key";
9
9
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
@@ -90,6 +90,25 @@ describe("trigger plan", () => {
90
90
  if (!result.ok)
91
91
  expect(result.error.code).toBe("validation_error");
92
92
  });
93
+ it("sends executionMode in the body and surfaces the echoed effective mode", async () => {
94
+ let seen;
95
+ server.use(http.post(`${TEST_BASE_URL}/api/triggers/plans/p1/run`, async ({ request: req }) => {
96
+ seen = await req.json();
97
+ return HttpResponse.json({ success: true, planRunId: "pr1", totalCases: 2, executionMode: "sequential" }, { status: 201 });
98
+ }));
99
+ const result = await runTriggerPlanRun({ planId: "p1", executionMode: "sequential" });
100
+ expect(result.ok).toBe(true);
101
+ if (result.ok && !("dryRun" in result.data)) {
102
+ expect(result.data.executionMode).toBe("sequential");
103
+ }
104
+ expect(seen).toMatchObject({ executionMode: "sequential" });
105
+ });
106
+ it("rejects an invalid executionMode", async () => {
107
+ const result = await runTriggerPlanRun({ planId: "p1", executionMode: "fastest" });
108
+ expect(result.ok).toBe(false);
109
+ if (!result.ok)
110
+ expect(result.error.code).toBe("validation_error");
111
+ });
93
112
  it("gets plan run", async () => {
94
113
  server.use(http.get(`${TEST_BASE_URL}/api/triggers/plans/p1/runs/pr1`, () => HttpResponse.json({ id: "pr1", status: "running" })));
95
114
  const result = await runTriggerPlanGet("p1", "pr1");
@@ -4,6 +4,7 @@ import { type DryRunResult } from "../../mutate.js";
4
4
  export interface UploadPutOptions {
5
5
  uploadId?: unknown;
6
6
  bytes?: Uint8Array | undefined;
7
+ presignedUrl?: unknown;
7
8
  dryRun?: unknown;
8
9
  }
9
10
  export declare function runUploadPut(options: UploadPutOptions, deps?: ClientDeps): Promise<Envelope<UploadPutResult | DryRunResult>>;
@@ -16,6 +17,10 @@ declare const _default: import("citty").CommandDef<{
16
17
  readonly type: "string";
17
18
  readonly description: "Local path to the file";
18
19
  };
20
+ readonly url: {
21
+ readonly type: "string";
22
+ readonly description: "presignedUrl from `upload create` output (required for S3-backed deployments)";
23
+ };
19
24
  readonly "dry-run": {
20
25
  readonly type: "boolean";
21
26
  readonly description: "Validate and show request without calling API";
@@ -2,34 +2,63 @@ import { readFile } from "node:fs/promises";
2
2
  import { defineCommand } from "citty";
3
3
  import { formatSuccess, formatError, printResult, validationError } from "../../output.js";
4
4
  import { handleApiError } from "../../errors.js";
5
- import { request } from "../../client.js";
6
- import { requireId } from "../../validate.js";
5
+ import { request, putToPresignedUrl } from "../../client.js";
6
+ import { requireId, optionalString } from "../../validate.js";
7
7
  import { dryRunEnvelope } from "../../mutate.js";
8
+ const PRESIGNED_URL_HINT = "This deployment stores uploads in S3, so the API does not accept file bytes directly. " +
9
+ 'Re-run with --url "<presignedUrl>" using the presignedUrl returned by `aximo upload create`.';
10
+ function isAbsoluteUrl(value) {
11
+ return value.startsWith("http://") || value.startsWith("https://");
12
+ }
13
+ function isMethodNotAllowed(err) {
14
+ return typeof err === "object" && err !== null && err.status === 405;
15
+ }
8
16
  export async function runUploadPut(options, deps) {
9
17
  let uploadId;
18
+ let urlOption;
10
19
  try {
11
20
  uploadId = requireId("uploadId", options.uploadId);
12
- if (!options.dryRun && !options.bytes)
13
- throw new Error("file bytes are required");
21
+ urlOption = optionalString("url", options.presignedUrl);
14
22
  }
15
23
  catch (err) {
16
24
  return validationError(err);
17
25
  }
18
- const path = `/api/uploads/${encodeURIComponent(uploadId)}`;
26
+ // The presignedUrl from `upload create` is either an absolute S3 URL
27
+ // (cloud deployments) or a relative API path (local storage).
28
+ const presignedUrl = urlOption;
29
+ const useDirectPut = presignedUrl !== undefined && isAbsoluteUrl(presignedUrl);
30
+ const target = presignedUrl ?? `/api/uploads/${encodeURIComponent(uploadId)}`;
19
31
  if (options.dryRun) {
20
- return dryRunEnvelope("PUT", path);
32
+ return dryRunEnvelope("PUT", target);
33
+ }
34
+ if (!options.bytes) {
35
+ return validationError(new Error("file bytes are required"));
36
+ }
37
+ const body = new Blob([new Uint8Array(options.bytes)]);
38
+ if (useDirectPut) {
39
+ try {
40
+ await putToPresignedUrl(target, body, "application/octet-stream", deps ?? {});
41
+ return formatSuccess({ success: true });
42
+ }
43
+ catch (err) {
44
+ return handleApiError(err);
45
+ }
21
46
  }
22
47
  try {
23
- await request(path, {
48
+ await request(target, {
24
49
  method: "PUT",
25
- rawBody: options.bytes ? new Blob([new Uint8Array(options.bytes)]) : undefined,
50
+ rawBody: body,
26
51
  contentType: "application/octet-stream",
27
52
  expectJson: false,
28
53
  }, deps ?? {});
29
54
  return formatSuccess({ success: true });
30
55
  }
31
56
  catch (err) {
32
- return handleApiError(err);
57
+ const envelope = handleApiError(err);
58
+ if (isMethodNotAllowed(err)) {
59
+ envelope.error.hint = PRESIGNED_URL_HINT;
60
+ }
61
+ return envelope;
33
62
  }
34
63
  }
35
64
  export default defineCommand({
@@ -37,12 +66,16 @@ export default defineCommand({
37
66
  args: {
38
67
  uploadId: { type: "positional", required: true },
39
68
  file: { type: "string", description: "Local path to the file" },
69
+ url: {
70
+ type: "string",
71
+ description: "presignedUrl from `upload create` output (required for S3-backed deployments)",
72
+ },
40
73
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
41
74
  },
42
75
  async run({ args }) {
43
76
  const dryRun = args["dry-run"];
44
77
  if (dryRun) {
45
- printResult(await runUploadPut({ uploadId: args.uploadId, dryRun: true }));
78
+ printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, dryRun: true }));
46
79
  return;
47
80
  }
48
81
  if (!args.file) {
@@ -57,6 +90,6 @@ export default defineCommand({
57
90
  printResult(formatError("validation_error", `Cannot read file: ${args.file}`, false, err instanceof Error ? err.message : undefined));
58
91
  return;
59
92
  }
60
- printResult(await runUploadPut({ uploadId: args.uploadId, bytes }));
93
+ printResult(await runUploadPut({ uploadId: args.uploadId, presignedUrl: args.url, bytes }));
61
94
  },
62
95
  });
@@ -98,6 +98,75 @@ describe("runUploadPut", () => {
98
98
  expect(body).not.toBeNull();
99
99
  expect(new Uint8Array(body)).toEqual(new Uint8Array([9, 9]));
100
100
  });
101
+ it("PUTs directly to an absolute presigned URL without auth headers", async () => {
102
+ let seenCT = "";
103
+ let seenAuth = "unset";
104
+ let seenApiKey = "unset";
105
+ let body = null;
106
+ server.use(http.put("https://bucket.s3.test/uploads/u1", async ({ request: req }) => {
107
+ seenCT = req.headers.get("content-type") ?? "";
108
+ seenAuth = req.headers.get("authorization");
109
+ seenApiKey = req.headers.get("x-api-key");
110
+ body = await req.arrayBuffer();
111
+ return new HttpResponse(null, { status: 200 });
112
+ }));
113
+ const result = await runUploadPut({
114
+ uploadId: "u1",
115
+ bytes: new Uint8Array([7, 7]),
116
+ presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
117
+ });
118
+ expect(result.ok).toBe(true);
119
+ if (result.ok && !("dryRun" in result.data))
120
+ expect(result.data.success).toBe(true);
121
+ expect(seenCT).toContain("application/octet-stream");
122
+ expect(seenAuth).toBeNull();
123
+ expect(seenApiKey).toBeNull();
124
+ expect(new Uint8Array(body)).toEqual(new Uint8Array([7, 7]));
125
+ });
126
+ it("maps a presigned URL failure to an error envelope", async () => {
127
+ server.use(http.put("https://bucket.s3.test/uploads/u2", () => HttpResponse.text("SignatureDoesNotMatch", { status: 403 })));
128
+ const result = await runUploadPut({
129
+ uploadId: "u2",
130
+ bytes: new Uint8Array([1]),
131
+ presignedUrl: "https://bucket.s3.test/uploads/u2",
132
+ });
133
+ expect(result.ok).toBe(false);
134
+ if (!result.ok)
135
+ expect(result.error.code).toBe("unauthorized");
136
+ });
137
+ it("PUTs via the authenticated API when --url is relative", async () => {
138
+ let seenApiKey = null;
139
+ server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, ({ request: req }) => {
140
+ seenApiKey = req.headers.get("x-api-key");
141
+ return HttpResponse.json({ success: true });
142
+ }));
143
+ const result = await runUploadPut({
144
+ uploadId: "u1",
145
+ bytes: new Uint8Array([1]),
146
+ presignedUrl: "/api/uploads/u1",
147
+ });
148
+ expect(result.ok).toBe(true);
149
+ expect(seenApiKey).toBe("test-key");
150
+ });
151
+ it("hints at --url when the API PUT returns 405", async () => {
152
+ server.use(http.put(`${TEST_BASE_URL}/api/uploads/u1`, () => HttpResponse.json({ error: "Uploads use presigned URLs; PUT the file to the presigned URL instead" }, { status: 405 })));
153
+ const result = await runUploadPut({ uploadId: "u1", bytes: new Uint8Array([1]) });
154
+ expect(result.ok).toBe(false);
155
+ if (!result.ok) {
156
+ expect(result.error.hint).toContain("--url");
157
+ expect(result.error.hint).toContain("aximo upload create");
158
+ }
159
+ });
160
+ it("rejects a non-string --url", async () => {
161
+ const result = await runUploadPut({
162
+ uploadId: "u1",
163
+ bytes: new Uint8Array([1]),
164
+ presignedUrl: 42,
165
+ });
166
+ expect(result.ok).toBe(false);
167
+ if (!result.ok)
168
+ expect(result.error.code).toBe("validation_error");
169
+ });
101
170
  it("rejects missing bytes", async () => {
102
171
  const result = await runUploadPut({ uploadId: "u1" });
103
172
  expect(result.ok).toBe(false);
@@ -107,6 +176,19 @@ describe("runUploadPut", () => {
107
176
  expect(result.ok).toBe(true);
108
177
  if (result.ok && "dryRun" in result.data) {
109
178
  expect(result.data.method).toBe("PUT");
179
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/uploads/u1`);
180
+ }
181
+ });
182
+ it("--dry-run reflects the presigned URL target", async () => {
183
+ const result = await runUploadPut({
184
+ uploadId: "u1",
185
+ presignedUrl: "https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc",
186
+ dryRun: true,
187
+ });
188
+ expect(result.ok).toBe(true);
189
+ if (result.ok && "dryRun" in result.data) {
190
+ expect(result.data.method).toBe("PUT");
191
+ expect(result.data.url).toBe("https://bucket.s3.test/uploads/u1?X-Amz-Signature=abc");
110
192
  }
111
193
  });
112
194
  it("rejects uploadId with forbidden chars", async () => {