oopsx 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devesh@760
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # merr
2
+
3
+ > Play a meme sound when your terminal command fails.
4
+
5
+ Ever wish your terminal could roast you for typos and bad commands? **merr** wraps any shell command and plays a sound when it exits with a non-zero code. Pick your own meme sound, or use the default.
6
+
7
+ ```
8
+ $ merr git puhs origin main
9
+ fatal: 'puhs' is not a git command.
10
+ 🔊 *sad trombone plays*
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ ### npx (zero install)
18
+
19
+ ```bash
20
+ npx merr ls /nonexistent/path
21
+ ```
22
+
23
+ ### Global install via npm
24
+
25
+ ```bash
26
+ npm install -g merr
27
+ ```
28
+
29
+ ### Curl install (macOS / Linux)
30
+
31
+ ```bash
32
+ curl -fsSL https://raw.githubusercontent.com/devesh760/merr/main/install.sh | bash
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ # Basic — plays default sound on failure
41
+ merr ls /does/not/exist
42
+
43
+ # Use a custom local sound
44
+ merr --sound ~/sounds/bruh.mp3 make build
45
+
46
+ # Use a sound from a URL (cached automatically)
47
+ merr --url https://example.com/fail.mp3 npm test
48
+
49
+ # Set volume (0–100)
50
+ merr --volume 50 python script.py
51
+
52
+ # Save a sound as the new default
53
+ merr --sound ~/sounds/oof.mp3 --set-default echo "saved!"
54
+
55
+ # Play sound only once per shell session
56
+ merr --once cargo build
57
+ ```
58
+
59
+ ### CLI Options
60
+
61
+ | Option | Description |
62
+ | ------------------- | ---------------------------------------------- |
63
+ | `--sound <path>` | Use a local `.mp3` or `.wav` file |
64
+ | `--url <url>` | Download and cache a sound from a URL |
65
+ | `--volume <0-100>` | Playback volume (default: `80`) |
66
+ | `--set-default` | Persist the resolved sound as the new default |
67
+ | `--once` | Play sound only once per shell session |
68
+
69
+ ---
70
+
71
+ ## How It Works
72
+
73
+ 1. **merr** runs your command with inherited stdio (you see all output as normal).
74
+ 2. If the command exits with code `0` — nothing happens.
75
+ 3. If the command exits with a non-zero code — merr plays a sound.
76
+ 4. merr then exits with the **same exit code** as the original command.
77
+
78
+ ### Sound Resolution Priority
79
+
80
+ 1. `--sound <path>` — explicit local file
81
+ 2. `--url <url>` — downloads and caches the file under `~/.merr/sounds/`
82
+ 3. Default sound at `~/.merr/default.mp3`
83
+
84
+ ---
85
+
86
+ ## Custom Sound Examples
87
+
88
+ ```bash
89
+ # Sad trombone on test failure
90
+ merr --sound ~/memes/sad-trombone.mp3 npm test
91
+
92
+ # Windows XP error from a URL
93
+ merr --url "https://example.com/xp-error.mp3" cargo build
94
+
95
+ # Set a default once, use it everywhere
96
+ merr --sound ~/memes/bruh.mp3 --set-default echo "done"
97
+ merr npm test # now uses bruh.mp3 automatically
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Config
103
+
104
+ merr stores configuration in `~/.merr/`:
105
+
106
+ ```
107
+ ~/.merr/
108
+ ├── config.json # volume and preferences
109
+ ├── default.mp3 # default sound file
110
+ └── sounds/ # cached URL downloads (hashed filenames)
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Platform Support
116
+
117
+ | Platform | Player Backend |
118
+ | -------- | ---------------------- |
119
+ | macOS | `afplay` (built-in) |
120
+ | Linux | `aplay` / `mpg123` |
121
+ | Windows | PowerShell fallback |
122
+
123
+ ---
124
+
125
+ ## Uninstall
126
+
127
+ ```bash
128
+ # Remove the CLI
129
+ npm uninstall -g merr
130
+
131
+ # Remove config and cached sounds
132
+ rm -rf ~/.merr
133
+ ```
134
+
135
+ ---
136
+
137
+ ## License
138
+
139
+ MIT
Binary file
package/bin/merr.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { runCommand } from "../src/runner.js";
5
+ import { resolveSound, playAudio, setDefault, setVolume } from "../src/sound.js";
6
+ import { readConfig } from "../src/config.js";
7
+
8
+ const SESSION_KEY = `MERR_PLAYED_${process.ppid}`;
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .name("merr")
14
+ .description("Play a meme sound when your terminal command fails")
15
+ .version("1.0.0")
16
+ .argument("<command...>", "Shell command to execute")
17
+ .option("--sound <path>", "Use a local sound file (mp3/wav)")
18
+ .option("--url <url>", "Download and cache a sound from a URL")
19
+ .option("--volume <number>", "Playback volume 0–100", parseInt)
20
+ .option("--set-default", "Persist the resolved sound as the default")
21
+ .option("--once", "Play sound only once per shell session")
22
+ .allowUnknownOption(true)
23
+ .action(async (commandArgs, options) => {
24
+ const config = readConfig();
25
+ const volume = options.volume ?? config.volume ?? 80;
26
+
27
+ // Persist volume if explicitly provided
28
+ if (options.volume !== undefined) {
29
+ setVolume(options.volume);
30
+ }
31
+
32
+ const exitCode = await runCommand(commandArgs);
33
+
34
+ if (exitCode === 0) {
35
+ process.exit(0);
36
+ }
37
+
38
+ // --once: skip if already played in this shell session
39
+ if (options.once && process.env[SESSION_KEY]) {
40
+ process.exit(exitCode);
41
+ }
42
+
43
+ const soundFile = await resolveSound(options);
44
+
45
+ if (!soundFile) {
46
+ process.exit(exitCode);
47
+ }
48
+
49
+ // --set-default: persist the resolved sound
50
+ if (options.setDefault) {
51
+ setDefault(soundFile);
52
+ }
53
+
54
+ try {
55
+ await playAudio(soundFile, volume);
56
+ } catch (err) {
57
+ console.error(`Sound playback failed: ${err.message}`);
58
+ }
59
+
60
+ // Mark as played for --once (only useful if parent reads env, but we set it anyway)
61
+ if (options.once) {
62
+ process.env[SESSION_KEY] = "1";
63
+ }
64
+
65
+ process.exit(exitCode);
66
+ });
67
+
68
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "oopsx",
3
+ "version": "1.0.0",
4
+ "description": "Play a meme sound when your terminal command fails",
5
+ "type": "module",
6
+ "bin": {
7
+ "merr": "./bin/merr.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "assets/"
16
+ ],
17
+ "keywords": [
18
+ "cli",
19
+ "meme",
20
+ "sound",
21
+ "error",
22
+ "terminal",
23
+ "fun"
24
+ ],
25
+ "author": "devesh@760 <drajawat760@gmail.com>",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/devesh760/merr.git"
30
+ },
31
+ "homepage": "https://github.com/devesh760/merr#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/devesh760/merr/issues"
34
+ },
35
+ "dependencies": {
36
+ "commander": "^12.1.0",
37
+ "play-sound": "^1.1.6"
38
+ }
39
+ }
package/src/config.js ADDED
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ const MERR_DIR = join(homedir(), ".merr");
9
+ const SOUNDS_DIR = join(MERR_DIR, "sounds");
10
+ const CONFIG_PATH = join(MERR_DIR, "config.json");
11
+ const DEFAULT_SOUND_PATH = join(MERR_DIR, "default.mp3");
12
+ const BUNDLED_SOUND_PATH = join(__dirname, "..", "assets", "default.mp3");
13
+
14
+ const DEFAULT_CONFIG = {
15
+ volume: 80,
16
+ };
17
+
18
+ const ensureDir = (dir) => {
19
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20
+ };
21
+
22
+ const ensureDirs = () => {
23
+ ensureDir(MERR_DIR);
24
+ ensureDir(SOUNDS_DIR);
25
+ };
26
+
27
+ const readConfig = () => {
28
+ ensureDirs();
29
+ if (!existsSync(CONFIG_PATH)) return { ...DEFAULT_CONFIG };
30
+
31
+ try {
32
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
33
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
34
+ } catch {
35
+ return { ...DEFAULT_CONFIG };
36
+ }
37
+ };
38
+
39
+ const writeConfig = (config) => {
40
+ ensureDirs();
41
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
42
+ };
43
+
44
+ const updateConfig = (partial) => {
45
+ const current = readConfig();
46
+ const updated = { ...current, ...partial };
47
+ writeConfig(updated);
48
+ return updated;
49
+ };
50
+
51
+ export {
52
+ MERR_DIR,
53
+ SOUNDS_DIR,
54
+ CONFIG_PATH,
55
+ DEFAULT_SOUND_PATH,
56
+ BUNDLED_SOUND_PATH,
57
+ readConfig,
58
+ writeConfig,
59
+ updateConfig,
60
+ ensureDirs,
61
+ };
package/src/runner.js ADDED
@@ -0,0 +1,26 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Execute a shell command, piping stdout/stderr through to the parent process.
5
+ * Returns the exit code of the child process.
6
+ */
7
+ const runCommand = (commandArgs) =>
8
+ new Promise((resolve) => {
9
+ const [cmd, ...args] = commandArgs;
10
+
11
+ const child = spawn(cmd, args, {
12
+ stdio: "inherit",
13
+ shell: true,
14
+ });
15
+
16
+ child.on("error", (err) => {
17
+ console.error(`Failed to start command: ${err.message}`);
18
+ resolve(1);
19
+ });
20
+
21
+ child.on("close", (code) => {
22
+ resolve(code ?? 1);
23
+ });
24
+ });
25
+
26
+ export { runCommand };
package/src/sound.js ADDED
@@ -0,0 +1,108 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, createWriteStream, copyFileSync } from "node:fs";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { join } from "node:path";
5
+ import playSound from "play-sound";
6
+ import {
7
+ SOUNDS_DIR,
8
+ DEFAULT_SOUND_PATH,
9
+ BUNDLED_SOUND_PATH,
10
+ readConfig,
11
+ updateConfig,
12
+ ensureDirs,
13
+ } from "./config.js";
14
+
15
+ const player = playSound();
16
+
17
+ /** Hash a URL to create a deterministic cache filename. */
18
+ const hashUrl = (url) =>
19
+ createHash("sha1").update(url).digest("hex");
20
+
21
+ /** Download a file from a URL and save it to disk. */
22
+ const downloadFile = async (url, dest) => {
23
+ const res = await fetch(url);
24
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
25
+ if (!res.body) throw new Error("Empty response body");
26
+ await pipeline(res.body, createWriteStream(dest));
27
+ };
28
+
29
+ /** Get cached path for a URL, downloading if not already cached. */
30
+ const resolveFromUrl = async (url) => {
31
+ ensureDirs();
32
+ const hash = hashUrl(url);
33
+ const cached = join(SOUNDS_DIR, `${hash}.mp3`);
34
+
35
+ if (!existsSync(cached)) {
36
+ console.log(`⬇ Downloading sound from ${url}...`);
37
+ await downloadFile(url, cached);
38
+ console.log(`✓ Cached at ${cached}`);
39
+ }
40
+
41
+ return cached;
42
+ };
43
+
44
+ /**
45
+ * Resolve which sound file to play based on CLI options.
46
+ * Priority: --sound > --url > default
47
+ */
48
+ const resolveSound = async (options) => {
49
+ if (options.sound) {
50
+ if (!existsSync(options.sound)) {
51
+ console.error(`Error: sound file not found: ${options.sound}`);
52
+ return null;
53
+ }
54
+ return options.sound;
55
+ }
56
+
57
+ if (options.url) {
58
+ try {
59
+ return await resolveFromUrl(options.url);
60
+ } catch (err) {
61
+ console.error(`Error downloading sound: ${err.message}`);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ if (existsSync(DEFAULT_SOUND_PATH)) return DEFAULT_SOUND_PATH;
67
+
68
+ // Auto-copy bundled sound on first run
69
+ if (existsSync(BUNDLED_SOUND_PATH)) {
70
+ ensureDirs();
71
+ copyFileSync(BUNDLED_SOUND_PATH, DEFAULT_SOUND_PATH);
72
+ return DEFAULT_SOUND_PATH;
73
+ }
74
+
75
+ console.warn(
76
+ "Warning: No default sound found. Run with --sound <file> --set-default to set one."
77
+ );
78
+ return null;
79
+ };
80
+
81
+ /** Play an audio file at the given volume (0–100). */
82
+ const playAudio = (filePath, volume = 80) =>
83
+ new Promise((resolve, reject) => {
84
+ const vol = Math.max(0, Math.min(100, volume));
85
+ // afplay on macOS supports volume as 0-255 scale; we normalize 0-100 → 0-2.55
86
+ const opts = process.platform === "darwin"
87
+ ? { afplay: ["-v", (vol / 100 * 2).toFixed(2)] }
88
+ : {};
89
+
90
+ player.play(filePath, opts, (err) => {
91
+ if (err) reject(err);
92
+ else resolve();
93
+ });
94
+ });
95
+
96
+ /** Persist a sound file as the default for future runs. */
97
+ const setDefault = (filePath) => {
98
+ ensureDirs();
99
+ copyFileSync(filePath, DEFAULT_SOUND_PATH);
100
+ console.log(`✓ Default sound saved to ${DEFAULT_SOUND_PATH}`);
101
+ };
102
+
103
+ /** Persist volume to config. */
104
+ const setVolume = (vol) => {
105
+ updateConfig({ volume: vol });
106
+ };
107
+
108
+ export { resolveSound, playAudio, setDefault, setVolume, resolveFromUrl };