Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ sshshot config add/remove SSH remotes
sshshot menubar install install the macOS menu-bar plugin (SwiftBar)
sshshot menubar status show whether the plugin is installed
sshshot menubar uninstall remove the menu-bar plugin
sshshot update-check manually check npm for the latest sshshot version
sshshot update install the latest sshshot globally via npm
sshshot uninstall stop daemon + remove ~/.config/sshshot
```

Expand Down Expand Up @@ -154,9 +156,10 @@ Clicking the icon opens a submenu with:

- Pause / resume / restart / stop daemon (whichever applies)
- A list of all your configured remotes — click one to switch active target
- Manual update check / update action for the npm-installed CLI
- Open config / open latest log

The plugin re-reads `sshshot status --json` every 5 seconds, so the indicator updates as state changes from any source (CLI, daemon, another machine pushing config changes). Click-actions invoke the same `sshshot` commands you'd run by hand — no daemon restart, no separate process.
The plugin re-reads `sshshot status --json` every 5 seconds, so the indicator updates as state changes from any source (CLI, daemon, another machine pushing config changes). Click-actions invoke the same `sshshot` commands you'd run by hand — no daemon restart, no separate process. Update checks only run when you click the update menu item.

Linux/Windows menu-bar support isn't on the roadmap yet; the equivalent for those platforms is the CLI commands below.

Expand Down Expand Up @@ -264,7 +267,7 @@ What sshshot **does not** do, by deliberate design:

- **Does not read your shell history** (`~/.bash_history`, `~/.zsh_history`). Reading shell history is the #1 behavioral signal of credential-stealing malware (info-stealers do exactly that to find AWS/SSH credentials), and Socket.dev's AI scanner correctly flags packages that do it. Earlier upstream versions scanned history; this fork removed it. Users without `~/.ssh/config` entries can add hosts manually via the setup prompt.
- **Does not capture the screen.** The OS captures; sshshot only forwards what you produced via Cmd+Shift / Cmd+Ctrl+Shift / Print Screen.
- **Does not phone home.** No telemetry, analytics, or auto-update checks.
- **Does not phone home.** No telemetry, analytics, error reporting, or automatic update checks. `sshshot update-check` and `sshshot update` contact the public npm registry only when you run them.
- **Does not require root, sudo, or any TCC entitlement.**

## Contributing
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ sshshot is a CLI utility that ships local screenshots to a remote SSH host. By d

- **Does not read shell history** (`~/.bash_history`, `~/.zsh_history`) — the strongest info-stealer signal. Earlier upstream versions did; this fork removed that path entirely. Users without `~/.ssh/config` entries can add hosts manually via the interactive setup prompt.
- **Does not capture the screen** — sshshot only forwards screenshots **the user** took with the OS's built-in keystrokes. macOS Screen Recording / TCC permissions are not requested.
- **Does not phone home** — no telemetry, analytics, error reporting, or auto-update checks.
- **Does not phone home** — no telemetry, analytics, error reporting, or automatic update checks. `sshshot update-check` and `sshshot update` contact the public npm registry only when you run them.
- **Does not request root, sudo, or any TCC entitlement.**
- **Does not exfiltrate to anywhere the user did not configure.** The remote target comes from interactive selection out of the user's own `~/.ssh/config`. There is no hard-coded server, no fallback host, no opt-out telemetry endpoint.

Expand Down
21 changes: 21 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { promptConfirm, promptSelect, promptInput, promptMultiSelect } from './prompts'
import { startMonitor } from './monitor'
import { runMenubarCommand } from './menubar'
import { checkForUpdate, formatUpdateCheck, updateToLatest } from './update'

const isWindows = process.platform === 'win32'

Expand Down Expand Up @@ -304,6 +305,8 @@ Commands:
toggle Flip between pause/resume
config Modify configuration
menubar Manage the macOS menu-bar plugin (install/uninstall/status)
update-check Manually check npm for the latest sshshot version
update Install the latest sshshot globally via npm
uninstall Remove config and stop process
version Print version and exit
help Show this help
Expand Down Expand Up @@ -546,6 +549,24 @@ async function main(): Promise<void> {
return
}

if (command === 'update-check') {
const result = checkForUpdate(getVersion())
console.log(formatUpdateCheck(result))
if (result.status === 'unavailable') process.exitCode = 1
return
}

if (command === 'update') {
const code = updateToLatest()
if (code === 0) {
console.log('\nUpdated sshshot to the latest npm release.')
console.log('If you use the menu-bar plugin, run: sshshot menubar install')
} else {
process.exitCode = code
}
return
}

// `status --json` is for machine consumption (menu-bar plugin, scripts).
// Bypass the human-facing banner so the entire stdout is parseable JSON.
if (command === 'status' && args.includes('--json')) {
Expand Down
45 changes: 45 additions & 0 deletions src/menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export PATH=${shellSingleQuote(opts.pathPrefix)}":\${PATH:-/usr/bin:/bin}"
SSHSHOT=${shellSingleQuote(opts.sshshotBin)}
CONFIG_PATH="\${HOME}/.config/sshshot/config.json"
LOG_DIR="\${HOME}/.config/sshshot/logs"
UPDATE_LOG="\${LOG_DIR}/update.log"

notify() {
MESSAGE="\${1:-}"
MESSAGE="\${MESSAGE//\\\\/\\\\\\\\}"
MESSAGE="\${MESSAGE//\\"/\\\\\\"}"
osascript -e "display notification \\"$MESSAGE\\" with title \\"sshshot\\""
}

# --- Click-action handlers (run before any output) ---
ACTION="\${1:-}"
Expand Down Expand Up @@ -145,6 +153,38 @@ case "$ACTION" in
[[ -n "$LATEST" ]] && open "$LATEST" || osascript -e 'display notification "No log file yet" with title "sshshot"'
exit 0
;;
open-update-log)
[[ -f "$UPDATE_LOG" ]] && open "$UPDATE_LOG" || notify "No update log file yet"
exit 0
;;
update-check)
RESULT="$("$SSHSHOT" update-check 2>&1)"
RC=$?
MESSAGE="$(printf '%s' "$RESULT" | tail -n 2 | tr '\\n' ' ' | cut -c 1-180)"
[[ -n "$MESSAGE" ]] || MESSAGE="Update check failed"
notify "$MESSAGE"
exit "$RC"
;;
update)
mkdir -p "$LOG_DIR" 2>/dev/null
(
date '+%Y-%m-%d %H:%M:%S %z'
"$SSHSHOT" update
RC=$?
if [[ "$RC" -eq 0 ]]; then
"$SSHSHOT" menubar install
RC=$?
fi
exit "$RC"
) > "$UPDATE_LOG" 2>&1
RC=$?
if [[ "$RC" -eq 0 ]]; then
notify "Updated sshshot and refreshed the menu-bar plugin"
else
notify "Update failed; open the sshshot update log"
fi
exit "$RC"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
;;
esac

# --- Read current state ---
Expand Down Expand Up @@ -227,6 +267,11 @@ done
echo "---"

# --- Utility ---
VERSION="$("$SSHSHOT" version 2>/dev/null | head -1)"
[[ -n "$VERSION" ]] && echo "Version: $VERSION | size=12 font=Menlo"
echo "🔎 Check for sshshot update | bash=$0 param1=update-check terminal=false refresh=true"
echo "⬆️ Update sshshot | bash=$0 param1=update terminal=false refresh=true"
[[ -f "$UPDATE_LOG" ]] && echo "📝 Open update log | bash=$0 param1=open-update-log terminal=false"
echo "📂 Open config | bash=$0 param1=open-config terminal=false"
echo "📜 Open latest log | bash=$0 param1=open-log terminal=false"
echo "🔄 Refresh | refresh=true"
Expand Down
134 changes: 134 additions & 0 deletions src/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { spawnSync } from 'child_process'

export const PACKAGE_NAME = '@flamerged/sshshot'

export type UpdateStatus = 'current' | 'outdated' | 'development' | 'unavailable'

export interface UpdateCheckResult {
status: UpdateStatus
currentVersion: string
latestVersion?: string
error?: string
}

export interface CommandResult {
status: number | null
stdout?: string | Buffer
stderr?: string | Buffer
error?: Error
}

export type CommandRunner = (
command: string,
args: string[],
options: {
encoding?: BufferEncoding
timeout?: number
stdio?: 'pipe' | 'inherit'
windowsHide?: boolean
}
) => CommandResult

const defaultRunner = spawnSync as CommandRunner

function normalizeVersion(version: string): string {
return version.trim().replace(/^v/, '')
}

function parseVersion(version: string): [number, number, number] | null {
const normalized = normalizeVersion(version)
if (normalized.includes('managed-by-semantic-release')) return null

const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/)
if (!match) return null
return [Number(match[1]), Number(match[2]), Number(match[3])]
}

export function compareVersions(a: string, b: string): number | null {
const parsedA = parseVersion(a)
const parsedB = parseVersion(b)
if (!parsedA || !parsedB) return null

for (let i = 0; i < 3; i += 1) {
if (parsedA[i] < parsedB[i]) return -1
if (parsedA[i] > parsedB[i]) return 1
}
return 0
}

export function fetchLatestVersion(runner: CommandRunner = defaultRunner): {
version?: string
error?: string
} {
const result = runner('npm', ['view', PACKAGE_NAME, 'version', '--silent'], {
encoding: 'utf8',
timeout: 10000,
windowsHide: true
})

if (result.status !== 0) {
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''
return { error: stderr || result.error?.message || 'npm view failed' }
}

const version = typeof result.stdout === 'string' ? result.stdout.trim() : ''
if (!version) {
return { error: 'npm returned an empty latest version' }
}

return { version }
}

export function checkForUpdate(
currentVersion: string,
runner: CommandRunner = defaultRunner
): UpdateCheckResult {
const latest = fetchLatestVersion(runner)
if (!latest.version) {
return { status: 'unavailable', currentVersion, error: latest.error }
}

const comparison = compareVersions(currentVersion, latest.version)
if (comparison === null) {
return {
status: 'development',
currentVersion,
latestVersion: latest.version
}
}

return {
status: comparison < 0 ? 'outdated' : 'current',
currentVersion,
latestVersion: latest.version
}
}

export function formatUpdateCheck(result: UpdateCheckResult): string {
switch (result.status) {
case 'current':
return `sshshot ${result.currentVersion} is current.`
case 'outdated':
return `sshshot ${result.currentVersion} -> ${result.latestVersion} available.\nUpdate with: sshshot update`
case 'development':
return `sshshot ${result.currentVersion} is a development build; latest npm release is ${result.latestVersion}.`
case 'unavailable':
return `Could not check latest sshshot version: ${result.error ?? 'unknown error'}`
}
}

export function updateToLatest(runner: CommandRunner = defaultRunner): number {
const result = runner('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
stdio: 'inherit',
windowsHide: true
})

if (result.status === 0) {
return 0
}

if (result.error) {
console.error(`Could not run npm: ${result.error.message}`)
}
return result.status ?? 1
}
23 changes: 22 additions & 1 deletion test/menubar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,32 @@ describe('renderPlugin', () => {
const out = renderPlugin({ sshshotBin: 'sshshot', pathPrefix: '/usr/local/bin' })
// `restart` is intentionally NOT in this list — it's a composite of
// stop+start in the plugin, not a real `sshshot restart` subcommand.
for (const action of ['target', 'pause', 'resume', 'start', 'stop']) {
for (const action of ['target', 'pause', 'resume', 'start', 'stop', 'update-check', 'update']) {
expect(out).toContain(`"$SSHSHOT" ${action}`)
}
})

it('adds manual update actions without checking in the normal refresh path', () => {
const out = renderPlugin({ sshshotBin: 'sshshot', pathPrefix: '/usr/local/bin' })
expect(out).toContain('param1=update-check')
expect(out).toContain('param1=update')
expect(out).toContain('UPDATE_LOG="${LOG_DIR}/update.log"')

const normalRenderBlock = out.split('esac')[1] ?? ''
expect(normalRenderBlock).not.toContain('"$SSHSHOT" update-check')
expect(normalRenderBlock).not.toContain('"$SSHSHOT" update')
})

it('runs the update action in a subshell so notifications still execute', () => {
const out = renderPlugin({ sshshotBin: 'sshshot', pathPrefix: '/usr/local/bin' })
const updateBlock = out.split(' update)')[1]?.split(';;')[0] ?? ''
expect(updateBlock).toContain(' (')
expect(updateBlock).toContain(' ) > "$UPDATE_LOG" 2>&1')
expect(updateBlock).toContain(' RC=$?')
expect(updateBlock).toContain('notify "Updated sshshot and refreshed the menu-bar plugin"')
expect(updateBlock).toContain('notify "Update failed; open the sshshot update log"')
})

it('restart handler composes stop + start', () => {
const out = renderPlugin({ sshshotBin: 'sshshot', pathPrefix: '/usr/local/bin' })
// Pull just the `restart)` case block and assert it contains both calls.
Expand Down
Loading
Loading