Skip to content
Merged
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
79 changes: 71 additions & 8 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,59 @@ async function checkMcpJson(): Promise<McpJsonCheck> {
return { found: false, configured: false, commands: [] };
}

export interface DoctorVerdict {
ok: boolean;
firstFailure: string | null;
summary: string;
warnings: Array<{ name: string; detail: string; fix?: string }>;
}

/**
* Reconcile the doctor's overall verdict so `ok`, `firstFailure`, and `summary`
* always tell ONE story.
*
* `ok` is true iff every REQUIRED check passed. A required check is any check
* that is not an optional warning: transport/auth layers, config, credentials,
* Node version. Optional warnings (missing MCP server, action-catalog drift,
* absent .claude/mcp.json) are surfaced in `warnings` and must NEVER flip `ok`
* or set `firstFailure` - collapsing the two is what produced the reported
* "ok:false + firstFailure:null + 'All layers healthy'" contradiction.
*/
export function doctorVerdict(
checks: ReadonlyArray<
Pick<Check, "ok" | "unknown" | "warn" | "layer" | "name" | "detail" | "fix">
>,
diagnosis: Pick<Diagnosis, "firstFailure" | "summary">,
): DoctorVerdict {
const hardFailures = checks.filter((c) => !c.ok && !c.unknown && !c.warn);
const warnings = checks.filter((c) => !c.ok && Boolean(c.warn));
const ok = hardFailures.length === 0;
// Null iff ok. Prefer the transport/auth layer the diagnosis already named;
// otherwise name the first required check that failed.
const firstFailure: string | null = ok
? null
: (diagnosis.firstFailure ??
hardFailures[0]?.layer ??
hardFailures[0]?.name ??
null);
// Reflect the same verdict: the diagnosis summary when the fault is a
// transport layer (or the path is clean), otherwise name the failing check.
const summary =
ok || diagnosis.firstFailure
? diagnosis.summary
: `${hardFailures[0]?.name} failed: ${hardFailures[0]?.detail}`;
return {
ok,
firstFailure,
summary,
warnings: warnings.map((c) => ({
name: c.name,
detail: c.detail,
...(c.fix ? { fix: c.fix } : {}),
})),
};
}

