@staticduo/opencode-scheduler 1.3.2 → 1.3.4

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
@@ -96,7 +96,7 @@ Jobs run from the working directory where you created them, picking up your `ope
96
96
  - **No overlap**: if the previous run is still active, the next scheduled tick is skipped.
97
97
  - **Non-interactive by default**: scheduled runs force `OPENCODE_PERMISSION` to deny "question" prompts, so jobs don't hang waiting for approvals.
98
98
  - **Optional timeout**: set `timeoutSeconds` to hard-stop long runs (SIGTERM, then SIGKILL).
99
- - **Transactional systemd updates**: Linux unit installation restores the prior unit files, permissions, enabled state, and active state if any install step fails.
99
+ - **Transactional systemd updates**: Linux installation preserves regular files or exact symlink targets (including masked units), permissions, unit-file state, and active state on failure. A bounded cross-process lock serializes updates to the same scoped timer; live locks are never broken and dead stale locks are reclaimed.
100
100
 
101
101
  ### Platform Support
102
102
 
package/dist/index.js CHANGED
@@ -12335,7 +12335,7 @@ function tool(input) {
12335
12335
  }
12336
12336
  tool.schema = exports_external;
12337
12337
  // src/index.ts
12338
- import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
12338
+ import { createWriteStream, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
12339
12339
  import { basename, dirname, join as join2, resolve as resolvePath } from "path";
12340
12340
  import { homedir, platform } from "os";
12341
12341
  import { execFileSync, execSync, spawn } from "child_process";
@@ -12435,10 +12435,14 @@ function cronToSystemdCalendars(cron) {
12435
12435
  import {
12436
12436
  chmodSync,
12437
12437
  existsSync,
12438
+ lstatSync,
12438
12439
  mkdirSync,
12439
12440
  readFileSync,
12441
+ readlinkSync,
12440
12442
  renameSync,
12443
+ rmSync,
12441
12444
  statSync,
12445
+ symlinkSync,
12442
12446
  unlinkSync,
12443
12447
  writeFileSync
12444
12448
  } from "fs";
@@ -12467,36 +12471,83 @@ function withSystemdRuntimeEnv(env, dependencies = defaultRuntimeEnvDependencies
12467
12471
  var defaultFileSystem = {
12468
12472
  chmod: chmodSync,
12469
12473
  exists: existsSync,
12474
+ lstat: lstatSync,
12470
12475
  mkdir: mkdirSync,
12471
12476
  readFile: readFileSync,
12477
+ readlink: readlinkSync,
12472
12478
  rename: renameSync,
12479
+ rm: rmSync,
12473
12480
  stat: statSync,
12481
+ symlink: symlinkSync,
12474
12482
  unlink: unlinkSync,
12475
12483
  writeFile: writeFileSync
12476
12484
  };
12485
+ var sleepArray = new Int32Array(new SharedArrayBuffer(4));
12486
+ var defaultLockOptions = {
12487
+ timeoutMs: 1e4,
12488
+ staleAfterMs: 60000,
12489
+ pollMs: 25,
12490
+ now: Date.now,
12491
+ pid: process.pid,
12492
+ isPidAlive(pid) {
12493
+ try {
12494
+ process.kill(pid, 0);
12495
+ return true;
12496
+ } catch {
12497
+ return false;
12498
+ }
12499
+ },
12500
+ sleep(milliseconds) {
12501
+ Atomics.wait(sleepArray, 0, 0, milliseconds);
12502
+ }
12503
+ };
12477
12504
  function snapshotFile(path, fileSystem) {
12478
- if (!fileSystem.exists(path))
12479
- return { path, existed: false };
12480
- return {
12481
- path,
12482
- existed: true,
12483
- content: fileSystem.readFile(path),
12484
- mode: fileSystem.stat(path).mode & 511
12485
- };
12505
+ let stats;
12506
+ try {
12507
+ stats = fileSystem.lstat(path);
12508
+ } catch (error45) {
12509
+ if (isErrorCode(error45, "ENOENT"))
12510
+ return { path, type: "missing" };
12511
+ throw error45;
12512
+ }
12513
+ if (stats.isSymbolicLink())
12514
+ return { path, type: "symlink", target: fileSystem.readlink(path) };
12515
+ if (stats.isFile()) {
12516
+ return { path, type: "regular", content: fileSystem.readFile(path), mode: stats.mode & 511 };
12517
+ }
12518
+ throw new Error(`Unsupported systemd unit node type at ${path}; refusing to mutate it`);
12486
12519
  }
12487
12520
  var temporaryFileSequence = 0;
12488
- function atomicReplace(path, content, mode, fileSystem) {
12521
+ function temporaryPath(path) {
12489
12522
  temporaryFileSequence += 1;
12490
- const temporaryPath = `${path}.tmp-${process.pid}-${temporaryFileSequence}`;
12523
+ return `${path}.tmp-${process.pid}-${temporaryFileSequence}`;
12524
+ }
12525
+ function atomicReplace(path, content, mode, fileSystem) {
12526
+ const temporary = temporaryPath(path);
12491
12527
  try {
12492
- fileSystem.writeFile(temporaryPath, content, { mode });
12493
- fileSystem.chmod(temporaryPath, mode);
12494
- fileSystem.rename(temporaryPath, path);
12528
+ fileSystem.writeFile(temporary, content, { mode });
12529
+ fileSystem.chmod(temporary, mode);
12530
+ fileSystem.rename(temporary, path);
12495
12531
  fileSystem.chmod(path, mode);
12496
12532
  } finally {
12497
- try {
12498
- fileSystem.unlink(temporaryPath);
12499
- } catch {}
12533
+ removeNode(temporary, fileSystem);
12534
+ }
12535
+ }
12536
+ function atomicSymlink(path, target, fileSystem) {
12537
+ const temporary = temporaryPath(path);
12538
+ try {
12539
+ fileSystem.symlink(target, temporary);
12540
+ fileSystem.rename(temporary, path);
12541
+ } finally {
12542
+ removeNode(temporary, fileSystem);
12543
+ }
12544
+ }
12545
+ function removeNode(path, fileSystem) {
12546
+ try {
12547
+ fileSystem.unlink(path);
12548
+ } catch (error45) {
12549
+ if (!isErrorCode(error45, "ENOENT"))
12550
+ throw error45;
12500
12551
  }
12501
12552
  }
12502
12553
  function commandOutput(value) {
@@ -12513,57 +12564,153 @@ function commandOutput(value) {
12513
12564
  }
12514
12565
  return "";
12515
12566
  }
12516
- function queryTimerState(run, timerUnit, query) {
12567
+ function queryUnitFileState(run, timerUnit) {
12568
+ let output = "";
12569
+ try {
12570
+ output = commandOutput(run(`systemctl --user is-enabled ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12571
+ } catch (error45) {
12572
+ output = commandOutput(error45);
12573
+ }
12574
+ const supported = [
12575
+ "enabled",
12576
+ "enabled-runtime",
12577
+ "disabled",
12578
+ "static",
12579
+ "indirect",
12580
+ "masked",
12581
+ "masked-runtime",
12582
+ "linked",
12583
+ "linked-runtime",
12584
+ "alias",
12585
+ "not-found"
12586
+ ];
12587
+ if (supported.includes(output))
12588
+ return output;
12589
+ const unrecoverable = ["generated", "transient", "bad"];
12590
+ if (unrecoverable.includes(output)) {
12591
+ throw new Error(`Cannot safely restore ${timerUnit} from systemd unit-file state ${output}; refusing to mutate it`);
12592
+ }
12593
+ throw new Error(`Unable to determine ${timerUnit} unit-file state: ${output || "no status returned"}`);
12594
+ }
12595
+ function queryActiveState(run, timerUnit) {
12517
12596
  let output = "";
12518
12597
  try {
12519
- output = commandOutput(run(`systemctl --user ${query} ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12598
+ output = commandOutput(run(`systemctl --user is-active ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12520
12599
  } catch (error45) {
12521
12600
  output = commandOutput(error45);
12522
12601
  }
12523
- return query === "is-enabled" ? ["enabled", "enabled-runtime", "linked", "linked-runtime", "alias"].includes(output) : ["active", "activating", "reloading"].includes(output);
12602
+ if (["active", "activating", "reloading"].includes(output))
12603
+ return true;
12604
+ if (["inactive", "failed", "deactivating", "unknown"].includes(output))
12605
+ return false;
12606
+ throw new Error(`Unable to determine whether ${timerUnit} is active: ${output || "no status returned"}`);
12524
12607
  }
12525
12608
  function restoreFile(snapshot, fileSystem) {
12526
- if (!snapshot.existed) {
12527
- try {
12528
- fileSystem.unlink(snapshot.path);
12529
- } catch {}
12530
- return;
12609
+ if (snapshot.type === "missing") {
12610
+ removeNode(snapshot.path, fileSystem);
12611
+ } else if (snapshot.type === "symlink") {
12612
+ atomicSymlink(snapshot.path, snapshot.target, fileSystem);
12613
+ } else {
12614
+ atomicReplace(snapshot.path, snapshot.content, snapshot.mode, fileSystem);
12531
12615
  }
12532
- atomicReplace(snapshot.path, snapshot.content ?? Buffer.alloc(0), snapshot.mode ?? 420, fileSystem);
12533
12616
  }
12534
12617
  function bestEffort(action) {
12535
12618
  try {
12536
12619
  action();
12537
12620
  } catch {}
12538
12621
  }
12622
+ function restoreUnitFileState(run, timerUnit, state) {
12623
+ if (state === "enabled")
12624
+ run(`systemctl --user enable ${timerUnit}`, { stdio: "ignore" });
12625
+ if (state === "enabled-runtime")
12626
+ run(`systemctl --user enable --runtime ${timerUnit}`, { stdio: "ignore" });
12627
+ if (state === "disabled")
12628
+ run(`systemctl --user disable ${timerUnit}`, { stdio: "ignore" });
12629
+ }
12630
+ function isErrorCode(error45, code) {
12631
+ return typeof error45 === "object" && error45 !== null && "code" in error45 && error45.code === code;
12632
+ }
12633
+ function readLockMetadata(lockPath, fileSystem) {
12634
+ try {
12635
+ const parsed = JSON.parse(fileSystem.readFile(join(lockPath, "owner.json"), "utf8"));
12636
+ if (typeof parsed !== "object" || parsed === null)
12637
+ return {};
12638
+ const record2 = parsed;
12639
+ return {
12640
+ pid: typeof record2.pid === "number" ? record2.pid : undefined,
12641
+ timestamp: typeof record2.timestamp === "number" ? record2.timestamp : undefined
12642
+ };
12643
+ } catch {
12644
+ return {};
12645
+ }
12646
+ }
12647
+ function acquireLock(lockRoot, timerUnit, fileSystem, overrides) {
12648
+ const options = { ...defaultLockOptions, ...overrides };
12649
+ fileSystem.mkdir(lockRoot, { recursive: true });
12650
+ const lockPath = join(lockRoot, `${timerUnit}.lock`);
12651
+ const startedAt = options.now();
12652
+ while (true) {
12653
+ try {
12654
+ fileSystem.mkdir(lockPath);
12655
+ try {
12656
+ fileSystem.writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: options.pid, timestamp: options.now() }));
12657
+ } catch (error45) {
12658
+ fileSystem.rm(lockPath, { recursive: true, force: true });
12659
+ throw error45;
12660
+ }
12661
+ return () => fileSystem.rm(lockPath, { recursive: true, force: true });
12662
+ } catch (error45) {
12663
+ if (!isErrorCode(error45, "EEXIST"))
12664
+ throw error45;
12665
+ const metadata = readLockMetadata(lockPath, fileSystem);
12666
+ const timestamp = metadata.timestamp ?? fileSystem.lstat(lockPath).mtimeMs;
12667
+ const oldEnough = options.now() - timestamp >= options.staleAfterMs;
12668
+ const ownerAlive = metadata.pid !== undefined && options.isPidAlive(metadata.pid);
12669
+ if (oldEnough && !ownerAlive) {
12670
+ fileSystem.rm(lockPath, { recursive: true, force: true });
12671
+ continue;
12672
+ }
12673
+ if (options.now() - startedAt >= options.timeoutMs) {
12674
+ throw new Error(`Timed out waiting ${options.timeoutMs}ms for systemd install lock ${lockPath}`);
12675
+ }
12676
+ options.sleep(options.pollMs);
12677
+ }
12678
+ }
12679
+ }
12539
12680
  function installSystemdUnits(request) {
12540
12681
  const fileSystem = request.fileSystem ?? defaultFileSystem;
12541
- fileSystem.mkdir(request.unitDir, { recursive: true });
12542
- const servicePath = join(request.unitDir, request.serviceUnit);
12543
- const timerPath = join(request.unitDir, request.timerUnit);
12544
- const serviceSnapshot = snapshotFile(servicePath, fileSystem);
12545
- const timerSnapshot = snapshotFile(timerPath, fileSystem);
12546
- const wasEnabled = queryTimerState(request.run, request.timerUnit, "is-enabled");
12547
- const wasActive = queryTimerState(request.run, request.timerUnit, "is-active");
12682
+ const releaseLock = acquireLock(request.lockDir ?? join(request.unitDir, ".opencode-scheduler-locks"), request.timerUnit, fileSystem, request.lock);
12548
12683
  try {
12549
- atomicReplace(servicePath, request.serviceContent, 420, fileSystem);
12550
- atomicReplace(timerPath, request.timerContent, 420, fileSystem);
12551
- request.run("systemctl --user daemon-reload");
12552
- request.run(`systemctl --user enable ${request.timerUnit}`);
12553
- request.run(`systemctl --user start ${request.timerUnit}`);
12554
- } catch (error45) {
12555
- if (!wasActive)
12556
- bestEffort(() => request.run(`systemctl --user stop ${request.timerUnit}`, { stdio: "ignore" }));
12557
- if (!wasEnabled)
12558
- bestEffort(() => request.run(`systemctl --user disable ${request.timerUnit}`, { stdio: "ignore" }));
12559
- bestEffort(() => restoreFile(serviceSnapshot, fileSystem));
12560
- bestEffort(() => restoreFile(timerSnapshot, fileSystem));
12561
- bestEffort(() => request.run("systemctl --user daemon-reload", { stdio: "ignore" }));
12562
- if (wasEnabled)
12563
- bestEffort(() => request.run(`systemctl --user enable ${request.timerUnit}`, { stdio: "ignore" }));
12564
- if (wasActive)
12565
- bestEffort(() => request.run(`systemctl --user start ${request.timerUnit}`, { stdio: "ignore" }));
12566
- throw error45;
12684
+ fileSystem.mkdir(request.unitDir, { recursive: true });
12685
+ const servicePath = join(request.unitDir, request.serviceUnit);
12686
+ const timerPath = join(request.unitDir, request.timerUnit);
12687
+ const serviceSnapshot = snapshotFile(servicePath, fileSystem);
12688
+ const timerSnapshot = snapshotFile(timerPath, fileSystem);
12689
+ const unitFileState = queryUnitFileState(request.run, request.timerUnit);
12690
+ const wasActive = queryActiveState(request.run, request.timerUnit);
12691
+ let enabledByAttempt = false;
12692
+ try {
12693
+ atomicReplace(servicePath, request.serviceContent, 420, fileSystem);
12694
+ atomicReplace(timerPath, request.timerContent, 420, fileSystem);
12695
+ request.run("systemctl --user daemon-reload");
12696
+ request.run(`systemctl --user enable ${request.timerUnit}`);
12697
+ enabledByAttempt = true;
12698
+ request.run(`systemctl --user start ${request.timerUnit}`);
12699
+ } catch (error45) {
12700
+ if (!wasActive)
12701
+ bestEffort(() => request.run(`systemctl --user stop ${request.timerUnit}`, { stdio: "ignore" }));
12702
+ if (enabledByAttempt)
12703
+ bestEffort(() => request.run(`systemctl --user disable ${request.timerUnit}`, { stdio: "ignore" }));
12704
+ bestEffort(() => restoreFile(serviceSnapshot, fileSystem));
12705
+ bestEffort(() => restoreFile(timerSnapshot, fileSystem));
12706
+ bestEffort(() => request.run("systemctl --user daemon-reload", { stdio: "ignore" }));
12707
+ bestEffort(() => restoreUnitFileState(request.run, request.timerUnit, unitFileState));
12708
+ if (wasActive)
12709
+ bestEffort(() => request.run(`systemctl --user start ${request.timerUnit}`, { stdio: "ignore" }));
12710
+ throw error45;
12711
+ }
12712
+ } finally {
12713
+ releaseLock();
12567
12714
  }
12568
12715
  }
