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
5 changes: 5 additions & 0 deletions apps/spark-cockpit/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type SparkCockpitCliOptions,
} from "./cli/coordination.ts";
import { startCockpitProductionHost } from "./cli/production-start.ts";
import { helpFlagRequested } from "./cli/shared.ts";
import { runCockpitWebCli } from "./cli/web-cli.ts";

/**
Expand All @@ -27,6 +28,10 @@ export async function runSparkCockpitCli(
process.stdout.write(sparkCockpitHelpText());
return 0;
case "start":
if (helpFlagRequested(rest)) {
process.stdout.write(sparkCockpitHelpText());
return 0;
}
return await startCockpitProductionHost(rest);
case "web":
return await runCockpitWebCli(rest);
Expand Down
4 changes: 4 additions & 0 deletions apps/spark-cockpit/src/cli/coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createId, parseSparkAssignment } from "@zendev-lab/spark-protocol";
import {
consoleSparkCliErrorOutput,
consoleSparkCliOutput,
helpFlagRequested,
parseSparkCliOptions,
printSparkCliResult,
readBooleanOption,
Expand Down Expand Up @@ -260,6 +261,9 @@ export function parseSparkCockpitCliArgs(argv: string[]): SparkCockpitCliCommand
if (resourceToken === "help" || resourceToken === "--help" || resourceToken === "-h") {
return { resource: "help" };
}
if (helpFlagRequested(argv)) {
return { resource: "help" };
}
const parsed = parseSparkCliOptions(rest);
const json = readBooleanOption(parsed.options, "json");
const limit = readNumberOption(parsed.options, "limit");
Expand Down
6 changes: 6 additions & 0 deletions apps/spark-cockpit/src/cli/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export function readNumberOption(
return parsed;
}

export function helpFlagRequested(argv: readonly string[]): boolean {
const delimiterIndex = argv.indexOf("--");
const options = delimiterIndex < 0 ? argv : argv.slice(0, delimiterIndex);
return options.some((arg) => arg === "--help" || arg === "-h");
}

export function printSparkCliResult(
output: SparkCliOutput,
value: unknown,
Expand Down
18 changes: 18 additions & 0 deletions apps/spark-cockpit/src/cli/web-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import {
startCockpitWebService,
stopCockpitWebService,
} from "./web-service.ts";
import { helpFlagRequested } from "./shared.ts";

/** Handle `spark cockpit web …` after the surface dispatcher peels off `web`. */
export async function runCockpitWebCli(argv: string[]): Promise<number> {
if (helpFlagRequested(argv)) {
process.stdout.write(cockpitWebHelpText());
return 0;
}

const [command = "status"] = argv;
const json = argv.includes("--json");
switch (command) {
Expand Down Expand Up @@ -41,3 +47,15 @@ export async function runCockpitWebCli(argv: string[]): Promise<number> {
throw new Error(`Unknown spark cockpit web command: ${command}`);
}
}

function cockpitWebHelpText(): string {
return `spark cockpit web - manage the background Cockpit Web service

Usage:
spark cockpit web start [--json]
spark cockpit web status [--json]
spark cockpit web stop [--json]
spark cockpit web logs [--lines <n>] [--json]
spark cockpit web --help
`;
}
21 changes: 18 additions & 3 deletions apps/spark-daemon/scripts/build-cli.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { realpathSync } from "node:fs";
import { chmod, cp, rm } from "node:fs/promises";
import { chmod, copyFile, mkdir, readdir, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
Expand Down Expand Up @@ -31,5 +31,20 @@ await build({
});

await chmod("dist/cli.js", 0o755);
await rm(migrationsDestination, { recursive: true, force: true });
await cp(migrationsSource, migrationsDestination, { recursive: true });
await mkdir(migrationsDestination, { recursive: true });

const migrationNames = await readdir(migrationsSource);
await Promise.all(
migrationNames.map((name) =>
copyFile(join(migrationsSource, name), join(migrationsDestination, name)),
),
);

const staleMigrationNames = (await readdir(migrationsDestination)).filter(
(name) => !migrationNames.includes(name),
);
await Promise.all(
staleMigrationNames.map((name) =>
rm(join(migrationsDestination, name), { recursive: true, force: true }),
),
);
29 changes: 28 additions & 1 deletion apps/spark-tui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,33 @@ export async function runSparkCli(
}
}

export function formatSparkCliFailure(error: unknown, argv: readonly string[]): string {
const message =
error instanceof Error
? error.message || error.name
: typeof error === "string"
? error
: String(error);
if (!jsonFlagRequested(argv)) return message;
return JSON.stringify(
{
action: "error",
error: {
code: "cli_error",
message,
},
},
null,
2,
);
}

function jsonFlagRequested(argv: readonly string[]): boolean {
const delimiterIndex = argv.indexOf("--");
const options = delimiterIndex < 0 ? argv : argv.slice(0, delimiterIndex);
return options.includes("--json");
}

function isInteractiveSparkCliTerminal(options: RunSparkCliOptions): boolean {
return Boolean(
(options.terminal?.stdinIsTTY ?? processStdin.isTTY) &&
Expand Down Expand Up @@ -2000,7 +2027,7 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
process.exitCode = code;
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
console.error(formatSparkCliFailure(error, process.argv.slice(2)));
process.exitCode = 1;
});
}
4 changes: 4 additions & 0 deletions apps/spark-tui/src/cli/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
} from "../native-tui.ts";
import {
consoleSparkCliOutput,
helpFlagRequested,
parseSparkCliOptions,
printSparkCliResult,
readBooleanOption,
Expand Down Expand Up @@ -702,6 +703,9 @@ export function parseSparkDaemonCliArgs(argv: string[]): SparkDaemonCliCommand {
if (action === "help" || action === "--help" || action === "-h") {
return { action: "help" };
}
if (helpFlagRequested(argv)) {
return { action: "help" };
}

const parsed = parseSparkCliOptions(rest);
const json = readBooleanOption(parsed.options, "json");
Expand Down
6 changes: 6 additions & 0 deletions apps/spark-tui/src/cli/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export function readNumberOption(
return parsed;
}

export function helpFlagRequested(argv: readonly string[]): boolean {
const delimiterIndex = argv.indexOf("--");
const options = delimiterIndex < 0 ? argv : argv.slice(0, delimiterIndex);
return options.some((arg) => arg === "--help" || arg === "-h");
}

export function printSparkCliResult(
output: SparkCliOutput,
value: unknown,
Expand Down
22 changes: 21 additions & 1 deletion test/spark-cockpit-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const PLAN = {
};

test("parseSparkCockpitCliArgs routes Cockpit coordination resources", () => {
assert.deepEqual(parseSparkCockpitCliArgs(["access", "create", "--help"]), {
resource: "help",
});
assert.deepEqual(parseSparkCockpitCliArgs(["status", "--json"]), {
resource: "status",
verb: "show",
Expand Down Expand Up @@ -142,6 +145,22 @@ test("spark-cockpit thin bin routes through the TypeScript surface entry", async
const missingWeb = await runBin(bin, ["web", "not-a-real-op"]);
assert.notEqual(missingWeb.code, 0);
assert.match(`${missingWeb.stdout}${missingWeb.stderr}`, /Unknown spark cockpit web command/u);

const sparkHome = await mkdtemp(join(tmpdir(), "spark-cockpit-help-"));
try {
const nestedHelp = await runBin(bin, ["web", "start", "--help"], {
...process.env,
SPARK_HOME: sparkHome,
});
assert.equal(nestedHelp.code, 0);
assert.match(
nestedHelp.stdout,
/spark cockpit web - manage the background Cockpit Web service/u,
);
assert.equal(existsSync(join(sparkHome, "apps", "cockpit", "run", "cockpit.pid")), false);
} finally {
await rm(sparkHome, { recursive: true, force: true });
}
});

test("spark cockpit status/project/task/goal/artifact/review/workflow expose stable JSON", async () => {
Expand Down Expand Up @@ -544,9 +563,10 @@ type AcceptanceInvocation = {
async function runBin(
bin: string,
args: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
Expand Down
19 changes: 19 additions & 0 deletions test/spark-daemon-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SparkDaemonRemoteError } from "@zendev-lab/spark-daemon-client";
import { parseSparkDaemonEvent, parseSparkInteractionRequest } from "@zendev-lab/spark-protocol";

import {
formatSparkCliFailure,
handleSparkRpcLine,
parseSparkCliCommand,
runSparkCli,
Expand Down Expand Up @@ -377,6 +378,22 @@ test("parseSparkCliCommand routes daemon and print commands without changing def
);
});

test("Spark CLI failures are concise and honor JSON mode before the option delimiter", () => {
const failure = new Error("Configure a model before submitting.");
assert.equal(formatSparkCliFailure(failure, ["daemon", "submit"]), failure.message);
assert.deepEqual(JSON.parse(formatSparkCliFailure(failure, ["daemon", "submit", "--json"])), {
action: "error",
error: {
code: "cli_error",
message: failure.message,
},
});
assert.equal(
formatSparkCliFailure(failure, ["daemon", "submit", "--", "--json"]),
failure.message,
);
});

test("daemon channel status is read from daemon local RPC client", async () => {
let calls = 0;
const result = await handleSparkDaemonCliCommand(
Expand Down Expand Up @@ -1195,6 +1212,8 @@ test("parseSparkCliCommand parses Pi-compatible global modes and resource comman
test("parseSparkDaemonCliArgs parses daemon IPC commands", async () => {
assert.deepEqual(parseSparkDaemonCliArgs([]), { action: "service", argv: [] });
assert.deepEqual(parseSparkDaemonCliArgs(["--help"]), { action: "help" });
assert.deepEqual(parseSparkDaemonCliArgs(["doctor", "--help"]), { action: "help" });
assert.deepEqual(parseSparkDaemonCliArgs(["start", "-h"]), { action: "help" });
assert.deepEqual(parseSparkDaemonCliArgs(["submit", "--session", "s1", "-p", "hello"]), {
action: "submit",
json: false,
Expand Down
Loading