From 195811e81b8daaaf8e6ab1dcc6c686afeda36c8b Mon Sep 17 00:00:00 2001 From: Maxwell Date: Mon, 29 Jun 2026 14:18:33 +0200 Subject: [PATCH 1/2] chore(project): bootstrap Deno workspace and CI - Deno 2.x project structure with deno.json and task definitions - JSR dependencies: @cliffy/command, @std/assert, @std/testing, @std/yaml, @std/dotenv, @std/fs, @std/path - Full CLI command tree with stubs for all 15 issues - Shared interfaces (ProcessRunner, config types, ExitCode) for parallel work - FakeProcessRunner with recording, pre-programmed responses, and dry-run support - CI pipeline: fmt, lint, typecheck, test, coverage, and cross-platform build - .gitignore for generated and environment-specific files --- .github/workflows/ci.yml | 71 +++++++++++ .gitignore | 29 +++++ deno.json | 52 ++++++++ deno.lock | 68 +++++++++++ src/cli/mod.ts | 249 ++++++++++++++++++++++++++++++++++++++ src/cli/mod_test.ts | 49 ++++++++ src/config/types.ts | 119 ++++++++++++++++++ src/main.ts | 19 +++ src/process/types.ts | 63 ++++++++++ src/testing/fakes.ts | 161 ++++++++++++++++++++++++ src/testing/fakes_test.ts | 96 +++++++++++++++ src/testing/mod.ts | 1 + src/version.ts | 7 ++ src/version_test.ts | 6 + 14 files changed, 990 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 src/cli/mod.ts create mode 100644 src/cli/mod_test.ts create mode 100644 src/config/types.ts create mode 100644 src/main.ts create mode 100644 src/process/types.ts create mode 100644 src/testing/fakes.ts create mode 100644 src/testing/fakes_test.ts create mode 100644 src/testing/mod.ts create mode 100644 src/version.ts create mode 100644 src/version_test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed3d892 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +/** + * CI workflow for stackctl. + * + * Runs on every push and PR to main/dev branches. + * Validates format, linting, type checking, tests, and coverage. + */ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +env: + DENO_VERSION: "2.x" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Cache dependencies + run: deno task cache + + - name: Check formatting + run: deno task fmt:check + + - name: Lint + run: deno task lint + + - name: Type check + run: deno task check + + - name: Run tests + run: deno task test + + - name: Generate coverage report + if: success() + run: | + deno test --allow-read --allow-write --allow-env --allow-run --allow-sys --coverage=.coverage + deno coverage --detailed .coverage + + build: + runs-on: ubuntu-latest + needs: check + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Build Linux x64 + run: deno task build:linux:x64 + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: stackctl-linux-x64 + path: dist/stackctl-linux-x64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..135cad0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# stackctl +.stackctl.local +.stackctl.local.* + +# secrets +*.env +!.env.example +*.env.enc +age-key.txt +age.key + +# build output +dist/ + +# rendered stacks +.rendered/ + +# OS +.DS_Store +Thumbs.db + +# editor +.vscode/settings.json +*.swp +*.swo +*~ + +# test coverage +.coverage/ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d4e3782 --- /dev/null +++ b/deno.json @@ -0,0 +1,52 @@ +{ + "name": "@anitrend/stackctl", + "version": "0.1.0-dev", + "exports": "./src/main.ts", + "tasks": { + "cache": "deno cache src/main.ts", + "check": "deno check src/main.ts", + "fmt": "deno fmt", + "fmt:check": "deno fmt --check", + "lint": "deno lint", + "test": "deno test --allow-read --allow-write --allow-env --allow-run --allow-sys", + "coverage": "deno coverage --detailed", + "build": "deno compile --output dist/stackctl src/main.ts", + "build:darwin:x64": "deno compile --target x86_64-apple-darwin --output dist/stackctl-darwin-x64 src/main.ts", + "build:darwin:arm64": "deno compile --target aarch64-apple-darwin --output dist/stackctl-darwin-arm64 src/main.ts", + "build:linux:x64": "deno compile --target x86_64-unknown-linux-gnu --output dist/stackctl-linux-x64 src/main.ts", + "build:linux:arm64": "deno compile --target aarch64-unknown-linux-gnu --output dist/stackctl-linux-arm64 src/main.ts" + }, + "imports": { + "@cliffy/command": "jsr:@cliffy/command@^1.0.0", + "@std/assert": "jsr:@std/assert@^1.0.18", + "@std/dotenv": "jsr:@std/dotenv@^0.225.6", + "@std/fs": "jsr:@std/fs@^1.0.0", + "@std/path": "jsr:@std/path@^1.1.4", + "@std/testing": "jsr:@std/testing@^1.0.17", + "@std/yaml": "jsr:@std/yaml@^1.1.1", + "@std/fmt": "jsr:@std/fmt@^1.0.5" + }, + "lint": { + "include": ["src/"], + "rules": { + "tags": ["recommended"], + "exclude": ["no-unused-vars"] + } + }, + "fmt": { + "include": ["src/"], + "useTabs": false, + "lineWidth": 100, + "indentWidth": 2, + "singleQuote": false, + "proseWrap": "always" + }, + "compilerOptions": { + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false + }, + "lock": true, + "nodeModulesDir": "none" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..160f1c9 --- /dev/null +++ b/deno.lock @@ -0,0 +1,68 @@ +{ + "version": "5", + "specifiers": { + "jsr:@cliffy/command@1": "1.2.1", + "jsr:@cliffy/flags@1.2.1": "1.2.1", + "jsr:@cliffy/internal@1.2.1": "1.2.1", + "jsr:@cliffy/table@1.2.1": "1.2.1", + "jsr:@std/assert@^1.0.18": "1.0.19", + "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/internal@^1.0.12": "1.0.13", + "jsr:@std/text@^1.0.19": "1.0.19" + }, + "jsr": { + "@cliffy/command@1.2.1": { + "integrity": "b7b017c81560d96580ea810c8564566884d8194416f6fc06bfdc8846e8fd590f", + "dependencies": [ + "jsr:@cliffy/flags", + "jsr:@cliffy/internal", + "jsr:@cliffy/table", + "jsr:@std/fmt", + "jsr:@std/text" + ] + }, + "@cliffy/flags@1.2.1": { + "integrity": "10034920ef7595db586fecec5a3b10a4ec800c513cbbc368414cd748dac706fb", + "dependencies": [ + "jsr:@cliffy/internal", + "jsr:@std/text" + ] + }, + "@cliffy/internal@1.2.1": { + "integrity": "e398f41c6839d3d4d249c963bfcb1058013091425926d53a3cb482b1ca70684b" + }, + "@cliffy/table@1.2.1": { + "integrity": "be333e62f2c754f8bf75a38c8177515b152040572c9f75298e9bcef1314b82ff", + "dependencies": [ + "jsr:@std/fmt" + ] + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/fmt@1.0.10": { + "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" + }, + "@std/internal@1.0.13": { + "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" + }, + "@std/text@1.0.19": { + "integrity": "003a0e032d360e8c3a4e0410fb792c77a66bd6553fee9d60c6ec1bce30d29223" + } + }, + "workspace": { + "dependencies": [ + "jsr:@cliffy/command@1", + "jsr:@std/assert@^1.0.18", + "jsr:@std/dotenv@~0.225.6", + "jsr:@std/fmt@^1.0.5", + "jsr:@std/fs@1", + "jsr:@std/path@^1.1.4", + "jsr:@std/testing@^1.0.17", + "jsr:@std/yaml@^1.1.1" + ] + } +} diff --git a/src/cli/mod.ts b/src/cli/mod.ts new file mode 100644 index 0000000..2b815fe --- /dev/null +++ b/src/cli/mod.ts @@ -0,0 +1,249 @@ +import { Command } from "@cliffy/command"; +import { VERSION } from "../version.ts"; + +/** + * Parse and execute CLI commands. + * Returns the process exit code (0 for success). + */ +export async function main(args: string[]): Promise { + try { + const cmd = await buildCli().parse(args); + return cmd instanceof Error ? 1 : 0; + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return 1; + } +} + +/** + * Build the stackctl CLI command tree. + * Commands are registered here in their skeleton form; + * full implementations are added in subsequent issues. + */ +export function buildCli(): Command { + const cli = new Command() + .name("stackctl") + .version(VERSION) + .description( + "Standalone repository-aware Docker Swarm stack controller.\n" + + "Manage Docker Swarm stacks with generation, rendering, secrets, and lifecycle commands.", + ) + .help({ hints: true }) + .option("--debug", "Enable debug output and stack traces.", { hidden: false }); + + // Default action: show help when no subcommand matches + cli.action(() => { + cli.showHelp(); + Deno.exit(0); + }); + + // --- init (issue #3) --- + cli.command("init", "Generate a commented .stackctl configuration file.") + .option("--detect", "Detect repository layout and infer config values.") + .option("--preset ", "Use a preset configuration template.") + .option("--profile ", "Create an additional profile config file.") + .option("--write-gitignore", "Append .stackctl.local and .env to .gitignore.") + .option("--force", "Overwrite existing .stackctl file.") + .option("--dry-run", "Print the config that would be written without writing.") + .action(() => { + console.error("init: not yet implemented (issue #3)"); + Deno.exit(1); + }); + + // --- generate (issue #4) --- + cli.command("generate", "Generate canonical stack files from per-service Compose sources.") + .option("--dry-run", "Print generated output without writing files.") + .option("--stacks ", "Comma-separated list of stack names to generate.") + .option("--output-dir ", "Write generated stacks to a specific directory.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("generate: not yet implemented (issue #4)"); + Deno.exit(1); + }); + + // --- render (issue #5) --- + cli.command( + "render", + "Resolve ${VAR} placeholders in stack files using service-local env values.", + ) + .option("--stacks ", "Comma-separated list of stack names to render.") + .option("--profile ", "Use a specific profile.") + .option("--strict", "Fail on any unresolved variable.") + .option("--output-dir ", "Write rendered output to a specific directory.") + .option( + "--override ", + "Comma-separated list of override files to apply before rendering.", + ) + .action(() => { + console.error("render: not yet implemented (issue #5)"); + Deno.exit(1); + }); + + // --- up (issue #6) --- + cli.command("up", "Deploy stacks to Docker Swarm.") + .option("--no-logs", "Do not follow logs after deploy.") + .option("--dry-run", "Print planned actions without executing.") + .option("--skip-generate", "Skip stack generation step.") + .option("--allow-unrendered", "Deploy unrendered stack files (not recommended).") + .option("--stacks ", "Comma-separated list of stack names to deploy.") + .option("--profile ", "Use a specific profile.") + .option("--override ", "Comma-separated list of override files.") + .action(() => { + console.error("up: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- down (issue #6) --- + cli.command("down", "Remove stacks from Docker Swarm.") + .option("--yes", "Skip confirmation prompt.") + .option("--dry-run", "Print planned actions without executing.") + .option("--remove-network", "Also remove the configured overlay network.") + .option("--stacks ", "Comma-separated list of stack names to remove.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("down: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- status (issue #6) --- + cli.command("status", "Show stack service status.") + .option("--json", "Output JSON machine-readable status.") + .option("--stacks ", "Comma-separated list of stack names.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("status: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- logs (issue #6) --- + cli.command("logs", "Follow service logs.") + .arguments("[services...:string]") + .option("--stacks ", "Comma-separated list of stack names.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("logs: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- sync (issue #6) --- + cli.command("sync", "Validate that generated stacks match committed stack files.") + .option("--quiet", "Suppress diff output.") + .option("--non-interactive", "Skip confirmation; exit 1 on drift.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("sync: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- doctor (issue #6) --- + cli.command("doctor", "Check system and project health.") + .option("--fix-volumes", "Create missing external volumes.") + .option("--check-secrets", "Also check for secrets tooling (sops, age).") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("doctor: not yet implemented (issue #6)"); + Deno.exit(1); + }); + + // --- reload (issue #9) --- + cli.command("reload", "Re-render and redeploy stacks without tearing down.") + .option("--force-service-update", "Force update all services after deploy.") + .option("--no-force-service-update", "Skip force update (config override).") + .option("--no-generate", "Skip stack generation step.") + .option("--stacks ", "Comma-separated list of stack names.") + .option("--profile ", "Use a specific profile.") + .option("--override ", "Comma-separated list of override files.") + .option("--dry-run", "Print planned actions without executing.") + .action(() => { + console.error("reload: not yet implemented (issue #9)"); + Deno.exit(1); + }); + + // --- secrets (issue #7) --- + const secretsCmd = cli.command("secrets", "Manage SOPS/age encrypted secrets."); + secretsCmd.command("encrypt", "Encrypt .env files to encrypted output.") + .arguments("[services...:string]") + .option("--profile ", "Use a specific profile.") + .option("--dry-run", "Print planned actions without executing.") + .action(() => { + console.error("secrets encrypt: not yet implemented (issue #7)"); + Deno.exit(1); + }); + secretsCmd.command("decrypt", "Decrypt encrypted .env files to plaintext.") + .arguments("[services...:string]") + .option("--profile ", "Use a specific profile.") + .option("--dry-run", "Print planned actions without executing.") + .action(() => { + console.error("secrets decrypt: not yet implemented (issue #7)"); + Deno.exit(1); + }); + secretsCmd.command("deploy", "Decrypt and deploy stacks with secret values.") + .arguments("[services...:string]") + .option("--profile ", "Use a specific profile.") + .option("--dry-run", "Print planned actions without executing.") + .action(() => { + console.error("secrets deploy: not yet implemented (issue #7)"); + Deno.exit(1); + }); + secretsCmd.command("clean", "Remove plaintext .env files that have encrypted counterparts.") + .option("--profile ", "Use a specific profile.") + .option("--dry-run", "Print planned actions without executing.") + .action(() => { + console.error("secrets clean: not yet implemented (issue #7)"); + Deno.exit(1); + }); + secretsCmd.command("check", "Check secrets tooling availability.") + .option("--profile ", "Use a specific profile.") + .action(() => { + console.error("secrets check: not yet implemented (issue #7)"); + Deno.exit(1); + }); + + // --- env (issue #14) --- + cli.command("env", "Manage .env files and profile env presets.") + .option("--list", "List discovered services and .env status.") + .option("--recreate", "Create missing .env files from .env.example.") + .option("--force", "Overwrite existing .env files.") + .option("--yes", "Skip confirmation.") + .option("--dry-run", "Print planned changes without writing.") + .option("--paths ", "Comma-separated list of service paths.") + .option("--profile ", "Use a specific profile.") + .option("--from-profile ", "Materialize env from a profile preset.") + .option("--materialize", "Materialize profile preset env values.") + .action(() => { + console.error("env: not yet implemented (issue #14)"); + Deno.exit(1); + }); + + // --- plan (issue #15) --- + cli.command("plan", "Produce a deterministic plan of what an operation would do.") + .arguments("") + .option("--profile ", "Use a specific profile.") + .option("--stacks ", "Comma-separated list of stack names.") + .option("--override ", "Comma-separated list of override files.") + .option("--json", "Output machine-readable JSON.") + .action(() => { + console.error("plan: not yet implemented (issue #15)"); + Deno.exit(1); + }); + + // --- completions (issue #10) --- + const completionsCmd = cli.command("completions", "Generate shell completion scripts."); + completionsCmd.command("bash", "Generate bash completion script.") + .action(() => { + console.error("completions bash: not yet implemented (issue #10)"); + Deno.exit(1); + }); + completionsCmd.command("zsh", "Generate zsh completion script.") + .action(() => { + console.error("completions zsh: not yet implemented (issue #10)"); + Deno.exit(1); + }); + completionsCmd.command("fish", "Generate fish completion script.") + .action(() => { + console.error("completions fish: not yet implemented (issue #10)"); + Deno.exit(1); + }); + + return cli as unknown as Command; +} diff --git a/src/cli/mod_test.ts b/src/cli/mod_test.ts new file mode 100644 index 0000000..5e03bae --- /dev/null +++ b/src/cli/mod_test.ts @@ -0,0 +1,49 @@ +import { assertEquals } from "@std/assert"; +import { buildCli } from "../cli/mod.ts"; + +Deno.test("buildCli returns stackctl command", () => { + const cmd = buildCli(); + assertEquals(cmd.getName(), "stackctl"); +}); + +Deno.test("main returns 1 for init (unimplemented)", async () => { + // Override Deno.exit to prevent actual exit during test + const origExit = Deno.exit; + Deno.exit = (_code?: number) => { + throw new Error("exit"); + }; + + const { main } = await import("../cli/mod.ts"); + try { + const code = await main(["init"]); + assertEquals(code, 1); + } catch { + // exit was called + } + + Deno.exit = origExit; +}); + +Deno.test("buildCli produces correct help output smoke test", () => { + const cmd = buildCli(); + assertEquals(cmd.getHelp().includes("stackctl"), true); + assertEquals(cmd.getHelp().includes("init"), true); + assertEquals(cmd.getHelp().includes("generate"), true); + assertEquals(cmd.getHelp().includes("render"), true); + assertEquals(cmd.getHelp().includes("up"), true); + assertEquals(cmd.getHelp().includes("down"), true); + assertEquals(cmd.getHelp().includes("status"), true); + assertEquals(cmd.getHelp().includes("logs"), true); + assertEquals(cmd.getHelp().includes("sync"), true); + assertEquals(cmd.getHelp().includes("doctor"), true); + assertEquals(cmd.getHelp().includes("reload"), true); + assertEquals(cmd.getHelp().includes("secrets"), true); + assertEquals(cmd.getHelp().includes("env"), true); + assertEquals(cmd.getHelp().includes("plan"), true); + assertEquals(cmd.getHelp().includes("completions"), true); +}); + +Deno.test("buildCli version is set", () => { + const cmd = buildCli(); + assertEquals(cmd.getVersion(), "0.1.0-dev"); +}); diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..de9cf37 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,119 @@ +/** + * Shared configuration types for stackctl. + * + * These types define the shape of `.stackctl` config files, + * profile overlays, and resolved merged configuration. + */ + +/** Core stackctl configuration. */ +export interface StackctlConfig { + /** Human-readable project name. */ + project: string; + /** Repository root (auto-detected, overridable). */ + repoRoot?: string; + /** Default profile name */ + defaultProfile?: string; + /** Stack generation configuration. */ + stack: StackConfig; + /** Stack rendering configuration. */ + render: RenderConfig; + /** Environment file configuration. */ + env: EnvConfig; + /** Secrets configuration. */ + secrets?: SecretsConfig; + /** Command-specific defaults. */ + commands?: CommandsConfig; +} + +export interface StackConfig { + /** Subdirectory name for generated stacks (default: "stacks"). */ + directory: string; + /** Generated stack names. */ + names: string[]; + /** Compose discovery metadata key (default: "x-stack"). */ + composeStackKey?: string; + /** Directories to skip during discovery. */ + skipDirectories?: string[]; + /** External network name for all stacks. */ + network: string; + /** External network driver (default: "overlay"). */ + networkDriver?: string; +} + +export interface RenderConfig { + /** Subdirectory name for rendered output (default: ".rendered"). */ + outputDirectory: string; +} + +export interface EnvConfig { + /** Active .env file name (default: ".env"). */ + activeName?: string; + /** Allow plaintext profile env files (default: false). */ + allowPlaintextProfiles?: boolean; + /** Pattern for plaintext profile env files. */ + plaintextProfilePattern?: string; + /** Pattern for encrypted profile env files. */ + encryptedProfilePattern?: string; +} + +export interface SecretsConfig { + /** Encrypted dotenv file name (default: ".env.enc"). */ + encryptedFileName?: string; +} + +export interface CommandsConfig { + /** Default settings for `up` command. */ + up?: UpConfig; + /** Default settings for `reload` command. */ + reload?: ReloadConfig; +} + +export interface UpConfig { + /** Follow logs after deploy (default: true). */ + followLogs?: boolean; +} + +export interface ReloadConfig { + /** Follow logs after reload (default: false). */ + followLogs?: boolean; + /** Auto-generate stacks (default: true). */ + autoGenerate?: boolean; + /** Force service update after deploy (default: false). */ + forceServiceUpdate?: boolean; +} + +/** A resolved profile configuration — partial config that overlays base config. */ +export type ProfileConfig = Partial; + +/** Override file entry (profile or explicit). */ +export interface OverrideEntry { + /** Source of this override file. */ + source: "profile" | "explicit"; + /** Absolute path to the override YAML file. */ + path: string; +} + +/** Final merged configuration after all layers are resolved. */ +export interface ResolvedConfig { + /** The fully-resolved base config. */ + base: StackctlConfig; + /** Active profile name, if selected. */ + profile?: string; + /** Profile config overlay, if any. */ + profileConfig?: ProfileConfig; + /** Local config overlay (.stackctl.local). */ + localConfig?: ProfileConfig; + /** Local profile config overlay (.stackctl.local.). */ + localProfileConfig?: ProfileConfig; + /** Override files discovered or provided. */ + overrides: OverrideEntry[]; +} + +/** Exit code constants. */ +export enum ExitCode { + Success = 0, + DriftOrValidation = 1, + UserConfigError = 2, + MissingDependency = 3, + UnexpectedError = 4, +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..e9447b1 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,docker-compose,sops,age,age-keygen,shred,rm +/** + * stackctl — standalone repository-aware Docker Swarm stack controller. + * + * Compiled binary permissions allow: + * --allow-read (read stack files, config, env) + * --allow-write (write rendered output) + * --allow-env (read shell environment for interpolation) + * --allow-sys (host info, OS detection) + * --allow-run=docker,docker-compose,sops,age,age-keygen,shred,rm + * + * @module + */ + +import { main } from "./cli/mod.ts"; + +if (import.meta.main) { + Deno.exit(await main(Deno.args)); +} diff --git a/src/process/types.ts b/src/process/types.ts new file mode 100644 index 0000000..ebb0433 --- /dev/null +++ b/src/process/types.ts @@ -0,0 +1,63 @@ +/** + * Typed process runner abstraction. + * + * All external commands must go through this interface. + * This enables dry-run, test faking, signal forwarding, and permission validation. + */ + +/** Result of a completed process execution. */ +export interface ProcessResult { + /** Standard output (captured, not streamed). */ + stdout: string; + /** Standard error. */ + stderr: string; + /** Process exit code. */ + code: number; + /** Whether the process exited successfully (code === 0). */ + success: boolean; + /** Command that was executed (for diagnostics and dry-run output). */ + command: string[]; +} + +/** Options for running a command. */ +export interface RunOptions { + /** Working directory for the command. */ + cwd?: string; + /** Environment variables to set for the command. */ + env?: Record; + /** Timeout in milliseconds. */ + timeout?: number; + /** Signal to use for timeout (default: "SIGTERM"). */ + timeoutSignal?: "SIGTERM" | "SIGKILL"; +} + +/** Options for streaming a command's output. */ +export interface StreamOptions extends RunOptions { + /** Handler for each stdout line. */ + onStdout?: (line: string) => void; + /** Handler for each stderr line. */ + onStderr?: (line: string) => void; +} + +/** + * Process runner interface. + * + * Two modes: + * - `run()` — capture stdout/stderr, return when process exits. + * - `stream()` — pipe stdout/stderr to handlers, return when process exits. + */ +export interface ProcessRunner { + /** Run a command and capture its output. */ + run(cmd: string[], options?: RunOptions): Promise; + + /** Run a command with streaming output. */ + stream(cmd: string[], options?: StreamOptions): Promise; + + /** Validate that a command binary exists on PATH. */ + which(name: string): Promise; + + /** Current dry-run mode. In dry-run, run/stream log commands but do not execute. */ + readonly dryRun: boolean; + /** Create a new runner with the given dry-run mode. */ + withDryRun(dryRun: boolean): ProcessRunner; +} diff --git a/src/testing/fakes.ts b/src/testing/fakes.ts new file mode 100644 index 0000000..25def23 --- /dev/null +++ b/src/testing/fakes.ts @@ -0,0 +1,161 @@ +/** + * Test utilities: fakes and test helpers. + * + * Provides FakeProcessRunner for unit testing command execution + * without requiring Docker, sops, age, or other external tools. + */ + +import { + type ProcessResult, + type ProcessRunner, + type RunOptions, + type StreamOptions, +} from "../process/types.ts"; + +/** Pre-programmed response for a single command. */ +export interface CommandResponse { + /** Expected command pattern (checked via .startsWith or .includes). */ + match: string[]; + /** Whether to match by exact equality (default: false — uses startsWith). */ + exact?: boolean; + /** Response to return. */ + result: ProcessResult; +} + +/** Builder for FakeProcessRunner. */ +export class FakeProcessRunnerBuilder { + private responses: CommandResponse[] = []; + private _dryRun = false; + + /** Add a response that matches a command. */ + addResponse(response: CommandResponse): this { + this.responses.push(response); + return this; + } + + /** Set dry-run mode. */ + dryRun(value: boolean): this { + this._dryRun = value; + return this; + } + + /** Build the fake runner. */ + build(): FakeProcessRunner { + return new FakeProcessRunner(this.responses, this._dryRun); + } + + /** Create builder with sensible defaults for tests. */ + static success(stdout = "", stderr = ""): FakeProcessRunnerBuilder { + return new FakeProcessRunnerBuilder().addResponse({ + match: [], + result: { stdout, stderr, code: 0, success: true, command: [] }, + }); + } + + /** Create builder that matches a specific command. */ + static forCommand( + command: string[], + result: Partial, + ): FakeProcessRunnerBuilder { + return new FakeProcessRunnerBuilder().addResponse({ + match: command, + exact: true, + result: { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.code ?? 0, + success: result.code == null || result.code === 0, + command, + }, + }); + } +} + +/** + * Fake process runner for unit tests. + * + * Records all executed commands and returns pre-programmed responses. + * Fails test if an unknown command is executed. + */ +export class FakeProcessRunner implements ProcessRunner { + readonly recorded: string[][] = []; + readonly dryRun: boolean; + private responses: CommandResponse[]; + + constructor(responses: CommandResponse[] = [], dryRun = false) { + this.responses = responses; + this.dryRun = dryRun; + } + + run(cmd: string[], _options?: RunOptions): Promise { + this.recorded.push(cmd); + const response = this.matchResponse(cmd); + if (!response) { + throw new Error( + `FakeProcessRunner: no response configured for command: ${cmd.join(" ")}`, + ); + } + return Promise.resolve({ ...response.result, command: cmd }); + } + + stream(cmd: string[], _options?: StreamOptions): Promise { + return this.run(cmd, _options); + } + + which(name: string): Promise { + const cmd = ["which", name]; + this.recorded.push(cmd); + const response = this.matchResponse(cmd); + return Promise.resolve(response?.result.success ?? false); + } + + withDryRun(dryRun: boolean): ProcessRunner { + return new FakeProcessRunner(this.responses, dryRun); + } + + /** Get all recorded command invocations (for assertions). */ + get commands(): string[][] { + return [...this.recorded]; + } + + /** Verify that a command was executed. */ + containsCommand(partial: string[]): boolean { + return this.recorded.some((cmd) => partial.every((p, i) => cmd[i] === p)); + } + + private matchResponse(cmd: string[]): CommandResponse | undefined { + for (const response of this.responses) { + if (response.match.length === 0) return response; // catch-all + if (response.exact) { + if ( + cmd.length === response.match.length && + cmd.every((p, i) => p === response.match[i]) + ) { + return response; + } + } else { + if ( + cmd.length >= response.match.length && + response.match.every((p, i) => cmd[i] === p) + ) { + return response; + } + } + } + return undefined; + } +} + +/** Helper to create a successful process result. */ +export function successResult(stdout = "", stderr = ""): ProcessResult { + return { stdout, stderr, code: 0, success: true, command: [] }; +} + +/** Helper to create a failure process result. */ +export function failureResult( + code: number, + stderr: string, + stdout = "", +): ProcessResult { + return { stdout, stderr, code, success: false, command: [] }; +} diff --git a/src/testing/fakes_test.ts b/src/testing/fakes_test.ts new file mode 100644 index 0000000..375ded4 --- /dev/null +++ b/src/testing/fakes_test.ts @@ -0,0 +1,96 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { + failureResult, + FakeProcessRunner, + FakeProcessRunnerBuilder, + successResult, +} from "./fakes.ts"; + +Deno.test("FakeProcessRunnerBuilder.success creates catch-all runner", async () => { + const runner = FakeProcessRunnerBuilder.success("ok").build(); + const result = await runner.run(["any", "command"]); + assertEquals(result.stdout, "ok"); + assertEquals(result.success, true); + assertEquals(result.code, 0); +}); + +Deno.test("FakeProcessRunnerBuilder.forCommand matches exact command", async () => { + const runner = FakeProcessRunnerBuilder + .forCommand(["docker", "ps"], { stdout: "running" }) + .build(); + const result = await runner.run(["docker", "ps"]); + assertEquals(result.stdout, "running"); + assertEquals(result.success, true); +}); + +Deno.test("FakeProcessRunner rejects for unmatched command", async () => { + const runner = new FakeProcessRunner(); // no catch-all + await assertRejects( + async () => await runner.run(["unknown", "cmd"]), + Error, + "FakeProcessRunner: no response configured for command: unknown cmd", + ); +}); + +Deno.test("FakeProcessRunner records commands", async () => { + const runner = new FakeProcessRunner([ + { match: [], result: successResult() }, + ]); + await runner.run(["docker", "ps"]); + await runner.run(["which", "docker"]); + assertEquals(runner.commands.length, 2); + assertEquals(runner.commands[0], ["docker", "ps"]); + assertEquals(runner.commands[1], ["which", "docker"]); +}); + +Deno.test("FakeProcessRunner containsCommand", async () => { + const runner = new FakeProcessRunner([ + { match: [], result: successResult() }, + ]); + await runner.run(["docker", "stack", "deploy"]); + assertEquals(runner.containsCommand(["docker"]), true); + assertEquals(runner.containsCommand(["docker", "stack"]), true); + assertEquals(runner.containsCommand(["docker", "ps"]), false); +}); + +Deno.test("FakeProcessRunner.withDryRun propagates mode", () => { + const runner = new FakeProcessRunner([], true); + assertEquals(runner.dryRun, true); + const runner2 = runner.withDryRun(false); + assertEquals(runner2.dryRun, false); +}); + +Deno.test("FakeProcessRunner.which returns pre-configured result", async () => { + const runner = new FakeProcessRunner([ + { match: ["which", "docker"], exact: true, result: successResult() }, + { match: [], result: failureResult(1, "not found") }, + ]); + assertEquals(await runner.which("docker"), true); + assertEquals(await runner.which("sops"), false); +}); + +Deno.test("FakeProcessRunner runs stream method", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "logs"], + { stdout: "log line" }, + ).build(); + const result = await runner.stream(["docker", "logs"]); + assertEquals(result.stdout, "log line"); +}); + +Deno.test("FakeProcessRunnerBuilder fluent API", () => { + const builder = FakeProcessRunnerBuilder.success(); + assertEquals(typeof builder.build, "function"); +}); + +Deno.test("successResult and failureResult helpers", () => { + const s = successResult("out", "err"); + assertEquals(s.stdout, "out"); + assertEquals(s.stderr, "err"); + assertEquals(s.success, true); + + const f = failureResult(3, "error msg"); + assertEquals(f.code, 3); + assertEquals(f.stderr, "error msg"); + assertEquals(f.success, false); +}); diff --git a/src/testing/mod.ts b/src/testing/mod.ts new file mode 100644 index 0000000..d08be1c --- /dev/null +++ b/src/testing/mod.ts @@ -0,0 +1 @@ +export * from "./fakes.ts"; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..544b459 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,7 @@ +/** + * stackctl version. + * + * Single source of truth for the CLI version string. + * Updated during release workflow. + */ +export const VERSION = "0.1.0-dev"; diff --git a/src/version_test.ts b/src/version_test.ts new file mode 100644 index 0000000..5ab4344 --- /dev/null +++ b/src/version_test.ts @@ -0,0 +1,6 @@ +import { assertEquals } from "@std/assert"; +import { VERSION } from "./version.ts"; + +Deno.test("VERSION is dev", () => { + assertEquals(VERSION, "0.1.0-dev"); +}); From 64668384d5d31ae422fff747ee0284cf3c9a6181 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Mon, 29 Jun 2026 17:41:55 +0200 Subject: [PATCH 2/2] fix(ci): address PR #16 review feedback - Fix CI YAML comments: replace JS-style /** */ with YAML # comments - Add scoped --allow-run permissions to all deno compile build tasks - Remove broad --allow-run --allow-sys from test task (Phase 1 tests need none) - Use 'deno task test --coverage' then 'deno task coverage' instead of separate command - Run build job on all PR triggers, not just push to main/dev - Replace Deno.exit() in action handlers with thrown Errors so main() controls exit --- .github/workflows/ci.yml | 18 ++++-------- deno.json | 12 ++++---- src/cli/mod.ts | 61 +++++++++++++--------------------------- 3 files changed, 32 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed3d892..9a8059a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,6 @@ -/** - * CI workflow for stackctl. - * - * Runs on every push and PR to main/dev branches. - * Validates format, linting, type checking, tests, and coverage. - */ +# CI workflow for stackctl. +# Runs on every push and PR to main/dev branches. +# Validates format, linting, type checking, tests, and coverage. name: CI on: @@ -39,19 +36,16 @@ jobs: - name: Type check run: deno task check - - name: Run tests - run: deno task test + - name: Run tests with coverage + run: deno task test --coverage=.coverage - name: Generate coverage report if: success() - run: | - deno test --allow-read --allow-write --allow-env --allow-run --allow-sys --coverage=.coverage - deno coverage --detailed .coverage + run: deno task coverage build: runs-on: ubuntu-latest needs: check - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' steps: - name: Checkout uses: actions/checkout@v4 diff --git a/deno.json b/deno.json index d4e3782..2327387 100644 --- a/deno.json +++ b/deno.json @@ -8,13 +8,13 @@ "fmt": "deno fmt", "fmt:check": "deno fmt --check", "lint": "deno lint", - "test": "deno test --allow-read --allow-write --allow-env --allow-run --allow-sys", + "test": "deno test", "coverage": "deno coverage --detailed", - "build": "deno compile --output dist/stackctl src/main.ts", - "build:darwin:x64": "deno compile --target x86_64-apple-darwin --output dist/stackctl-darwin-x64 src/main.ts", - "build:darwin:arm64": "deno compile --target aarch64-apple-darwin --output dist/stackctl-darwin-arm64 src/main.ts", - "build:linux:x64": "deno compile --target x86_64-unknown-linux-gnu --output dist/stackctl-linux-x64 src/main.ts", - "build:linux:arm64": "deno compile --target aarch64-unknown-linux-gnu --output dist/stackctl-linux-arm64 src/main.ts" + "build": "deno compile --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,sops,age,age-keygen,shred,rm --output dist/stackctl src/main.ts", + "build:darwin:x64": "deno compile --target x86_64-apple-darwin --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,sops,age,age-keygen,shred,rm --output dist/stackctl-darwin-x64 src/main.ts", + "build:darwin:arm64": "deno compile --target aarch64-apple-darwin --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,sops,age,age-keygen,shred,rm --output dist/stackctl-darwin-arm64 src/main.ts", + "build:linux:x64": "deno compile --target x86_64-unknown-linux-gnu --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,sops,age,age-keygen,shred,rm --output dist/stackctl-linux-x64 src/main.ts", + "build:linux:arm64": "deno compile --target aarch64-unknown-linux-gnu --allow-read --allow-write --allow-env --allow-sys --allow-run=git,docker,sops,age,age-keygen,shred,rm --output dist/stackctl-linux-arm64 src/main.ts" }, "imports": { "@cliffy/command": "jsr:@cliffy/command@^1.0.0", diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 2b815fe..12593ae 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -34,7 +34,6 @@ export function buildCli(): Command { // Default action: show help when no subcommand matches cli.action(() => { cli.showHelp(); - Deno.exit(0); }); // --- init (issue #3) --- @@ -46,8 +45,7 @@ export function buildCli(): Command { .option("--force", "Overwrite existing .stackctl file.") .option("--dry-run", "Print the config that would be written without writing.") .action(() => { - console.error("init: not yet implemented (issue #3)"); - Deno.exit(1); + throw new Error("init: not yet implemented (issue #3)"); }); // --- generate (issue #4) --- @@ -57,8 +55,7 @@ export function buildCli(): Command { .option("--output-dir ", "Write generated stacks to a specific directory.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("generate: not yet implemented (issue #4)"); - Deno.exit(1); + throw new Error("generate: not yet implemented (issue #4)"); }); // --- render (issue #5) --- @@ -75,8 +72,7 @@ export function buildCli(): Command { "Comma-separated list of override files to apply before rendering.", ) .action(() => { - console.error("render: not yet implemented (issue #5)"); - Deno.exit(1); + throw new Error("render: not yet implemented (issue #5)"); }); // --- up (issue #6) --- @@ -89,8 +85,7 @@ export function buildCli(): Command { .option("--profile ", "Use a specific profile.") .option("--override ", "Comma-separated list of override files.") .action(() => { - console.error("up: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("up: not yet implemented (issue #6)"); }); // --- down (issue #6) --- @@ -101,8 +96,7 @@ export function buildCli(): Command { .option("--stacks ", "Comma-separated list of stack names to remove.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("down: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("down: not yet implemented (issue #6)"); }); // --- status (issue #6) --- @@ -111,8 +105,7 @@ export function buildCli(): Command { .option("--stacks ", "Comma-separated list of stack names.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("status: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("status: not yet implemented (issue #6)"); }); // --- logs (issue #6) --- @@ -121,8 +114,7 @@ export function buildCli(): Command { .option("--stacks ", "Comma-separated list of stack names.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("logs: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("logs: not yet implemented (issue #6)"); }); // --- sync (issue #6) --- @@ -131,8 +123,7 @@ export function buildCli(): Command { .option("--non-interactive", "Skip confirmation; exit 1 on drift.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("sync: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("sync: not yet implemented (issue #6)"); }); // --- doctor (issue #6) --- @@ -141,8 +132,7 @@ export function buildCli(): Command { .option("--check-secrets", "Also check for secrets tooling (sops, age).") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("doctor: not yet implemented (issue #6)"); - Deno.exit(1); + throw new Error("doctor: not yet implemented (issue #6)"); }); // --- reload (issue #9) --- @@ -155,8 +145,7 @@ export function buildCli(): Command { .option("--override ", "Comma-separated list of override files.") .option("--dry-run", "Print planned actions without executing.") .action(() => { - console.error("reload: not yet implemented (issue #9)"); - Deno.exit(1); + throw new Error("reload: not yet implemented (issue #9)"); }); // --- secrets (issue #7) --- @@ -166,37 +155,32 @@ export function buildCli(): Command { .option("--profile ", "Use a specific profile.") .option("--dry-run", "Print planned actions without executing.") .action(() => { - console.error("secrets encrypt: not yet implemented (issue #7)"); - Deno.exit(1); + throw new Error("secrets encrypt: not yet implemented (issue #7)"); }); secretsCmd.command("decrypt", "Decrypt encrypted .env files to plaintext.") .arguments("[services...:string]") .option("--profile ", "Use a specific profile.") .option("--dry-run", "Print planned actions without executing.") .action(() => { - console.error("secrets decrypt: not yet implemented (issue #7)"); - Deno.exit(1); + throw new Error("secrets decrypt: not yet implemented (issue #7)"); }); secretsCmd.command("deploy", "Decrypt and deploy stacks with secret values.") .arguments("[services...:string]") .option("--profile ", "Use a specific profile.") .option("--dry-run", "Print planned actions without executing.") .action(() => { - console.error("secrets deploy: not yet implemented (issue #7)"); - Deno.exit(1); + throw new Error("secrets deploy: not yet implemented (issue #7)"); }); secretsCmd.command("clean", "Remove plaintext .env files that have encrypted counterparts.") .option("--profile ", "Use a specific profile.") .option("--dry-run", "Print planned actions without executing.") .action(() => { - console.error("secrets clean: not yet implemented (issue #7)"); - Deno.exit(1); + throw new Error("secrets clean: not yet implemented (issue #7)"); }); secretsCmd.command("check", "Check secrets tooling availability.") .option("--profile ", "Use a specific profile.") .action(() => { - console.error("secrets check: not yet implemented (issue #7)"); - Deno.exit(1); + throw new Error("secrets check: not yet implemented (issue #7)"); }); // --- env (issue #14) --- @@ -211,8 +195,7 @@ export function buildCli(): Command { .option("--from-profile ", "Materialize env from a profile preset.") .option("--materialize", "Materialize profile preset env values.") .action(() => { - console.error("env: not yet implemented (issue #14)"); - Deno.exit(1); + throw new Error("env: not yet implemented (issue #14)"); }); // --- plan (issue #15) --- @@ -223,26 +206,22 @@ export function buildCli(): Command { .option("--override ", "Comma-separated list of override files.") .option("--json", "Output machine-readable JSON.") .action(() => { - console.error("plan: not yet implemented (issue #15)"); - Deno.exit(1); + throw new Error("plan: not yet implemented (issue #15)"); }); // --- completions (issue #10) --- const completionsCmd = cli.command("completions", "Generate shell completion scripts."); completionsCmd.command("bash", "Generate bash completion script.") .action(() => { - console.error("completions bash: not yet implemented (issue #10)"); - Deno.exit(1); + throw new Error("completions bash: not yet implemented (issue #10)"); }); completionsCmd.command("zsh", "Generate zsh completion script.") .action(() => { - console.error("completions zsh: not yet implemented (issue #10)"); - Deno.exit(1); + throw new Error("completions zsh: not yet implemented (issue #10)"); }); completionsCmd.command("fish", "Generate fish completion script.") .action(() => { - console.error("completions fish: not yet implemented (issue #10)"); - Deno.exit(1); + throw new Error("completions fish: not yet implemented (issue #10)"); }); return cli as unknown as Command;