export function register(program: Command): void {
program
.command("doctor")
Expand Down Expand Up @@ -413,6 +466,10 @@ export function register(program: Command): void {
checks.push({
name: "Action authority",
ok: healthy,
// Catalog drift is an optional-tooling concern, not a broken
// transport: it must surface as a warning, never a hard failure
// that flips the overall verdict or sets firstFailure.
warn: !healthy,
detail:
healthy
? `${catalog.length} version-pinned capabilities, exact contract match`
Expand All @@ -429,6 +486,7 @@ export function register(program: Command): void {
checks.push({
name: "Action authority",
ok: false,
warn: true,
detail: err instanceof Error ? err.message : String(err),
fix: "Upgrade the control plane and run: miosa actions catalog",
section: "Authority",
Expand Down Expand Up @@ -515,16 +573,21 @@ export function register(program: Command): void {

// ── Output ───────────────────────────────────────────────────────────
if (json) {
// One reconciled verdict so ok / firstFailure / summary never
// contradict each other, with optional findings split into warnings.
const verdict = doctorVerdict(checks, diagnosis);
return printJson({
// `ok` counts only checks that actually FAILED. A check that could
// not be determined is reported in `unknown`, never silently folded
// into a pass or a fail.
ok: checks.every((c) => c.ok || c.unknown),
// True iff every REQUIRED check passed. Optional warnings live in
// `warnings` below and never flip this.
ok: verdict.ok,
unknown: checks.some((c) => c.unknown),
// The one field a script should branch on: which layer broke first.
// null when the whole path is clean.
firstFailure: diagnosis.firstFailure,
summary: diagnosis.summary,
// The one field a script should branch on: which required layer/check
// broke first. null iff ok is true.
firstFailure: verdict.firstFailure,
summary: verdict.summary,
// Optional, non-fatal findings. Present for visibility only; ignored
// by `ok` and `firstFailure`.
warnings: verdict.warnings,
resolver: diagnosis.resolver,
endpoint: {
url: diagnosis.endpoint,
Expand Down
24 changes: 24 additions & 0 deletions src/commands/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4462,6 +4462,26 @@ async function preflightSandboxConnector(
);
}

/**
* Propagate a remote command's exit code to the CLI process.
*
* A `sandbox exec` whose remote command exits nonzero must make the CLI exit
* nonzero too, or CI and shell `&&` chains treat a failed command as a success.
* This applies even in --json mode: the JSON already carries `exit_code`, but a
* script that pipes to `jq` still relies on `$?`. Exit codes are clamped to the
* 1-255 range a process can actually report; any nonzero-but-unrepresentable
* code collapses to 1.
*/
function applyRemoteExitCode(value: unknown): void {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
const raw = (value as Record<string, unknown>)["exit_code"];
if (raw == null) return;
const code = Number(raw);
if (!Number.isFinite(code) || code === 0) return;
const clamped = Number.isInteger(code) && code >= 1 && code <= 255 ? code : 1;
process.exitCode = clamped;
}

async function postSandboxExecAndPrint(
sandboxId: string,
opts: JsonOptions,
Expand All @@ -4475,6 +4495,7 @@ async function postSandboxExecAndPrint(
),
);
printValue(value, opts);
applyRemoteExitCode(value);
} catch (err) {
if (err instanceof ApiResponseError && err.code === "SANDBOX_NOT_RUNNING") {
throw await enrichSandboxLifecycleError(sandboxId, err);
Expand Down Expand Up @@ -5160,6 +5181,9 @@ async function runFollowExec(
if (!isJsonMode(opts) && exitCode !== 0) {
console.error(chalk.red(`\nexit code: ${exitCode}`));
}
// Streamed commands must fail the CLI process on a nonzero remote exit too,
// so `--follow` in CI behaves the same as a plain exec.
applyRemoteExitCode({ exit_code: exitCode });
}

async function waitForInternalHttp(
Expand Down
121 changes: 104 additions & 17 deletions src/commands/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,45 @@ function printIntegrationSnippet(t: SandboxTemplate): void {
}
}

// Named shapes a template's default size may reference. Kept in lockstep with
// the sandbox size vocabulary so `templates create --size` and `sandbox create
// --size` accept the same names.
const TEMPLATE_SIZES = ["xs", "small", "medium", "large", "xl"] as const;

function parseTemplateSize(value: string): string {
const size = String(value).trim().toLowerCase();
if ((TEMPLATE_SIZES as readonly string[]).includes(size)) return size;
throw new Error(
`Invalid --size: ${value}. Expected one of: ${TEMPLATE_SIZES.join(", ")}.`,
);
}

function parseCpuCount(value: string): number {
const n = Number.parseInt(String(value).trim(), 10);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Invalid --cpu: ${value}. Expected a positive integer.`);
}
return n;
}

// Parse a memory/disk size to an integer number of MiB. Accepts a bare number
// (already MiB), or a value suffixed with a unit: mb/m/mib are treated as MiB,
// gb/g/gib as GiB (1 GiB = 1024 MiB). Examples: "4gb", "4096", "4096mib".
function parseToMib(value: string, flag: string): number {
const match = String(value)
.trim()
.match(/^(\d+)\s*(mib|gib|mb|gb|m|g)?$/i);
if (!match) {
throw new Error(
`Invalid ${flag} value: ${value}. Use e.g. 4gb, 4096, or 4096mib.`,
);
}
const amount = Number(match[1]);
const unit = (match[2] ?? "mib").toLowerCase();
if (unit === "gb" || unit === "g" || unit === "gib") return amount * 1024;
return amount; // mb / m / mib → MiB
}

export function register(program: Command): void {
const templates = program
.command("templates")
Expand Down Expand Up @@ -584,35 +623,83 @@ export function register(program: Command): void {
"--dockerfile <path>",
"Path to Dockerfile to build the template from",
)
.option("--cpu <n>", "vCPU count for the template's default shape", parseCpuCount)
.option(
"--memory <val>",
"Memory for the default shape, e.g. 4gb, 4096, or 4096mib",
(v: string) => parseToMib(v, "--memory"),
)
.option(
"--disk <val>",
"Disk floor for the default shape, e.g. 30gb or 30720",
(v: string) => parseToMib(v, "--disk"),
)
.option(
"--size <name>",
`Named default shape: ${TEMPLATE_SIZES.join(", ")}`,
parseTemplateSize,
)
.option("--json", "Output raw JSON")
.action(
async (opts: { name: string; dockerfile: string; json?: boolean }) => {
async (opts: {
name: string;
dockerfile: string;
cpu?: number;
memory?: number;
disk?: number;
size?: string;
json?: boolean;
}) => {
// Resolve JSON mode once. In JSON mode NOTHING but the single JSON
// payload may reach stdout - no spinner, no status lines, no hints.
const json = isJsonMode(opts);
try {
let dockerfileContent: string;
try {
dockerfileContent = readFileSync(opts.dockerfile, "utf8");
} catch (err) {
console.error(
chalk.red(
`Cannot read Dockerfile at ${opts.dockerfile}: ${err instanceof Error ? err.message : String(err)}`,
),
);
const message = `Cannot read Dockerfile at ${opts.dockerfile}: ${err instanceof Error ? err.message : String(err)}`;
if (json) {
printJson({
ok: false,
error: { code: "DOCKERFILE_UNREADABLE", message },
});
} else {
console.error(chalk.red(message));
}
process.exit(1);
}

const config = loadConfig();
const client = new MiosaClient(config);
const spinner = spin(`Creating template ${opts.name}...`);
const tmpl = unwrapTemplate(
await client.apiPost("/api/v1/sandbox-templates", {
name: opts.name,
dockerfile: dockerfileContent,
}),
);
spinner.succeed(`Created template ${tmpl.name}`);

if (opts.json) {
console.log(JSON.stringify(tmpl, null, 2));
// Only forward resource fields the user actually set. When none are
// given we omit them entirely so the server picks its own default,
// rather than the CLI pinning an unusable shape. vCPU and memory go
// as raw MiB/count integers; disk is a floor the platform grows to.
const body: Record<string, unknown> = {
name: opts.name,
dockerfile: dockerfileContent,
};
if (opts.cpu != null) body["cpu_count"] = opts.cpu;
if (opts.memory != null) body["memory_mb"] = opts.memory;
if (opts.disk != null) body["disk_size_mb"] = opts.disk;
if (opts.size) body["size"] = opts.size;

const spinner = json ? null : spin(`Creating template ${opts.name}...`);
let tmpl: SandboxTemplate;
try {
tmpl = unwrapTemplate(
await client.apiPost("/api/v1/sandbox-templates", body),
);
} catch (err) {
spinner?.stop();
throw err;
}
spinner?.succeed(`Created template ${tmpl.name}`);

if (json) {
printJson(tmpl);
return;
}

Expand All @@ -630,7 +717,7 @@ export function register(program: Command): void {
);
console.log();
} catch (err) {
handleError(err);
handleError(err, { json });
}
},
);
Expand Down
Loading
Loading