mirror of https://github.com/openclaw/openclaw.git
feat(cli): support targeting running containerized openclaw instances (#52651)
Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
parent
dd11bdd003
commit
91adc5e718
|
|
@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
|
||||
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
|
||||
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
|
||||
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,17 @@ describe("argv helpers", () => {
|
|||
it("extracts command path while skipping known root option values", () => {
|
||||
expect(
|
||||
getCommandPathWithRootOptions(
|
||||
["node", "openclaw", "--profile", "work", "--no-color", "config", "validate"],
|
||||
[
|
||||
"node",
|
||||
"openclaw",
|
||||
"--profile",
|
||||
"work",
|
||||
"--container",
|
||||
"demo",
|
||||
"--no-color",
|
||||
"config",
|
||||
"validate",
|
||||
],
|
||||
2,
|
||||
),
|
||||
).toEqual(["config", "validate"]);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import { replaceCliName, resolveCliName } from "./cli-name.js";
|
|||
import { normalizeProfileName } from "./profile-utils.js";
|
||||
|
||||
const CLI_PREFIX_RE = /^(?:pnpm|npm|bunx|npx)\s+openclaw\b|^openclaw\b/;
|
||||
const CONTAINER_FLAG_RE = /(?:^|\s)--container(?:\s|=|$)/;
|
||||
const PROFILE_FLAG_RE = /(?:^|\s)--profile(?:\s|=|$)/;
|
||||
const DEV_FLAG_RE = /(?:^|\s)--dev(?:\s|$)/;
|
||||
const UPDATE_COMMAND_RE =
|
||||
/^(?:pnpm|npm|bunx|npx)\s+openclaw\b.*(?:^|\s)update(?:\s|$)|^openclaw\b.*(?:^|\s)update(?:\s|$)/;
|
||||
|
||||
export function formatCliCommand(
|
||||
command: string,
|
||||
|
|
@ -11,15 +14,32 @@ export function formatCliCommand(
|
|||
): string {
|
||||
const cliName = resolveCliName();
|
||||
const normalizedCommand = replaceCliName(command, cliName);
|
||||
const container = env.OPENCLAW_CONTAINER_HINT?.trim();
|
||||
const profile = normalizeProfileName(env.OPENCLAW_PROFILE);
|
||||
if (!profile) {
|
||||
if (!container && !profile) {
|
||||
return normalizedCommand;
|
||||
}
|
||||
if (!CLI_PREFIX_RE.test(normalizedCommand)) {
|
||||
return normalizedCommand;
|
||||
}
|
||||
if (PROFILE_FLAG_RE.test(normalizedCommand) || DEV_FLAG_RE.test(normalizedCommand)) {
|
||||
const additions: string[] = [];
|
||||
if (
|
||||
container &&
|
||||
!CONTAINER_FLAG_RE.test(normalizedCommand) &&
|
||||
!UPDATE_COMMAND_RE.test(normalizedCommand)
|
||||
) {
|
||||
additions.push(`--container ${container}`);
|
||||
}
|
||||
if (
|
||||
!container &&
|
||||
profile &&
|
||||
!PROFILE_FLAG_RE.test(normalizedCommand) &&
|
||||
!DEV_FLAG_RE.test(normalizedCommand)
|
||||
) {
|
||||
additions.push(`--profile ${profile}`);
|
||||
}
|
||||
if (additions.length === 0) {
|
||||
return normalizedCommand;
|
||||
}
|
||||
return normalizedCommand.replace(CLI_PREFIX_RE, (match) => `${match} --profile ${profile}`);
|
||||
return normalizedCommand.replace(CLI_PREFIX_RE, (match) => `${match} ${additions.join(" ")}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,623 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
maybeRunCliInContainer,
|
||||
parseCliContainerArgs,
|
||||
resolveCliContainerTarget,
|
||||
} from "./container-target.js";
|
||||
|
||||
describe("parseCliContainerArgs", () => {
|
||||
it("extracts a root --container flag before the command", () => {
|
||||
expect(
|
||||
parseCliContainerArgs(["node", "openclaw", "--container", "demo", "status", "--deep"]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
container: "demo",
|
||||
argv: ["node", "openclaw", "status", "--deep"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the equals form", () => {
|
||||
expect(parseCliContainerArgs(["node", "openclaw", "--container=demo", "health"])).toEqual({
|
||||
ok: true,
|
||||
container: "demo",
|
||||
argv: ["node", "openclaw", "health"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing container value", () => {
|
||||
expect(parseCliContainerArgs(["node", "openclaw", "--container"])).toEqual({
|
||||
ok: false,
|
||||
error: "--container requires a value",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not consume an adjacent flag as the container value", () => {
|
||||
expect(
|
||||
parseCliContainerArgs(["node", "openclaw", "--container", "--no-color", "status"]),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: "--container requires a value",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves argv unchanged when the flag is absent", () => {
|
||||
expect(parseCliContainerArgs(["node", "openclaw", "status"])).toEqual({
|
||||
ok: true,
|
||||
container: null,
|
||||
argv: ["node", "openclaw", "status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts --container after the command like other root options", () => {
|
||||
expect(
|
||||
parseCliContainerArgs(["node", "openclaw", "status", "--container", "demo", "--deep"]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
container: "demo",
|
||||
argv: ["node", "openclaw", "status", "--deep"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stops parsing --container after the -- terminator", () => {
|
||||
expect(
|
||||
parseCliContainerArgs([
|
||||
"node",
|
||||
"openclaw",
|
||||
"nodes",
|
||||
"run",
|
||||
"--",
|
||||
"docker",
|
||||
"run",
|
||||
"--container",
|
||||
"demo",
|
||||
"alpine",
|
||||
]),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
container: null,
|
||||
argv: [
|
||||
"node",
|
||||
"openclaw",
|
||||
"nodes",
|
||||
"run",
|
||||
"--",
|
||||
"docker",
|
||||
"run",
|
||||
"--container",
|
||||
"demo",
|
||||
"alpine",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliContainerTarget", () => {
|
||||
it("uses argv first and falls back to OPENCLAW_CONTAINER", () => {
|
||||
expect(
|
||||
resolveCliContainerTarget(["node", "openclaw", "--container", "demo", "status"], {}),
|
||||
).toBe("demo");
|
||||
expect(resolveCliContainerTarget(["node", "openclaw", "status"], {})).toBeNull();
|
||||
expect(
|
||||
resolveCliContainerTarget(["node", "openclaw", "status"], {
|
||||
OPENCLAW_CONTAINER: "demo",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("demo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeRunCliInContainer", () => {
|
||||
it("passes through when no container target is provided", () => {
|
||||
expect(maybeRunCliInContainer(["node", "openclaw", "status"], { env: {} })).toEqual({
|
||||
handled: false,
|
||||
argv: ["node", "openclaw", "status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_CONTAINER when the flag is absent", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "status"], {
|
||||
env: { OPENCLAW_CONTAINER: "demo" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"podman",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"--env",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"--env",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"status",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
OPENCLAW_CONTAINER: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears inherited OPENCLAW_CONTAINER before execing into the child CLI", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
maybeRunCliInContainer(["node", "openclaw", "status"], {
|
||||
env: {
|
||||
OPENCLAW_CONTAINER: "demo",
|
||||
OPENCLAW_GATEWAY_TOKEN: "token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"podman",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"--env",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"--env",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"status",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
OPENCLAW_CONTAINER: "",
|
||||
OPENCLAW_GATEWAY_TOKEN: "token",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("executes through podman when the named container is running", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"podman",
|
||||
["inspect", "--format", "{{.State.Running}}", "demo"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"podman",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"--env",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"--env",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"status",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: { OPENCLAW_CONTAINER: "" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to docker when podman does not have the container", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "health"], {
|
||||
env: { USER: "openclaw" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"docker",
|
||||
["inspect", "--format", "{{.State.Running}}", "demo"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-e",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"-e",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"health",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: { USER: "openclaw", OPENCLAW_CONTAINER: "" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to sudo -u openclaw podman for the documented dedicated-user flow", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "openclaw", "status"], {
|
||||
env: { USER: "somalley" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"sudo",
|
||||
["-u", "openclaw", "podman", "inspect", "--format", "{{.State.Running}}", "openclaw"],
|
||||
{ encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] },
|
||||
);
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"sudo",
|
||||
[
|
||||
"-u",
|
||||
"openclaw",
|
||||
"podman",
|
||||
"exec",
|
||||
"-i",
|
||||
"--env",
|
||||
"OPENCLAW_CONTAINER_HINT=openclaw",
|
||||
"--env",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"openclaw",
|
||||
"openclaw",
|
||||
"status",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: { USER: "somalley", OPENCLAW_CONTAINER: "" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("checks docker before the dedicated-user podman fallback", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
|
||||
env: { USER: "somalley" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"podman",
|
||||
["inspect", "--format", "{{.State.Running}}", "demo"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"docker",
|
||||
["inspect", "--format", "{{.State.Running}}", "demo"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-e",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"-e",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"status",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: { USER: "somalley", OPENCLAW_CONTAINER: "" },
|
||||
},
|
||||
);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects ambiguous matches across runtimes", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
|
||||
env: { USER: "somalley" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toThrow(
|
||||
'Container "demo" is running under multiple runtimes (podman, docker); use a unique container name.',
|
||||
);
|
||||
});
|
||||
|
||||
it("allocates a tty for interactive terminal sessions", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "setup"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: true,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"podman",
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-t",
|
||||
"--env",
|
||||
"OPENCLAW_CONTAINER_HINT=demo",
|
||||
"--env",
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
"demo",
|
||||
"openclaw",
|
||||
"setup",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: { OPENCLAW_CONTAINER: "" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers --container over OPENCLAW_CONTAINER", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "flag-demo", "health"], {
|
||||
env: { OPENCLAW_CONTAINER: "env-demo" } as NodeJS.ProcessEnv,
|
||||
spawnSync,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(spawnSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"podman",
|
||||
["inspect", "--format", "{{.State.Running}}", "flag-demo"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the named container is not running", () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
}),
|
||||
).toThrow('No running container matched "demo" under podman or docker.');
|
||||
});
|
||||
|
||||
it("skips recursion when the bypass env is set", () => {
|
||||
expect(
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "status"], {
|
||||
env: { OPENCLAW_CLI_CONTAINER_BYPASS: "1" } as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: false,
|
||||
argv: ["node", "openclaw", "--container", "demo", "status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks updater commands from running inside the container", () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "update"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
}),
|
||||
).toThrow(
|
||||
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
|
||||
);
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks update after interleaved root flags", () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "--no-color", "update"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
}),
|
||||
).toThrow(
|
||||
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
|
||||
);
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks the --update shorthand from running inside the container", () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "true\n",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
maybeRunCliInContainer(["node", "openclaw", "--container", "demo", "--update"], {
|
||||
env: {},
|
||||
spawnSync,
|
||||
}),
|
||||
).toThrow(
|
||||
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
|
||||
);
|
||||
expect(spawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
consumeRootOptionToken,
|
||||
FLAG_TERMINATOR,
|
||||
isValueToken,
|
||||
} from "../infra/cli-root-options.js";
|
||||
import { getPrimaryCommand } from "./argv.js";
|
||||
|
||||
type CliContainerParseResult =
|
||||
| { ok: true; container: string | null; argv: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type CliContainerTargetResult =
|
||||
| { handled: true; exitCode: number }
|
||||
| { handled: false; argv: string[] };
|
||||
|
||||
type ContainerTargetDeps = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
spawnSync: typeof spawnSync;
|
||||
stdinIsTTY: boolean;
|
||||
stdoutIsTTY: boolean;
|
||||
};
|
||||
|
||||
type ContainerRuntimeExec = {
|
||||
runtime: "podman" | "docker";
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
};
|
||||
|
||||
function takeValue(
|
||||
raw: string,
|
||||
next: string | undefined,
|
||||
): {
|
||||
value: string | null;
|
||||
consumedNext: boolean;
|
||||
} {
|
||||
if (raw.includes("=")) {
|
||||
const [, value] = raw.split("=", 2);
|
||||
const trimmed = (value ?? "").trim();
|
||||
return { value: trimmed || null, consumedNext: false };
|
||||
}
|
||||
const consumedNext = isValueToken(next);
|
||||
const trimmed = consumedNext ? next!.trim() : "";
|
||||
return { value: trimmed || null, consumedNext };
|
||||
}
|
||||
|
||||
export function parseCliContainerArgs(argv: string[]): CliContainerParseResult {
|
||||
if (argv.length < 2) {
|
||||
return { ok: true, container: null, argv };
|
||||
}
|
||||
|
||||
const out: string[] = argv.slice(0, 2);
|
||||
let container: string | null = null;
|
||||
|
||||
const args = argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (arg === FLAG_TERMINATOR) {
|
||||
out.push(arg, ...args.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg === "--container" || arg.startsWith("--container=")) {
|
||||
const next = args[i + 1];
|
||||
const { value, consumedNext } = takeValue(arg, next);
|
||||
if (consumedNext) {
|
||||
i += 1;
|
||||
}
|
||||
if (!value) {
|
||||
return { ok: false, error: "--container requires a value" };
|
||||
}
|
||||
container = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const consumedRootOption = consumeRootOptionToken(args, i);
|
||||
if (consumedRootOption > 0) {
|
||||
for (let offset = 0; offset < consumedRootOption; offset += 1) {
|
||||
const token = args[i + offset];
|
||||
if (token !== undefined) {
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
i += consumedRootOption - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(arg);
|
||||
}
|
||||
|
||||
return { ok: true, container, argv: out };
|
||||
}
|
||||
|
||||
export function resolveCliContainerTarget(
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
const parsed = parseCliContainerArgs(argv);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
return parsed.container ?? env.OPENCLAW_CONTAINER?.trim() ?? null;
|
||||
}
|
||||
|
||||
function isContainerRunning(params: {
|
||||
exec: ContainerRuntimeExec;
|
||||
containerName: string;
|
||||
deps: Pick<ContainerTargetDeps, "spawnSync">;
|
||||
}): boolean {
|
||||
const result = params.deps.spawnSync(
|
||||
params.exec.command,
|
||||
[...params.exec.argsPrefix, "inspect", "--format", "{{.State.Running}}", params.containerName],
|
||||
params.exec.command === "sudo"
|
||||
? { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] }
|
||||
: { encoding: "utf8" },
|
||||
);
|
||||
return result.status === 0 && result.stdout.trim() === "true";
|
||||
}
|
||||
|
||||
function candidateContainerRuntimes(env: NodeJS.ProcessEnv): ContainerRuntimeExec[] {
|
||||
const candidates: ContainerRuntimeExec[] = [
|
||||
{
|
||||
runtime: "podman",
|
||||
command: "podman",
|
||||
argsPrefix: [],
|
||||
},
|
||||
{
|
||||
runtime: "docker",
|
||||
command: "docker",
|
||||
argsPrefix: [],
|
||||
},
|
||||
];
|
||||
const podmanUser = env.OPENCLAW_PODMAN_USER?.trim() || "openclaw";
|
||||
const currentUser = env.USER?.trim() || env.LOGNAME?.trim() || "";
|
||||
if (podmanUser && currentUser && podmanUser !== currentUser) {
|
||||
candidates.push({
|
||||
runtime: "podman",
|
||||
command: "sudo",
|
||||
argsPrefix: ["-u", podmanUser, "podman"],
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function describeContainerRuntimeExec(exec: ContainerRuntimeExec): string {
|
||||
if (exec.command === "sudo") {
|
||||
const podmanUser = exec.argsPrefix[1];
|
||||
return `podman (via sudo -u ${podmanUser})`;
|
||||
}
|
||||
return exec.runtime;
|
||||
}
|
||||
|
||||
function resolveRunningContainer(params: {
|
||||
containerName: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
deps: Pick<ContainerTargetDeps, "spawnSync">;
|
||||
}): (ContainerRuntimeExec & { containerName: string }) | null {
|
||||
const matches: Array<ContainerRuntimeExec & { containerName: string }> = [];
|
||||
const candidates = candidateContainerRuntimes(params.env);
|
||||
for (const exec of candidates) {
|
||||
if (
|
||||
isContainerRunning({
|
||||
exec,
|
||||
containerName: params.containerName,
|
||||
deps: params.deps,
|
||||
})
|
||||
) {
|
||||
matches.push({ ...exec, containerName: params.containerName });
|
||||
if (exec.runtime === "docker") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const runtimes = matches.map(describeContainerRuntimeExec).join(", ");
|
||||
throw new Error(
|
||||
`Container "${params.containerName}" is running under multiple runtimes (${runtimes}); use a unique container name.`,
|
||||
);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function buildContainerExecArgs(params: {
|
||||
exec: ContainerRuntimeExec;
|
||||
containerName: string;
|
||||
argv: string[];
|
||||
stdinIsTTY: boolean;
|
||||
stdoutIsTTY: boolean;
|
||||
}): string[] {
|
||||
const envFlag = params.exec.runtime === "docker" ? "-e" : "--env";
|
||||
const interactiveFlags = ["-i", ...(params.stdinIsTTY && params.stdoutIsTTY ? ["-t"] : [])];
|
||||
return [
|
||||
...params.exec.argsPrefix,
|
||||
"exec",
|
||||
...interactiveFlags,
|
||||
envFlag,
|
||||
`OPENCLAW_CONTAINER_HINT=${params.containerName}`,
|
||||
envFlag,
|
||||
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
|
||||
params.containerName,
|
||||
"openclaw",
|
||||
...params.argv,
|
||||
];
|
||||
}
|
||||
|
||||
function buildContainerExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
// The child CLI should render container-aware follow-up commands via
|
||||
// OPENCLAW_CONTAINER_HINT, but it should not treat itself as still
|
||||
// container-targeted for validation/routing.
|
||||
OPENCLAW_CONTAINER: "",
|
||||
};
|
||||
}
|
||||
|
||||
function isBlockedContainerCommand(argv: string[]): boolean {
|
||||
if (getPrimaryCommand(["node", "openclaw", ...argv]) === "update") {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg || arg === FLAG_TERMINATOR) {
|
||||
return false;
|
||||
}
|
||||
if (arg === "--update") {
|
||||
return true;
|
||||
}
|
||||
const consumedRootOption = consumeRootOptionToken(argv, i);
|
||||
if (consumedRootOption > 0) {
|
||||
i += consumedRootOption - 1;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function maybeRunCliInContainer(
|
||||
argv: string[],
|
||||
deps?: Partial<ContainerTargetDeps>,
|
||||
): CliContainerTargetResult {
|
||||
const resolvedDeps: ContainerTargetDeps = {
|
||||
env: deps?.env ?? process.env,
|
||||
spawnSync: deps?.spawnSync ?? spawnSync,
|
||||
stdinIsTTY: deps?.stdinIsTTY ?? Boolean(process.stdin.isTTY),
|
||||
stdoutIsTTY: deps?.stdoutIsTTY ?? Boolean(process.stdout.isTTY),
|
||||
};
|
||||
|
||||
if (resolvedDeps.env.OPENCLAW_CLI_CONTAINER_BYPASS === "1") {
|
||||
return { handled: false, argv };
|
||||
}
|
||||
|
||||
const parsed = parseCliContainerArgs(argv);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
const containerName = resolveCliContainerTarget(argv, resolvedDeps.env);
|
||||
if (!containerName) {
|
||||
return { handled: false, argv: parsed.argv };
|
||||
}
|
||||
if (isBlockedContainerCommand(parsed.argv.slice(2))) {
|
||||
throw new Error(
|
||||
"openclaw update is not supported with --container; rebuild or restart the container image instead.",
|
||||
);
|
||||
}
|
||||
|
||||
const runningContainer = resolveRunningContainer({
|
||||
containerName,
|
||||
env: resolvedDeps.env,
|
||||
deps: resolvedDeps,
|
||||
});
|
||||
if (!runningContainer) {
|
||||
throw new Error(`No running container matched "${containerName}" under podman or docker.`);
|
||||
}
|
||||
|
||||
const result = resolvedDeps.spawnSync(
|
||||
runningContainer.command,
|
||||
buildContainerExecArgs({
|
||||
exec: runningContainer,
|
||||
containerName: runningContainer.containerName,
|
||||
argv: parsed.argv.slice(2),
|
||||
stdinIsTTY: resolvedDeps.stdinIsTTY,
|
||||
stdoutIsTTY: resolvedDeps.stdoutIsTTY,
|
||||
}),
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: buildContainerExecEnv(resolvedDeps.env),
|
||||
},
|
||||
);
|
||||
return {
|
||||
handled: true,
|
||||
exitCode: typeof result.status === "number" ? result.status : 1,
|
||||
};
|
||||
}
|
||||
|
|
@ -67,6 +67,26 @@ describe("runServiceRestart token drift", () => {
|
|||
stubEmptyGatewayEnv();
|
||||
});
|
||||
|
||||
it("prints the container restart hint when restart is requested for a not-loaded service", async () => {
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
vi.stubEnv("OPENCLAW_CONTAINER_HINT", "openclaw-demo-container");
|
||||
|
||||
await runServiceRestart({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
renderStartHints: () => [
|
||||
"Restart the container or the service that manages it for openclaw-demo-container.",
|
||||
"openclaw gateway install",
|
||||
],
|
||||
opts: { json: false },
|
||||
});
|
||||
|
||||
expect(runtimeLogs).toContain("Gateway service not loaded.");
|
||||
expect(runtimeLogs).toContain(
|
||||
"Start with: Restart the container or the service that manages it for openclaw-demo-container.",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits drift warning when enabled", async () => {
|
||||
await runServiceRestart(createServiceRunArgs(true));
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type DaemonActionResponse,
|
||||
emitDaemonActionJson,
|
||||
} from "./response.js";
|
||||
import { filterContainerGenericHints } from "./shared.js";
|
||||
|
||||
type DaemonLifecycleOptions = {
|
||||
json?: boolean;
|
||||
|
|
@ -81,7 +82,9 @@ async function handleServiceNotLoaded(params: {
|
|||
json: boolean;
|
||||
emit: ReturnType<typeof createActionIO>["emit"];
|
||||
}) {
|
||||
const hints = await maybeAugmentSystemdHints(params.renderStartHints());
|
||||
const hints = filterContainerGenericHints(
|
||||
await maybeAugmentSystemdHints(params.renderStartHints()),
|
||||
);
|
||||
params.emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { resolveRuntimeStatusColor } from "./shared.js";
|
||||
import {
|
||||
filterContainerGenericHints,
|
||||
renderGatewayServiceStartHints,
|
||||
resolveRuntimeStatusColor,
|
||||
} from "./shared.js";
|
||||
|
||||
describe("resolveRuntimeStatusColor", () => {
|
||||
it("maps known runtime states to expected theme colors", () => {
|
||||
|
|
@ -14,3 +18,55 @@ describe("resolveRuntimeStatusColor", () => {
|
|||
expect(resolveRuntimeStatusColor(undefined)).toBe(theme.muted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderGatewayServiceStartHints", () => {
|
||||
it("prepends a single container restart hint when OPENCLAW_CONTAINER is set", () => {
|
||||
expect(
|
||||
renderGatewayServiceStartHints({
|
||||
OPENCLAW_CONTAINER: "openclaw-demo-container",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Restart the container or the service that manages it for openclaw-demo-container.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("prepends a single container restart hint when OPENCLAW_CONTAINER_HINT is set", () => {
|
||||
expect(
|
||||
renderGatewayServiceStartHints({
|
||||
OPENCLAW_CONTAINER_HINT: "openclaw-demo-container",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Restart the container or the service that manages it for openclaw-demo-container.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterContainerGenericHints", () => {
|
||||
it("drops the generic container foreground hint when OPENCLAW_CONTAINER is set", () => {
|
||||
expect(
|
||||
filterContainerGenericHints(
|
||||
[
|
||||
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
|
||||
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
|
||||
],
|
||||
{ OPENCLAW_CONTAINER: "openclaw-demo-container" } as NodeJS.ProcessEnv,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops the generic container foreground hint when OPENCLAW_CONTAINER_HINT is set", () => {
|
||||
expect(
|
||||
filterContainerGenericHints(
|
||||
[
|
||||
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
|
||||
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
|
||||
],
|
||||
{ OPENCLAW_CONTAINER_HINT: "openclaw-demo-container" } as NodeJS.ProcessEnv,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -181,11 +181,30 @@ export function renderRuntimeHints(
|
|||
|
||||
export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const profile = env.OPENCLAW_PROFILE;
|
||||
return buildPlatformServiceStartHints({
|
||||
const container = env.OPENCLAW_CONTAINER_HINT?.trim() || env.OPENCLAW_CONTAINER?.trim();
|
||||
const hints = buildPlatformServiceStartHints({
|
||||
installCommand: formatCliCommand("openclaw gateway install", env),
|
||||
startCommand: formatCliCommand("openclaw gateway", env),
|
||||
launchAgentPlistPath: `~/Library/LaunchAgents/${resolveGatewayLaunchAgentLabel(profile)}.plist`,
|
||||
systemdServiceName: resolveGatewaySystemdServiceName(profile),
|
||||
windowsTaskName: resolveGatewayWindowsTaskName(profile),
|
||||
});
|
||||
if (!container) {
|
||||
return hints;
|
||||
}
|
||||
return [`Restart the container or the service that manages it for ${container}.`];
|
||||
}
|
||||
|
||||
export function filterContainerGenericHints(
|
||||
hints: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
if (!(env.OPENCLAW_CONTAINER_HINT?.trim() || env.OPENCLAW_CONTAINER?.trim())) {
|
||||
return hints;
|
||||
}
|
||||
return hints.filter(
|
||||
(hint) =>
|
||||
!hint.includes("If you're in a container, run the gateway in the foreground instead of") &&
|
||||
!hint.includes("systemd user services are unavailable; install/enable systemd"),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ vi.mock("../infra/device-pairing.js", () => ({
|
|||
vi.mock("../runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../runtime.js")>()),
|
||||
defaultRuntime: runtime,
|
||||
writeRuntimeJson: (
|
||||
targetRuntime: { log: (...args: unknown[]) => void },
|
||||
value: unknown,
|
||||
space = 2,
|
||||
) => targetRuntime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
|
||||
}));
|
||||
|
||||
let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe("ensurePluginRegistryLoaded", () => {
|
|||
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onlyPluginIds: [],
|
||||
onlyPluginIds: ["telegram"],
|
||||
throwOnLoadError: true,
|
||||
}),
|
||||
);
|
||||
|
|
@ -86,7 +86,7 @@ describe("ensurePluginRegistryLoaded", () => {
|
|||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ onlyPluginIds: [], throwOnLoadError: true }),
|
||||
expect.objectContaining({ onlyPluginIds: ["telegram"], throwOnLoadError: true }),
|
||||
);
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,29 @@ describe("parseCliProfileArgs", () => {
|
|||
expect(res.argv).toEqual(["node", "openclaw", "gateway", "--dev", "--allow-unconfigured"]);
|
||||
});
|
||||
|
||||
it("leaves gateway --dev for subcommands after leading root options", () => {
|
||||
const res = parseCliProfileArgs([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--no-color",
|
||||
"gateway",
|
||||
"--dev",
|
||||
"--allow-unconfigured",
|
||||
]);
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
expect(res.profile).toBeNull();
|
||||
expect(res.argv).toEqual([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--no-color",
|
||||
"gateway",
|
||||
"--dev",
|
||||
"--allow-unconfigured",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still accepts global --dev before subcommand", () => {
|
||||
const res = parseCliProfileArgs(["node", "openclaw", "--dev", "gateway"]);
|
||||
if (!res.ok) {
|
||||
|
|
@ -37,6 +60,24 @@ describe("parseCliProfileArgs", () => {
|
|||
expect(res.argv).toEqual(["node", "openclaw", "status"]);
|
||||
});
|
||||
|
||||
it("parses interleaved --profile after the command token", () => {
|
||||
const res = parseCliProfileArgs(["node", "openclaw", "status", "--profile", "work", "--deep"]);
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
expect(res.profile).toBe("work");
|
||||
expect(res.argv).toEqual(["node", "openclaw", "status", "--deep"]);
|
||||
});
|
||||
|
||||
it("parses interleaved --dev after the command token", () => {
|
||||
const res = parseCliProfileArgs(["node", "openclaw", "status", "--dev"]);
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
expect(res.profile).toBe("dev");
|
||||
expect(res.argv).toEqual(["node", "openclaw", "status"]);
|
||||
});
|
||||
|
||||
it("rejects missing profile value", () => {
|
||||
const res = parseCliProfileArgs(["node", "openclaw", "--profile"]);
|
||||
expect(res.ok).toBe(false);
|
||||
|
|
@ -45,6 +86,7 @@ describe("parseCliProfileArgs", () => {
|
|||
it.each([
|
||||
["--dev first", ["node", "openclaw", "--dev", "--profile", "work", "status"]],
|
||||
["--profile first", ["node", "openclaw", "--profile", "work", "--dev", "status"]],
|
||||
["interleaved after command", ["node", "openclaw", "status", "--profile", "work", "--dev"]],
|
||||
])("rejects combining --dev with --profile (%s)", (_name, argv) => {
|
||||
const res = parseCliProfileArgs(argv);
|
||||
expect(res.ok).toBe(false);
|
||||
|
|
@ -165,4 +207,28 @@ describe("formatCliCommand", () => {
|
|||
"pnpm openclaw --profile work doctor",
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts --container when a container hint is set", () => {
|
||||
expect(
|
||||
formatCliCommand("openclaw gateway status --deep", { OPENCLAW_CONTAINER_HINT: "demo" }),
|
||||
).toBe("openclaw --container demo gateway status --deep");
|
||||
});
|
||||
|
||||
it("preserves both --container and --profile hints", () => {
|
||||
expect(
|
||||
formatCliCommand("openclaw doctor", {
|
||||
OPENCLAW_CONTAINER_HINT: "demo",
|
||||
OPENCLAW_PROFILE: "work",
|
||||
}),
|
||||
).toBe("openclaw --container demo doctor");
|
||||
});
|
||||
|
||||
it("does not prepend --container for update commands", () => {
|
||||
expect(formatCliCommand("openclaw update", { OPENCLAW_CONTAINER_HINT: "demo" })).toBe(
|
||||
"openclaw update",
|
||||
);
|
||||
expect(
|
||||
formatCliCommand("pnpm openclaw update --channel beta", { OPENCLAW_CONTAINER_HINT: "demo" }),
|
||||
).toBe("pnpm openclaw update --channel beta");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
consumeRootOptionToken,
|
||||
FLAG_TERMINATOR,
|
||||
isValueToken,
|
||||
} from "../infra/cli-root-options.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { getPrimaryCommand } from "./argv.js";
|
||||
import { isValidProfileName } from "./profile-utils.js";
|
||||
|
||||
export type CliProfileParseResult =
|
||||
|
|
@ -19,8 +25,9 @@ function takeValue(
|
|||
const trimmed = (value ?? "").trim();
|
||||
return { value: trimmed || null, consumedNext: false };
|
||||
}
|
||||
const trimmed = (next ?? "").trim();
|
||||
return { value: trimmed || null, consumedNext: Boolean(next) };
|
||||
const consumedNext = isValueToken(next);
|
||||
const trimmed = consumedNext ? next!.trim() : "";
|
||||
return { value: trimmed || null, consumedNext };
|
||||
}
|
||||
|
||||
export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
|
||||
|
|
@ -31,7 +38,6 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
|
|||
const out: string[] = argv.slice(0, 2);
|
||||
let profile: string | null = null;
|
||||
let sawDev = false;
|
||||
let sawCommand = false;
|
||||
|
||||
const args = argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
|
|
@ -39,13 +45,16 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
|
|||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sawCommand) {
|
||||
out.push(arg);
|
||||
continue;
|
||||
if (arg === FLAG_TERMINATOR) {
|
||||
out.push(arg, ...args.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg === "--dev") {
|
||||
if (getPrimaryCommand(out) === "gateway") {
|
||||
out.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (profile && profile !== "dev") {
|
||||
return { ok: false, error: "Cannot combine --dev with --profile" };
|
||||
}
|
||||
|
|
@ -76,9 +85,15 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (!arg.startsWith("-")) {
|
||||
sawCommand = true;
|
||||
out.push(arg);
|
||||
const consumedRootOption = consumeRootOptionToken(args, i);
|
||||
if (consumedRootOption > 0) {
|
||||
for (let offset = 0; offset < consumedRootOption; offset += 1) {
|
||||
const token = args[i + offset];
|
||||
if (token !== undefined) {
|
||||
out.push(token);
|
||||
}
|
||||
}
|
||||
i += consumedRootOption - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
|||
.name(CLI_NAME)
|
||||
.description("")
|
||||
.version(ctx.programVersion)
|
||||
.option(
|
||||
"--container <name>",
|
||||
"Run the CLI inside a running Podman/Docker container named <name> (default: env OPENCLAW_CONTAINER)",
|
||||
)
|
||||
.option(
|
||||
"--dev",
|
||||
"Dev profile: isolate state under ~/.openclaw-dev, default gateway port 19001, and shift derived ports (browser/canvas)",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,21 @@ const assertRuntimeMock = vi.hoisted(() => vi.fn());
|
|||
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const outputRootHelpMock = vi.hoisted(() => vi.fn());
|
||||
const buildProgramMock = vi.hoisted(() => vi.fn());
|
||||
const maybeRunCliInContainerMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
|
||||
>((argv: string[]) => ({ handled: false, argv })),
|
||||
);
|
||||
|
||||
vi.mock("./route.js", () => ({
|
||||
tryRouteCli: tryRouteCliMock,
|
||||
}));
|
||||
|
||||
vi.mock("./container-target.js", () => ({
|
||||
maybeRunCliInContainer: maybeRunCliInContainerMock,
|
||||
parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }),
|
||||
}));
|
||||
|
||||
vi.mock("./dotenv.js", () => ({
|
||||
loadCliDotEnv: loadDotEnvMock,
|
||||
}));
|
||||
|
|
@ -57,6 +67,7 @@ describe("runCli exit behavior", () => {
|
|||
|
||||
await runCli(["node", "openclaw", "status"]);
|
||||
|
||||
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
|
||||
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
|
||||
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
|
|
@ -70,6 +81,7 @@ describe("runCli exit behavior", () => {
|
|||
|
||||
await runCli(["node", "openclaw", "--help"]);
|
||||
|
||||
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]);
|
||||
expect(tryRouteCliMock).not.toHaveBeenCalled();
|
||||
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildProgramMock).not.toHaveBeenCalled();
|
||||
|
|
@ -77,4 +89,31 @@ describe("runCli exit behavior", () => {
|
|||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns after a handled container-target invocation", async () => {
|
||||
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 0 });
|
||||
|
||||
await runCli(["node", "openclaw", "--container", "demo", "status"]);
|
||||
|
||||
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--container",
|
||||
"demo",
|
||||
"status",
|
||||
]);
|
||||
expect(loadDotEnvMock).not.toHaveBeenCalled();
|
||||
expect(tryRouteCliMock).not.toHaveBeenCalled();
|
||||
expect(closeAllMemorySearchManagersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a handled container-target exit code", async () => {
|
||||
const exitCode = process.exitCode;
|
||||
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 7 });
|
||||
|
||||
await runCli(["node", "openclaw", "--container", "demo", "status"]);
|
||||
|
||||
expect(process.exitCode).toBe(7);
|
||||
process.exitCode = exitCode;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
const dotenvState = vi.hoisted(() => {
|
||||
const state = {
|
||||
profileAtDotenvLoad: undefined as string | undefined,
|
||||
containerAtDotenvLoad: undefined as string | undefined,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
loadDotEnv: vi.fn(() => {
|
||||
state.profileAtDotenvLoad = process.env.OPENCLAW_PROFILE;
|
||||
state.containerAtDotenvLoad = process.env.OPENCLAW_CONTAINER;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const maybeRunCliInContainerMock = vi.hoisted(() =>
|
||||
vi.fn((argv: string[]) => ({ handled: false, argv })),
|
||||
);
|
||||
|
||||
vi.mock("./dotenv.js", () => ({
|
||||
loadCliDotEnv: dotenvState.loadDotEnv,
|
||||
}));
|
||||
|
|
@ -36,19 +42,39 @@ vi.mock("./windows-argv.js", () => ({
|
|||
normalizeWindowsArgv: (argv: string[]) => argv,
|
||||
}));
|
||||
|
||||
vi.mock("./container-target.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./container-target.js")>();
|
||||
return {
|
||||
...actual,
|
||||
maybeRunCliInContainer: maybeRunCliInContainerMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { runCli } from "./run-main.js";
|
||||
|
||||
describe("runCli profile env bootstrap", () => {
|
||||
const originalProfile = process.env.OPENCLAW_PROFILE;
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
const originalContainer = process.env.OPENCLAW_CONTAINER;
|
||||
const originalGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
|
||||
const originalGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
|
||||
const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
const originalGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCLAW_PROFILE;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
delete process.env.OPENCLAW_CONTAINER;
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
dotenvState.state.profileAtDotenvLoad = undefined;
|
||||
dotenvState.state.containerAtDotenvLoad = undefined;
|
||||
dotenvState.loadDotEnv.mockClear();
|
||||
maybeRunCliInContainerMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -57,6 +83,11 @@ describe("runCli profile env bootstrap", () => {
|
|||
} else {
|
||||
process.env.OPENCLAW_PROFILE = originalProfile;
|
||||
}
|
||||
if (originalContainer === undefined) {
|
||||
delete process.env.OPENCLAW_CONTAINER;
|
||||
} else {
|
||||
process.env.OPENCLAW_CONTAINER = originalContainer;
|
||||
}
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
|
|
@ -67,6 +98,26 @@ describe("runCli profile env bootstrap", () => {
|
|||
} else {
|
||||
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
|
||||
}
|
||||
if (originalGatewayPort === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PORT = originalGatewayPort;
|
||||
}
|
||||
if (originalGatewayUrl === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_URL;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_URL = originalGatewayUrl;
|
||||
}
|
||||
if (originalGatewayToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
|
||||
}
|
||||
if (originalGatewayPassword === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = originalGatewayPassword;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies --profile before dotenv loading", async () => {
|
||||
|
|
@ -76,4 +127,87 @@ describe("runCli profile env bootstrap", () => {
|
|||
expect(dotenvState.state.profileAtDotenvLoad).toBe("rawdog");
|
||||
expect(process.env.OPENCLAW_PROFILE).toBe("rawdog");
|
||||
});
|
||||
|
||||
it("rejects --container combined with --profile", async () => {
|
||||
await expect(
|
||||
runCli(["node", "openclaw", "--container", "demo", "--profile", "rawdog", "status"]),
|
||||
).rejects.toThrow(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
|
||||
expect(dotenvState.loadDotEnv).not.toHaveBeenCalled();
|
||||
expect(process.env.OPENCLAW_PROFILE).toBe("rawdog");
|
||||
});
|
||||
|
||||
it("rejects --container combined with interleaved --profile", async () => {
|
||||
await expect(
|
||||
runCli(["node", "openclaw", "status", "--container", "demo", "--profile", "rawdog"]),
|
||||
).rejects.toThrow(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects --container combined with interleaved --dev", async () => {
|
||||
await expect(
|
||||
runCli(["node", "openclaw", "status", "--container", "demo", "--dev"]),
|
||||
).rejects.toThrow(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let dotenv change container target resolution", async () => {
|
||||
dotenvState.loadDotEnv.mockImplementationOnce(() => {
|
||||
process.env.OPENCLAW_CONTAINER = "demo";
|
||||
dotenvState.state.profileAtDotenvLoad = process.env.OPENCLAW_PROFILE;
|
||||
dotenvState.state.containerAtDotenvLoad = process.env.OPENCLAW_CONTAINER;
|
||||
});
|
||||
|
||||
await runCli(["node", "openclaw", "status"]);
|
||||
|
||||
expect(dotenvState.loadDotEnv).toHaveBeenCalledOnce();
|
||||
expect(process.env.OPENCLAW_CONTAINER).toBe("demo");
|
||||
expect(dotenvState.state.containerAtDotenvLoad).toBe("demo");
|
||||
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
|
||||
expect(maybeRunCliInContainerMock).toHaveReturnedWith({
|
||||
handled: false,
|
||||
argv: ["node", "openclaw", "status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects container mode when OPENCLAW_PROFILE is already set in env", async () => {
|
||||
process.env.OPENCLAW_PROFILE = "work";
|
||||
|
||||
await expect(runCli(["node", "openclaw", "--container", "demo", "status"])).rejects.toThrow(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["OPENCLAW_GATEWAY_PORT", "19001"],
|
||||
["OPENCLAW_GATEWAY_URL", "ws://127.0.0.1:18789"],
|
||||
["OPENCLAW_GATEWAY_TOKEN", "demo-token"],
|
||||
["OPENCLAW_GATEWAY_PASSWORD", "demo-password"],
|
||||
])("rejects container mode when %s is set in env", async (key, value) => {
|
||||
process.env[key] = value;
|
||||
|
||||
await expect(runCli(["node", "openclaw", "--container", "demo", "status"])).rejects.toThrow(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows container mode when only OPENCLAW_STATE_DIR is set in env", async () => {
|
||||
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-host-state";
|
||||
|
||||
await expect(
|
||||
runCli(["node", "openclaw", "--container", "demo", "status"]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows container mode when only OPENCLAW_CONFIG_PATH is set in env", async () => {
|
||||
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-host-state/openclaw.json";
|
||||
|
||||
await expect(
|
||||
runCli(["node", "openclaw", "--container", "demo", "status"]),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
hasHelpOrVersion,
|
||||
isRootHelpInvocation,
|
||||
} from "./argv.js";
|
||||
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
|
||||
import { loadCliDotEnv } from "./dotenv.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
|
|
@ -80,15 +81,42 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean {
|
|||
}
|
||||
|
||||
export async function runCli(argv: string[] = process.argv) {
|
||||
let normalizedArgv = normalizeWindowsArgv(argv);
|
||||
const parsedProfile = parseCliProfileArgs(normalizedArgv);
|
||||
const originalArgv = normalizeWindowsArgv(argv);
|
||||
const parsedContainer = parseCliContainerArgs(originalArgv);
|
||||
if (!parsedContainer.ok) {
|
||||
throw new Error(parsedContainer.error);
|
||||
}
|
||||
const parsedProfile = parseCliProfileArgs(parsedContainer.argv);
|
||||
if (!parsedProfile.ok) {
|
||||
throw new Error(parsedProfile.error);
|
||||
}
|
||||
if (parsedProfile.profile) {
|
||||
applyCliProfileEnv({ profile: parsedProfile.profile });
|
||||
}
|
||||
normalizedArgv = parsedProfile.argv;
|
||||
const containerTargetName =
|
||||
parsedContainer.container ?? process.env.OPENCLAW_CONTAINER?.trim() ?? null;
|
||||
if (
|
||||
containerTargetName &&
|
||||
(parsedProfile.profile ||
|
||||
process.env.OPENCLAW_PROFILE?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_PORT?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_URL?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim())
|
||||
) {
|
||||
throw new Error(
|
||||
"--container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
}
|
||||
|
||||
const containerTarget = maybeRunCliInContainer(originalArgv);
|
||||
if (containerTarget.handled) {
|
||||
if (containerTarget.exitCode !== 0) {
|
||||
process.exitCode = containerTarget.exitCode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let normalizedArgv = parsedProfile.argv;
|
||||
|
||||
loadCliDotEnv({ quiet: true });
|
||||
normalizeEnv();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ vi.mock("./gateway-rpc.js", () => ({
|
|||
vi.mock("../runtime.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../runtime.js")>()),
|
||||
defaultRuntime,
|
||||
writeRuntimeJson: (runtime: { log: (...args: unknown[]) => void }, value: unknown, space = 2) =>
|
||||
runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)),
|
||||
}));
|
||||
|
||||
const { registerSystemCli } = await import("./system-cli.js");
|
||||
|
|
|
|||
|
|
@ -438,10 +438,11 @@ describe("update-cli", () => {
|
|||
name: "json output",
|
||||
options: { json: true },
|
||||
assert: () => {
|
||||
const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0];
|
||||
expect(typeof last).toBe("string");
|
||||
const parsed = JSON.parse(String(last));
|
||||
expect(parsed.channel.value).toBe("stable");
|
||||
const last = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
|
||||
expect(last).toBeDefined();
|
||||
const parsed = last as Record<string, unknown>;
|
||||
const channel = parsed.channel as { value?: unknown };
|
||||
expect(channel.value).toBe("stable");
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -727,19 +728,11 @@ describe("update-cli", () => {
|
|||
name: "outputs JSON when --json is set",
|
||||
run: async () => {
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue(makeOkUpdateResult());
|
||||
vi.mocked(defaultRuntime.log).mockClear();
|
||||
vi.mocked(defaultRuntime.writeJson).mockClear();
|
||||
await updateCommand({ json: true });
|
||||
},
|
||||
assert: () => {
|
||||
const logCalls = vi.mocked(defaultRuntime.log).mock.calls;
|
||||
const jsonOutput = logCalls.find((call) => {
|
||||
try {
|
||||
JSON.parse(call[0] as string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0];
|
||||
expect(jsonOutput).toBeDefined();
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ describe("entry root help fast path", () => {
|
|||
|
||||
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], {
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
|
|
@ -18,9 +19,25 @@ describe("entry root help fast path", () => {
|
|||
|
||||
const handled = tryHandleRootHelpFastPath(["node", "openclaw", "status", "--help"], {
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(outputRootHelpMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the host help fast path when a container target is active", () => {
|
||||
const outputRootHelpMock = vi.fn();
|
||||
|
||||
const handled = tryHandleRootHelpFastPath(
|
||||
["node", "openclaw", "--container", "demo", "--help"],
|
||||
{
|
||||
outputRootHelp: outputRootHelpMock,
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(outputRootHelpMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
32
src/entry.ts
32
src/entry.ts
|
|
@ -4,6 +4,7 @@ import { enableCompileCache } from "node:module";
|
|||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js";
|
||||
import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
||||
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
|
||||
import { buildCliRespawnPlan } from "./entry.respawn.js";
|
||||
|
|
@ -99,6 +100,9 @@ if (
|
|||
}
|
||||
|
||||
function tryHandleRootVersionFastPath(argv: string[]): boolean {
|
||||
if (resolveCliContainerTarget(argv)) {
|
||||
return false;
|
||||
}
|
||||
if (!isRootVersionInvocation(argv)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -121,13 +125,35 @@ if (
|
|||
process.argv = normalizeWindowsArgv(process.argv);
|
||||
|
||||
if (!ensureCliRespawnReady()) {
|
||||
const parsed = parseCliProfileArgs(process.argv);
|
||||
const parsedContainer = parseCliContainerArgs(process.argv);
|
||||
if (!parsedContainer.ok) {
|
||||
console.error(`[openclaw] ${parsedContainer.error}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const parsed = parseCliProfileArgs(parsedContainer.argv);
|
||||
if (!parsed.ok) {
|
||||
// Keep it simple; Commander will handle rich help/errors after we strip flags.
|
||||
console.error(`[openclaw] ${parsed.error}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const containerTargetName = resolveCliContainerTarget(process.argv);
|
||||
if (
|
||||
containerTargetName &&
|
||||
(parsed.profile ||
|
||||
process.env.OPENCLAW_PROFILE?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_PORT?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_URL?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim())
|
||||
) {
|
||||
console.error(
|
||||
"[openclaw] --container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (parsed.profile) {
|
||||
applyCliProfileEnv({ profile: parsed.profile });
|
||||
// Keep Commander and ad-hoc argv checks consistent.
|
||||
|
|
@ -145,8 +171,12 @@ export function tryHandleRootHelpFastPath(
|
|||
deps: {
|
||||
outputRootHelp?: () => void;
|
||||
onError?: (error: unknown) => void;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {},
|
||||
): boolean {
|
||||
if (resolveCliContainerTarget(argv, deps.env)) {
|
||||
return false;
|
||||
}
|
||||
if (!isRootHelpInvocation(argv)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true));
|
|||
const normalizeEnvMock = vi.hoisted(() => vi.fn());
|
||||
const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv));
|
||||
const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv })));
|
||||
const resolveCliContainerTargetMock = vi.hoisted(() => vi.fn<() => string | null>(() => null));
|
||||
const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234"));
|
||||
const runCliMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true));
|
||||
|
||||
vi.mock("./cli/argv.js", () => ({
|
||||
|
|
@ -18,11 +20,20 @@ vi.mock("./cli/argv.js", () => ({
|
|||
isRootVersionInvocation: isRootVersionInvocationMock,
|
||||
}));
|
||||
|
||||
vi.mock("./cli/container-target.js", () => ({
|
||||
parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }),
|
||||
resolveCliContainerTarget: resolveCliContainerTargetMock,
|
||||
}));
|
||||
|
||||
vi.mock("./cli/profile.js", () => ({
|
||||
applyCliProfileEnv: applyCliProfileEnvMock,
|
||||
parseCliProfileArgs: parseCliProfileArgsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./cli/run-main.js", () => ({
|
||||
runCli: runCliMock,
|
||||
}));
|
||||
|
||||
vi.mock("./cli/respawn-policy.js", () => ({
|
||||
shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
|
||||
}));
|
||||
|
|
@ -58,12 +69,15 @@ vi.mock("./version.js", () => ({
|
|||
|
||||
describe("entry root version fast path", () => {
|
||||
let originalArgv: string[];
|
||||
let originalGatewayToken: string | undefined;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
originalArgv = [...process.argv];
|
||||
originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
process.argv = ["node", "openclaw", "--version"];
|
||||
exitSpy = vi
|
||||
.spyOn(process, "exit")
|
||||
|
|
@ -72,6 +86,11 @@ describe("entry root version fast path", () => {
|
|||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
if (originalGatewayToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
|
||||
}
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -101,4 +120,37 @@ describe("entry root version fast path", () => {
|
|||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("skips the host version fast path when a container target is active", async () => {
|
||||
resolveCliContainerTargetMock.mockReturnValue("demo");
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await import("./entry.js");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]);
|
||||
});
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects container mode for root version when gateway override env vars are set", async () => {
|
||||
resolveCliContainerTargetMock.mockReturnValue("demo");
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "demo-token";
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await import("./entry.js");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"[openclaw] --container cannot be combined with --profile/--dev or gateway override env vars",
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(2);
|
||||
});
|
||||
expect(runCliMock).not.toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ describe("consumeRootOptionToken", () => {
|
|||
{ args: ["--dev"], index: 0, expected: 1 },
|
||||
{ args: ["--profile=work"], index: 0, expected: 1 },
|
||||
{ args: ["--log-level=debug"], index: 0, expected: 1 },
|
||||
{ args: ["--container=openclaw-demo"], index: 0, expected: 1 },
|
||||
{ args: ["--profile", "work"], index: 0, expected: 2 },
|
||||
{ args: ["--container", "openclaw-demo"], index: 0, expected: 2 },
|
||||
{ args: ["--profile", "-1"], index: 0, expected: 2 },
|
||||
{ args: ["--log-level", "-1.5"], index: 0, expected: 2 },
|
||||
{ args: ["--profile", "--no-color"], index: 0, expected: 1 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const FLAG_TERMINATOR = "--";
|
||||
|
||||
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
|
||||
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
|
||||
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level", "--container"]);
|
||||
|
||||
export function isValueToken(arg: string | undefined): boolean {
|
||||
if (!arg || arg === FLAG_TERMINATOR) {
|
||||
|
|
@ -21,7 +21,11 @@ export function consumeRootOptionToken(args: ReadonlyArray<string>, index: numbe
|
|||
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
|
||||
return 1;
|
||||
}
|
||||
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
|
||||
if (
|
||||
arg.startsWith("--profile=") ||
|
||||
arg.startsWith("--log-level=") ||
|
||||
arg.startsWith("--container=")
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (ROOT_VALUE_FLAGS.has(arg)) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue