@staticduo/opencode-scheduler 1.3.3 → 1.3.5

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,97 @@ 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);
12527
+ let primaryError;
12491
12528
  try {
12492
- fileSystem.writeFile(temporaryPath, content, { mode });
12493
- fileSystem.chmod(temporaryPath, mode);
12494
- fileSystem.rename(temporaryPath, path);
12529
+ fileSystem.writeFile(temporary, content, { mode });
12530
+ fileSystem.chmod(temporary, mode);
12531
+ fileSystem.rename(temporary, path);
12495
12532
  fileSystem.chmod(path, mode);
12533
+ } catch (error45) {
12534
+ primaryError = error45;
12535
+ throw error45;
12496
12536
  } finally {
12497
- try {
12498
- fileSystem.unlink(temporaryPath);
12499
- } catch {}
12537
+ if (primaryError)
12538
+ bestEffort(() => removeNode(temporary, fileSystem));
12539
+ else
12540
+ removeNode(temporary, fileSystem);
12541
+ }
12542
+ }
12543
+ function atomicSymlink(path, target, fileSystem) {
12544
+ const temporary = temporaryPath(path);
12545
+ let primaryError;
12546
+ try {
12547
+ fileSystem.symlink(target, temporary);
12548
+ fileSystem.rename(temporary, path);
12549
+ } catch (error45) {
12550
+ primaryError = error45;
12551
+ throw error45;
12552
+ } finally {
12553
+ if (primaryError)
12554
+ bestEffort(() => removeNode(temporary, fileSystem));
12555
+ else
12556
+ removeNode(temporary, fileSystem);
12557
+ }
12558
+ }
12559
+ function removeNode(path, fileSystem) {
12560
+ try {
12561
+ fileSystem.unlink(path);
12562
+ } catch (error45) {
12563
+ if (!isErrorCode(error45, "ENOENT"))
12564
+ throw error45;
12500
12565
  }
12501
12566
  }
12502
12567
  function commandOutput(value) {
@@ -12513,69 +12578,182 @@ function commandOutput(value) {
12513
12578
  }
12514
12579
  return "";
12515
12580
  }
