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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ There is no Homebrew formula, npm package, or GitHub Release yet. Building the
app from this checkout is the current install path. `~/.local/bin` must be on
`PATH`, or the `source .../env.sh` step is required in each new shell.

`simbroker` help and `simbroker doctor` print human-readable text by default.
Pass `--json` for machine-readable payloads.

If the app shows **Set Up This Mac**, click **Complete first-time setup**.
That creates a starter simulator pool. The CLI equivalent is
`simbroker host init --bootstrap-config`, which also creates real simulator
Expand Down
4 changes: 4 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

CLI client and compatibility wrappers live here.

Help and `doctor` print human-readable text by default. Pass `--json` for
machine-readable payloads. Other commands still emit JSON by default so
existing wrappers keep working.

Current entrypoint:

- `node client/bin/simbroker.mjs`
Expand Down
23 changes: 17 additions & 6 deletions client/bin/simbroker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ async function stopService(paths) {
}

async function runServiceAwareRequest(paths, request) {
const canUseService = !localOnlyMode(process.env);
const canUseService = !localOnlyMode(process.env) && request.group !== "help";
let service = canUseService
? await probeService(paths, { timeoutMs: serviceCommandTimeoutMs(request) })
: null;
Expand Down Expand Up @@ -560,12 +560,23 @@ async function runServiceAwareRequest(paths, request) {
return payload;
}

const invocation = parseArgs(process.argv.slice(2));

function wantsJson() {
return invocation.flags.has("json");
}

async function main() {
const { flags, positionals } = parseArgs(process.argv.slice(2));
const { flags, positionals } = invocation;
rejectExtraPositionals(positionals);
const [group, command] = positionals;
const paths = buildPaths(flags);

if (flags.has("help") || group === "help" || command === "help") {
const request = createCommandRequest(paths, group, command, flags);
return runServiceAwareRequest(paths, request);
}

switch (`${group ?? ""}:${command ?? ""}`) {
case "service:start":
rejectUnknownServiceControlFlags(flags);
Expand All @@ -591,11 +602,11 @@ main()
process.stdout.write(format({
ok: true,
...payload,
}));
}, { json: wantsJson() }));
})
.catch((error) => {
if (error instanceof BrokerError) {
process.stdout.write(format(error.payload));
process.stdout.write(format(error.payload, { json: true }));
process.exit(error.exitCode);
return;
}
Expand All @@ -606,7 +617,7 @@ main()
exitCode,
ok: false,
reasonCode: error.reasonCode,
}));
}, { json: true }));
process.exit(exitCode);
return;
}
Expand All @@ -617,6 +628,6 @@ main()
ok: false,
reasonCode: INTERNAL_ERROR_REASON_CODE,
stack: error?.stack ?? null,
}));
}, { json: true }));
process.exit(BROKER_EXIT_CODES.internal);
});
187 changes: 185 additions & 2 deletions client/command-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,142 @@ function hostInitOptions(flags) {
};
}

