storebound/cli/lib/cli-shim.ts

81 lines
5.3 KiB
TypeScript

// 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<string, string | undefined> }): 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<string, string | undefined> = 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;
}