diff --git a/README.md b/README.md index 0671d7d..09a41ed 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 02b10bd..a7dd366 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/src/index.ts b/src/index.ts index 3193e00..d953b13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -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 @@ -546,6 +549,24 @@ async function main(): Promise { 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')) { diff --git a/src/menubar.ts b/src/menubar.ts index 9e4f0a9..1616a36 100644 --- a/src/menubar.ts +++ b/src/menubar.ts @@ -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:-}" @@ -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" + ;; esac # --- Read current state --- @@ -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" diff --git a/src/update.ts b/src/update.ts new file mode 100644 index 0000000..41e6923 --- /dev/null +++ b/src/update.ts @@ -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 +} diff --git a/test/menubar.test.ts b/test/menubar.test.ts index 19d902d..fbe98e4 100644 --- a/test/menubar.test.ts +++ b/test/menubar.test.ts @@ -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. diff --git a/test/update.test.ts b/test/update.test.ts new file mode 100644 index 0000000..e8ffb0f --- /dev/null +++ b/test/update.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + checkForUpdate, + compareVersions, + fetchLatestVersion, + formatUpdateCheck, + updateToLatest, + type CommandRunner +} from '../src/update' + +function runner(stdout: string, status = 0, stderr = ''): CommandRunner { + return () => ({ status, stdout, stderr }) +} + +describe('compareVersions', () => { + it('compares semver triples and ignores a leading v', () => { + expect(compareVersions('0.9.0', '0.9.1')).toBe(-1) + expect(compareVersions('v0.9.1', '0.9.0')).toBe(1) + expect(compareVersions('0.9.0', '0.9.0')).toBe(0) + }) + + it('returns null for the semantic-release placeholder', () => { + expect(compareVersions('0.0.0-managed-by-semantic-release', '0.9.0')).toBe(null) + }) +}) + +describe('fetchLatestVersion', () => { + it('reads the latest npm version with npm view', () => { + let command = '' + let args: string[] = [] + const fakeRunner: CommandRunner = (cmd, nextArgs) => { + command = cmd + args = nextArgs + return { status: 0, stdout: '0.9.1\n' } + } + + expect(fetchLatestVersion(fakeRunner)).toEqual({ version: '0.9.1' }) + expect(command).toBe('npm') + expect(args).toEqual(['view', '@flamerged/sshshot', 'version', '--silent']) + }) + + it('surfaces npm errors', () => { + expect(fetchLatestVersion(runner('', 1, 'network down'))).toEqual({ error: 'network down' }) + }) +}) + +describe('checkForUpdate', () => { + it('marks older versions as outdated', () => { + expect(checkForUpdate('0.9.0', runner('0.9.1\n'))).toEqual({ + status: 'outdated', + currentVersion: '0.9.0', + latestVersion: '0.9.1' + }) + }) + + it('marks matching versions as current', () => { + expect(checkForUpdate('0.9.1', runner('0.9.1\n')).status).toBe('current') + }) + + it('keeps source checkout placeholders separate from release versions', () => { + expect(checkForUpdate('0.0.0-managed-by-semantic-release', runner('0.9.1\n')).status).toBe( + 'development' + ) + }) +}) + +describe('formatUpdateCheck', () => { + it('prints the command to run when an update exists', () => { + const message = formatUpdateCheck({ + status: 'outdated', + currentVersion: '0.9.0', + latestVersion: '0.9.1' + }) + + expect(message).toContain('0.9.0 -> 0.9.1') + expect(message).toContain('sshshot update') + }) +}) + +describe('updateToLatest', () => { + it('runs npm install -g against the latest package tag', () => { + let command = '' + let args: string[] = [] + const fakeRunner: CommandRunner = (cmd, nextArgs) => { + command = cmd + args = nextArgs + return { status: 0 } + } + + expect(updateToLatest(fakeRunner)).toBe(0) + expect(command).toBe('npm') + expect(args).toEqual(['install', '-g', '@flamerged/sshshot@latest']) + }) +})