650 lines
19 KiB
TypeScript
650 lines
19 KiB
TypeScript
import { Command } from "jsr:@cliffy/command@1.0.0-rc.7";
|
|
import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7";
|
|
import * as colors from "jsr:@std/fmt@0.225.2/colors";
|
|
import * as path from "jsr:@std/path@0.225.2";
|
|
|
|
const ENV_PATH = path.join("infra", ".env");
|
|
const COMPOSE_PATH = path.join("infra", "compose.yml");
|
|
const SPIRE_COMPOSE_PATH = path.join("infra", "compose.spire.yml");
|
|
|
|
export interface AuthSetupConfig {
|
|
reg: string;
|
|
ghcrReg: string;
|
|
domainName: string;
|
|
dbPassword: string;
|
|
dbDataPath: string;
|
|
appSecret: string;
|
|
}
|
|
|
|
const DEFAULT_AUTH_CONFIG: AuthSetupConfig = {
|
|
reg: "quay.atyg.org",
|
|
ghcrReg: "ghcr.atyg.org",
|
|
domainName: "auth.system.local",
|
|
dbPassword: "",
|
|
dbDataPath: "/volume1/docker/auth-yes/db",
|
|
appSecret: "",
|
|
};
|
|
|
|
export async function readEnv(): Promise<Partial<AuthSetupConfig>> {
|
|
try {
|
|
const text = await Deno.readTextFile(ENV_PATH);
|
|
const config: Partial<AuthSetupConfig> = {};
|
|
for (const line of text.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const [key, ...rest] = trimmed.split("=");
|
|
const val = rest.join("=").trim();
|
|
if (key === "REG") config.reg = val;
|
|
if (key === "GHCR_REG") config.ghcrReg = val;
|
|
if (key === "SYSTEM_DOMAIN") config.domainName = val;
|
|
if (key === "POSTGRES_PASSWORD") config.dbPassword = val;
|
|
if (key === "DB_DATA_PATH") config.dbDataPath = val;
|
|
if (key === "APP_SECRET") config.appSecret = val;
|
|
}
|
|
return config;
|
|
} catch (_e) {
|
|
// Ignore and check next
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export function generateEnv(config: AuthSetupConfig): string {
|
|
return `# --- Container Registry ---
|
|
REG=${config.reg}
|
|
GHCR_REG=${config.ghcrReg || "ghcr.atyg.org"}
|
|
|
|
# --- Network & Routing ---
|
|
SYSTEM_DOMAIN=${config.domainName}
|
|
RP_ID=${config.domainName}
|
|
ORIGIN=https://${config.domainName}
|
|
|
|
# --- Identity Provider Internal App Secret ---
|
|
APP_SECRET=${config.appSecret}
|
|
|
|
# --- Database Configuration ---
|
|
POSTGRES_HOST=auth-db
|
|
POSTGRES_PORT=5432
|
|
POSTGRES_USER=postgres
|
|
POSTGRES_DB=authdb
|
|
POSTGRES_PASSWORD=${config.dbPassword}
|
|
DB_DATA_PATH=${config.dbDataPath}
|
|
|
|
# --- Valkey Configuration ---
|
|
VALKEY_HOST=auth-valkey
|
|
VALKEY_PORT=6379
|
|
VALKEY_URL=redis://auth-valkey:6379
|
|
`;
|
|
}
|
|
|
|
export function generateDockerCompose(): string {
|
|
return `version: "3.8"
|
|
|
|
services:
|
|
auth-api:
|
|
image: \${REG}/library/auth-yes-api:latest
|
|
env_file: .env
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.docker.network=traefik-net"
|
|
- "traefik.http.routers.auth-api.rule=Host(\`\${SYSTEM_DOMAIN}\`)"
|
|
- "traefik.http.routers.auth-api.entrypoints=websecure"
|
|
- "traefik.http.routers.auth-api.tls=true"
|
|
- "traefik.http.services.auth-api.loadbalancer.server.port=8000"
|
|
expose:
|
|
- "8000"
|
|
depends_on:
|
|
- auth-db
|
|
- auth-valkey
|
|
networks:
|
|
- default
|
|
- traefik-net
|
|
volumes:
|
|
- spire-socket:/var/run/spire:ro
|
|
|
|
auth-db:
|
|
image: acr.atyg.org/library/postgres:18-alpine
|
|
environment:
|
|
- POSTGRES_USER=\${POSTGRES_USER}
|
|
- POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
|
|
- POSTGRES_DB=\${POSTGRES_DB}
|
|
volumes:
|
|
- auth-db-data:/var/lib/postgresql/data
|
|
networks:
|
|
- default
|
|
|
|
auth-valkey:
|
|
image: acr.atyg.org/valkey/valkey:8-alpine
|
|
networks:
|
|
- default
|
|
|
|
volumes:
|
|
auth-db-data:
|
|
driver: local
|
|
driver_opts:
|
|
type: none
|
|
device: \${DB_DATA_PATH}
|
|
o: bind
|
|
spire-socket:
|
|
name: spire-socket
|
|
|
|
networks:
|
|
default:
|
|
name: auth-internal-net
|
|
traefik-net:
|
|
external: true
|
|
`;
|
|
}
|
|
|
|
export function generateSpireDockerCompose(): string {
|
|
return `version: "3.8"
|
|
|
|
services:
|
|
spire-server:
|
|
image: \${GHCR_REG:-ghcr.atyg.org}/spiffe/spire-server:1.9.3
|
|
container_name: spire-server
|
|
hostname: spire-server
|
|
networks:
|
|
- auth-internal-net
|
|
volumes:
|
|
- ./spire/server/data:/opt/spire/data
|
|
- ./spire/server/conf/server.conf:/opt/spire/conf/server.conf:ro
|
|
command: ["-config", "/opt/spire/conf/server.conf"]
|
|
|
|
spire-agent:
|
|
image: \${GHCR_REG:-ghcr.atyg.org}/spiffe/spire-agent:1.9.3
|
|
container_name: spire-agent
|
|
hostname: spire-agent
|
|
pid: host
|
|
networks:
|
|
- auth-internal-net
|
|
volumes:
|
|
- spire-socket:/var/run/spire
|
|
- ./spire/agent/data:/opt/spire/data
|
|
- ./spire/agent/conf/agent.conf:/opt/spire/conf/agent.conf:ro
|
|
command: ["-config", "/opt/spire/conf/agent.conf"]
|
|
depends_on:
|
|
- spire-server
|
|
|
|
volumes:
|
|
spire-socket:
|
|
name: spire-socket
|
|
|
|
networks:
|
|
auth-internal-net:
|
|
external: true
|
|
`;
|
|
}
|
|
|
|
export function generateProtobufCompilationCommands(): string[] {
|
|
return [
|
|
"deno",
|
|
"run",
|
|
"-A",
|
|
"npm:@bufbuild/buf",
|
|
"generate",
|
|
"server/auth.proto",
|
|
"--template",
|
|
'{"version":"v1","plugins":[{"plugin":"buf.build/bufbuild/es:v1.10.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"},{"plugin":"buf.build/connectrpc/es:v1.4.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"}]}',
|
|
];
|
|
}
|
|
|
|
// SIDE EFFECT: Runs the protoc compilation
|
|
export async function executeProtobufCompilation(): Promise<void> {
|
|
const commands = generateProtobufCompilationCommands();
|
|
const cmd = new Deno.Command(commands[0], {
|
|
args: commands.slice(1),
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
|
|
const { code } = await cmd.output();
|
|
if (code !== 0) {
|
|
throw new Error("Failed to compile protobuf definitions.");
|
|
}
|
|
}
|
|
|
|
export async function downloadWorkloadProto(): Promise<void> {
|
|
console.log(colors.blue("\nDownloading workload.proto..."));
|
|
const res = await fetch(
|
|
"https://raw.githubusercontent.com/spiffe/go-spiffe/main/proto/spiffe/workload/workload.proto",
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to download workload.proto: ${res.statusText}`);
|
|
}
|
|
const text = await res.text();
|
|
await Deno.mkdir("spire_ffi/proto", { recursive: true });
|
|
await Deno.writeTextFile("spire_ffi/proto/workload.proto", text);
|
|
console.log(colors.green("✓ workload.proto downloaded successfully."));
|
|
}
|
|
|
|
export function generateBuildCommands(reg: string): string[] {
|
|
return [
|
|
`podman build -t ${reg}/library/auth-yes-api:latest -f Dockerfile .`,
|
|
`podman push ${reg}/library/auth-yes-api:latest`,
|
|
];
|
|
}
|
|
|
|
// SIDE EFFECT: Executes docker build and push commands to the host system
|
|
export async function executeBuildImage(commands: string[]): Promise<void> {
|
|
for (const cmd of commands) {
|
|
console.log(colors.cyan(`\nExecuting: ${cmd}`));
|
|
const args = cmd.split(" ");
|
|
const process = new Deno.Command(args[0], {
|
|
args: args.slice(1),
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
stdin: "inherit",
|
|
});
|
|
|
|
const { code } = await process.output();
|
|
if (code !== 0) {
|
|
throw new Error(`Command failed with exit code ${code}: ${cmd}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function generateAuthSetupFiles(
|
|
config: AuthSetupConfig,
|
|
): Promise<void> {
|
|
const envContent = generateEnv(config);
|
|
await Deno.writeTextFile(ENV_PATH, envContent);
|
|
|
|
const composeContent = generateDockerCompose();
|
|
await Deno.writeTextFile(COMPOSE_PATH, composeContent);
|
|
|
|
const spireComposeContent = generateSpireDockerCompose();
|
|
await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent);
|
|
|
|
console.log(
|
|
colors.green(
|
|
`\n✓ Successfully generated ${ENV_PATH} and ${COMPOSE_PATH}!`,
|
|
),
|
|
);
|
|
console.log(
|
|
colors.green("Setup complete. You may now deploy your stack by running:\n"),
|
|
);
|
|
console.log(
|
|
colors.cyan(
|
|
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function handleAuthSetup(
|
|
currentConfig: AuthSetupConfig,
|
|
): Promise<AuthSetupConfig> {
|
|
console.log(
|
|
colors.gray(
|
|
"Please provide the following Auth Yes configuration details.\n",
|
|
),
|
|
);
|
|
|
|
const reg = await Input.prompt({
|
|
message: "Enter the Primary Container Registry URL:",
|
|
default: currentConfig.reg,
|
|
});
|
|
|
|
const ghcrReg = await Input.prompt({
|
|
message: "Enter the GHCR Mirror Registry URL (for SPIRE):",
|
|
default: currentConfig.ghcrReg || "ghcr.atyg.org",
|
|
});
|
|
|
|
const domainName = await Input.prompt({
|
|
message: "Enter the Auth Domain Name:",
|
|
hint: "E.g., auth.system.local",
|
|
default: currentConfig.domainName,
|
|
});
|
|
|
|
const dbDataPath = await Input.prompt({
|
|
message: "Enter the Database Path on the Host:",
|
|
default: currentConfig.dbDataPath,
|
|
});
|
|
|
|
const appSecret = await Secret.prompt({
|
|
message: "Enter the App Secret for the IDP:",
|
|
default: currentConfig.appSecret,
|
|
minLength: 16,
|
|
});
|
|
|
|
const pwdMessage = currentConfig.dbPassword
|
|
? "Enter the PostgreSQL database password: (Leave blank to keep existing password)"
|
|
: "Enter the PostgreSQL database password:";
|
|
|
|
const pwdInput = await Secret.prompt({
|
|
message: pwdMessage,
|
|
minLength: currentConfig.dbPassword ? 0 : 1,
|
|
});
|
|
|
|
const dbPassword = pwdInput === "" && currentConfig.dbPassword !== ""
|
|
? currentConfig.dbPassword
|
|
: pwdInput;
|
|
|
|
const newConfig: AuthSetupConfig = {
|
|
reg,
|
|
ghcrReg,
|
|
domainName,
|
|
dbPassword,
|
|
dbDataPath,
|
|
appSecret,
|
|
};
|
|
|
|
await generateAuthSetupFiles(newConfig);
|
|
|
|
return newConfig;
|
|
}
|
|
|
|
export async function handleTestConnection(domainName: string): Promise<void> {
|
|
console.log(
|
|
colors.bold(
|
|
colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
const res1 = await fetch(`https://${domainName}/`, {
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeoutId);
|
|
|
|
if (res1.status === 502 || res1.status === 503 || res1.status === 504) {
|
|
throw new Error(`Gateway error: ${res1.status}`);
|
|
}
|
|
|
|
if (res1.body) {
|
|
await res1.body.cancel();
|
|
}
|
|
|
|
console.log(
|
|
colors.green(
|
|
`✓ Auth service is up and reachable at https://${domainName}/`,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
console.log(
|
|
colors.red(
|
|
`✗ Error: Could not reach the auth service at https://${domainName}/`,
|
|
),
|
|
);
|
|
if (error instanceof Error) {
|
|
console.log(colors.red(` Reason: ${error.message}`));
|
|
} else {
|
|
console.log(colors.red(` Reason: ${error}`));
|
|
}
|
|
console.log(
|
|
colors.red(
|
|
" Please verify the container is running and DNS/Traefik is resolving.",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleReviewConfigs(): Promise<void> {
|
|
const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null);
|
|
const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() =>
|
|
null
|
|
);
|
|
const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch(
|
|
() => null,
|
|
);
|
|
|
|
if (!envContent && !composeContent && !spireComposeContent) {
|
|
console.log(
|
|
colors.red("\n✗ No generated configs found. Run the setup first.\n"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
while (true) {
|
|
const action = await Select.prompt({
|
|
message: "Review Generated Configs",
|
|
options: [
|
|
{ name: "[Show .env]", value: "env" },
|
|
{ name: "[Show compose.yml]", value: "compose" },
|
|
{ name: "[Show compose.spire.yml]", value: "spire_compose" },
|
|
{ name: "[Back to Main Menu]", value: "back" },
|
|
],
|
|
});
|
|
|
|
if (action === "env") {
|
|
console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`)));
|
|
console.log(envContent || colors.yellow("File not found."));
|
|
console.log();
|
|
} else if (action === "compose") {
|
|
console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`)));
|
|
console.log(composeContent || colors.yellow("File not found."));
|
|
console.log();
|
|
} else if (action === "spire_compose") {
|
|
console.log(
|
|
colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)),
|
|
);
|
|
console.log(spireComposeContent || colors.yellow("File not found."));
|
|
console.log();
|
|
} else if (action === "back") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// SIDE EFFECT: Runs the interactive CLI wizard.
|
|
export async function runSetupWizard(): Promise<void> {
|
|
console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n")));
|
|
|
|
const loadedEnv = await readEnv();
|
|
let currentConfig: AuthSetupConfig = {
|
|
...DEFAULT_AUTH_CONFIG,
|
|
...loadedEnv,
|
|
};
|
|
|
|
if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) {
|
|
await handleTestConnection(currentConfig.domainName);
|
|
console.log();
|
|
}
|
|
|
|
while (true) {
|
|
const action = await Select.prompt({
|
|
message: "Main Menu",
|
|
options: [
|
|
{ name: "[Test Auth Connection]", value: "test" },
|
|
{ name: "[Configure Auth Yes API]", value: "auth" },
|
|
{ name: "[Compile Protobuf Definitions]", value: "compile_proto" },
|
|
{ name: "[Review Generated Configs]", value: "review" },
|
|
{ name: "[Build and Push Auth Image]", value: "build" },
|
|
{ name: "[Exit]", value: "exit" },
|
|
],
|
|
});
|
|
|
|
if (action === "test") {
|
|
await handleTestConnection(currentConfig.domainName);
|
|
console.log();
|
|
} else if (action === "auth") {
|
|
currentConfig = await handleAuthSetup(currentConfig);
|
|
} else if (action === "compile_proto") {
|
|
try {
|
|
await downloadWorkloadProto();
|
|
await executeProtobufCompilation();
|
|
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log(
|
|
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
|
);
|
|
} else {
|
|
console.log(
|
|
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
|
);
|
|
}
|
|
}
|
|
} else if (action === "review") {
|
|
await handleReviewConfigs();
|
|
} else if (action === "build") {
|
|
try {
|
|
const commands = generateBuildCommands(currentConfig.reg);
|
|
await executeBuildImage(commands);
|
|
console.log(colors.green("\n✓ Successfully built and pushed image!"));
|
|
console.log(
|
|
colors.green("You may now deploy your stack by running:\n"),
|
|
);
|
|
console.log(
|
|
colors.cyan(
|
|
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log(
|
|
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
|
);
|
|
} else {
|
|
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
|
}
|
|
}
|
|
} else if (action === "exit") {
|
|
console.log(colors.gray("Exiting...\n"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
if (!Deno.stdin.isTerminal() && Deno.args.length === 0) {
|
|
console.error(
|
|
colors.red(
|
|
"Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.",
|
|
),
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const cmd = new Command()
|
|
.name("auth-setup")
|
|
.description("Auth setup wizard and CLI")
|
|
.action(() => {
|
|
runSetupWizard();
|
|
})
|
|
.command("auth", "Configure Auth Yes API")
|
|
.option("--auto, --headless", "Run in headless mode")
|
|
.option("--registry <reg:string>", "Container Registry URL")
|
|
.option("--ghcr-registry <ghcrReg:string>", "GHCR Mirror Registry URL")
|
|
.option("--domain <domain:string>", "Auth Domain Name")
|
|
.option("--db-path <path:string>", "Database Path on the Host")
|
|
.action(async (options) => {
|
|
const loadedEnv = await readEnv();
|
|
const currentConfig: AuthSetupConfig = {
|
|
...DEFAULT_AUTH_CONFIG,
|
|
...loadedEnv,
|
|
};
|
|
|
|
if (options.auto) {
|
|
if (!options.registry || !options.domain || !options.dbPath) {
|
|
console.error(
|
|
colors.red(
|
|
"Error: --registry, --domain, and --db-path are required in headless mode.",
|
|
),
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
const dbPassword = Deno.env.get("POSTGRES_PASSWORD") ||
|
|
currentConfig.dbPassword;
|
|
const appSecret = Deno.env.get("APP_SECRET") ||
|
|
currentConfig.appSecret;
|
|
if (!dbPassword) {
|
|
console.error(
|
|
colors.red(
|
|
"Error: POSTGRES_PASSWORD environment variable is required in headless mode.",
|
|
),
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
if (!appSecret) {
|
|
console.error(
|
|
colors.red(
|
|
"Error: APP_SECRET environment variable is required in headless mode.",
|
|
),
|
|
);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
const newConfig: AuthSetupConfig = {
|
|
reg: options.registry,
|
|
ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg ||
|
|
"ghcr.atyg.org",
|
|
domainName: options.domain,
|
|
dbPassword,
|
|
dbDataPath: options.dbPath,
|
|
appSecret,
|
|
};
|
|
await generateAuthSetupFiles(newConfig);
|
|
} else {
|
|
if (options.registry) currentConfig.reg = options.registry;
|
|
if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry;
|
|
if (options.domain) currentConfig.domainName = options.domain;
|
|
if (options.dbPath) currentConfig.dbDataPath = options.dbPath;
|
|
await handleAuthSetup(currentConfig);
|
|
}
|
|
})
|
|
.command("test", "Test Auth Connection")
|
|
.action(async () => {
|
|
const loadedEnv = await readEnv();
|
|
const currentConfig: AuthSetupConfig = {
|
|
...DEFAULT_AUTH_CONFIG,
|
|
...loadedEnv,
|
|
};
|
|
await handleTestConnection(currentConfig.domainName);
|
|
})
|
|
.command("compile_proto", "Compile Protobuf Definitions")
|
|
.action(async () => {
|
|
try {
|
|
await downloadWorkloadProto();
|
|
await executeProtobufCompilation();
|
|
console.log(colors.green("\n✓ Successfully compiled protobufs!\n"));
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log(
|
|
colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`),
|
|
);
|
|
} else {
|
|
console.log(
|
|
colors.red(`\n✗ Protobuf compilation failed: ${error}\n`),
|
|
);
|
|
}
|
|
Deno.exit(1);
|
|
}
|
|
})
|
|
.command("build", "Build and Push Auth Image")
|
|
.action(async () => {
|
|
try {
|
|
const loadedEnv = await readEnv();
|
|
const currentConfig: AuthSetupConfig = {
|
|
...DEFAULT_AUTH_CONFIG,
|
|
...loadedEnv,
|
|
};
|
|
const commands = generateBuildCommands(currentConfig.reg);
|
|
await executeBuildImage(commands);
|
|
console.log(
|
|
colors.green("\n✓ Successfully built and pushed auth image!"),
|
|
);
|
|
console.log(
|
|
colors.green("You may now deploy your stack by running:\n"),
|
|
);
|
|
console.log(
|
|
colors.cyan(
|
|
"podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n",
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log(
|
|
colors.red(`\n✗ Build process failed: ${error.message}\n`),
|
|
);
|
|
} else {
|
|
console.log(colors.red(`\n✗ Build process failed: ${error}\n`));
|
|
}
|
|
Deno.exit(1);
|
|
}
|
|
});
|
|
|
|
await cmd.parse(Deno.args);
|
|
}
|