bx-mac 0.2.1 → 0.3.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 (2) hide show
  1. package/dist/bx.js +223 -109
  2. package/package.json +4 -3
package/dist/bx.js CHANGED
@@ -1,21 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ const __VERSION__ = "0.3.0";
2
3
  import { accessSync, constants, cpSync, existsSync, globSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
4
  import { dirname, join, resolve } from "node:path";
4
5
  import { spawn } from "node:child_process";
5
6
  import { fileURLToPath } from "node:url";
6
- //#region src/index.ts
7
- const __dirname = dirname(fileURLToPath(import.meta.url));
8
- if (process.env.CODEBOX_SANDBOX === "1") {
9
- console.error("sandbox: ERROR already running inside a bx sandbox.");
10
- console.error("sandbox: Nesting sandbox-exec causes silent failures. Aborting.");
11
- process.exit(1);
7
+ import process from "node:process";
8
+ //#region src/guards.ts
9
+ /**
10
+ * Abort if we're already inside a bx sandbox (env var set by us).
11
+ */
12
+ function checkOwnSandbox() {
13
+ if (process.env.CODEBOX_SANDBOX === "1") {
14
+ console.error("sandbox: ERROR — already running inside a bx sandbox.");
15
+ console.error("sandbox: Nesting sandbox-exec causes silent failures. Aborting.");
16
+ process.exit(1);
17
+ }
12
18
  }
13
- if (process.env.VSCODE_PID) {
14
- console.error("sandbox: WARNING running from inside a VSCode terminal.");
15
- console.error("sandbox: This will launch a *new* instance in a sandbox.");
16
- console.error("sandbox: The current VSCode instance will NOT be sandboxed.");
19
+ /**
20
+ * Warn if launched from inside a VSCode terminal.
21
+ */
22
+ function checkVSCodeTerminal() {
23
+ if (process.env.VSCODE_PID) {
24
+ console.error("sandbox: WARNING — running from inside a VSCode terminal.");
25
+ console.error("sandbox: This will launch a *new* instance in a sandbox.");
26
+ console.error("sandbox: The current VSCode instance will NOT be sandboxed.");
27
+ }
17
28
  }
18
- function isAlreadySandboxed() {
29
+ /**
30
+ * Detect if we're inside an unknown sandbox by probing well-known
31
+ * directories that exist on every Mac but would be blocked.
32
+ */
33
+ function checkExternalSandbox() {
19
34
  for (const dir of [
20
35
  "Documents",
21
36
  "Desktop",
@@ -25,141 +40,187 @@ function isAlreadySandboxed() {
25
40
  try {
26
41
  accessSync(target, constants.R_OK);
27
42
  } catch (e) {
28
- if (e.code === "EPERM") return true;
43
+ if (e.code === "EPERM") {
44
+ console.error("sandbox: ERROR — already running inside a sandbox!");
45
+ console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
46
+ process.exit(1);
47
+ }
29
48
  }
30
49
  }
31
- return false;
32
- }
33
- if (isAlreadySandboxed()) {
34
- console.error("sandbox: ERROR — already running inside a sandbox!");
35
- console.error("sandbox: Nesting sandbox-exec may cause silent failures. Aborting.");
36
- process.exit(1);
37
50
  }
51
+ //#endregion
52
+ //#region src/args.ts
38
53
  const MODES = [
39
54
  "code",
40
55
  "term",
41
56
  "claude",
42
57
  "exec"
43
58
  ];
44
- const rawArgs = process.argv.slice(2);
45
- const verbose = rawArgs.includes("--verbose");
46
- const profileSandbox = rawArgs.includes("--profile-sandbox");
47
- const positional = rawArgs.filter((a) => !a.startsWith("--"));
48
- const doubleDashIdx = rawArgs.indexOf("--");
49
- const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
50
- const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
51
- let mode = "code";
52
- let workArg = ".";
53
- if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
54
- mode = beforeDash[0];
55
- workArg = beforeDash[1] ?? ".";
56
- } else if (beforeDash.length > 0) workArg = beforeDash[0];
57
- if (mode === "exec" && execCmd.length === 0) {
58
- console.error("sandbox: exec mode requires a command after \"--\"");
59
- console.error("usage: bx exec [workdir] -- command [args...]");
60
- process.exit(1);
59
+ function parseArgs() {
60
+ const rawArgs = process.argv.slice(2);
61
+ const verbose = rawArgs.includes("--verbose");
62
+ const profileSandbox = rawArgs.includes("--profile-sandbox");
63
+ const positional = rawArgs.filter((a) => !a.startsWith("--"));
64
+ const doubleDashIdx = rawArgs.indexOf("--");
65
+ const execCmd = doubleDashIdx >= 0 ? rawArgs.slice(doubleDashIdx + 1) : [];
66
+ const beforeDash = doubleDashIdx >= 0 ? rawArgs.slice(0, doubleDashIdx).filter((a) => !a.startsWith("--")) : positional;
67
+ let mode = "code";
68
+ let workArgs;
69
+ if (beforeDash.length > 0 && MODES.includes(beforeDash[0])) {
70
+ mode = beforeDash[0];
71
+ workArgs = beforeDash.slice(1);
72
+ } else workArgs = beforeDash;
73
+ if (workArgs.length === 0) workArgs = ["."];
74
+ if (mode === "exec" && execCmd.length === 0) {
75
+ console.error("sandbox: exec mode requires a command after \"--\"");
76
+ console.error("usage: bx exec [workdir...] -- command [args...]");
77
+ process.exit(1);
78
+ }
79
+ return {
80
+ mode,
81
+ workArgs,
82
+ verbose,
83
+ profileSandbox,
84
+ execCmd
85
+ };
61
86
  }
62
- const HOME = process.env.HOME;
63
- const SCRIPT_DIR = __dirname;
64
- const WORK_DIR = resolve(workArg);
65
- const allowedDirs = new Set([WORK_DIR]);
66
- const sandboxAllowPath = join(HOME, ".bxallow");
67
- if (existsSync(sandboxAllowPath)) for (const raw of readFileSync(sandboxAllowPath, "utf-8").split("\n")) {
68
- const line = raw.trim();
69
- if (!line || line.startsWith("#")) continue;
70
- const absolute = resolve(HOME, line);
71
- if (existsSync(absolute) && statSync(absolute).isDirectory()) allowedDirs.add(absolute);
87
+ //#endregion
88
+ //#region src/profile.ts
89
+ const PROTECTED_DOTDIRS = [
90
+ ".Trash",
91
+ ".ssh",
92
+ ".gnupg",
93
+ ".docker",
94
+ ".zsh_sessions",
95
+ ".cargo",
96
+ ".gradle",
97
+ ".gem"
98
+ ];
99
+ /**
100
+ * Parse a config file with one entry per line (supports # comments).
101
+ */
102
+ function parseLines(filePath) {
103
+ if (!existsSync(filePath)) return [];
104
+ return readFileSync(filePath, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
72
105
  }
73
- const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
74
- const VSCODE_DATA = join(HOME, ".vscode-sandbox");
75
- const VSCODE_EXTENSIONS_GLOBAL = join(HOME, ".vscode", "extensions");
76
- const VSCODE_EXTENSIONS_LOCAL = join(VSCODE_DATA, "extensions");
77
- if (mode === "code" && profileSandbox) {
78
- mkdirSync(VSCODE_DATA, { recursive: true });
79
- if (!existsSync(VSCODE_EXTENSIONS_LOCAL) && existsSync(VSCODE_EXTENSIONS_GLOBAL)) {
80
- console.error("sandbox: copying extensions from global install...");
81
- cpSync(VSCODE_EXTENSIONS_GLOBAL, VSCODE_EXTENSIONS_LOCAL, { recursive: true });
106
+ /**
107
+ * Apply a single .bxignore file: resolve glob patterns relative to baseDir.
108
+ */
109
+ function applyIgnoreFile(filePath, baseDir, ignored) {
110
+ for (const line of parseLines(filePath)) for (const match of globSync(line, { cwd: baseDir })) ignored.push(resolve(baseDir, match));
111
+ }
112
+ /**
113
+ * Recursively find and apply .bxignore files in a directory tree.
114
+ */
115
+ function collectIgnoreFilesRecursive(dir, ignored) {
116
+ const ignoreFile = join(dir, ".bxignore");
117
+ if (existsSync(ignoreFile)) applyIgnoreFile(ignoreFile, dir, ignored);
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(dir);
121
+ } catch {
122
+ return;
82
123
  }
124
+ for (const name of entries) {
125
+ if (name.startsWith(".") || name === "node_modules") continue;
126
+ const fullPath = join(dir, name);
127
+ try {
128
+ if (statSync(fullPath).isDirectory()) collectIgnoreFilesRecursive(fullPath, ignored);
129
+ } catch {}
130
+ }
131
+ }
132
+ /**
133
+ * Parse ~/.bxallow and return a set of all allowed directories.
134
+ */
135
+ function parseAllowedDirs(home, workDirs) {
136
+ const allowed = new Set(workDirs);
137
+ for (const line of parseLines(join(home, ".bxallow"))) {
138
+ const absolute = resolve(home, line);
139
+ if (existsSync(absolute) && statSync(absolute).isDirectory()) allowed.add(absolute);
140
+ }
141
+ return allowed;
83
142
  }
84
- function collectBlockedDirs(parentDir) {
143
+ /**
144
+ * Recursively collect directories to block under parentDir.
145
+ * Never blocks a parent of an allowed path — instead descends and blocks siblings.
146
+ */
147
+ function collectBlockedDirs(parentDir, home, scriptDir, allowedDirs) {
85
148
  const blocked = [];
86
149
  for (const name of readdirSync(parentDir)) {
87
150
  if (name.startsWith(".")) continue;
88
151
  const fullPath = join(parentDir, name);
89
152
  if (!statSync(fullPath).isDirectory()) continue;
90
- if (parentDir === HOME && name === "Library") continue;
91
- if (SCRIPT_DIR.startsWith(fullPath + "/") || SCRIPT_DIR === fullPath) continue;
153
+ if (parentDir === home && name === "Library") continue;
154
+ if (scriptDir.startsWith(fullPath + "/") || scriptDir === fullPath) continue;
92
155
  if (allowedDirs.has(fullPath)) continue;
93
156
  if ([...allowedDirs].some((d) => d.startsWith(fullPath + "/"))) {
94
- blocked.push(...collectBlockedDirs(fullPath));
157
+ blocked.push(...collectBlockedDirs(fullPath, home, scriptDir, allowedDirs));
95
158
  continue;
96
159
  }
97
160
  blocked.push(fullPath);
98
161
  }
99
162
  return blocked;
100
163
  }
101
- const blockedDirs = collectBlockedDirs(HOME);
102
- const PROTECTED_DOTDIRS = [
103
- ".Trash",
104
- ".ssh",
105
- ".gnupg",
106
- ".docker",
107
- ".zsh_sessions",
108
- ".cargo",
109
- ".gradle",
110
- ".gem"
111
- ];
112
- const ignoredPaths = PROTECTED_DOTDIRS.map((d) => join(HOME, d));
113
- function parseSandboxIgnore(filePath, baseDir) {
114
- if (!existsSync(filePath)) return;
115
- for (const raw of readFileSync(filePath, "utf-8").split("\n")) {
116
- const line = raw.trim();
117
- if (!line || line.startsWith("#")) continue;
118
- const matches = globSync(line, { cwd: baseDir });
119
- for (const match of matches) ignoredPaths.push(resolve(baseDir, match));
120
- }
164
+ /**
165
+ * Collect paths to deny from .bxignore files and built-in protected dotdirs.
166
+ * Searches ~/.bxignore and recursively through all workdirs.
167
+ */
168
+ function collectIgnoredPaths(home, workDirs) {
169
+ const ignored = PROTECTED_DOTDIRS.map((d) => join(home, d));
170
+ applyIgnoreFile(join(home, ".bxignore"), home, ignored);
171
+ for (const workDir of workDirs) collectIgnoreFilesRecursive(workDir, ignored);
172
+ return ignored;
121
173
  }
122
- parseSandboxIgnore(join(HOME, ".bxignore"), HOME);
123
- parseSandboxIgnore(join(WORK_DIR, ".bxignore"), WORK_DIR);
124
- const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
125
- if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
126
- const profile = `; Auto-generated sandbox profile
127
- ; Working directory: ${WORK_DIR}
174
+ /**
175
+ * Generate the SBPL sandbox profile string.
176
+ */
177
+ function generateProfile(workDirs, blockedDirs, ignoredPaths) {
178
+ const denyRules = blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n");
179
+ const ignoredRules = ignoredPaths.length > 0 ? `\n; Hidden paths from .bxignore\n(deny file*\n${ignoredPaths.map((p) => {
180
+ return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
181
+ }).join("\n")}\n)\n` : "";
182
+ return `; Auto-generated sandbox profile
183
+ ; Working directories: ${workDirs.join(", ")}
128
184
 
129
185
  (version 1)
130
186
  (allow default)
131
187
 
132
188
  ; Blocked directories (auto-generated from $HOME contents)
133
189
  (deny file*
134
- ${blockedDirs.map((dir) => ` (subpath "${dir}")`).join("\n")}
190
+ ${denyRules}
135
191
  )
136
-
137
- ${ignoredPaths.length > 0 ? `
138
- ; Hidden paths from .bxignore
139
- (deny file*
140
- ${ignoredPaths.map((p) => {
141
- return existsSync(p) && statSync(p).isDirectory() ? ` (subpath "${p}")` : ` (literal "${p}")`;
142
- }).join("\n")}
143
- )
144
- ` : ""}
192
+ ${ignoredRules}
145
193
  `;
146
- const profilePath = join("/tmp", `bx-${process.pid}.sb`);
147
- writeFileSync(profilePath, profile);
148
- console.error(`sandbox: ${mode} mode, working directory: ${WORK_DIR}`);
149
- if (verbose) {
150
- console.error("\n--- Generated sandbox profile ---");
151
- console.error(profile);
152
- console.error("--- End of profile ---\n");
153
194
  }
154
- function buildCommand() {
195
+ //#endregion
196
+ //#region src/modes.ts
197
+ const VSCODE_APP = "/Applications/Visual Studio Code.app/Contents/MacOS/Electron";
198
+ /**
199
+ * Prepare VSCode isolated profile if --profile-sandbox is set.
200
+ */
201
+ function setupVSCodeProfile(home) {
202
+ const dataDir = join(home, ".vscode-sandbox");
203
+ const globalExt = join(home, ".vscode", "extensions");
204
+ const localExt = join(dataDir, "extensions");
205
+ mkdirSync(dataDir, { recursive: true });
206
+ if (!existsSync(localExt) && existsSync(globalExt)) {
207
+ console.error("sandbox: copying extensions from global install...");
208
+ cpSync(globalExt, localExt, { recursive: true });
209
+ }
210
+ }
211
+ /**
212
+ * Build the command + args to run inside the sandbox for the given mode.
213
+ */
214
+ function buildCommand(mode, workDirs, home, profileSandbox, execCmd) {
155
215
  switch (mode) {
156
216
  case "code": {
217
+ const dataDir = join(home, ".vscode-sandbox");
157
218
  const args = ["--no-sandbox"];
158
219
  if (profileSandbox) {
159
- args.push("--user-data-dir", join(VSCODE_DATA, "data"));
160
- args.push("--extensions-dir", VSCODE_EXTENSIONS_LOCAL);
220
+ args.push("--user-data-dir", join(dataDir, "data"));
221
+ args.push("--extensions-dir", join(dataDir, "extensions"));
161
222
  }
162
- args.push(WORK_DIR);
223
+ args.push(...workDirs);
163
224
  return {
164
225
  bin: VSCODE_APP,
165
226
  args
@@ -171,7 +232,7 @@ function buildCommand() {
171
232
  };
172
233
  case "claude": return {
173
234
  bin: "claude",
174
- args: [WORK_DIR]
235
+ args: [workDirs[0]]
175
236
  };
176
237
  case "exec": return {
177
238
  bin: execCmd[0],
@@ -179,18 +240,71 @@ function buildCommand() {
179
240
  };
180
241
  }
181
242
  }
182
- const cmd = buildCommand();
243
+ //#endregion
244
+ //#region src/index.ts
245
+ const __dirname = dirname(fileURLToPath(import.meta.url));
246
+ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev";
247
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
248
+ console.log(`bx ${VERSION}`);
249
+ process.exit(0);
250
+ }
251
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
252
+ console.log(`bx ${VERSION} — launch apps in a macOS sandbox
253
+
254
+ Usage:
255
+ bx [workdir...] VSCode (default)
256
+ bx code [workdir...] VSCode
257
+ bx term [workdir...] sandboxed login shell
258
+ bx claude [workdir...] Claude Code CLI
259
+ bx exec [workdir...] -- command [args...] arbitrary command
260
+
261
+ Options:
262
+ --verbose print the generated sandbox profile
263
+ --profile-sandbox use an isolated VSCode profile (code mode only)
264
+ -v, --version show version
265
+ -h, --help show this help
266
+
267
+ Configuration:
268
+ ~/.bxallow extra allowed directories (one per line)
269
+ ~/.bxignore extra blocked paths in $HOME (one per line)
270
+ <workdir>/.bxignore blocked paths in project (supports globs, searched recursively)
271
+
272
+ https://github.com/holtwick/bx-mac`);
273
+ process.exit(0);
274
+ }
275
+ checkOwnSandbox();
276
+ checkVSCodeTerminal();
277
+ checkExternalSandbox();
278
+ const { mode, workArgs, verbose, profileSandbox, execCmd } = parseArgs();
279
+ const HOME = process.env.HOME;
280
+ const WORK_DIRS = workArgs.map((a) => resolve(a));
281
+ if (mode === "code" && profileSandbox) setupVSCodeProfile(HOME);
282
+ const blockedDirs = collectBlockedDirs(HOME, HOME, __dirname, parseAllowedDirs(HOME, WORK_DIRS));
283
+ const ignoredPaths = collectIgnoredPaths(HOME, WORK_DIRS);
284
+ const extraIgnored = ignoredPaths.length - PROTECTED_DOTDIRS.length;
285
+ if (extraIgnored > 0) console.error(`sandbox: .bxignore hides ${extraIgnored} extra path(s)`);
286
+ const profile = generateProfile(WORK_DIRS, blockedDirs, ignoredPaths);
287
+ const profilePath = join("/tmp", `bx-${process.pid}.sb`);
288
+ writeFileSync(profilePath, profile);
289
+ const dirLabel = WORK_DIRS.length === 1 ? WORK_DIRS[0] : `${WORK_DIRS.length} directories`;
290
+ console.error(`sandbox: ${mode} mode, working directory: ${dirLabel}`);
291
+ if (verbose) {
292
+ console.error("\n--- Generated sandbox profile ---");
293
+ console.error(profile);
294
+ console.error("--- End of profile ---\n");
295
+ }
296
+ const cmd = buildCommand(mode, WORK_DIRS, HOME, profileSandbox, execCmd);
183
297
  spawn("sandbox-exec", [
184
298
  "-f",
185
299
  profilePath,
186
300
  "-D",
187
301
  `HOME=${HOME}`,
188
302
  "-D",
189
- `WORK=${WORK_DIR}`,
303
+ `WORK=${WORK_DIRS[0]}`,
190
304
  cmd.bin,
191
305
  ...cmd.args
192
306
  ], {
193
- cwd: WORK_DIR,
307
+ cwd: WORK_DIRS[0],
194
308
  stdio: "inherit",
195
309
  env: {
196
310
  ...process.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bx-mac",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Launch apps in a macOS sandbox — only the project directory is accessible",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,8 +11,8 @@
11
11
  ],
12
12
  "scripts": {
13
13
  "build": "rolldown -c",
14
- "release": "./scripts/release.sh",
15
- "prepublishOnly": "npm run build"
14
+ "prepublishOnly": "npm run build",
15
+ "post:release": "./scripts/release.sh"
16
16
  },
17
17
  "keywords": [
18
18
  "sandbox",
@@ -24,6 +24,7 @@
24
24
  "author": "Dirk Holtwick",
25
25
  "license": "MIT",
26
26
  "devDependencies": {
27
+ "@types/node": "^25.5.0",
27
28
  "rolldown": "^1.0.0-rc.12"
28
29
  },
29
30
  "engines": {