fix(compose): handle stack up alias in model guard

This commit is contained in:
EgerDev 2026-09-11 19:05:51 -07:00
parent 8e3b9ef89e
commit 69a007a175
2 changed files with 146 additions and 2 deletions

View File

@ -214,6 +214,61 @@ services:
expect(isStackDeployCommand("-cD remote stack deploy --help")).toBe(false);
});
it("treats stack up as the documented stack deploy alias", () => {
expect(isStackDeployCommand("stack up -c docker-compose.yml demo")).toBe(
true,
);
expect(
isStackDeployCommand("'stack' 'up' -c docker-compose.yml demo"),
).toBe(true);
expect(
isStackDeployCommand(
"--context remote stack up -c docker-compose.yml demo",
),
).toBe(true);
expect(
isStackDeployCommand(
"--context=remote stack up -c docker-compose.yml demo",
),
).toBe(true);
expect(isStackDeployCommand("-D stack up -c docker-compose.yml demo")).toBe(
true,
);
expect(
isStackDeployCommand("st\"ack\" u'p' -c docker-compose.yml demo"),
).toBe(true);
});
it("does not treat compose commands as stack up", () => {
expect(isStackDeployCommand("compose up")).toBe(false);
expect(isStackDeployCommand("compose run app stack up")).toBe(false);
expect(isStackDeployCommand("compose exec app stack up")).toBe(false);
expect(isStackDeployCommand("compose -f stack.yml up")).toBe(false);
expect(isStackDeployCommand("--context stack compose up")).toBe(false);
});
it("skips deprecated stack --orchestrator before deploy or up", () => {
expect(
isStackDeployCommand(
"stack --orchestrator swarm deploy -c docker-compose.yml demo",
),
).toBe(true);
expect(
isStackDeployCommand(
"stack --orchestrator=swarm up -c docker-compose.yml demo",
),
).toBe(true);
expect(
isStackDeployCommand(
"--context remote stack --orchestrator swarm up -c docker-compose.yml demo",
),
).toBe(true);
expect(isStackDeployCommand("stack --orchestrator deploy")).toBe(false);
expect(
isStackDeployCommand("stack --orchestrator deploy -c file.yml demo"),
).toBe(false);
});
it("classifies quoted shell argv the way /bin/sh does", () => {
expect(
isStackDeployCommand("'stack' 'deploy' -c docker-compose.yml demo"),
@ -407,6 +462,50 @@ services:
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
});
it("rejects custom stack up commands when models are present", async () => {
const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } }));
for (const command of [
"stack up -c docker-compose.yml demo",
"'stack' 'up' -c docker-compose.yml demo",
"--context remote stack up -c docker-compose.yml demo",
"--context=remote stack up -c docker-compose.yml demo",
"-D stack up -c docker-compose.yml demo",
"stack --orchestrator swarm up -c docker-compose.yml demo",
"stack --orchestrator=swarm deploy -c docker-compose.yml demo",
]) {
await expect(
writeDeploy(
compose({
composeType: "docker-compose",
command,
composeFile: models,
}),
),
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
}
});
it("allows compose commands that only resemble stack up", async () => {
const models = yaml(spec({ models: { llm: { model: "ai/smollm2" } } }));
for (const command of [
"compose up",
"compose run app stack up",
"compose exec app stack up",
"compose -f stack.yml up",
"--context stack compose up",
]) {
await expect(
writeDeploy(
compose({
composeType: "docker-compose",
command,
composeFile: models,
}),
),
).resolves.toContain("base64 -d");
}
});
it("does not treat a compose file token containing 'stack deploy' as stack deploy", async () => {
await expect(
writeDeploy(
@ -627,6 +726,27 @@ describe("getBuildComposeCommand stack models", () => {
),
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
});
it("does not emit stack up when models are present", async () => {
await expect(
getBuildComposeCommand(
buildArgs({
composeType: "docker-compose",
command: "stack up -c docker-compose.yml demo",
composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })),
}),
),
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
await expect(
getBuildComposeCommand(
buildArgs({
composeType: "docker-compose",
command: "--context remote stack up -c docker-compose.yml demo",
composeFile: yaml(spec({ models: { llm: { model: "ai/smollm2" } } })),
}),
),
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
});
});
const fakeDockerArgv = (command: string, pathPrefix: string) => {
@ -753,6 +873,13 @@ process.stdout.write(JSON.stringify(process.argv.slice(2)));
expect(
fakeDockerArgv("'stack' 'deploy' -c file.yml demo", fakePath),
).toEqual(["stack", "deploy", "-c", "file.yml", "demo"]);
expect(fakeDockerArgv("'stack' 'up' -c file.yml demo", fakePath)).toEqual([
"stack",
"up",
"-c",
"file.yml",
"demo",
]);
expect(
fakeDockerArgv(
'--config "/tmp/docker config" stack deploy -c file.yml demo',

View File

@ -228,11 +228,28 @@ const skipDockerGlobalOptions = (tokens: string[]) => {
return i;
};
const skipStackOrchestratorOption = (tokens: string[], i: number) => {
const token = tokens[i];
if (!token) return i;
if (token === "--orchestrator") {
if (i + 1 >= tokens.length) return -1;
return i + 2;
}
if (token.startsWith("--orchestrator=")) return i + 1;
return i;
};
// Docker documents `stack up` as an alias of `stack deploy` (CLI 28.5+ / 29.x).
// Deprecated `--orchestrator` is a stack-level string flag that may sit between
// `stack` and `deploy`/`up`; `stack --orchestrator deploy` consumes `deploy` as
// the flag value and is not a deployment.
export const isStackDeployCommand = (command: string) => {
const tokens = tokenizeDockerCommand(command);
const i = skipDockerGlobalOptions(tokens);
if (i < 0) return false;
return tokens[i] === "stack" && tokens[i + 1] === "deploy";
if (i < 0 || tokens[i] !== "stack") return false;
const j = skipStackOrchestratorOption(tokens, i + 1);
if (j < 0) return false;
return tokens[j] === "deploy" || tokens[j] === "up";
};
export const writeDomainsToCompose = async (