Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Command } from "@cliffy/command";
import { Command, ValidationError } from "@cliffy/command";
import { greaterOrEqual, parse as semverParse } from "@std/semver";
import { sandboxCommand } from "./sandbox/mod.ts";
import { deployCommand } from "./deploy/mod.ts";
import { actionHandler, getApp, getOrg } from "./config.ts";
import { error, ExitCode, writeJsonResult } from "./util.ts";

const MINIMUM_DENO_VERSION = "2.4.2";
if (
Expand Down Expand Up @@ -30,10 +31,53 @@ export type GlobalContext = {
nonInteractive?: true;
};

if (Deno.env.has("DENO_DEPLOY_CLI_SANDBOX")) {
await sandboxCommand.parse(Deno.args);
} else {
await deployCommand.command("sandbox", sandboxCommand).parse(Deno.args);
// `.noExit()` makes Cliffy throw parse errors instead of exiting, so they can be
// routed through the CLI error contract (see `handleCliError`). It also stops
// `--help`/`--version` from exiting: Cliffy prints them and `parse()` returns, so
// the process still exits 0. `.reset()` first repoints the builder back to the
// root command (mounting/defining subcommands leaves it selecting a child), so
// `.noExit()` applies to the command we actually parse.
try {
if (Deno.env.has("DENO_DEPLOY_CLI_SANDBOX")) {
await sandboxCommand.reset().noExit().parse(Deno.args);
} else {
await deployCommand.command("sandbox", sandboxCommand).reset().noExit()
.parse(Deno.args);
}
} catch (e) {
handleCliError(e);
}

/**
* Map an error thrown out of Cliffy's `parse()` onto the CLI error contract.
* `ValidationError` (unknown/invalid/conflicting flag, missing value) is a usage
* error and must exit with `ExitCode.USAGE` (2); anything else is generic.
*/
function handleCliError(e: unknown): never {
const context: GlobalContext = {
debug: Deno.args.includes("--debug"),
endpoint: "",
json: Deno.args.some(isJsonModeArg) ? true : undefined,
};

if (e instanceof ValidationError) {
error(context, e.message, {
code: ExitCode.USAGE,
errorCode: "VALIDATION_ERROR",
});
}

error(context, e instanceof Error ? e.message : String(e));
}

/**
* Best-effort `--json` detection without a parsed context, used by
* `handleCliError` to pick the error output format when `parse()` itself throws.
* Matches `--json`, `--json=...`, `-j`, and combined short flags like `-jy`.
*/
function isJsonModeArg(arg: string): boolean {
return arg === "-j" || arg === "--json" || arg.startsWith("--json=") ||
/^-[a-z]*j[a-z]*$/.test(arg);
}

export function createSwitchCommand(
Expand All @@ -52,10 +96,14 @@ export function createSwitchCommand(
app = out.app;
}

console.log(
`Switched to organization '${org}'${
app ? ` and application '${app}'` : ""
}.`,
);
if (options.json) {
writeJsonResult({ org, app: app ?? null });
} else {
console.error(
`Switched to organization '${org}'${
app ? ` and application '${app}'` : ""
}.`,
);
}
}));
}
9 changes: 5 additions & 4 deletions tests/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ Deno.test("publish (default command) --json keeps stdout clean and emits an AUTH
});

Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", async () => {
// Cliffy's ValidationError handler exits with code 1 by default;
// verify the agent can pattern-match on stderr text either way.
// Cliffy `ValidationError`s are now routed through the CLI error contract, so
// a bad flag value exits with USAGE (2) and keeps stdout clean.
const res = await deployRaw(
"create",
"--dry-run",
Expand All @@ -171,8 +171,9 @@ Deno.test("non-zero exit code matches taxonomy for invalid flag (USAGE=2)", asyn
"--source",
"invalid",
);
assert(res.code !== 0);
assertStringIncludes(res.stderr + res.stdout, "Invalid source");
assertEquals(res.code, 2, `stderr: ${res.stderr}`);
assertEquals(res.stdout.trim(), "", `stdout should be empty: ${res.stdout}`);
assertStringIncludes(res.stderr, "Invalid source");
});

async function sandboxRaw(...args: string[]): Promise<
Expand Down
71 changes: 71 additions & 0 deletions tests/cli_contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { assert, assertEquals } from "@std/assert";
import { fromFileUrl } from "@std/path";

// These tests drive `main.ts` as a subprocess and assert the exit-code contract.
// Unlike the other suites here, they don't touch the backend, so no token is
// required: bad flags / `--help` / `--version` are resolved entirely by the
// argument parser before any command action runs.

const MAIN_TS = fromFileUrl(new URL("../main.ts", import.meta.url));

async function runCli(
args: string[],
env: Record<string, string> = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
const { code, stdout, stderr } = await new Deno.Command(Deno.execPath(), {
args: ["run", "-A", MAIN_TS, ...args],
env,
stdout: "piped",
stderr: "piped",
}).output();
return {
code,
stdout: new TextDecoder().decode(stdout),
stderr: new TextDecoder().decode(stderr),
};
}

Deno.test("unknown flag exits with USAGE (2)", async () => {
const res = await runCli(["--does-not-exist"]);
assertEquals(res.code, 2, `stderr: ${res.stderr}`);
});

Deno.test("unknown flag with --json emits a USAGE envelope on stderr, clean stdout", async () => {
const res = await runCli(["--json", "--does-not-exist"]);
assertEquals(res.code, 2, `stderr: ${res.stderr}`);
assertEquals(res.stdout.trim(), "", `stdout should be empty: ${res.stdout}`);
const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!);
assertEquals(envelope.error.code, "VALIDATION_ERROR");
assert(
typeof envelope.error.message === "string" &&
envelope.error.message.length > 0,
`expected a message; got: ${JSON.stringify(envelope)}`,
);
});

Deno.test("combined short flag -jy is detected as JSON mode for the error envelope", async () => {
// `-jy` bundles `-j` (json) and `-y` (non-interactive); a bad flag must still
// surface the structured envelope on stderr, not the human-readable error.
const res = await runCli(["-jy", "--does-not-exist"]);
assertEquals(res.code, 2, `stderr: ${res.stderr}`);
assertEquals(res.stdout.trim(), "", `stdout should be empty: ${res.stdout}`);
const envelope = JSON.parse(res.stderr.trim().split("\n").pop()!);
assertEquals(envelope.error.code, "VALIDATION_ERROR");
});

Deno.test("--help exits 0", async () => {
const res = await runCli(["--help"]);
assertEquals(res.code, 0, `stderr: ${res.stderr}`);
});

Deno.test("--version exits 0", async () => {
const res = await runCli(["--version"]);
assertEquals(res.code, 0, `stderr: ${res.stderr}`);
});

Deno.test("unknown flag exits with USAGE (2) on the standalone sandbox root", async () => {
const res = await runCli(["--does-not-exist"], {
DENO_DEPLOY_CLI_SANDBOX: "1",
});
assertEquals(res.code, 2, `stderr: ${res.stderr}`);
});
Loading