Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/runtime/cron.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ Bun uses [launchd](https://developer.apple.com/library/archive/documentation/Mac

The plist uses `StartCalendarInterval` to define the schedule. Complex patterns with ranges, lists, or steps are supported — Bun expands them into multiple `StartCalendarInterval` dicts via Cartesian product.

**Working directory:** The job's working directory is set to the directory containing the script file (equivalent to `import.meta.dir`), so relative paths in your cron job resolve from the script's location.

**Viewing registered jobs:**

```sh
Expand All @@ -312,15 +314,15 @@ launchctl list | grep bun.cron
**Logs:** stdout and stderr are written to:

```
/tmp/bun.cron.<title>.stdout.log
/tmp/bun.cron.<title>.stderr.log
~/Library/Logs/bun/cron/bun.cron.<title>.stdout.log
~/Library/Logs/bun/cron/bun.cron.<title>.stderr.log
```

For example, a job titled `weekly-report`:

```sh
cat /tmp/bun.cron.weekly-report.stdout.log
tail -f /tmp/bun.cron.weekly-report.stderr.log
cat ~/Library/Logs/bun/cron/bun.cron.weekly-report.stdout.log
tail -f ~/Library/Logs/bun/cron/bun.cron.weekly-report.stderr.log
```

**Manually uninstalling without code:**
Expand Down
42 changes: 37 additions & 5 deletions src/bun.js/api/cron.zig
Original file line number Diff line number Diff line change
Expand Up @@ -295,18 +295,47 @@ pub const CronRegisterJob = struct {
};
defer bun.default_allocator.free(launch_agents_dir);
bun.FD.cwd().makePath(u8, launch_agents_dir) catch {
this.setErr("Failed to create ~/Library/LaunchAgents directory", .{});
this.setErr("Failed to create {s} directory", .{launch_agents_dir});
this.finish();
return;
};

// Use ~/Library/Logs/bun/cron/ for log files instead of /tmp/ to avoid
// writing to the globally shared temp directory (security issue #28298).
const log_dir = std.fmt.allocPrint(bun.default_allocator, "{s}/Library/Logs/bun/cron", .{home}) catch {
this.setErr("Out of memory", .{});
this.finish();
return;
};
defer bun.default_allocator.free(log_dir);
bun.FD.cwd().makePath(u8, log_dir) catch {
this.setErr("Failed to create {s} directory", .{log_dir});
Comment thread
robobun marked this conversation as resolved.
this.finish();
return;
};
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
const xml_log_dir = xmlEscape(log_dir) catch {
this.setErr("Out of memory", .{});
this.finish();
return;
};
defer bun.default_allocator.free(xml_log_dir);

const plist_path = allocPrintZ(bun.default_allocator, "{s}/Library/LaunchAgents/bun.cron.{s}.plist", .{ home, this.title }) catch {
this.setErr("Out of memory", .{});
this.finish();
return;
};
this.tmp_path = plist_path;
Comment thread
robobun marked this conversation as resolved.

// Derive the script's directory for WorkingDirectory (equivalent to import.meta.dir)
const script_dir = bun.path.dirname(this.abs_path, .auto);
const xml_script_dir = xmlEscape(if (script_dir.len == 0) "/" else script_dir) catch {
this.setErr("Out of memory", .{});
this.finish();
return;
};
defer bun.default_allocator.free(xml_script_dir);

// XML-escape all dynamic values
const xml_title = xmlEscape(this.title) catch {
this.setErr("Out of memory", .{});
Expand Down Expand Up @@ -350,14 +379,16 @@ pub const CronRegisterJob = struct {
\\ </array>
\\ <key>StartCalendarInterval</key>
\\{s}
\\ <key>WorkingDirectory</key>
\\ <string>{s}</string>
\\ <key>StandardOutPath</key>
\\ <string>/tmp/bun.cron.{s}.stdout.log</string>
\\ <string>{s}/bun.cron.{s}.stdout.log</string>
\\ <key>StandardErrorPath</key>
\\ <string>/tmp/bun.cron.{s}.stderr.log</string>
\\ <string>{s}/bun.cron.{s}.stderr.log</string>
\\</dict>
\\</plist>
\\
, .{ xml_title, xml_bun, xml_title, xml_sched, xml_path, calendar_xml, xml_title, xml_title }) catch {
, .{ xml_title, xml_bun, xml_title, xml_sched, xml_path, calendar_xml, xml_script_dir, xml_log_dir, xml_title, xml_log_dir, xml_title }) catch {
this.setErr("Out of memory", .{});
this.finish();
return;
Expand Down Expand Up @@ -405,7 +436,8 @@ pub const CronRegisterJob = struct {
};
defer bun.default_allocator.free(uid_str);
var argv = [_:null]?[*:0]const u8{ "/bin/launchctl", "bootstrap", uid_str.ptr, plist_path.ptr, null };
this.tmp_path = null; // don't delete the installed plist
this.tmp_path = null; // don't delete the installed plist; free the path string only
defer bun.default_allocator.free(plist_path);
this.spawnCmd(&argv, .ignore, .ignore);
}

Expand Down
23 changes: 19 additions & 4 deletions test/js/bun/cron/cron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,14 +1063,29 @@ describe.skipIf(!hasLaunchctl)("cron registration (macOS)", () => {
await Bun.cron(`${dir}/job.ts`, "0 0 * * *", "test-mac-logpaths");
const plist = await Bun.file(plistPath("test-mac-logpaths")).text();
expect(plist).toContain("<key>StandardOutPath</key>");
expect(plist).toContain("<string>/tmp/bun.cron.test-mac-logpaths.stdout.log</string>");
const logDir = `${process.env.HOME}/Library/Logs/bun/cron`;
expect(plist).toContain(`<string>${logDir}/bun.cron.test-mac-logpaths.stdout.log</string>`);
expect(plist).toContain("<key>StandardErrorPath</key>");
expect(plist).toContain("<string>/tmp/bun.cron.test-mac-logpaths.stderr.log</string>");
expect(plist).toContain(`<string>${logDir}/bun.cron.test-mac-logpaths.stderr.log</string>`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
removeLaunchdJob("test-mac-logpaths");
}
});

test("plist sets WorkingDirectory to the script's directory", async () => {
using dir = tempDir("bun-cron-test", {
"job.ts": `export default { scheduled() {} };`,
});
try {
await Bun.cron(`${dir}/job.ts`, "0 0 * * *", "test-mac-workdir");
const plist = await Bun.file(plistPath("test-mac-workdir")).text();
expect(plist).toContain("<key>WorkingDirectory</key>");
expect(plist).toContain(`<string>${dir}</string>`);
} finally {
removeLaunchdJob("test-mac-workdir");
}
});

test("registers multiple different jobs", async () => {
using dir = tempDir("bun-cron-test", {
"a.ts": `export default { scheduled() {} };`,
Expand Down Expand Up @@ -1190,10 +1205,10 @@ describe.skipIf(!hasLaunchctl)("cron end-to-end (macOS)", () => {
unlinkSync(markerPath);
} catch {}
try {
unlinkSync("/tmp/bun.cron.test-mac-e2e.stdout.log");
unlinkSync(`${process.env.HOME}/Library/Logs/bun/cron/bun.cron.test-mac-e2e.stdout.log`);
} catch {}
try {
unlinkSync("/tmp/bun.cron.test-mac-e2e.stderr.log");
unlinkSync(`${process.env.HOME}/Library/Logs/bun/cron/bun.cron.test-mac-e2e.stderr.log`);
} catch {}
}
});
Expand Down
Loading