desktop: the app's own CLI on the terminal PATH (launcher rewritten on every start; npm installs stay first)

This commit is contained in:
CRTkafa 2026-11-06 21:01:19 +07:00
parent 31f863ed06
commit 0950ef2c99
4 changed files with 133 additions and 0 deletions

View file

@ -3,6 +3,8 @@
## Unreleased
- Desktop app: `storebound` works in a terminal with only the app installed. On every start the app puts a launcher for its own CLI on the PATH (Windows: `%LOCALAPPDATA%\storebound\bin`, added to the user PATH once; macOS / Linux: `~/.local/bin`, added to `~/.zprofile` / `~/.profile` when missing). An npm / bun install stays first, a file that is not the app's launcher is never overwritten, AppImage runs skip it, `STOREBOUND_NO_CLI_SHIM=1` turns it off.
## 0.8.67 · 2026-11-03
- `apple build --platform macos`: runs `apple.macos.prebuild` (new) before `tauri build`, no longer `build.prebuild` / `apple.prebuild`, which belong to the iOS project (a Capacitor sync or OTA gate ran for a Tauri Mac build); the dry run names the skipped one.

81
cli/lib/cli-shim.ts Normal file
View file

@ -0,0 +1,81 @@
// 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;
}

View file

@ -28,6 +28,7 @@ import { androidSdk, detectTools } from './lib/tools';
import { compiled, pickCallEnv, runCommand, type RunOptions, type Running } from './lib/runner';
import { inputSchema, notSentProp, type ToolDef, TOOLS, toArgv, validateInput } from './mcp-tools';
import * as rt from './lib/rt';
import { ensureCliShim } from './lib/cli-shim';
type Id = string | number;
interface Msg { jsonrpc?: string; id?: Id | null; method?: string; params?: unknown }
@ -613,6 +614,8 @@ export async function serveStdio(): Promise<void> {
const send: Send = o => { process.stdout.write(JSON.stringify(o) + '\n'); };
const session = new Session(send);
console.error(`[storebound serve] ${pkg.version} on stdio (${DESKTOP_PROTOCOL}), waiting for session.hello`);
// the app's own CLI on the terminal PATH (lib/cli-shim.ts); only the bundled sidecar, after the handshake had its turn
if (compiled()) setTimeout(() => { for (const l of ensureCliShim()) console.error(`[storebound serve] ${l}`); }, 2000);
let buf = '';
const decoder = new TextDecoder();
const reader = rt.stdinStream().getReader();

47
tests/cli-shim.test.ts Normal file
View file

@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ensureCliShim, onPath, SHIM_MARKER, shimPlan } from '../cli/lib/cli-shim';
describe('desktop terminal launcher', () => {
test('per platform: file, content, PATH folder, profile', () => {
const w = shimPlan({ exe: 'C:\\Users\\a\\AppData\\Local\\storebound\\storebound.exe', platform: 'win32', home: 'C:\\Users\\a', env: { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' } });
expect(w.file.endsWith('storebound.cmd')).toBe(true);
expect(w.content).toContain('"C:\\Users\\a\\AppData\\Local\\storebound\\storebound.exe" %*');
expect(w.skip).toBeNull();
const m = shimPlan({ exe: "/Applications/store'bound.app/Contents/MacOS/storebound", platform: 'darwin', home: '/Users/a', env: {} });
expect(m.content).toContain(`exec '/Applications/store'\\''bound.app/Contents/MacOS/storebound' "$@"`);
expect(m.profile!.endsWith('.zprofile')).toBe(true);
expect(shimPlan({ exe: '/usr/bin/storebound', platform: 'linux', home: '/home/a', env: {} }).profile!.endsWith('.profile')).toBe(true);
});
test('skips AppImage runs and the opt-out', () => {
expect(shimPlan({ exe: '/tmp/.mount_sbX/usr/bin/storebound', platform: 'linux', home: '/h', env: {} }).skip).toContain('AppImage');
expect(shimPlan({ exe: '/usr/bin/storebound', platform: 'linux', home: '/h', env: { STOREBOUND_NO_CLI_SHIM: '1' } }).skip).toContain('STOREBOUND_NO_CLI_SHIM');
});
test('onPath: trailing slashes, case on Windows only', () => {
expect(onPath('C:\\A\\bin', 'c:\\a\\bin\\;D:\\x', 'win32')).toBe(true);
expect(onPath('/h/.local/bin', '/usr/bin:/h/.local/bin/', 'linux')).toBe(true);
expect(onPath('/h/.local/bin', '/usr/bin:/H/.local/bin', 'linux')).toBe(false);
});
test('writes once, rewrites a moved app, never touches a file that is not ours, adds the profile line once', () => {
const home = mkdtempSync(join(tmpdir(), 'sb-shim-'));
const env = { PATH: '/usr/bin' };
const first = ensureCliShim('/opt/a/storebound', 'linux', env, home);
expect(first.join('\n')).toContain('terminal launcher written');
expect(first.join('\n')).toContain('added ~/.local/bin to PATH');
const file = join(home, '.local', 'bin', 'storebound');
expect(readFileSync(file, 'utf8')).toContain(SHIM_MARKER);
expect(ensureCliShim('/opt/a/storebound', 'linux', env, home).join()).toContain('up to date');
expect(ensureCliShim('/opt/b/storebound', 'linux', env, home).join()).toContain('written');
expect(readFileSync(file, 'utf8')).toContain('/opt/b/storebound');
expect(readFileSync(join(home, '.profile'), 'utf8').split(SHIM_MARKER).length - 1).toBe(2 - 1);
writeFileSync(file, '#!/bin/sh\nexec npm-storebound "$@"\n');
expect(ensureCliShim('/opt/c/storebound', 'linux', env, home).join()).toContain('is not ours');
expect(readFileSync(file, 'utf8')).toContain('npm-storebound');
if (process.platform === 'win32') return; // a C:\ temp folder has a colon: no POSIX PATH can name it
const home2 = mkdtempSync(join(tmpdir(), 'sb-shim-'));
mkdirSync(join(home2, '.local', 'bin'), { recursive: true });
expect(ensureCliShim('/opt/a/storebound', 'linux', { PATH: `/usr/bin:${join(home2, '.local', 'bin')}` }, home2).join()).not.toContain('PATH');
});
});