bx-mac 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -87,6 +87,9 @@ bx claude ~/work/my-project
87
87
  # ⚡ Run a script in a sandbox
88
88
  bx exec ~/work/my-project -- python train.py
89
89
 
90
+ # 🔍 Preview what will be protected (no launch)
91
+ bx --dry ~/work/my-project
92
+
90
93
  # 🔍 See the generated sandbox profile
91
94
  bx --verbose ~/work/my-project
92
95
  ```
@@ -95,6 +98,7 @@ bx --verbose ~/work/my-project
95
98
 
96
99
  | Option | Description |
97
100
  |---|---|
101
+ | `--dry` | Show a tree of all protected, read-only, and accessible paths — don't launch anything |
98
102
  | `--verbose` | Print the generated sandbox profile to stderr |
99
103
  | `--profile-sandbox` | Use an isolated VSCode profile (separate extensions/settings, `code` mode only) |
100
104
 
@@ -128,14 +132,25 @@ Deny rules are applied **in addition** to the built-in protected list:
128
132
 
129
133
  ### `<project>/.bxignore`
130
134
 
131
- Block paths within the working directory. Supports glob patterns. bx searches for `.bxignore` files **recursively** through the entire project tree (skipping `.`-prefixed dirs and `node_modules`), so you can place them in subdirectories to hide secrets close to where they live.
135
+ Block paths within the working directory. Uses [`.gitignore`-style pattern matching](https://git-scm.com/docs/gitignore#_pattern_format):
136
+
137
+ | Pattern | Matches | Why |
138
+ |---|---|---|
139
+ | `.env` | `.env` at any depth | No `/` → recursive |
140
+ | `.env.*` | `.env.local`, `sub/.env.production` | No `/` → recursive |
141
+ | `*.pem` | `key.pem`, `sub/deep/cert.pem` | No `/` → recursive |
142
+ | `secrets/` | `secrets/` at any depth | Trailing `/` is a dir marker, not a path separator |
143
+ | `/.env` | Only `<workdir>/.env` | Leading `/` → anchored to root |
144
+ | `config/secrets` | Only `<workdir>/config/secrets` | Contains `/` → relative to workdir |
145
+
146
+ bx searches for `.bxignore` files **recursively** through the entire project tree (skipping `.`-prefixed dirs and `node_modules`), so you can place them in subdirectories to hide secrets close to where they live.
132
147
 
133
148
  ```gitignore
134
149
  .env
135
150
  .env.*
136
151
  secrets/
