pondorasti 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Pondorasti CLI
2
+
3
+ 🖥️ Mission control for pondorasti - A command-line tool for automated macOS setup and configuration.
4
+
5
+ Built with [Bun](https://bun.sh), [Yargs](https://yargs.js.org/) for command parsing, and [Ink](https://github.com/vadimdemedes/ink) for rich terminal UI when needed.
6
+
7
+ ## Features
8
+
9
+ - 🍺 **Homebrew Management**: Install, update, and manage Homebrew packages
10
+ - 📂 **Smart Cloning**: Clone GitHub repos to organized `~/repos/<owner>/<repo>` structure
11
+ - 📦 **Native Output**: Shows real brew output for transparency
12
+ - 🚀 **Fast**: Powered by Bun for blazing-fast execution
13
+ - 🔧 **Extensible**: Easy to add new commands
14
+
15
+ ## Prerequisites
16
+
17
+ - macOS (optimized for Apple Silicon)
18
+ - [Bun](https://bun.sh) runtime
19
+ - [GitHub CLI](https://cli.github.com/) (`gh`) for clone command
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ # Install Bun if you haven't already
25
+ curl -fsSL https://bun.sh/install | bash
26
+
27
+ # Clone the repository
28
+ git clone https://github.com/pondorasti/pondorasti.git
29
+ cd pondorasti
30
+
31
+ # Install dependencies
32
+ bun install
33
+
34
+ # Run commands
35
+ bun run packages/cli/src/index.ts <command>
36
+
37
+ # Or install globally
38
+ bun link
39
+ pondorasti <command>
40
+ # or use the short alias
41
+ pd <command>
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### Commands
47
+
48
+ #### `clone` - Clone GitHub repositories
49
+
50
+ Clones repositories to `~/repos/<owner>/<repo>` and opens a shell in the directory.
51
+
52
+ ```bash
53
+ # Clone using various URL formats
54
+ pd clone https://github.com/owner/repo
55
+ pd clone git@github.com:owner/repo.git
56
+ pd clone owner/repo
57
+
58
+ # Also works with tree/blob URLs (branch/file paths are stripped)
59
+ pd clone https://github.com/owner/repo/tree/main
60
+ pd clone https://github.com/owner/repo/blob/main/src/file.ts
61
+ ```
62
+
63
+ #### `brew` - Manage Homebrew
64
+
65
+ ```bash
66
+ # Install Homebrew
67
+ pd brew install
68
+
69
+ # Run brew bundle from Brewfile
70
+ pd brew bundle
71
+ ```
72
+
73
+ ### Global Options
74
+
75
+ ```bash
76
+ --help, -h Show help
77
+ --version, -v Show version
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ # Run in development mode
84
+ bun dev
85
+
86
+ # Run tests
87
+ bun test
88
+
89
+ # Build for production
90
+ bun build --compile --outfile=pondorasti src/index.ts
91
+ ```
92
+
93
+ ## Architecture
94
+
95
+ ```
96
+ packages/cli/
97
+ ├── src/
98
+ │ ├── index.ts # CLI entry with yargs
99
+ │ ├── commands/ # Command implementations
100
+ │ │ ├── brew.ts # Homebrew management
101
+ │ │ └── clone.ts # GitHub repo cloning
102
+ │ ├── tools/ # External tool wrappers
103
+ │ │ └── homebrew.ts # Homebrew operations
104
+ │ └── utils/ # Utilities
105
+ │ ├── cli-helpers.ts # CLI utilities
106
+ │ ├── github.ts # GitHub URL parsing
107
+ │ └── github.test.ts # Tests for GitHub utils
108
+ ├── Brewfile # Package definitions (at repo root)
109
+ └── package.json # Project configuration
110
+ ```
111
+
112
+ ## Future Commands
113
+
114
+ - `dotfiles` - Manage dotfiles and configurations
115
+ - `macos` - Configure macOS system preferences
116
+ - `setup` - Full system setup wizard
117
+
118
+ ## Contributing
119
+
120
+ 1. Fork the repository
121
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
122
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
123
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
124
+ 5. Open a Pull Request
125
+
126
+ ## License
127
+
128
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "pondorasti",
3
+ "version": "0.1.1",
4
+ "description": "CLI for pondorasti",
5
+ "author": "Alexandru Ţurcanu",
6
+ "license": "MIT",
7
+ "main": "src/index.ts",
8
+ "keywords": [
9
+ "cli",
10
+ "macos",
11
+ "homebrew",
12
+ "dotfiles",
13
+ "setup"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "bin": {
19
+ "pondorasti": "./src/index.ts",
20
+ "pd": "./src/index.ts"
21
+ },
22
+ "scripts": {
23
+ "start": "bun run src/index.ts",
24
+ "dev": "bun --watch src/index.ts",
25
+ "test": "bun test"
26
+ },
27
+ "dependencies": {
28
+ "ink": "^6.5.1",
29
+ "ink-gradient": "^3.0.0",
30
+ "ink-spinner": "^5.0.0",
31
+ "react": "^19.2.0",
32
+ "yargs": "^18.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.2.7",
36
+ "@types/yargs": "^17.0.35"
37
+ }
38
+ }
@@ -0,0 +1,89 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { $ } from "bun"
3
+ import { Homebrew } from "../tools/homebrew"
4
+ import { failHandler } from "../utils/cli-helpers"
5
+ import * as path from "path"
6
+ import * as fs from "fs"
7
+
8
+ // -------------------------------------------------------------------------------------------------------------------
9
+ // Subcommands
10
+ // -------------------------------------------------------------------------------------------------------------------
11
+
12
+ const installCommand: CommandModule = {
13
+ command: "install",
14
+ describe: "Install Homebrew",
15
+ handler: async () => {
16
+ if (Homebrew.isInstalled()) {
17
+ const brewPath = Homebrew.getBrewPath()
18
+ console.log(`✓ Homebrew is already installed at ${brewPath}`)
19
+
20
+ try {
21
+ await $`brew --version`
22
+ } catch (error) {
23
+ console.error("⚠ Homebrew is installed but not working properly")
24
+ }
25
+
26
+ return
27
+ }
28
+
29
+ try {
30
+ await $`/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
31
+ } catch (error) {
32
+ console.error("✗ Failed to install Homebrew")
33
+ process.exit(1)
34
+ }
35
+ },
36
+ }
37
+
38
+ const bundleCommand: CommandModule = {
39
+ command: "bundle",
40
+ describe: "Run brew bundle from Brewfile",
41
+ handler: async () => {
42
+ if (!Homebrew.isInstalled()) {
43
+ console.error("✗ Homebrew is not installed. Run 'pondorasti brew install' first.")
44
+ process.exit(1)
45
+ }
46
+
47
+ // Brewfile is always at the root of the pondorasti repo
48
+ const brewfilePath = path.join(__dirname, "..", "..", "..", "..", "Brewfile")
49
+
50
+ if (!fs.existsSync(brewfilePath)) {
51
+ console.error("✗ Brewfile not found at expected location:", brewfilePath)
52
+ console.error("This is likely a bug in the CLI configuration.")
53
+ process.exit(1)
54
+ }
55
+
56
+ try {
57
+ await Homebrew.runBundle(brewfilePath)
58
+ } catch (error) {
59
+ console.error("✗ Failed to run brew bundle")
60
+ process.exit(1)
61
+ }
62
+ },
63
+ }
64
+
65
+ // -------------------------------------------------------------------------------------------------------------------
66
+ // Main brew command
67
+ // -------------------------------------------------------------------------------------------------------------------
68
+
69
+ const brewCommand: CommandModule = {
70
+ command: "brew",
71
+ describe: "Manage Homebrew and install packages",
72
+ builder: (yargs) => {
73
+ return yargs //
74
+ .command(installCommand)
75
+ .command(bundleCommand)
76
+ .demandCommand(1)
77
+ .help()
78
+ .strict()
79
+ .fail(failHandler)
80
+ },
81
+ handler: () => {
82
+ // This handler is called when no subcommand is provided
83
+ // But the fail handler above will handle it
84
+ },
85
+ }
86
+
87
+ // -------------------------------------------------------------------------------------------------------------------
88
+
89
+ export default brewCommand
@@ -0,0 +1,83 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { $, spawn } from "bun"
3
+ import * as path from "path"
4
+ import * as fs from "fs"
5
+ import * as os from "os"
6
+ import { parseGitHubUrl } from "../utils/github"
7
+
8
+ const openShell = async (cwd: string) => {
9
+ console.log(`\nOpening shell in ${cwd}...`)
10
+ const shell = process.env.SHELL || "/bin/zsh"
11
+ const proc = spawn([shell], {
12
+ cwd,
13
+ stdin: "inherit",
14
+ stdout: "inherit",
15
+ stderr: "inherit",
16
+ })
17
+ await proc.exited
18
+ }
19
+
20
+ const cloneCommand: CommandModule<{}, { url: string }> = {
21
+ command: "clone <url>",
22
+ describe: "Clone a GitHub repository to ~/repos/<owner>/<repo>",
23
+ builder: (yargs) => {
24
+ return yargs.positional("url", {
25
+ describe: "GitHub repository URL",
26
+ type: "string",
27
+ demandOption: true,
28
+ })
29
+ },
30
+ handler: async (argv) => {
31
+ const { url } = argv
32
+
33
+ const parsed = parseGitHubUrl(url)
34
+ if (!parsed) {
35
+ console.error("✗ Invalid GitHub URL format")
36
+ console.error(" Expected formats:")
37
+ console.error(" https://github.com/owner/repo")
38
+ console.error(" git@github.com:owner/repo.git")
39
+ console.error(" owner/repo")
40
+ process.exit(1)
41
+ }
42
+
43
+ const { owner, repo } = parsed
44
+
45
+ // Check if gh CLI is installed
46
+ try {
47
+ await $`which gh`.quiet()
48
+ } catch {
49
+ console.error("✗ GitHub CLI (gh) is not installed")
50
+ console.error(" Install with: brew install gh")
51
+ process.exit(1)
52
+ }
53
+
54
+ // Construct the target directory
55
+ const reposDir = path.join(os.homedir(), "repos", owner)
56
+ const targetDir = path.join(reposDir, repo)
57
+
58
+ // Check if repo already exists
59
+ if (fs.existsSync(targetDir)) {
60
+ console.log(`✓ Repository already exists at ${targetDir}`)
61
+ await openShell(targetDir)
62
+ return
63
+ }
64
+
65
+ // Create the parent directory if it doesn't exist
66
+ if (!fs.existsSync(reposDir)) {
67
+ fs.mkdirSync(reposDir, { recursive: true })
68
+ }
69
+
70
+ // Clone the repository using gh CLI
71
+ console.log(`Cloning ${owner}/${repo}...`)
72
+ try {
73
+ await $`gh repo clone ${owner}/${repo} ${targetDir}`
74
+ console.log(`\n✓ Successfully cloned to ${targetDir}`)
75
+ await openShell(targetDir)
76
+ } catch (error) {
77
+ console.error(`\n✗ Failed to clone repository`)
78
+ process.exit(1)
79
+ }
80
+ },
81
+ }
82
+
83
+ export default cloneCommand
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bun
2
+ import yargs from "yargs"
3
+ import { hideBin } from "yargs/helpers"
4
+ import brewCommand from "./commands/brew"
5
+ import cloneCommand from "./commands/clone"
6
+ import { failHandler } from "./utils/cli-helpers"
7
+
8
+ yargs(hideBin(process.argv))
9
+ .scriptName("pondorasti")
10
+ .usage("$0 <command> [options]")
11
+ .command(brewCommand)
12
+ .command(cloneCommand)
13
+ .demandCommand(1)
14
+ .recommendCommands()
15
+ .strict()
16
+ .version()
17
+ .alias("v", "version")
18
+ .help()
19
+ .alias("h", "help")
20
+ .epilogue("For more information, visit https://github.com/pondorasti/pondorasti/blob/main/packages/cli/README.md")
21
+ .fail(failHandler)
22
+ .parse()
@@ -0,0 +1,58 @@
1
+ import * as fs from "fs"
2
+ import { $ } from "bun"
3
+
4
+ const BREW_PATH_M1 = "/opt/homebrew/bin/brew"
5
+
6
+ class Homebrew {
7
+ private static brewPath: string | null = null
8
+
9
+ static isInstalled(): boolean {
10
+ if (fs.existsSync(BREW_PATH_M1)) {
11
+ this.brewPath = BREW_PATH_M1
12
+ return true
13
+ }
14
+
15
+ try {
16
+ const result = Bun.spawnSync(["which", "brew"], { stdout: "pipe", stderr: "pipe" })
17
+
18
+ if (result.exitCode === 0 && result.stdout) {
19
+ this.brewPath = result.stdout.toString().trim()
20
+ return true
21
+ }
22
+ } catch {}
23
+
24
+ return false
25
+ }
26
+
27
+ static getBrewPath(): string | null {
28
+ if (this.brewPath) {
29
+ return this.brewPath
30
+ }
31
+
32
+ if (fs.existsSync(BREW_PATH_M1)) {
33
+ this.brewPath = BREW_PATH_M1
34
+ return this.brewPath
35
+ }
36
+
37
+ try {
38
+ const result = Bun.spawnSync(["which", "brew"], { stdout: "pipe", stderr: "pipe" })
39
+
40
+ if (result.exitCode === 0 && result.stdout) {
41
+ this.brewPath = result.stdout.toString().trim()
42
+ return this.brewPath
43
+ }
44
+ } catch {}
45
+
46
+ return null
47
+ }
48
+
49
+ static async runBundle(brewfilePath?: string): Promise<void> {
50
+ if (brewfilePath) {
51
+ await $`brew bundle --file=${brewfilePath}`
52
+ } else {
53
+ await $`brew bundle`
54
+ }
55
+ }
56
+ }
57
+
58
+ export { Homebrew }
@@ -0,0 +1,28 @@
1
+ import type { Argv } from "yargs"
2
+
3
+ function failHandler(msg: string | undefined | null, err: Error | undefined, yargs: Argv) {
4
+ // Handle the case where no command/subcommand was provided
5
+ if (!err && (!msg || msg.includes("Not enough non-option arguments"))) {
6
+ yargs.showHelp("log")
7
+ process.exit(0)
8
+ }
9
+
10
+ // Handle unknown arguments - show error in red but help in normal color
11
+ if (!err && msg && msg.includes("Unknown argument")) {
12
+ console.error(msg)
13
+ console.log()
14
+ yargs.showHelp("log")
15
+ process.exit(1)
16
+ }
17
+
18
+ if (err) {
19
+ console.error("Error:", err.message)
20
+ } else if (msg) {
21
+ console.error(msg)
22
+ console.log()
23
+ yargs.showHelp("error")
24
+ }
25
+ process.exit(1)
26
+ }
27
+
28
+ export { failHandler }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseGitHubUrl } from "./github"
3
+
4
+ describe("parseGitHubUrl", () => {
5
+ test("parses standard HTTPS URLs", () => {
6
+ expect(parseGitHubUrl("https://github.com/owner/repo")).toEqual({
7
+ owner: "owner",
8
+ repo: "repo",
9
+ })
10
+ })
11
+
12
+ test("parses HTTPS URLs with .git suffix", () => {
13
+ expect(parseGitHubUrl("https://github.com/owner/repo.git")).toEqual({
14
+ owner: "owner",
15
+ repo: "repo",
16
+ })
17
+ })
18
+
19
+ test("parses SSH URLs", () => {
20
+ expect(parseGitHubUrl("git@github.com:owner/repo.git")).toEqual({
21
+ owner: "owner",
22
+ repo: "repo",
23
+ })
24
+ })
25
+
26
+ test("parses SSH URLs without .git suffix", () => {
27
+ expect(parseGitHubUrl("git@github.com:owner/repo")).toEqual({
28
+ owner: "owner",
29
+ repo: "repo",
30
+ })
31
+ })
32
+
33
+ test("parses short owner/repo format", () => {
34
+ expect(parseGitHubUrl("owner/repo")).toEqual({
35
+ owner: "owner",
36
+ repo: "repo",
37
+ })
38
+ })
39
+
40
+ test("parses tree URLs (branch paths)", () => {
41
+ expect(parseGitHubUrl("https://github.com/owner/repo/tree/main")).toEqual({
42
+ owner: "owner",
43
+ repo: "repo",
44
+ })
45
+ })
46
+
47
+ test("parses tree URLs with nested paths", () => {
48
+ expect(parseGitHubUrl("https://github.com/owner/repo/tree/main/src/components")).toEqual({
49
+ owner: "owner",
50
+ repo: "repo",
51
+ })
52
+ })
53
+
54
+ test("parses blob URLs (file paths)", () => {
55
+ expect(parseGitHubUrl("https://github.com/owner/repo/blob/main/file.ts")).toEqual({
56
+ owner: "owner",
57
+ repo: "repo",
58
+ })
59
+ })
60
+
61
+ test("parses blob URLs with nested file paths", () => {
62
+ expect(parseGitHubUrl("https://github.com/owner/repo/blob/main/src/utils/helpers.ts")).toEqual({
63
+ owner: "owner",
64
+ repo: "repo",
65
+ })
66
+ })
67
+
68
+ test("handles trailing slashes", () => {
69
+ expect(parseGitHubUrl("https://github.com/owner/repo/")).toEqual({
70
+ owner: "owner",
71
+ repo: "repo",
72
+ })
73
+ })
74
+
75
+ test("handles whitespace", () => {
76
+ expect(parseGitHubUrl(" https://github.com/owner/repo ")).toEqual({
77
+ owner: "owner",
78
+ repo: "repo",
79
+ })
80
+ })
81
+
82
+ test("returns null for invalid URLs", () => {
83
+ expect(parseGitHubUrl("not-a-valid-url")).toBeNull()
84
+ })
85
+
86
+ test("returns null for empty string", () => {
87
+ expect(parseGitHubUrl("")).toBeNull()
88
+ })
89
+
90
+ test("returns null for URLs without owner/repo", () => {
91
+ expect(parseGitHubUrl("https://github.com/")).toBeNull()
92
+ })
93
+ })
@@ -0,0 +1,20 @@
1
+ export const parseGitHubUrl = (url: string): { owner: string; repo: string } | null => {
2
+ const normalized = url.trim().replace(/\/+$/, "")
3
+
4
+ // Strip /tree/... or /blob/... suffixes (branch/file paths)
5
+ const withoutTree = normalized.replace(/\/(tree|blob)\/.*$/, "")
6
+
7
+ const patterns = [
8
+ /github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/, // Handles https and SSH formats
9
+ /^([^/]+)\/([^/.]+)(\.git)?$/, // Handles owner/repo format
10
+ ]
11
+
12
+ for (const pattern of patterns) {
13
+ const match = withoutTree.match(pattern)
14
+ if (match) {
15
+ return { owner: match[1], repo: match[2] }
16
+ }
17
+ }
18
+
19
+ return null
20
+ }