From 0950ef2c99a84900208361e6c093fb01586169eb Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 6 Nov 2026 21:01:19 +0700 Subject: [PATCH] desktop: the app's own CLI on the terminal PATH (launcher rewritten on every start; npm installs stay first) --- CHANGELOG.md | 2 ++ cli/lib/cli-shim.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ cli/serve.ts | 3 ++ tests/cli-shim.test.ts | 47 ++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 cli/lib/cli-shim.ts create mode 100644 tests/cli-shim.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f4cf1..38eb3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cli/lib/cli-shim.ts b/cli/lib/cli-shim.ts new file mode 100644 index 0000000..8ece9b1 --- /dev/null +++ b/cli/lib/cli-shim.ts @@ -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 }): 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; +} diff --git a/cli/serve.ts b/cli/serve.ts index f5f08c0..6407728 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -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 { 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(); diff --git a/tests/cli-shim.test.ts b/tests/cli-shim.test.ts new file mode 100644 index 0000000..036e4c5 --- /dev/null +++ b/tests/cli-shim.test.ts @@ -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'); + }); +});