export function format(payload) {
return `${JSON.stringify(payload, null, 2)}\n`;
export function format(payload, options = {}) {
if (options.json === true || !shouldFormatAsText(payload)) {
return `${JSON.stringify(payload, null, 2)}\n`;
}
if (isHelpPayload(payload)) {
return formatHelpText(payload);
}
return formatDoctorText(payload);
}

function isHelpPayload(payload) {
return payload != null
&& typeof payload.usage === "string"
&& Array.isArray(payload.commands)
&& typeof payload.group === "string";
}

function isDoctorPayload(payload) {
return payload != null
&& typeof payload.ok === "boolean"
&& Array.isArray(payload.issues)
&& typeof payload.hostConfigPath === "string"
&& typeof payload.stateRoot === "string";
}

function shouldFormatAsText(payload) {
return isHelpPayload(payload) || isDoctorPayload(payload);
}

function formatHelpText(payload) {
const lines = [
"Simulator Broker",
"",
`Usage: ${payload.usage}`,
"",
];

if (payload.group === "global") {
const topLevel = new Set(["doctor"]);
const groups = payload.commands.filter((command) => !topLevel.has(command));
const topLevelCommands = payload.commands.filter((command) => topLevel.has(command));
lines.push("Command groups:");
for (const command of groups) {
lines.push(` ${command}`);
}
if (topLevelCommands.length > 0) {
lines.push("", "Top-level commands:");
for (const command of topLevelCommands) {
lines.push(` ${command}`);
}
}
lines.push(
"",
"Use `simbroker <group> --help` for the commands in a group.",
"Use `simbroker doctor --help` for doctor usage.",
);
} else {
lines.push("Commands:");
for (const command of payload.commands) {
lines.push(` ${command}`);
}
}

lines.push(
"",
"Pass --json for machine-readable output.",
"",
);
return `${lines.join("\n")}`;
}

function formatDoctorIssue(issue) {
if (issue == null || typeof issue !== "object") {
return "- Unexpected doctor issue. Re-run with --json for details.";
}

if (issue.reasonCode === "missing-registry") {
return [
"- Registry: missing.",
" Next: run `simbroker host init --bootstrap-config` if this Mac is not set up yet.",
].join("\n");
}

if (issue.reasonCode === "alias-unhealthy") {
const alias = typeof issue.alias === "string" ? issue.alias : "unknown";
const health = typeof issue.health === "string" ? issue.health : "unhealthy";
return [
`- Alias ${alias}: ${health}.`,
` Next: inspect with \`simbroker host status\`, then repair with \`simbroker simulators repair --alias ${alias}\` if needed.`,
].join("\n");
}

if (typeof issue.error === "string" && issue.error.trim() !== "") {
const reason = typeof issue.reasonCode === "string" ? ` (${issue.reasonCode})` : "";
return `- ${issue.error}${reason}`;
}

if (typeof issue.reasonCode === "string") {
return `- ${issue.reasonCode}. Re-run \`simbroker doctor --json\` for details.`;
}

return "- Unexpected doctor issue. Re-run with --json for details.";
}

function formatDoctorText(payload) {
const lines = [
"Simulator Broker doctor",
"",
`Status: ${payload.ok ? "healthy" : "needs attention"}`,
`Host config: ${payload.hostConfigPath}`,
`State root: ${payload.stateRoot}`,
"",
"Checklist:",
];

if (payload.issues.length === 0) {
lines.push(
"- Host config: ok",
"- Registry: ok",
"- Alias health: ok",
"",
"No issues found.",
"Next: run `simbroker host status` or `simbroker project init` in a repo.",
);
} else {
for (const issue of payload.issues) {
lines.push(formatDoctorIssue(issue));
}
lines.push(
"",
"Pass --json for the machine-readable issue list.",
);
}

lines.push("");
return `${lines.join("\n")}`;
}

export function eventFilters(flags) {
Expand Down Expand Up @@ -500,6 +634,49 @@ function helpPayload(group) {
group: "lease",
usage: "simbroker lease <command>",
},
events: {
commands: [
"events watch [--follow] [--json-lines] [--limit <n>] [--after-event-id <id>]",
],
group: "events",
usage: "simbroker events <command>",
},
pin: {
commands: [
"pin create --purpose <purpose> --alias <alias> [--repo-root <repo>] [--note <note>]",
"pin clear --alias <alias>",
],
group: "pin",
usage: "simbroker pin <command>",
},
simulators: {
commands: [
"simulators list",
"simulators boot --alias <alias>",
"simulators shutdown --alias <alias>",
"simulators erase --alias <alias>",
"simulators repair --alias <alias>",
],
group: "simulators",
usage: "simbroker simulators <command>",
},
service: {
commands: [
"service start",
"service status",
"service stop",
],
group: "service",
usage: "simbroker service <command>",
},
doctor: {
commands: [
"doctor",
"doctor --json",
],
group: "doctor",
usage: "simbroker doctor [--json]",
},
};
return {
ok: true,
Expand All @@ -514,6 +691,7 @@ function helpPayload(group) {
"pin",
"simulators",
"service",
"doctor",
],
group: "global",
usage: "simbroker <group> <command> [flags]",
Expand Down Expand Up @@ -1003,6 +1181,11 @@ export function executeBrokerCommand(paths, request) {
case "help:lease":
case "help:capacity":
case "help:idle":
case "help:events":
case "help:pin":
case "help:simulators":
case "help:service":
case "help:doctor":
return helpPayload(request.command);
case "doctor:status":
payload = doctorBroker(paths, options);
Expand Down
Loading
Loading