// The desktop app's own storebound on the terminal: the app ships the CLI as its sidecar, and on every start the sidecar // puts a small launcher on the person's PATH, so `storebound` works in a terminal without an npm install. // Windows %LOCALAPPDATA%\storebound\bin\storebound.cmd, that folder appended to the user PATH (once) // macOS / Linux ~/.local/bin/storebound (a sh launcher), ~/.local/bin added to ~/.zprofile / ~/.profile when missing // An npm / bun install stays first: the folder is appended, and a launcher is never written over a file that is not // ours (the marker line). Rewritten on every start, so a moved or updated app fixes itself. Not from an AppImage (its // mount path changes per run) or a dev run. STOREBOUND_NO_CLI_SHIM=1 turns it off. import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, appendFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import * as rt from './rt'; export const SHIM_MARKER = 'storebound desktop launcher'; export interface ShimPlan { /** Why nothing is written (null: write). */ skip: string | null; file: string; content: string; /** The folder to add to PATH when it is not on it (Windows: the user PATH; else a shell profile line). */ pathDir: string; /** macOS / Linux: the shell profile that gets the PATH line. */ profile?: string; } /** What to write for the sidecar at `exe`. Pure. */ export function shimPlan(o: { exe: string; platform: string; home: string; env: Record }): ShimPlan { const win = o.platform === 'win32'; const local = o.env.LOCALAPPDATA ?? join(o.home, 'AppData', 'Local'); const dir = win ? join(local, 'storebound', 'bin') : join(o.home, '.local', 'bin'); const file = join(dir, win ? 'storebound.cmd' : 'storebound'); const content = win ? `@echo off\r\nrem ${SHIM_MARKER}\r\n"${o.exe}" %*\r\n` : `#!/bin/sh\n# ${SHIM_MARKER}\nexec '${o.exe.replace(/'/g, `'\\''`)}' "$@"\n`; const skip = o.env.STOREBOUND_NO_CLI_SHIM ? 'STOREBOUND_NO_CLI_SHIM is set' : o.env.APPIMAGE || /^\/tmp\/\.mount_/.test(o.exe) ? 'an AppImage (its path changes per run)' : null; return { skip, file, content, pathDir: dir, ...(win ? {} : { profile: join(o.home, o.platform === 'darwin' ? '.zprofile' : '.profile') }) }; } /** True when `dir` is one of the PATH entries (case-insensitive on Windows). Pure. */ export function onPath(dir: string, path: string | undefined, platform: string): boolean { const norm = (s: string) => { const t = s.replace(/[\\/]+$/, ''); return platform === 'win32' ? t.toLowerCase() : t; }; return (path ?? '').split(platform === 'win32' ? ';' : ':').some(p => p && norm(p) === norm(dir)); } /** Writes the launcher and the PATH entry; returns what it did, one line each. Never throws. */ export function ensureCliShim(exe: string = process.execPath, platform: string = process.platform, env: Record = process.env, home: string = homedir()): string[] { const done: string[] = []; try { const p = shimPlan({ exe, platform, home, env }); if (p.skip) return [`terminal launcher skipped: ${p.skip}`]; if (existsSync(p.file)) { const cur = readFileSync(p.file, 'utf8'); if (!cur.includes(SHIM_MARKER)) return [`terminal launcher not written: ${p.file} exists and is not ours`]; if (cur === p.content) done.push(`terminal launcher up to date: ${p.file}`); } if (!done.length) { mkdirSync(p.pathDir, { recursive: true }); writeFileSync(p.file, p.content); if (platform !== 'win32') chmodSync(p.file, 0o755); done.push(`terminal launcher written: ${p.file}`); } if (platform === 'win32') { // the user PATH from the registry (not this process's, which a running app inherited before the change) const q = rt.spawnSync(['reg', 'query', 'HKCU\\Environment', '/v', 'Path'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); const userPath = /Path\s+REG_(?:EXPAND_)?SZ\s+(.*)/i.exec(new TextDecoder().decode(q.stdout ?? new Uint8Array()))?.[1]?.trim() ?? ''; if (!onPath(p.pathDir, userPath, platform)) { // SetEnvironmentVariable keeps REG_EXPAND_SZ entries as typed and tells open Explorer windows (new terminals see it) const ps = `$p=[Environment]::GetEnvironmentVariable('Path','User'); if(-not $p){$p=''}; if(($p -split ';') -notcontains '${p.pathDir}'){ [Environment]::SetEnvironmentVariable('Path', ($p.TrimEnd(';') + ';${p.pathDir}').TrimStart(';'), 'User') }`; const r = rt.spawnSync(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 }); done.push(r.success ? `added to the user PATH: ${p.pathDir} (new terminals)` : `user PATH not changed (exit ${r.exitCode})`); } } else if (!onPath(p.pathDir, env.PATH, platform) && p.profile) { const line = `export PATH="$PATH:$HOME/.local/bin" # ${SHIM_MARKER}`; const cur = existsSync(p.profile) ? readFileSync(p.profile, 'utf8') : ''; if (!cur.includes(SHIM_MARKER)) { appendFileSync(p.profile, `${cur && !cur.endsWith('\n') ? '\n' : ''}${line}\n`); done.push(`added ~/.local/bin to PATH in ${p.profile} (new terminals)`); } } } catch (e) { done.push(`terminal launcher failed: ${(e as Error).message.split('\n')[0]}`); } return done; }