mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(compose): bound custom stack input to the managed file
This commit is contained in:
parent
69a007a175
commit
439f68b07f
@ -15,6 +15,7 @@ import {
|
||||
writeDomainsToCompose,
|
||||
} from "@dokploy/server/utils/docker/domain";
|
||||
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
|
||||
import * as processUtils from "@dokploy/server/utils/process/execAsync";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
@ -28,6 +29,7 @@ import { parse, stringify } from "yaml";
|
||||
|
||||
const disk = vi.hoisted(() => ({
|
||||
yaml: "services:\n app:\n image: nginx:alpine\n",
|
||||
reads: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
@ -36,6 +38,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
...actual,
|
||||
existsSync: (p: Parameters<typeof actual.existsSync>[0]) => {
|
||||
const path = String(p);
|
||||
disk.reads.push(path);
|
||||
if (path.includes("/code/") && path.endsWith("docker-compose.yml")) {
|
||||
return true;
|
||||
}
|
||||
@ -46,6 +49,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
enc?: BufferEncoding,
|
||||
) => {
|
||||
const path = String(p);
|
||||
disk.reads.push(path);
|
||||
if (path.includes("/code/") && path.endsWith("docker-compose.yml")) {
|
||||
return disk.yaml;
|
||||
}
|
||||
@ -89,6 +93,129 @@ const compose = (
|
||||
const writeDeploy = (c: ReturnType<typeof compose>) =>
|
||||
writeDomainsToCompose(c, [], createCommand(c as never));
|
||||
|
||||
describe("managed Stack command regressions", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
it.each([
|
||||
"-D=false stack deploy",
|
||||
"-D=true stack deploy",
|
||||
"-D stack deploy",
|
||||
"--debug=false stack up",
|
||||
"-Dlerror stack deploy",
|
||||
"-l=error stack deploy",
|
||||
"-- stack deploy",
|
||||
"stack --orchestrator swarm --orchestrator=swarm deploy",
|
||||
"stack --orchestrator deploy --orchestrator up deploy",
|
||||
"-c --something stack --orchestrator=--something deploy",
|
||||
])("recognizes and blocks models for %s", async (prefix) => {
|
||||
const command = `${prefix} -c docker-compose.yml demo`;
|
||||
expect(isStackDeployCommand(command)).toBe(true);
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({ command, composeFile: yaml(spec({ models: {} })) }),
|
||||
),
|
||||
).rejects.toThrow(STACK_COMPOSE_MODELS_ERROR);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"-c models.yml",
|
||||
"-c plain.yml",
|
||||
"-c docker-compose.yml -c models.yml",
|
||||
"-c docker-compose.yml --compose-file docker-compose.yml",
|
||||
"--compose-file=docker-compose.yml,models.yml",
|
||||
"-c -",
|
||||
"-c ../outside.yml",
|
||||
"-c /etc/compose.yml",
|
||||
"-c subdir/../docker-compose.yml",
|
||||
"-c linked-compose.yml",
|
||||
"-c '../outside file.yml'",
|
||||
"",
|
||||
])("rejects unmanaged input without reading files: %s", async (files) => {
|
||||
disk.reads.length = 0;
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
sourceType: "github",
|
||||
command: `stack deploy ${files} demo`,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/exactly one.*managed Compose file/);
|
||||
expect(disk.reads).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports selected-file policy before configured models", async () => {
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
command: "stack deploy -c plain.yml demo",
|
||||
composeFile: yaml(spec({ models: {} })),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/exactly one.*managed Compose file/);
|
||||
});
|
||||
|
||||
it("accepts the managed Git path with quoted spaces", async () => {
|
||||
vi.spyOn(db.query.patch, "findMany").mockResolvedValue([]);
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
sourceType: "github",
|
||||
composePath: "dir with spaces/docker-compose.yml",
|
||||
command: 'stack deploy -c "dir with spaces/docker-compose.yml" demo',
|
||||
}),
|
||||
),
|
||||
).resolves.toContain("base64 -d");
|
||||
});
|
||||
|
||||
it("rejects remote alternative input before opening an SSH read", async () => {
|
||||
const remote = vi
|
||||
.spyOn(processUtils, "execAsyncRemote")
|
||||
.mockRejectedValue(new Error("unexpected SSH read"));
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({
|
||||
sourceType: "github",
|
||||
serverId: "fixture",
|
||||
command: "stack deploy -c models.yml demo",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/exactly one.*managed Compose file/);
|
||||
expect(remote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the managed remote document and ordinary transforms", async () => {
|
||||
vi.spyOn(db.query.patch, "findMany").mockResolvedValue([]);
|
||||
const remote = vi
|
||||
.spyOn(processUtils, "execAsyncRemote")
|
||||
.mockResolvedValue({ stdout: yaml(spec()), stderr: "" });
|
||||
await expect(
|
||||
writeDeploy(compose({ sourceType: "github", serverId: "fixture" })),
|
||||
).resolves.toContain("base64 -d");
|
||||
expect(remote).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"stack deploy -cdocker-compose.yml demo",
|
||||
"stack deploy -c=docker-compose.yml demo",
|
||||
"stack deploy -qdc./docker-compose.yml demo",
|
||||
"stack deploy demo --compose-file=./docker-compose.yml",
|
||||
"stack deploy --resolve-image -c -c docker-compose.yml demo",
|
||||
"stack deploy --orchestrator swarm -c docker-compose.yml demo",
|
||||
"stack deploy -c docker-compose.yml -- demo",
|
||||
])("accepts one managed input: %s", async (command) => {
|
||||
await expect(writeDeploy(compose({ command }))).resolves.toContain(
|
||||
"base64 -d",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat operands after -- as selected inputs", async () => {
|
||||
await expect(
|
||||
writeDeploy(
|
||||
compose({ command: "stack deploy -- demo -c docker-compose.yml" }),
|
||||
),
|
||||
).rejects.toThrow(/exactly one.*managed Compose file/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeSpecificationUsesModels", () => {
|
||||
it("detects top-level and service-level models structurally", () => {
|
||||
expect(composeSpecificationUsesModels(spec())).toBe(false);
|
||||
@ -760,55 +887,7 @@ const fakeDockerArgv = (command: string, pathPrefix: string) => {
|
||||
return JSON.parse(result.stdout) as string[];
|
||||
};
|
||||
|
||||
const argvLooksLikeStackDeploy = (argv: string[]) => {
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const token = argv[i];
|
||||
if (!token || token === "-" || !token.startsWith("-")) break;
|
||||
if (token === "--") {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
token === "-D" ||
|
||||
token === "-v" ||
|
||||
token === "-h" ||
|
||||
token.startsWith("--debug") ||
|
||||
token.startsWith("--tls") ||
|
||||
token.startsWith("--help") ||
|
||||
token.startsWith("--version")
|
||||
) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
token.startsWith("--context") ||
|
||||
token.startsWith("--config") ||
|
||||
token.startsWith("--host") ||
|
||||
token.startsWith("--log-level") ||
|
||||
token.startsWith("--tlscacert") ||
|
||||
token.startsWith("--tlscert") ||
|
||||
token.startsWith("--tlskey") ||
|
||||
token === "-c" ||
|
||||
token.startsWith("-c") ||
|
||||
token === "-H" ||
|
||||
token.startsWith("-H") ||
|
||||
token === "-l" ||
|
||||
token.startsWith("-l")
|
||||
) {
|
||||
if (token.includes("=") || (token.startsWith("-c") && token.length > 2)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return argv[i] === "stack" && argv[i + 1] === "deploy";
|
||||
};
|
||||
|
||||
describe("fake-docker shell argv differential", () => {
|
||||
describe("captured Docker shell argv", () => {
|
||||
let fakePath = "";
|
||||
|
||||
beforeAll(() => {
|
||||
@ -827,39 +906,6 @@ process.stdout.write(JSON.stringify(process.argv.slice(2)));
|
||||
if (fakePath) rmSync(fakePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const corpus = [
|
||||
"stack deploy -c docker-compose.yml demo",
|
||||
"'stack' 'deploy' -c docker-compose.yml demo",
|
||||
"st\"ack\" de'ploy' -c docker-compose.yml demo",
|
||||
'--config "/tmp/docker config" stack deploy -c file.yml demo',
|
||||
"--config '/tmp/docker config' stack deploy -c file.yml demo",
|
||||
'--context "remote" stack deploy -c file.yml demo',
|
||||
"-c 'remote' stack deploy -c file.yml demo",
|
||||
"-D stack deploy -c file.yml demo",
|
||||
"--debug=false stack deploy -c file.yml demo",
|
||||
"--context=remote stack deploy -c file.yml demo",
|
||||
"-c remote stack deploy -c file.yml demo",
|
||||
"-H unix:///var/run/docker.sock stack deploy -c file.yml demo",
|
||||
"'compose' up",
|
||||
"compose run app 'stack' 'deploy'",
|
||||
"--context remote compose up",
|
||||
"-D compose up",
|
||||
"--debug false stack deploy --help",
|
||||
"-H stack deploy --help",
|
||||
"compose -f my-stack deploy-file.yml up",
|
||||
"stack\tdeploy -c file.yml demo",
|
||||
" stack deploy -c file.yml demo",
|
||||
];
|
||||
|
||||
it("agrees with /bin/sh argv for the supported custom-command corpus", () => {
|
||||
for (const command of corpus) {
|
||||
const argv = fakeDockerArgv(command, fakePath);
|
||||
expect(isStackDeployCommand(command), command).toBe(
|
||||
argvLooksLikeStackDeploy(argv),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies default createCommand stack deploy as stack deploy in generated-shell argv", () => {
|
||||
const command = createCommand(compose({ command: "" }) as never);
|
||||
expect(command.startsWith("stack deploy")).toBe(true);
|
||||
|
||||
@ -6,7 +6,7 @@ import { network, patch } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { parse as parseShell, quote } from "shell-quote";
|
||||
import { quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../providers/bitbucket";
|
||||
@ -17,6 +17,10 @@ import { cloneGitlabRepository } from "../providers/gitlab";
|
||||
import { getCreateComposeFileCommand } from "../providers/raw";
|
||||
import { randomizeDeployableSpecificationFile } from "./collision";
|
||||
import { randomizeSpecificationFile } from "./compose";
|
||||
import {
|
||||
isStackDeployCommand,
|
||||
validateStackComposeInput,
|
||||
} from "./stack-command";
|
||||
import type {
|
||||
ComposeSpecification,
|
||||
DefinitionsService,
|
||||
@ -24,6 +28,8 @@ import type {
|
||||
} from "./types";
|
||||
import { encodeBase64 } from "./utils";
|
||||
|
||||
export { isStackDeployCommand } from "./stack-command";
|
||||
|
||||
export const cloneCompose = async (compose: Compose) => {
|
||||
let command = "set -e;";
|
||||
const entity = {
|
||||
@ -132,131 +138,15 @@ export const composeSpecificationUsesModels = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const DOCKER_VALUE_LONG = new Set([
|
||||
"config",
|
||||
"context",
|
||||
"host",
|
||||
"log-level",
|
||||
"tlscacert",
|
||||
"tlscert",
|
||||
"tlskey",
|
||||
]);
|
||||
const DOCKER_VALUE_SHORT = new Set(["c", "H", "l"]);
|
||||
const DOCKER_BOOL_LONG = new Set([
|
||||
"debug",
|
||||
"help",
|
||||
"tls",
|
||||
"tlsverify",
|
||||
"version",
|
||||
]);
|
||||
const DOCKER_BOOL_SHORT = new Set(["D", "h", "v"]);
|
||||
|
||||
const tokenizeDockerCommand = (command: string) => {
|
||||
const tokens: string[] = [];
|
||||
for (const entry of parseShell(command, {})) {
|
||||
if (typeof entry === "string") {
|
||||
tokens.push(entry);
|
||||
continue;
|
||||
}
|
||||
if ("comment" in entry) continue;
|
||||
if ("op" in entry) {
|
||||
if (entry.op === "glob") {
|
||||
tokens.push(entry.pattern);
|
||||
continue;
|
||||
}
|
||||
if (entry.op === "&&") break;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const skipDockerGlobalOptions = (tokens: string[]) => {
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (!token || token === "-") break;
|
||||
if (token === "--") return i + 1;
|
||||
if (!token.startsWith("-")) break;
|
||||
|
||||
if (token.startsWith("--")) {
|
||||
const eq = token.indexOf("=");
|
||||
const name = eq === -1 ? token.slice(2) : token.slice(2, eq);
|
||||
if (DOCKER_BOOL_LONG.has(name)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (DOCKER_VALUE_LONG.has(name)) {
|
||||
if (eq !== -1) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (i + 1 >= tokens.length) return -1;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
let k = 1;
|
||||
let consumeNextValue = false;
|
||||
while (k < token.length) {
|
||||
const flag = token[k];
|
||||
if (!flag) break;
|
||||
if (DOCKER_BOOL_SHORT.has(flag)) {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
if (DOCKER_VALUE_SHORT.has(flag)) {
|
||||
if (k + 1 < token.length) {
|
||||
consumeNextValue = false;
|
||||
k = token.length;
|
||||
break;
|
||||
}
|
||||
consumeNextValue = true;
|
||||
break;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (consumeNextValue) {
|
||||
if (i + 1 >= tokens.length) return -1;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
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 || 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 (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
dockerCommand = "",
|
||||
) => {
|
||||
validateStackComposeInput(
|
||||
dockerCommand,
|
||||
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath,
|
||||
);
|
||||
let composeConverted: ComposeSpecification | null;
|
||||
try {
|
||||
composeConverted = await addDomainToCompose(compose, domains);
|
||||
|
||||
165
packages/server/src/utils/docker/stack-command.ts
Normal file
165
packages/server/src/utils/docker/stack-command.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { posix } from "node:path";
|
||||
import { parse } from "shell-quote";
|
||||
|
||||
type OptionKind = "boolean" | "value" | "file";
|
||||
type Options = Readonly<Record<string, OptionKind>>;
|
||||
|
||||
const ROOT_OPTIONS: Options = {
|
||||
"--config": "value",
|
||||
"--context": "value",
|
||||
"-c": "value",
|
||||
"--host": "value",
|
||||
"-H": "value",
|
||||
"--log-level": "value",
|
||||
"-l": "value",
|
||||
"--tlscacert": "value",
|
||||
"--tlscert": "value",
|
||||
"--tlskey": "value",
|
||||
"--debug": "boolean",
|
||||
"-D": "boolean",
|
||||
"--help": "boolean",
|
||||
"-h": "boolean",
|
||||
"--tls": "boolean",
|
||||
"--tlsverify": "boolean",
|
||||
"--version": "boolean",
|
||||
"-v": "boolean",
|
||||
};
|
||||
const STACK_OPTIONS: Options = { "--orchestrator": "value" };
|
||||
const DEPLOY_OPTIONS: Options = {
|
||||
...STACK_OPTIONS,
|
||||
"--compose-file": "file",
|
||||
"-c": "file",
|
||||
"--resolve-image": "value",
|
||||
"--detach": "boolean",
|
||||
"-d": "boolean",
|
||||
"--quiet": "boolean",
|
||||
"-q": "boolean",
|
||||
"--prune": "boolean",
|
||||
"--with-registry-auth": "boolean",
|
||||
"--help": "boolean",
|
||||
"-h": "boolean",
|
||||
};
|
||||
|
||||
const tokenize = (command: string): string[] => {
|
||||
const tokens: string[] = [];
|
||||
for (const entry of parse(command, {})) {
|
||||
if (typeof entry === "string") tokens.push(entry);
|
||||
else if ("op" in entry) {
|
||||
if (entry.op === "glob") tokens.push(entry.pattern);
|
||||
else if (entry.op === "&&") break;
|
||||
else return [];
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
// Docker's short boolean flags accept =VALUE; value flags consume the rest
|
||||
// of a short cluster or the next token, even when that token starts with '-'.
|
||||
const consumeOption = (
|
||||
tokens: readonly string[],
|
||||
index: number,
|
||||
options: Options,
|
||||
): { next: number; file?: string } | null => {
|
||||
const token = tokens[index];
|
||||
if (!token?.startsWith("-") || token === "-" || token === "--") return null;
|
||||
const long = token.startsWith("--");
|
||||
let offset = long ? 2 : 1;
|
||||
while (offset < token.length) {
|
||||
const equals = token.indexOf("=", offset);
|
||||
const name = long
|
||||
? token.slice(0, equals < 0 ? undefined : equals)
|
||||
: `-${token[offset]}`;
|
||||
const kind = options[name];
|
||||
if (!kind) return null;
|
||||
const rest = long
|
||||
? equals < 0
|
||||
? ""
|
||||
: token.slice(equals)
|
||||
: token.slice(offset + 1);
|
||||
if (kind === "boolean") {
|
||||
if (long || rest.startsWith("=")) return { next: index + 1 };
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const attached = rest.length > 0;
|
||||
const value = attached ? rest.replace(/^=/, "") : tokens[index + 1];
|
||||
if (value === undefined) return null;
|
||||
return {
|
||||
next: index + (attached ? 1 : 2),
|
||||
...(kind === "file" ? { file: value } : {}),
|
||||
};
|
||||
}
|
||||
return { next: index + 1 };
|
||||
};
|
||||
|
||||
const commandIndex = (
|
||||
tokens: readonly string[],
|
||||
start: number,
|
||||
options: Options,
|
||||
root = false,
|
||||
): number => {
|
||||
let index = start;
|
||||
while (tokens[index]?.startsWith("-")) {
|
||||
if (root && tokens[index] === "--") return index + 1;
|
||||
const option = consumeOption(tokens, index, options);
|
||||
if (!option) return -1;
|
||||
index = option.next;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const deploymentIndex = (tokens: readonly string[]): number => {
|
||||
const stack = commandIndex(tokens, 0, ROOT_OPTIONS, true);
|
||||
if (stack < 0 || tokens[stack] !== "stack") return -1;
|
||||
const deploy = commandIndex(tokens, stack + 1, STACK_OPTIONS);
|
||||
return deploy >= 0 && (tokens[deploy] === "deploy" || tokens[deploy] === "up")
|
||||
? deploy
|
||||
: -1;
|
||||
};
|
||||
|
||||
export const isStackDeployCommand = (command: string): boolean =>
|
||||
deploymentIndex(tokenize(command)) >= 0;
|
||||
|
||||
export const STACK_COMPOSE_INPUT_ERROR =
|
||||
"Custom Stack deployments must select exactly one Dokploy-managed Compose file. Use the configured Compose path with -c; alternate files, multiple files, CSV lists, stdin, absolute paths and parent traversal are not supported.";
|
||||
|
||||
const isManagedPath = (selected: string, managed: string): boolean => {
|
||||
if (
|
||||
!selected ||
|
||||
selected === "-" ||
|
||||
/[,"]/.test(selected) ||
|
||||
posix.isAbsolute(selected) ||
|
||||
selected.split("/").includes("..")
|
||||
)
|
||||
return false;
|
||||
return posix.normalize(selected) === posix.normalize(managed);
|
||||
};
|
||||
|
||||
// Only the configured document receives Dokploy's patches, domains and name
|
||||
// transforms. Confining Stack input avoids reading or merging arbitrary files.
|
||||
export const validateStackComposeInput = (
|
||||
command: string,
|
||||
managedPath: string,
|
||||
): void => {
|
||||
const tokens = tokenize(command);
|
||||
const deploy = deploymentIndex(tokens);
|
||||
if (deploy < 0) return;
|
||||
const files: string[] = [];
|
||||
let index = deploy + 1;
|
||||
while (index < tokens.length) {
|
||||
if (tokens[index] === "--") break;
|
||||
if (!tokens[index]?.startsWith("-")) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const option = consumeOption(tokens, index, DEPLOY_OPTIONS);
|
||||
if (!option) throw new Error(STACK_COMPOSE_INPUT_ERROR);
|
||||
if (option.file !== undefined) files.push(option.file);
|
||||
index = option.next;
|
||||
}
|
||||
if (
|
||||
files.length !== 1 ||
|
||||
!files.every((file) => isManagedPath(file, managedPath))
|
||||
)
|
||||
throw new Error(STACK_COMPOSE_INPUT_ERROR);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user