81 lines
5.3 KiB
TypeScript
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;
|
|
}
|