From 152e40fb8ab83a39da45c3fec3059cebbc083ed2 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 30 Oct 2026 00:26:07 +0700 Subject: [PATCH] doctor: warn when storebound runs from a folder macOS guards (a launchd job hangs opening it) --- CHANGELOG.md | 1 + cli/doctor.ts | 19 ++++++++++++++++++- tests/doctor-tcc.test.ts | 9 +++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/doctor-tcc.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 02edc77..baaae38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased +- `doctor` warns when storebound runs from a folder macOS guards (~/Desktop, ~/Documents, ~/Downloads, iCloud Drive; e.g. a `bun link` to a checkout there): a launchd job or CI agent without access to it blocks in open() with no output. - `apple build` prints a line before each slow step (App Store Connect reads, device families, web build), so a run without a terminal that stalls shows where. ## 0.8.61 · 2026-10-29 diff --git a/cli/doctor.ts b/cli/doctor.ts index cfd4830..4cf63f8 100644 --- a/cli/doctor.ts +++ b/cli/doctor.ts @@ -4,7 +4,8 @@ // storebound doctor [--dir .] [--json] // storebound doctor --install [--yes] runs this OS's install command for one tool (winget / brew / apt); // without --yes only prints it. Never sudo, never an admin prompt. -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import type { Args } from './lib/args'; @@ -114,6 +115,8 @@ export async function run(args: Args): Promise { const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key); log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`); log('doctor', playLine(r.play)); + const tcc = protectedInstall(realScript(), homedir()); + if (tcc) log('doctor', `WARN this storebound runs from ${tcc}: macOS guards that folder, so a launchd job, CI agent or anything started outside a terminal with access blocks forever when it opens the file (no output, no error). Install it from npm instead: bun add -g storebound@latest (or npm i -g storebound)`); if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`); const missing = r.tools.filter(t => !t.found || t.status === 'outdated'); if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; } @@ -125,3 +128,17 @@ export async function run(args: Args): Promise { } log('doctor', `${missing.length} missing. Store work needs none of them; screenshots, previews, icons and captures do.`); } + +/** The real path of the running storebound (a bun link / npm link resolves into the checkout). */ +function realScript(): string { try { return realpathSync(process.argv[1] ?? ''); } catch { return process.argv[1] ?? ''; } } + +/** macOS only: the guarded home folder (Desktop, Documents, Downloads, iCloud Drive) a path lies in, or null. A process + * without access to it (a launchd job, a CI agent) hangs in open() while macOS waits for a consent nobody can give. */ +export function protectedInstall(path: string, home: string, platform: string = process.platform): string | null { + if (platform !== 'darwin' || !path || !home) return null; + for (const d of ['Desktop', 'Documents', 'Downloads', 'Library/Mobile Documents']) { + const dir = `${home.endsWith('/') ? home.slice(0, -1) : home}/${d}`; // posix paths: the check is macOS-only + if (path === dir || path.startsWith(dir + '/')) return `~/${d}`; + } + return null; +} diff --git a/tests/doctor-tcc.test.ts b/tests/doctor-tcc.test.ts new file mode 100644 index 0000000..00b78ec --- /dev/null +++ b/tests/doctor-tcc.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'bun:test'; +import { protectedInstall } from '../cli/doctor'; + +test('a storebound running from ~/Desktop (a bun link to a checkout) is named; npm / bun global installs are fine', () => { + expect(protectedInstall('/Users/you/Desktop/storebound/dist/storebound.mjs', '/Users/you', 'darwin')).toBe('~/Desktop'); + expect(protectedInstall('/Users/you/Documents/x/cli/index.ts', '/Users/you', 'darwin')).toBe('~/Documents'); + expect(protectedInstall('/Users/you/.bun/install/global/node_modules/storebound/dist/storebound.mjs', '/Users/you', 'darwin')).toBeNull(); + expect(protectedInstall('/Users/you/Desktop/x.mjs', '/Users/you', 'linux')).toBeNull(); +});