bx-mac 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -3
  2. package/dist/bx.js +141 -36
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -44,6 +44,7 @@ bx ~/work/my-project ~/work/shared-lib
44
44
  - **No protection against root/sudo** — the sandbox applies to the user-level process
45
45
  - **macOS only** — relies on `sandbox-exec` (Apple-specific)
46
46
  - **Not dynamic** — the sandbox profile is a snapshot of `$HOME` at launch time; directories or files created later are **not** automatically blocked
47
+ - **File names visible** — blocked files cannot be read or written, but their names still appear in directory listings (a kernel-level `readdir` constraint, same as `chmod 000`)
47
48
  - **Not a vault** — `sandbox-exec` is undocumented; treat this as a safety net, not a guarantee
48
49
 
49
50
  ## 📥 Install
@@ -122,6 +123,9 @@ bx --dry ~/work/my-project
122
123
 
123
124
  # 🔍 See the generated sandbox profile
124
125
  bx --verbose ~/work/my-project
126
+
127
+ # 🔄 Use an isolated app profile
128
+ bx --vscode-user code ~/work/my-project
125
129
  ```
126
130
 
127
131
  ## ⚙️ Options
@@ -131,7 +135,7 @@ bx --verbose ~/work/my-project
131
135
  | `--dry` | Show a tree of all protected, read-only, and accessible paths — don't launch anything |
132
136
  | `--verbose` | Print the generated sandbox profile plus launch details (binary, arguments, cwd, focus command) |
133
137
  | `--background` | Run the app detached in the background (like `nohup &`), output goes to `/tmp/bx-<pid>.log` |
134
- | `--profile-sandbox` | Use an isolated VSCode profile (separate extensions/settings, `code` mode only) |
138
+ | `--vscode-user [path]` | Use an isolated app profile (default: `~/.vscode-sandbox`, or specify a custom path) |
135
139
 
136
140
  On normal runs, bx also prints a short policy summary (number of workdirs, blocked directories, hidden paths, and read-only directories).
137
141
 
@@ -166,8 +170,9 @@ path = "/usr/local/bin/code"
166
170
  | `fallback` | Absolute fallback path if `mdfind` discovery fails |
167
171
  | `args` | Extra arguments always passed to the app |
168
172
  | `passPaths` | Paths passed as app launch args (`true`/`false`/`N`/`["~/p1", "~/p2"]`) |
169
- | `paths` | Default working directories when none are given on the CLI (supports `~/` paths) |
173
+ | `paths` | Default working directories when none are given on the CLI (supports `~/` paths and `*` globs) |
170
174
  | `background` | Run the app detached in the background by default (`true`/`false`) |
175
+ | `profile` | Use an isolated app profile (`true` = `~/.vscode-sandbox`, `"path"` = custom path) |
171
176
 
172
177
  **Resolution order:** `path` → `mdfind` by `bundle` + `binary` → `fallback`
173
178
 
@@ -342,7 +347,7 @@ Or preconfigure them in `~/.bxconfig.toml`:
342
347
  paths = ["~/work/project-a", "~/work/project-b"]
343
348
  ```
344
349
 
345
- For VSCode specifically, `--profile-sandbox` forces a separate Electron process via an isolated `--user-data-dir`, but this means separate extensions and settings.
350
+ For VSCode specifically, `--vscode-user` forces a separate Electron process via an isolated `--user-data-dir`, but this means separate extensions and settings. You can specify a custom path (`--vscode-user ~/my-profile`) or use the default (`--vscode-user` alone uses `~/.vscode-sandbox`). This can also be configured per app in `~/.bxconfig.toml` via the `profile` field.
346
351
 
347
352
  ## 💡 Tips
348
353
 