12516
- function queryTimerState(run, timerUnit, query) {
12581
+ function queryUnitFileState(run, timerUnit) {
12517
12582
  let output = "";
12518
12583
  try {
12519
- output = commandOutput(run(`systemctl --user ${query} ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12584
+ output = commandOutput(run(`systemctl --user is-enabled ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12520
12585
  } catch (error45) {
12521
12586
  output = commandOutput(error45);
12522
12587
  }
12523
- const enabledStates = ["enabled", "enabled-runtime", "linked", "linked-runtime", "alias"];
12524
- const disabledStates = ["disabled", "static", "indirect", "masked", "masked-runtime", "not-found"];
12525
- const activeStates = ["active", "activating", "reloading"];
12526
- const inactiveStates = ["inactive", "failed", "deactivating", "unknown"];
12527
- if (query === "is-enabled" && enabledStates.includes(output))
12528
- return true;
12529
- if (query === "is-enabled" && disabledStates.includes(output))
12530
- return false;
12531
- if (query === "is-active" && activeStates.includes(output))
12588
+ const supported = [
12589
+ "enabled",
12590
+ "enabled-runtime",
12591
+ "disabled",
12592
+ "static",
12593
+ "indirect",
12594
+ "masked",
12595
+ "masked-runtime",
12596
+ "linked",
12597
+ "linked-runtime",
12598
+ "alias",
12599
+ "not-found"
12600
+ ];
12601
+ if (supported.includes(output))
12602
+ return output;
12603
+ const unrecoverable = ["generated", "transient", "bad"];
12604
+ if (unrecoverable.includes(output)) {
12605
+ throw new Error(`Cannot safely restore ${timerUnit} from systemd unit-file state ${output}; refusing to mutate it`);
12606
+ }
12607
+ throw new Error(`Unable to determine ${timerUnit} unit-file state: ${output || "no status returned"}`);
12608
+ }
12609
+ function queryActiveState(run, timerUnit) {
12610
+ let output = "";
12611
+ try {
12612
+ output = commandOutput(run(`systemctl --user is-active ${timerUnit}`, { stdio: ["ignore", "pipe", "ignore"] }));
12613
+ } catch (error45) {
12614
+ output = commandOutput(error45);
12615
+ }
12616
+ if (["active", "activating", "reloading"].includes(output))
12532
12617
  return true;
12533
- if (query === "is-active" && inactiveStates.includes(output))
12618
+ if (["inactive", "failed", "deactivating", "unknown"].includes(output))
12534
12619
  return false;
12535
- throw new Error(`Unable to determine whether ${timerUnit} ${query}: ${output || "no status returned"}`);
12620
+ throw new Error(`Unable to determine whether ${timerUnit} is active: ${output || "no status returned"}`);
12536
12621
  }
12537
12622
  function restoreFile(snapshot, fileSystem) {
12538
- if (!snapshot.existed) {
12539
- try {
12540
- fileSystem.unlink(snapshot.path);
12541
- } catch {}
12542
- return;
12623
+ if (snapshot.type === "missing") {
12624
+ removeNode(snapshot.path, fileSystem);
12625
+ } else if (snapshot.type === "symlink") {
12626
+ atomicSymlink(snapshot.path, snapshot.target, fileSystem);
12627
+ } else {
12628
+ atomicReplace(snapshot.path, snapshot.content, snapshot.mode, fileSystem);
12543
12629
  }
12544
- atomicReplace(snapshot.path, snapshot.content ?? Buffer.alloc(0), snapshot.mode ?? 420, fileSystem);
12545
12630
  }
12546
12631
  function bestEffort(action) {
12547
12632
  try {
12548
12633
  action();
12549
12634
  } catch {}
12550
12635
  }
12636
+ function restoreUnitFileState(run, timerUnit, state) {
12637
+ if (state === "enabled")
12638
+ run(`systemctl --user enable ${timerUnit}`, { stdio: "ignore" });
12639
+ if (state === "enabled-runtime")
12640
+ run(`systemctl --user enable --runtime ${timerUnit}`, { stdio: "ignore" });
12641
+ if (state === "disabled")
12642
+ run(`systemctl --user disable ${timerUnit}`, { stdio: "ignore" });
12643
+ }
12644
+ function isErrorCode(error45, code) {
12645
+ return typeof error45 === "object" && error45 !== null && "code" in error45 && error45.code === code;
12646
+ }
12647
+ function readLockMetadata(lockPath, fileSystem) {
12648
+ try {
12649
+ const parsed = JSON.parse(fileSystem.readFile(join(lockPath, "owner.json"), "utf8"));
12650
+ if (typeof parsed !== "object" || parsed === null)
12651
+ return {};
12652
+ const record2 = parsed;
12653
+ return {
12654
+ pid: typeof record2.pid === "number" ? record2.pid : undefined,
12655
+ timestamp: typeof record2.timestamp === "number" ? record2.timestamp : undefined
12656
+ };
12657
+ } catch {
12658
+ return {};
12659
+ }
12660
+ }
12661
+ function acquireLock(lockRoot, timerUnit, fileSystem, overrides) {
12662
+ const options = { ...defaultLockOptions, ...overrides };
12663
+ fileSystem.mkdir(lockRoot, { recursive: true });
12664
+ const lockPath = join(lockRoot, `${timerUnit}.lock`);
12665
+ const startedAt = options.now();
12666
+ while (true) {
12667
+ try {
12668
+ fileSystem.mkdir(lockPath);
12669
+ try {
12670
+ fileSystem.writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: options.pid, timestamp: options.now() }));
12671
+ } catch (error45) {
12672
+ fileSystem.rm(lockPath, { recursive: true, force: true });
12673
+ throw error45;
12674
+ }
12675
+ return () => fileSystem.rm(lockPath, { recursive: true, force: true });
12676
+ } catch (error45) {
12677
+ if (!isErrorCode(error45, "EEXIST"))
12678
+ throw error45;
12679
+ let lockStats;
12680
+ try {
12681
+ lockStats = fileSystem.lstat(lockPath);
12682
+ } catch (inspectError) {
12683
+ if (isErrorCode(inspectError, "ENOENT"))
12684
+ continue;
12685
+ throw inspectError;
12686
+ }
12687
+ if (lockStats.isSymbolicLink()) {
12688
+ throw new Error(`Refusing systemd install lock symlink at ${lockPath}`);
12689
+ }
12690
+ if (!lockStats.isDirectory()) {
12691
+ throw new Error(`Refusing non-directory systemd install lock at ${lockPath}`);
12692
+ }
12693
+ const metadata = readLockMetadata(lockPath, fileSystem);
12694
+ const timestamp = metadata.timestamp ?? lockStats.mtimeMs;
12695
+ const oldEnough = options.now() - timestamp >= options.staleAfterMs;
12696
+ const ownerAlive = metadata.pid !== undefined && options.isPidAlive(metadata.pid);
12697
+ if (oldEnough && !ownerAlive) {
12698
+ const quarantinePath = join(lockRoot, `${timerUnit}.stale-${options.pid}-${options.now()}-${temporaryFileSequence += 1}`);
12699
+ try {
12700
+ fileSystem.rename(lockPath, quarantinePath);
12701
+ } catch (claimError) {
12702
+ if (isErrorCode(claimError, "ENOENT") || isErrorCode(claimError, "EEXIST"))
12703
+ continue;
12704
+ throw claimError;
12705
+ }
12706
+ fileSystem.rm(quarantinePath, { recursive: true, force: true });
12707
+ continue;
12708
+ }
12709
+ if (options.now() - startedAt >= options.timeoutMs) {
12710
+ throw new Error(`Timed out waiting ${options.timeoutMs}ms for systemd install lock ${lockPath}`);
12711
+ }
12712
+ options.sleep(options.pollMs);
12713
+ }
12714
+ }
12715
+ }
12551
12716
  function installSystemdUnits(request) {
12552
12717
  const fileSystem = request.fileSystem ?? defaultFileSystem;
12553
- fileSystem.mkdir(request.unitDir, { recursive: true });
12554
- const servicePath = join(request.unitDir, request.serviceUnit);
12555
- const timerPath = join(request.unitDir, request.timerUnit);
12556
- const serviceSnapshot = snapshotFile(servicePath, fileSystem);
12557
- const timerSnapshot = snapshotFile(timerPath, fileSystem);
12558
- const wasEnabled = queryTimerState(request.run, request.timerUnit, "is-enabled");
12559
- const wasActive = queryTimerState(request.run, request.timerUnit, "is-active");
12718
+ const releaseLock = acquireLock(request.lockDir ?? join(request.unitDir, ".opencode-scheduler-locks"), request.timerUnit, fileSystem, request.lock);
12719
+ let primaryError;
12560
12720
  try {
12561
- atomicReplace(servicePath, request.serviceContent, 420, fileSystem);
12562
- atomicReplace(timerPath, request.timerContent, 420, fileSystem);
12563
- request.run("systemctl --user daemon-reload");
12564
- request.run(`systemctl --user enable ${request.timerUnit}`);
12565
- request.run(`systemctl --user start ${request.timerUnit}`);
12721
+ fileSystem.mkdir(request.unitDir, { recursive: true });
12722
+ const servicePath = join(request.unitDir, request.serviceUnit);
12723
+ const timerPath = join(request.unitDir, request.timerUnit);
12724
+ const serviceSnapshot = snapshotFile(servicePath, fileSystem);
12725
+ const timerSnapshot = snapshotFile(timerPath, fileSystem);
12726
+ const unitFileState = queryUnitFileState(request.run, request.timerUnit);
12727
+ const wasActive = queryActiveState(request.run, request.timerUnit);
12728
+ let enabledByAttempt = false;
12729
+ try {
12730
+ atomicReplace(servicePath, request.serviceContent, 420, fileSystem);
12731
+ atomicReplace(timerPath, request.timerContent, 420, fileSystem);
12732
+ request.run("systemctl --user daemon-reload");
12733
+ request.run(`systemctl --user enable ${request.timerUnit}`);
12734
+ enabledByAttempt = true;
12735
+ request.run(`systemctl --user start ${request.timerUnit}`);
12736
+ } catch (error45) {
12737
+ if (!wasActive)
12738
+ bestEffort(() => request.run(`systemctl --user stop ${request.timerUnit}`, { stdio: "ignore" }));
12739
+ if (enabledByAttempt)
12740
+ bestEffort(() => request.run(`systemctl --user disable ${request.timerUnit}`, { stdio: "ignore" }));
12741
+ bestEffort(() => restoreFile(serviceSnapshot, fileSystem));
12742
+ bestEffort(() => restoreFile(timerSnapshot, fileSystem));
12743
+ bestEffort(() => request.run("systemctl --user daemon-reload", { stdio: "ignore" }));
12744
+ bestEffort(() => restoreUnitFileState(request.run, request.timerUnit, unitFileState));
12745
+ if (wasActive)
12746
+ bestEffort(() => request.run(`systemctl --user start ${request.timerUnit}`, { stdio: "ignore" }));
12747
+ throw error45;
12748
+ }
12566
12749
  } catch (error45) {
12567
- if (!wasActive)
12568
- bestEffort(() => request.run(`systemctl --user stop ${request.timerUnit}`, { stdio: "ignore" }));
12569
- if (!wasEnabled)
12570
- bestEffort(() => request.run(`systemctl --user disable ${request.timerUnit}`, { stdio: "ignore" }));
12571
- bestEffort(() => restoreFile(serviceSnapshot, fileSystem));
12572
- bestEffort(() => restoreFile(timerSnapshot, fileSystem));
12573
- bestEffort(() => request.run("systemctl --user daemon-reload", { stdio: "ignore" }));
12574
- if (wasEnabled)
12575
- bestEffort(() => request.run(`systemctl --user enable ${request.timerUnit}`, { stdio: "ignore" }));
12576
- if (wasActive)
12577
- bestEffort(() => request.run(`systemctl --user start ${request.timerUnit}`, { stdio: "ignore" }));
12750
+ primaryError = error45;
12578
12751
  throw error45;
12752
+ } finally {
12753
+ if (primaryError)
12754
+ bestEffort(releaseLock);
12755
+ else
12756
+ releaseLock();
12579
12757
  }
12580
12758
  }
12581
12759
 
@@ -13436,6 +13614,7 @@ function installSystemdJob(job, run = defaultSystemdCommandRunner) {
13436
13614
  const timerUnit = timerPath.slice(SYSTEMD_USER_DIR.length + 1);
13437
13615
  installSystemdUnits({
13438
13616
  unitDir: SYSTEMD_USER_DIR,
13617
+ lockDir: join2(SCHEDULER_DIR, "systemd-install-locks"),
13439
13618
  serviceUnit,
13440
13619
  timerUnit,
13441
13620
  serviceContent: createSystemdService(job),
@@ -13823,7 +14002,7 @@ function removePaths(paths, errors3) {
13823
14002
  if (!existsSync2(path))
13824
14003
  continue;
13825
14004
  try {
13826
- rmSync(path, { recursive: true, force: true });
14005
+ rmSync2(path, { recursive: true, force: true });
13827
14006
  removed.push(path);
13828
14007
  } catch (error45) {
13829
14008
  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.3",
3
+ "version": "1.3.5",
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",