137
- **/*.pem
138
- **/*.key
152
+ *.pem
153
+ *.key
139
154
  ```
140
155
 
141
156
  For example, a monorepo might have:
package/dist/bx-native ADDED
Binary file
package/dist/bx.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- const __VERSION__ = "0.5.0";
2
+ const __VERSION__ = "0.7.0";
3
3
  import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ import { createInterface } from "node:readline";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import process from "node:process";
8
9
  //#region src/guards.ts
@@ -76,6 +77,7 @@ const MODES = [
76
77
  function parseArgs() {
77
78
  const rawArgs = process.argv.slice(2);
78
79
  const verbose = rawArgs.includes("--verbose");
80
+ const dry = rawArgs.includes("--dry");
79
81
  const profileSandbox = rawArgs.includes("--profile-sandbox");
80
82
  const positional = rawArgs.filter((a) => !a.startsWith("--"));
81
83
  const doubleDashIdx = rawArgs.indexOf("--");
@@ -83,11 +85,14 @@ function parseArgs() {
83
85
  const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
84
86
  let mode = "code";
85
87
  let workArgs;
88
+ let explicit = false;
86
89
  if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
87
90
  mode = beforeDash[0];
88
91
  workArgs = beforeDash.slice(1);
92
+ explicit = true;
89
93
  } else workArgs = beforeDash;
90
94
  if (workArgs.length === 0) workArgs = ["."];
95
+ else explicit = true;
91
96
  if (mode === "exec" && execCmd.length === 0) {
92
97
  console.error("sandbox: exec mode requires a command after \"--\"");
93
98
  console.error("usage: bx exec [workdir...] -- command [args...]");
@@ -97,8 +102,10 @@ function parseArgs() {
97
102
  mode,
98
103
  workArgs,
99
104
  verbose,
105
+ dry,
100
106
  profileSandbox,
101
- execCmd
107
+ execCmd,
108
+ implicit: !explicit
102
109
  };
103
110
  }
104
111
  //#endregion
@@ -121,10 +128,22 @@ function parseLines(filePath) {
121
128
  return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
122
129
  }
123
130
  /**
131
+ * Convert a .bxignore line to a glob pattern following .gitignore semantics:
132
+ * - Leading "/" anchors to the base dir (stripped before globbing)
133
+ * - Patterns without "/" (except trailing) match recursively via ** / prefix
134
+ * - Patterns with "/" (non-leading, non-trailing) are relative to baseDir
135
+ * - Trailing "/" marks directories only and doesn't count as path separator
136
+ */
137
+ function toGlobPattern(line) {
138
+ if (line.startsWith("/")) return line.slice(1);
139
+ if ((line.endsWith("/") ? line.slice(0, -1) : line).includes("/")) return line;
140
+ return `**/${line}`;
141
+ }
142
+ /**
124
143
  * Apply a single .bxignore file: resolve glob patterns relative to baseDir.
125
144
  */
126
145
  function applyIgnoreFile(filePath, baseDir, ignored) {
127
- for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
146
+ for (const line of parseLines(filePath)) for (const match of globSync(toGlobPattern(line), { cwd: baseDir })) ignored.push(resolve(baseDir, match));
128
147
  }
129
148
  /**
130
149
  * Recursively find and apply .bxignore files in a directory tree.
@@ -190,7 +209,13 @@ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
190
209
  for (const name of readdirSync(parentDir)) {
191
210
  if (name.startsWith(".")) continue;
192
211
  const fullPath = join(parentDir, name);
193
- if (!statSync(fullPath).isDirectory()) continue;
212
+ let isDir;
213
+ try {
214
+ isDir = statSync(fullPath).isDirectory();
215
+ } catch {
216
+ continue;
217
+ }
218
+ if (!isDir) continue;
194
219
  if (parentDir === home && name === "Library") continue;
195
220
  if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
196
221
  if (allowedDirs.has(fullPath)) continue;
@@ -211,7 +236,7 @@ function collectIgnoredPaths(home, workDirs) {
211
236
  const globalIgnore = join(home, ".bxignore");
212
237
  if (existsSync(globalIgnore)) {
213
238
  const denyLines = parseLines(globalIgnore).filter((l) => !l.match(/^(RW|RO):/i));
214
- for (const line of denyLines) for (const match of globSync(line, { cwd: home })) ignored.push(resolve(home, match));
239
+ for (const line of denyLines) for (const match of globSync(toGlobPattern(line), { cwd: home })) ignored.push(resolve(home, match));
215
240
  }
216
241
  for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
217
242
  return ignored;
@@ -222,7 +247,11 @@ function collectIgnoredPaths(home, workDirs) {
222
247
  function generateProfile(workDirs, blockedDirs, ignoredPaths, readOnlyDirs = []) {
223
248
  const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
224
249
  const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
225
- return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
250
+ let isDir = false;
251
+ try {
252
+ isDir = existsSync(p) && statSync(p).isDirectory();
253
+ } catch {}
254
+ return isDir ? ` (subpath "${p}")` : ` (literal "${p}")`;
226
255
  }).join("\n")}\n)\n` : "";
227
256
  const readOnlyRules = readOnlyDirs.length > 0 ? `\n; Read-only directories\n(deny file-write*\n${readOnlyDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}\n)\n` : "";
228
257
  return `; Auto-generated sandbox profile
@@ -305,6 +334,7 @@ Usage:
305
334
  bx exec [workdir...] -- command [args...] arbitrary command
306
335
 
307
336
  Options:
337
+ --dry show what will be protected, don't launch
308
338
  --verbose print the generated sandbox profile
309
339
  --profile-sandbox use an isolated VSCode profile (code mode only)
310
340
  -v, --version show version
@@ -315,17 +345,30 @@ Configuration:
315
345
  path block access (deny)
316
346
  rw:path allow read-write access
317
347
  ro:path allow read-only access
318
- <workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
348
+ <workdir>/.bxignore blocked paths in project (.gitignore-style matching)
319
349
 
320
350
  https://github.com/holtwick/bx-mac`);
321
351
  process.exit(0);
322
352
  }
323
- checkOwnSandbox();
324
- checkVSCodeTerminal();
325
- checkExternalSandbox();
326
- const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
353
+ const { mode, workArgs, verbose, dry, profileSandbox, execCmd, implicit } = parseArgs();
327
354
  const HOME = process.env.HOME;
328
355
  const WORK_DIRS = workArgs.map((a) => resolve(a));
356
+ if (implicit && !dry) {
357
+ const rl = createInterface({
358
+ input: process.stdin,
359
+ output: process.stderr
360
+ });
361
+ const answer = await new Promise((res) => {
362
+ rl.question(`sandbox: open ${WORK_DIRS[0]} in VSCode? [Y/n] `, res);
363
+ });
364
+ rl.close();
365
+ if (answer && !answer.match(/^y(es)?$/i)) process.exit(0);
366
+ }
367
+ if (!dry) {
368
+ checkOwnSandbox();
369
+ checkVSCodeTerminal();
370
+ checkExternalSandbox();
371
+ }
329
372
  checkWorkDirs(WORK_DIRS, HOME);
