oopsx 0.0.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/LICENSE +21 -0
- package/README.md +95 -0
- package/assets/default.mp3 +0 -0
- package/bin/merr.js +68 -0
- package/package.json +39 -0
- package/src/config.js +61 -0
- package/src/runner.js +26 -0
- package/src/sound.js +108 -0
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,95 @@
|
|
|
1
|
+
# oopsx
|
|
2
|
+
|
|
3
|
+
A CLI that plays a meme sound when your terminal command fails.
|
|
4
|
+
|
|
5
|
+
Wrap any command with `oopsx`. If it fails (non-zero exit code), you hear a sound. If it passes, nothing happens. Your original output and exit code are preserved.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ oopsx git puhs origin main
|
|
9
|
+
fatal: 'puhs' is not a git command.
|
|
10
|
+
🔊 *plays sound*
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g oopsx
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run without installing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx oopsx ls /nonexistent/path
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or via curl (macOS / Linux):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
curl -fsSL https://raw.githubusercontent.com/devesh760/oopsx/main/install.sh | bash
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
oopsx <command>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
oopsx ls /does/not/exist # plays default sound on failure
|
|
39
|
+
oopsx --sound ~/bruh.mp3 npm test # use a custom sound
|
|
40
|
+
oopsx --url https://x.com/f.mp3 make build # download + cache a sound
|
|
41
|
+
oopsx --volume 50 python script.py # set volume (0-100)
|
|
42
|
+
oopsx --sound ~/oof.mp3 --set-default echo hi # save as default
|
|
43
|
+
oopsx --once cargo build # play only once per session
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Option | Description |
|
|
49
|
+
| ------------------- | --------------------------------------------- |
|
|
50
|
+
| `--sound <path>` | Use a local `.mp3` or `.wav` file |
|
|
51
|
+
| `--url <url>` | Download and cache a sound from a URL |
|
|
52
|
+
| `--volume <0-100>` | Playback volume (default: `80`) |
|
|
53
|
+
| `--set-default` | Save the resolved sound as the new default |
|
|
54
|
+
| `--once` | Play sound only once per shell session |
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
1. Runs your command with inherited stdio — you see all output as normal.
|
|
59
|
+
2. If exit code is `0`, does nothing.
|
|
60
|
+
3. If exit code is non-zero, plays a sound.
|
|
61
|
+
4. Exits with the **same exit code** as your command.
|
|
62
|
+
|
|
63
|
+
A default sound is bundled out of the box. You can replace it with `--sound <file> --set-default`.
|
|
64
|
+
|
|
65
|
+
Sound priority: `--sound` > `--url` > `~/.merr/default.mp3`
|
|
66
|
+
|
|
67
|
+
## Config
|
|
68
|
+
|
|
69
|
+
Stored in `~/.merr/`:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
~/.merr/
|
|
73
|
+
├── config.json # volume
|
|
74
|
+
├── default.mp3 # default sound
|
|
75
|
+
└── sounds/ # cached URL downloads
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Platform support
|
|
79
|
+
|
|
80
|
+
| Platform | Backend |
|
|
81
|
+
| -------- | -------------------- |
|
|
82
|
+
| macOS | `afplay` (built-in) |
|
|
83
|
+
| Linux | `aplay` / `mpg123` |
|
|
84
|
+
| Windows | PowerShell fallback |
|
|
85
|
+
|
|
86
|
+
## Uninstall
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm uninstall -g oopsx
|
|
90
|
+
rm -rf ~/.merr
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
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 = `OOPSX_PLAYED_${process.ppid}`;
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("oopsx")
|
|
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": "0.0.1",
|
|
4
|
+
"description": "Play a meme sound when your terminal command fails",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"oopsx": "./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/oopsx.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/devesh760/oopsx#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/devesh760/oopsx/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 };
|