fix(cli): defer profile state creation past argument validation

This commit is contained in:
Peter Steinberger
2026-08-12 20:12:37 -07:00
parent 061c9c2f7f
commit a1e69e5e36
4 changed files with 70 additions and 12 deletions
+34 -4
View File
@@ -111,8 +111,12 @@ async function runCliProcess(params: {
stateEnv?: (stateDir: string) => Record<string, string>;
timeoutMs?: number;
expectedExitCode?: number;
pristineHome?: boolean;
}) {
const fixture = await createHelpProcessFixture(params.config);
const fixture = await createHelpProcessFixture(params.pristineHome ? undefined : params.config);
if (params.pristineHome) {
await fs.rm(fixture.stateDir, { force: true, recursive: true });
}
if (params.stateEnv) {
const lines = Object.entries(params.stateEnv(fixture.stateDir)).map(
([key, value]) => `${key}=${value}`,
@@ -151,9 +155,9 @@ async function runCliProcess(params: {
NODE_ENV: undefined,
NODE_OPTIONS: undefined,
NODE_USE_SYSTEM_CA: "1",
OPENCLAW_CONFIG_PATH: fixture.configPath,
OPENCLAW_CONFIG_PATH: params.pristineHome ? undefined : fixture.configPath,
OPENCLAW_NO_RESPAWN: params.allowRespawn ? undefined : "1",
OPENCLAW_STATE_DIR: fixture.stateDir,
OPENCLAW_STATE_DIR: params.pristineHome ? undefined : fixture.stateDir,
VITEST: undefined,
...params.env,
},
@@ -219,7 +223,7 @@ async function runCliProcess(params: {
}),
);
}
return { stderr, stdout };
return { root: fixture.root, stderr, stdout };
}
function parseJsonLines(stdout: string): Array<Record<string, unknown>> {
@@ -370,6 +374,32 @@ describe("CLI help process exit", () => {
});
});
describe("rejected CLI process state isolation", () => {
it("does not scaffold a selected profile before option validation", async () => {
const profile = "rejected-profile";
const result = await runCliProcess({
args: [
"onboard",
"--non-interactive",
"--accept-risk",
"--gateway-port",
"99999",
"--profile",
profile,
],
expectedExitCode: 1,
pristineHome: true,
});
expect(result.stderr).toContain(
"Error: --gateway-port must be an integer between 1 and 65535.",
);
await expect(fs.access(path.join(result.root, `.openclaw-${profile}`))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
describe("JSON console style process output", () => {
const loggingConfig = {
logging: {
+6 -1
View File
@@ -218,7 +218,7 @@ export async function runDoctorConfigPreflight(
const gatewayStartupCheckpointRequired = options.requireStartupMigrationCheckpoint === true;
const migrationCheckpointRequired =
gatewayStartupCheckpointRequired || options.requireStateMigrationCheckpoint === true;
const migrationCheckpoint = migrationCheckpointRequired
let migrationCheckpoint = migrationCheckpointRequired
? await measurePreflightStep(
"startup-checkpoint-import",
() => import("../infra/startup-migration-checkpoint.js"),
@@ -342,6 +342,11 @@ export async function runDoctorConfigPreflight(
skipPristineStartupStateMigrations = pristineStatePlan.skipAllStateMigrations;
skipPristineCoreStateMigrations ||= pristineStatePlan.skipCoreStateMigrations;
}
if (skipPristineStartupStateMigrations && !gatewayStartupCheckpointRequired) {
// A pristine non-Gateway command has nothing to checkpoint. Leave the state root absent
// until command execution reaches a real state consumer.
migrationCheckpoint = undefined;
}
// The gateway uses this last-moment guard to ensure its prepared config did not change before
// any automatic migration mutates state. A rejected guard skips every state migration stage.
const stateMigrationsAllowed =
@@ -106,6 +106,25 @@ afterEach(() => {
});
describe("pristine startup state", () => {
it("accepts a missing explicitly selected profile root", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pristine-profile-"));
roots.push(root);
const stateDir = path.join(root, ".openclaw-typo");
fs.mkdirSync(path.join(root, ".clawdbot"));
expect(
planPristineStartupStateMigrations({
HOME: root,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
OPENCLAW_STATE_DIR: stateDir,
}),
).toEqual({
skipAllStateMigrations: true,
skipCoreStateMigrations: true,
});
expect(fs.existsSync(stateDir)).toBe(false);
});
it("accepts the core-only Gateway benchmark config", () => {
const env = createFixture({
browser: { enabled: false },
@@ -263,16 +263,20 @@ export function planPristineStartupStateMigrations(
if (!homeDir) {
return { skipAllStateMigrations: false, skipCoreStateMigrations: false };
}
const legacyStateAbsent = resolveLegacyStateDirs(() => homeDir).every((legacyDir) => {
if (path.resolve(legacyDir) === path.resolve(stateDir)) {
return false;
}
return !fs.existsSync(legacyDir);
});
const explicitStateDir = env.OPENCLAW_STATE_DIR?.trim();
const legacyStateAbsent =
Boolean(explicitStateDir) ||
resolveLegacyStateDirs(() => homeDir).every((legacyDir) => {
if (path.resolve(legacyDir) === path.resolve(stateDir)) {
return false;
}
return !fs.existsSync(legacyDir);
});
if (!legacyStateAbsent) {
return { skipAllStateMigrations: false, skipCoreStateMigrations: false };
}
const configPlan = planPristineStartupConfigMigrations(tryReadJsonSync(configPath), env);
const config = fs.existsSync(configPath) ? tryReadJsonSync(configPath) : {};
const configPlan = planPristineStartupConfigMigrations(config, env);
return {
skipAllStateMigrations: configPlan.skipAllStateMigrations,
skipCoreStateMigrations: configPlan.skipCoreStateMigrations,