12569
12716
 
@@ -13424,6 +13571,7 @@ function installSystemdJob(job, run = defaultSystemdCommandRunner) {
13424
13571
  const timerUnit = timerPath.slice(SYSTEMD_USER_DIR.length + 1);
13425
13572
  installSystemdUnits({
13426
13573
  unitDir: SYSTEMD_USER_DIR,
13574
+ lockDir: join2(SCHEDULER_DIR, "systemd-install-locks"),
13427
13575
  serviceUnit,
13428
13576
  timerUnit,
13429
13577
  serviceContent: createSystemdService(job),
@@ -13811,7 +13959,7 @@ function removePaths(paths, errors3) {
13811
13959
  if (!existsSync2(path))
13812
13960
  continue;
13813
13961
  try {
13814
- rmSync(path, { recursive: true, force: true });
13962
+ rmSync2(path, { recursive: true, force: true });
13815
13963
  removed.push(path);
13816
13964
  } catch (error45) {
13817
13965
  const msg = error45 instanceof Error ? error45.message : String(error45);
package/dist/systemd.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
2
2
  import type { ExecSyncOptions } from "child_process";
3
3
  export type SystemdCommandRunner = (command: string, options?: ExecSyncOptions) => Buffer | string;
4
4
  export interface RuntimeEnvDependencies {
@@ -8,21 +8,37 @@ export interface RuntimeEnvDependencies {
8
8
  export declare function withSystemdRuntimeEnv(env: NodeJS.ProcessEnv, dependencies?: RuntimeEnvDependencies): NodeJS.ProcessEnv;
9
9
  export interface SystemdInstallRequest {
10
10
  unitDir: string;
11
+ lockDir?: string;
11
12
  serviceUnit: string;
12
13
  timerUnit: string;
13
14
  serviceContent: string;
14
15
  timerContent: string;
15
16
  run: SystemdCommandRunner;
16
17
  fileSystem?: SystemdFileSystem;
18
+ lock?: Partial<SystemdLockOptions>;
17
19
  }
18
20
  export interface SystemdFileSystem {
19
21
  chmod: typeof chmodSync;
20
22
  exists: typeof existsSync;
23
+ lstat: typeof lstatSync;
21
24
  mkdir: typeof mkdirSync;
22
25
  readFile: typeof readFileSync;
26
+ readlink: typeof readlinkSync;
23
27
  rename: typeof renameSync;
28
+ rm: typeof rmSync;
24
29
  stat: typeof statSync;
30
+ symlink: typeof symlinkSync;
25
31
  unlink: typeof unlinkSync;
26
32
  writeFile: typeof writeFileSync;
27
33
  }
34
+ interface SystemdLockOptions {
35
+ timeoutMs: number;
36
+ staleAfterMs: number;
37
+ pollMs: number;
38
+ now: () => number;
39
+ pid: number;
40
+ isPidAlive: (pid: number) => boolean;
41
+ sleep: (milliseconds: number) => void;
42
+ }
28
43
  export declare function installSystemdUnits(request: SystemdInstallRequest): void;
44
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staticduo/opencode-scheduler",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "OpenCode plugin for scheduling recurring jobs using launchd, systemd, Task Scheduler, or cron fallback",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",