330
373
  if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
331
374
  const { allowed, readOnly } = parseHomeConfig(HOME, WORK_DIRS);
@@ -335,8 +378,6 @@ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
335
378
  if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
336
379
  if (readOnly.size > 0) console.error(`sandbox: ${readOnly.size} read-only director${readOnly.size === 1 ? "y" : "ies"}`);
337
380
  const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths, [...readOnly]);
338
- const profilePath = join("/tmp", `bx-${process.pid}.sb`);
339
- writeFileSync(profilePath, profile);
340
381
  const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
341
382
  console.error(`sandbox: ${mode} mode, working directory: ${dirLabel}`);
342
383
  if (verbose) {
@@ -344,6 +385,54 @@ if (verbose) {
344
385
  console.error(profile);
345
386
  console.error("--- End of profile ---\n");
346
387
  }
388
+ if (dry) {
389
+ const R = "\x1B[31m", G = "\x1B[32m", Y = "\x1B[33m", C = "\x1B[36m", D = "\x1B[2m", X = "\x1B[0m";
390
+ const icon = (k) => k === "read-only" ? `${Y}◉${X}` : k === "workdir" ? `${G}✔${X}` : `${R}✖${X}`;
391
+ const tag = (k) => `${D}${k}${X}`;
392
+ const root = { children: /* @__PURE__ */ new Map() };
393
+ function addEntry(absPath, kind, isDir) {
394
+ const parts = (absPath.startsWith(HOME + "/") ? absPath.slice(HOME.length + 1) : absPath).split("/");
395
+ let node = root;
396
+ for (const part of parts) {
397
+ if (!node.children.has(part)) node.children.set(part, { children: /* @__PURE__ */ new Map() });
398
+ node = node.children.get(part);
399
+ }
400
+ node.kind = kind;
401
+ node.isDir = isDir;
402
+ }
403
+ for (const d of blockedDirs) addEntry(d, "blocked", true);
404
+ for (const p of ignoredPaths) {
405
+ let isDir = false;
406
+ try {
407
+ isDir = statSync(p).isDirectory();
408
+ } catch {
409
+ if (p.slice(p.lastIndexOf("/") + 1).startsWith(".")) isDir = true;
410
+ }
411
+ addEntry(p, "ignored", isDir);
412
+ }
413
+ for (const d of readOnly) addEntry(d, "read-only", true);
414
+ for (const d of WORK_DIRS) addEntry(d, "workdir", true);
415
+ function printTree(node, prefix) {
416
+ const entries = [...node.children.entries()].sort((a, b) => a[0].localeCompare(b[0]));
417
+ for (let i = 0; i < entries.length; i++) {
418
+ const [name, child] = entries[i];
419
+ const last = i === entries.length - 1;
420
+ const connector = last ? "└── " : "├── ";
421
+ const pipe = last ? " " : "│ ";
422
+ if (child.kind) {
423
+ const suffix = child.isDir ? "/" : "";
424
+ console.log(`${prefix}${connector}${icon(child.kind)} ${name}${suffix} ${tag(child.kind)}`);
425
+ } else console.log(`${prefix}${connector}${C}${name}/${X}`);
426
+ if (child.children.size > 0) printTree(child, prefix + pipe);
427
+ }
428
+ }
429
+ console.log(`\n${C}~/${X}`);
430
+ printTree(root, "");
431
+ console.log(`\n${R}✖${X} = denied ${Y}◉${X} = read-only ${G}✔${X} = read-write\n`);
432
+ process.exit(0);
433
+ }
434
+ const profilePath = join("/tmp", `bx-${process.pid}.sb`);
435
+ writeFileSync(profilePath, profile);
347
436
  const cmd = buildCommand(mode, WORK_DIRS, HOME, profileSandbox, execCmd);
348
437
  spawn("sandbox-exec", [
349
438
  "-f",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Launch apps in a macOS sandbox — only the project directory is accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,10 @@
10
10
  "dist"
11
11
  ],
12
12
  "scripts": {
13
+ "start": "npm run build && node ./dist/bx.js",
13
14
  "build": "rolldown -c",
15
+ "build:native": "bun build src/index.ts --compile --outfile dist/bx-native --define \"__VERSION__=\\\"$(node -p \"require('./package.json').version\")\\\"\"",
16
+ "sign": "./scripts/sign.sh",
14
17
  "test": "vitest run",
15
18
  "prepublishOnly": "npm run build",
16
19
  "post:release": "./scripts/release.sh"