@@ -369,12 +374,15 @@ Some AI coding tools ship with their own sandboxing. bx complements these by pro
369
374
  - [Claude Code](https://code.claude.com/docs/en/sandboxing) — built-in sandbox for file and command restrictions
370
375
  - [Gemini CLI](https://geminicli.com/docs/cli/sandbox/) — sandbox mode for file system access control
371
376
  - [OpenAI Codex](https://developers.openai.com/codex/concepts/sandboxing) — containerized sandboxing for code execution
377
+ - [VS Code Copilot](https://code.visualstudio.com/docs/copilot/agents/agent-tools#_sandbox-agent-commands) — agent sandbox mode (preview) that restricts write access to the working directory and blocks network access for terminal commands (`chat.agent.sandbox` setting)
372
378
 
373
379
  These are great when available, but they only protect within their own tool. bx wraps the entire process — so even if a tool's built-in sandbox is misconfigured, disabled, or absent, your files stay protected.
374
380
 
375
381
  ## 🔗 Alternatives
376
382
 
377
383
  - [Agent Safehouse](https://agent-safehouse.dev/) — macOS kernel-level sandboxing for LLM coding agents via `sandbox-exec`. Deny-first model that blocks write access outside the project directory.
384
+ - **Docker / VMs** — for stronger isolation, run AI tools in a virtualized environment (containers, VMs). Full process and network isolation at the cost of setup overhead.
385
+ - **Web sandboxes** — browser-based approaches for running AI agents. See Simon Willison's [Living dangerously with Claude](https://simonwillison.net/2025/Oct/22/living-dangerously-with-claude/) for an overview.
378
386
 
379
387
  ## 💛 Sponsor
380
388
 
package/dist/bx.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { accessSync, closeSync, constants, cpSync, existsSync, globSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { execFileSync, spawn } from "node:child_process";
4
+ import { execFileSync, execSync, spawn } from "node:child_process";
5
5
  import { createInterface } from "node:readline";
6
6
  import process$1 from "node:process";
7
7
  import { fileURLToPath } from "node:url";
@@ -811,12 +811,15 @@ function parsePassPaths(val) {
811
811
  if (typeof val === "number" && Number.isInteger(val) && val > 0) return val;
812
812
  if (Array.isArray(val)) {
813
813
  const paths = val.filter((a) => typeof a === "string");
814
+ if (val.length !== paths.length) console.error(fmt.warn("non-string items in array config were ignored"));
814
815
  if (paths.length > 0) return paths;
816
+ return false;
815
817
  }
816
818
  }
817
819
  function parseStringArray(val) {
818
820
  if (Array.isArray(val)) {
819
821
  const items = val.filter((a) => typeof a === "string");
822
+ if (val.length !== items.length) console.error(fmt.warn("non-string items in array config were ignored"));
820
823
  if (items.length > 0) return items;
821
824
  }
822
825
  }
@@ -830,7 +833,8 @@ function parseAppDef(def) {
830
833
  args: parseStringArray(def.args),
831
834
  passPaths: parsePassPaths(def.passPaths ?? def.passWorkPaths ?? def.passWorkdirs),
832
835
  paths: parseStringArray(def.paths ?? def.workdirs),
833
- background: typeof def.background === "boolean" ? def.background : void 0
836
+ background: typeof def.background === "boolean" ? def.background : void 0,
837
+ profile: typeof def.profile === "boolean" || typeof def.profile === "string" ? def.profile : void 0
834
838
  };
835
839
  }
836
840
  /**
@@ -855,7 +859,8 @@ function loadConfig(home) {
855
859
  "passWorkdirs",
856
860
  "paths",
857
861
  "workdirs",
858
- "background"
862
+ "background",
863
+ "profile"
859
864
  ]);
860
865
  for (const [key, val] of Object.entries(doc)) {
861
866
  if (key === "apps") continue;
@@ -1082,9 +1087,12 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
1082
1087
  } catch {
1083
1088
  continue;
1084
1089
  }
1085
- if (!isDir) continue;
1086
1090
  if (parentDir === home && name === "Library") continue;
1087
1091
  if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
1092
+ if (!isDir) {
1093
+ blocked.push(fullPath);
1094
+ continue;
1095
+ }
1088
1096
  const status = isAllowedOrAncestor(fullPath, allowedDirs);
1089
1097
  if (status === "allowed") continue;
1090
1098
  if (status === "ancestor") {
@@ -1120,7 +1128,7 @@ function collectIgnoredPaths(home, workDirs) {
1120
1128
  return ignored;
1121
1129
  }
1122
1130
  function sbplEscape(path) {
1123
- return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
1131
+ return path.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
1124
1132
  }
1125
1133
  function sbplSubpath(path) {
1126
1134
  return ` (subpath "${sbplEscape(path)}")`;
@@ -1155,7 +1163,7 @@ function collectSystemDenyPaths(home) {
1155
1163
  return paths;
1156
1164
  }
1157
1165
  function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = [], home = "") {
1158
- const blockedRules = sbplDenyBlock("Blocked directories (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplSubpath));
1166
+ const blockedRules = sbplDenyBlock("Blocked paths (auto-generated from $HOME contents)", "file*", blockedDirs.map(sbplPathRule));
1159
1167
  const ignoredRules = sbplDenyBlock("Hidden paths from .bxignore", "file*", ignoredPaths.map(sbplPathRule));
1160
1168
  const readOnlyRules = sbplDenyBlock("Read-only directories", "file-write*", readOnlyDirs.map(sbplSubpath));
1161
1169
  const systemRules = home ? sbplDenyBlock("System-level restrictions", "file*", collectSystemDenyPaths(home).map(sbplSubpath)) : "";
@@ -1236,7 +1244,7 @@ async function checkAppAlreadyRunning(mode, apps) {
1236
1244
  }
1237
1245
  console.error(`\n${fmt.warn(`"${appName}" is already running`)}`);
1238
1246
  console.error(fmt.detail("the workspace will open in the EXISTING instance — sandbox will NOT apply"));
1239
- if (mode === "code") console.error(fmt.detail("quit the app first, or use --profile-sandbox for an isolated instance"));
1247
+ if (mode === "code") console.error(fmt.detail("quit the app first, or use --vscode-user for an isolated instance"));
1240
1248
  else console.error(fmt.detail("quit the app first to ensure sandbox protection"));
1241
1249
  const rl = createInterface({
1242
1250
  input: process$1.stdin,
@@ -1280,12 +1288,12 @@ function parseArgs(validModes) {
1280
1288
  const rawArgs = process$1.argv.slice(2);
1281
1289
  const verbose = rawArgs.includes("--verbose");
1282
1290
  const dry = rawArgs.includes("--dry");
1283
- const profileSandbox = rawArgs.includes("--profile-sandbox");
1291
+ const vscodeUser = parseVscodeUserFlag(rawArgs);
1284
1292
  const background = rawArgs.includes("--background");
1285
- const positional = rawArgs.filter((a) => !a.startsWith("--"));
1293
+ const positional = collectPositional(rawArgs);
1286
1294
  const doubleDashIdx = rawArgs.indexOf("--");
1287
1295
  const appArgs = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
1288
- const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
1296
+ const beforeDash = doubleDashIdx >= 0 ? collectPositional(rawArgs.slice(0, doubleDashIdx)) : positional;
1289
1297
  let mode = "code";
1290
1298
  let workArgs;
1291
1299
  let implicitWorkdirs = false;
@@ -1307,12 +1315,42 @@ function parseArgs(validModes) {
1307
1315
  workArgs,
1308
1316
  verbose,
1309
1317
  dry,
1310
- profileSandbox,
1318
+ vscodeUser,
1311
1319
  background,
1312
1320
  appArgs,
1313
1321
  implicit: implicitWorkdirs
1314
1322
  };
1315
1323
  }
1324
+ function looksLikePath(val) {
1325
+ return val.includes("/") || val.startsWith("~") || val.startsWith(".");
1326
+ }
1327
+ /** Filter positional args, skipping the path value after --vscode-user */
1328
+ function collectPositional(args) {
1329
+ const result = [];
1330
+ for (let i = 0; i < args.length; i++) {
1331
+ if (args[i] === "--vscode-user" || args[i] === "--vscode-user-data") {
1332
+ const next = args[i + 1];
1333
+ if (next && looksLikePath(next)) i++;
1334
+ continue;
1335
+ }
1336
+ if (args[i].startsWith("--")) continue;
1337
+ result.push(args[i]);
1338
+ }
1339
+ return result;
1340
+ }
1341
+ function parseVscodeUserFlag(args) {
1342
+ for (let i = 0; i < args.length; i++) {
1343
+ if (args[i] === "--vscode-user" || args[i] === "--vscode-user-data") {
1344
+ const next = args[i + 1];
1345
+ if (next && looksLikePath(next)) return next;
1346
+ return true;
1347
+ }
1348
+ if (args[i] === "--vscode-user=false" || args[i] === "--vscode-user-data=false") return false;
1349
+ const eqMatch = args[i].match(/^--vscode-user=(.+)$/);
1350
+ if (eqMatch) return eqMatch[1];
1351
+ }
1352
+ return false;
1353
+ }
1316
1354
  //#endregion
1317
1355
  //#region src/modes.ts
1318
1356
  function isBuiltinMode(mode) {
@@ -1342,15 +1380,30 @@ function hasAppSandboxEntitlement(entitlements) {
1342
1380
  if (new RegExp(`<key>\\s*${SANDBOX_KEY.replace(/\./g, "\\.")}\\s*</key>\\s*<false\\s*/>`, "i").test(entitlements)) return false;
1343
1381
  return new RegExp(`${SANDBOX_KEY.replace(/\./g, "\\.")}\\s*[=:]\\s*(1|true)`, "i").test(entitlements);
1344
1382
  }
1345
- function setupVSCodeProfile(home) {
1346
- const dataDir = join(home, ".vscode-sandbox");
1383
+ function resolveProfileDir(home, profile) {
1384
+ if (typeof profile === "string") return resolve(profile.replace(/^~\//, home + "/"));
1385
+ return join(home, ".vscode-sandbox");
1386
+ }
1387
+ async function setupVSCodeProfile(home, profile) {
1388
+ const dataDir = resolveProfileDir(home, profile);
1347
1389
  const globalExt = join(home, ".vscode", "extensions");
1348
1390
  const localExt = join(dataDir, "extensions");
1349
1391
  mkdirSync(dataDir, { recursive: true });
1350
1392
  if (!existsSync(localExt) && existsSync(globalExt)) {
1351
- console.error(fmt.detail("copying extensions from global install..."));
1352
- cpSync(globalExt, localExt, { recursive: true });
1393
+ const rl = createInterface({
1394
+ input: process$1.stdin,
1395
+ output: process$1.stderr
1396
+ });
1397
+ const answer = await new Promise((res) => {
1398
+ rl.question(`${fmt.info(`copy extensions to ${dataDir}?`)} [Y/n] `, res);
1399
+ });
1400
+ rl.close();
1401
+ if (!answer || answer.match(/^y(es)?$/i)) {
1402
+ console.error(fmt.detail("copying extensions from global install..."));
1403
+ cpSync(globalExt, localExt, { recursive: true });
1404
+ }
1353
1405
  }
1406
+ console.error(fmt.detail(`profile: ${dataDir}`));
1354
1407
  }
1355
1408
  function buildCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1356
1409
  if (isBuiltinMode(mode)) return buildBuiltinCommand(mode, appArgs);
@@ -1358,10 +1411,20 @@ function buildCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1358
1411
  }
1359
1412
  function buildBuiltinCommand(mode, appArgs) {
1360
1413
  switch (mode) {
1361
- case "term": return {
1362
- bin: process$1.env.SHELL ?? "/bin/zsh",
1363
- args: ["-l"]
1364
- };
1414
+ case "term": {
1415
+ const shell = process$1.env.SHELL ?? "/bin/zsh";
1416
+ if (!existsSync(shell)) {
1417
+ console.error(fmt.warn(`shell not found: ${shell}, falling back to /bin/zsh`));
1418
+ return {
1419
+ bin: "/bin/zsh",
1420
+ args: ["-l"]
1421
+ };
1422
+ }
1423
+ return {
1424
+ bin: shell,
1425
+ args: ["-l"]
1426
+ };
1427
+ }
1365
1428
  case "claude": return {
1366
1429
  bin: "claude",
1367
1430
  args: []
@@ -1387,8 +1450,8 @@ function buildAppCommand(mode, workDirs, home, profileSandbox, appArgs, apps) {
1387
1450
  }
1388
1451
  const bin = executableFromBundle(resolvedPath, app);
1389
1452
  const args = [];
1390
- if (mode === "code" && profileSandbox) {
1391
- const dataDir = join(home, ".vscode-sandbox");
1453
+ if (profileSandbox) {
1454
+ const dataDir = resolveProfileDir(home, profileSandbox);
1392
1455
  args.push("--user-data-dir", join(dataDir, "data"));
1393
1456
  args.push("--extensions-dir", join(dataDir, "extensions"));
1394
1457
  }
@@ -1454,6 +1517,26 @@ function bringAppToFront(mode, apps) {
1454
1517
  }, 250);
1455
1518
  }
1456
1519
  //#endregion
1520
+ //#region src/paths.ts
1521
+ /** Expand glob patterns (e.g. ~/work/*) in path lists. Only directories are matched. */
1522
+ function expandGlobs(paths, home) {
1523
+ const result = [];
1524
+ for (const p of paths) {
1525
+ const resolved = p.replace(/^~(\/|$)/, home + "/");
1526
+ if (!resolved.includes("*")) {
1527
+ result.push(resolved);
1528
+ continue;
1529
+ }
1530
+ const dir = dirname(resolved);
1531
+ const pattern = basename(resolved);
1532
+ const regex = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
1533
+ try {
1534
+ for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isDirectory() && regex.test(entry.name)) result.push(join(dir, entry.name));
1535
+ } catch {}
1536
+ }
1537
+ return result;
1538
+ }
1539
+ //#endregion
1457
1540
  //#region src/help.ts
1458
1541
  function printHelp(version) {
1459
1542
  const HOME = process.env.HOME;
@@ -1485,9 +1568,10 @@ Options:
1485
1568
  --dry show what will be protected, don't launch
1486
1569
  --verbose print the generated sandbox profile
1487
1570
  --background run in background, log output to /tmp/bx-<pid>.log
1488
- --profile-sandbox use an isolated VSCode profile (code mode only)
1571
+ --vscode-user [path] use an isolated app profile (default: ~/.vscode-sandbox)
1489
1572
  -v, --version show version
1490
1573
  -h, --help show this help
1574
+ --docs open documentation in browser
1491
1575
 
1492
1576
  Configuration:
1493
1577
  ~/.bxconfig.toml app definitions (TOML):
@@ -1498,6 +1582,8 @@ Configuration:
1498
1582
  path = "..." explicit executable path
1499
1583
  args = ["..."] extra arguments
1500
1584
  passPaths = true|false|N|[...] paths passed as launch args
1585
+ profile = true|"path" use an isolated app profile
1586
+ paths = ["~/work/*"] default workdirs (globs supported)
1501
1587
  background = true run in background by default
1502
1588
  built-in apps (code, xcode) can be overridden
1503
1589
  ~/.bxignore sandbox rules (one per line):
@@ -1524,7 +1610,7 @@ function kindIcon(kind) {
1524
1610
  default: return `${RED}✖${RESET}`;
1525
1611
  }
1526
1612
  }
1527
- function insertPath(root, homeParts, absPath, kind, isDir) {
1613
+ function insertPath(root, absPath, kind, isDir) {
1528
1614
  const parts = absPath.split("/").filter(Boolean);
1529
1615
  let node = root;
1530
1616
  for (const part of parts) {
@@ -1539,7 +1625,6 @@ function insertPath(root, homeParts, absPath, kind, isDir) {
1539
1625
  * Intermediate directories on the home path are kept as navigation context. */
1540
1626
  function pruneTree(node, currentParts, homeParts, depth) {
1541
1627
  if (node.kind) return true;
1542
- depth < homeParts.length && (currentParts[depth], homeParts[depth]);
1543
1628
  for (const [name, child] of [...node.children]) if (!pruneTree(child, [...currentParts, name], homeParts, depth + 1)) node.children.delete(name);
1544
1629
  return node.children.size > 0;
1545
1630
  }
@@ -1547,7 +1632,7 @@ function isDirectory(path) {
1547
1632
  try {
1548
1633
  return statSync(path).isDirectory();
1549
1634
  } catch {
1550
- return path.slice(path.lastIndexOf("/") + 1).startsWith(".");
1635
+ return true;
1551
1636
  }
1552
1637
  }
1553
1638
  function printNode(node, prefix) {
@@ -1567,11 +1652,11 @@ function printNode(node, prefix) {
1567
1652
  function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDirs, systemDenyPaths = [] }) {
1568
1653
  const root = { children: /* @__PURE__ */ new Map() };
1569
1654
  const homeParts = home.split("/").filter(Boolean);
1570
- for (const dir of blockedDirs) insertPath(root, homeParts, dir, "blocked", true);
1571
- for (const path of ignoredPaths) insertPath(root, homeParts, path, "ignored", isDirectory(path));
1572
- for (const dir of readOnlyDirs) insertPath(root, homeParts, dir, "read-only", true);
1573
- for (const dir of workDirs) insertPath(root, homeParts, dir, "workdir", true);
1574
- for (const dir of systemDenyPaths) insertPath(root, homeParts, dir, "blocked", true);
1655
+ for (const dir of blockedDirs) insertPath(root, dir, "blocked", isDirectory(dir));
1656
+ for (const path of ignoredPaths) insertPath(root, path, "ignored", isDirectory(path));
1657
+ for (const dir of readOnlyDirs) insertPath(root, dir, "read-only", true);
1658
+ for (const dir of workDirs) insertPath(root, dir, "workdir", true);
1659
+ for (const dir of systemDenyPaths) insertPath(root, dir, "blocked", true);
1575
1660
  pruneTree(root, [], homeParts, 0);
1576
1661
  console.log(`\n${CYAN}/${RESET}`);
1577
1662
  printNode(root, "");
@@ -1579,7 +1664,7 @@ function printDryRunTree({ home, blockedDirs, ignoredPaths, readOnlyDirs, workDi
1579
1664
  }
1580
1665
  //#endregion
1581
1666
  //#region src/index.ts
1582
- const VERSION = "1.0.2";
1667
+ const VERSION = "1.2.0";
1583
1668
  const __dirname = dirname(fileURLToPath(import.meta.url));
1584
1669
  if (process$1.argv.includes("--version") || process$1.argv.includes("-v")) {
1585
1670
  console.log(`bx ${VERSION}`);
@@ -1589,6 +1674,10 @@ if (process$1.argv.includes("--help") || process$1.argv.includes("-h")) {
1589
1674
  printHelp(VERSION);
1590
1675
  process$1.exit(0);
1591
1676
  }
1677
+ if (process$1.argv.includes("--docs")) {
1678
+ execSync("open https://github.com/holtwick/bx-mac");
1679
+ process$1.exit(0);
1680
+ }
1592
1681
  if (!process$1.env.HOME) {
1593
1682
  console.error(`\n${fmt.error("$HOME environment variable is not set")}\n`);
1594
1683
  process$1.exit(1);
@@ -1598,9 +1687,13 @@ async function main() {
1598
1687
  checkOwnSandbox();
1599
1688
  checkExternalSandbox();
1600
1689
  const apps = getAvailableApps(loadConfig(HOME));
1601
- const { mode, workArgs, verbose, dry, profileSandbox, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1690
+ const { mode, workArgs, verbose, dry, vscodeUser: vscodeUserFlag, background: backgroundFlag, appArgs, implicit } = parseArgs(getValidModes(apps));
1602
1691
  const app = apps[mode];
1603
- const workDirs = (implicit && app?.paths?.length ? app.paths : workArgs).map((a) => realpathSync(resolve(a.replace(/^~\//, HOME + "/"))));
1692
+ const workDirs = expandGlobs(implicit && app?.paths?.length ? app.paths : workArgs, HOME).map((a) => realpathSync(resolve(a)));
1693
+ if (workDirs.length === 0) {
1694
+ console.error(`\n${fmt.error("no matching working directories found")}\n`);
1695
+ process$1.exit(1);
1696
+ }
1604
1697
  if (implicit && !app?.paths?.length) {
1605
1698
  if (workDirs.some((d) => d === HOME)) {
1606
1699
  console.error(`\n${fmt.error("no working directory specified and current directory is $HOME")}\n`);
@@ -1608,6 +1701,8 @@ async function main() {
1608
1701
  console.error(fmt.detail(`Config: set default paths in ~/.bxconfig.toml:\n`));
1609
1702
  console.error(fmt.detail(`[${mode}]`));
1610
1703
  console.error(fmt.detail(`paths = ["~/work/my-project"]\n`));
1704
+ console.error(fmt.detail(`Run bx --help for more info.`));
1705
+ console.error(fmt.detail(`Docs: https://github.com/holtwick/bx-mac\n`));
1611
1706
  process$1.exit(1);
1612
1707
  }
1613
1708
  if (!dry) await confirmLaunch(workDirs[0], mode);
@@ -1615,7 +1710,8 @@ async function main() {
1615
1710
  if (!dry) checkVSCodeTerminal();
1616
1711
  checkWorkDirs(workDirs, HOME);
1617
1712
  await checkAppAlreadyRunning(mode, apps);
1618
- if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
1713
+ const profileSandbox = vscodeUserFlag !== false ? vscodeUserFlag : app?.profile ?? false;
1714
+ if (profileSandbox) await setupVSCodeProfile(HOME, profileSandbox);
1619
1715
  const { allowed, readOnly } = parseHomeConfig(HOME, workDirs);
1620
1716
  const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, new Set([...allowed, ...readOnly]));
1621
1717
  const ignoredPaths = collectIgnoredPaths(HOME, workDirs);
@@ -1678,7 +1774,12 @@ async function main() {
1678
1774
  CODEBOX_SANDBOX: "1"
1679
1775
  }
1680
1776
  });
1777
+ child.on("error", (err) => {
1778
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1779
+ process$1.exit(1);
1780
+ });
1681
1781
  child.unref();
1782
+ closeSync(logFd);
1682
1783
  bringAppToFront(mode, apps);
1683
1784
  console.error(fmt.info(`running in background (pid ${child.pid})`));
1684
1785
  console.error(fmt.detail(`log: ${logPath}`));
@@ -1702,7 +1803,6 @@ async function main() {
1702
1803
  CODEBOX_SANDBOX: "1"
1703
1804
  }
1704
1805
  });
1705
- bringAppToFront(mode, apps);
1706
1806
  const cleanup = () => {
1707
1807
  try {
1708
1808
  rmSync(tmpDir, {
@@ -1712,6 +1812,11 @@ async function main() {
1712
1812
  } catch {}
1713
1813
  };
1714
1814
  process$1.on("exit", cleanup);
1815
+ child.on("error", (err) => {
1816
+ console.error(fmt.error(`failed to start sandbox: ${err.message}`));
1817
+ process$1.exit(1);
1818
+ });
1819
+ bringAppToFront(mode, apps);
1715
1820
  child.on("close", (code) => {
1716
1821
  process$1.exit(code ?? 0);
1717
1822
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Sandbox any macOS app — only your project directory stays accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "devDependencies": {
50
50
  "@types/node": "^25.5.0",
51
- "rolldown": "^1.0.0-rc.12",
51
+ "rolldown": "^1.0.0-rc.13",
52
52
  "smol-toml": "^1.6.1",
53
53
  "vitest": "^4.1.2"
54
54
  },