@openparachute/hub 0.6.1 → 0.6.3-rc.1

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.
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import type { InstallResult, RemoveResult } from "../cloudflare/connector-service.ts";
5
6
  import {
6
7
  type CloudflaredTunnelRecord,
7
8
  findTunnelRecord,
@@ -1745,3 +1746,274 @@ describe("exposeCloudflareOff", () => {
1745
1746
  });
1746
1747
  });
1747
1748
  });
1749
+
1750
+ describe("reboot-persistent connector service wiring", () => {
1751
+ // The default tunnel name for vault.example.com (#491 per-hostname derivation).
1752
+ const DERIVED = "parachute-vault-example-com";
1753
+
1754
+ function upRunner(uuid: string, derived = DERIVED) {
1755
+ return queueRunner([
1756
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
1757
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
1758
+ {
1759
+ code: 0,
1760
+ stdout: `Created tunnel ${derived} with id ${uuid}\n`,
1761
+ stderr: "",
1762
+ }, // tunnel create
1763
+ { code: 0, stdout: "", stderr: "" }, // route dns
1764
+ ]);
1765
+ }
1766
+
1767
+ test("up installs the connector service (no duplicate transient spawn) + records serviceManaged", async () => {
1768
+ const env = makeEnv();
1769
+ try {
1770
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000001";
1771
+ const { runner } = upRunner(uuid);
1772
+ const { spawner, seen } = fakeSpawner(91000);
1773
+ const installCalls: { tunnelName: string; configPath: string }[] = [];
1774
+ const installService = (args: {
1775
+ tunnelName: string;
1776
+ configPath: string;
1777
+ logPath: string;
1778
+ }): InstallResult => {
1779
+ installCalls.push({ tunnelName: args.tunnelName, configPath: args.configPath });
1780
+ return {
1781
+ outcome: "installed",
1782
+ kind: "launchd",
1783
+ servicePath: "/home/op/Library/LaunchAgents/x.plist",
1784
+ messages: ["Installed launchd LaunchAgent — starts on boot."],
1785
+ };
1786
+ };
1787
+ const logs: string[] = [];
1788
+
1789
+ const code = await exposeCloudflareUp("vault.example.com", {
1790
+ runner,
1791
+ spawner,
1792
+ // The service-spawned connector (93333) is alive; no prior record exists
1793
+ // so the orphan sweep finds nothing else to kill.
1794
+ alive: (pid) => pid === 93333,
1795
+ kill: () => {},
1796
+ // Service manages the connector; report a live pid so the up-path
1797
+ // records it WITHOUT spawning a transient one.
1798
+ connectorPids: () => [93333],
1799
+ installService,
1800
+ log: (l) => logs.push(l),
1801
+ manifestPath: env.manifestPath,
1802
+ statePath: env.statePath,
1803
+ exposeStatePath: env.exposeStatePath,
1804
+ configPath: env.configPath,
1805
+ logPath: env.logPath,
1806
+ cloudflaredHome: env.cloudflaredHome,
1807
+ configDir: env.configDir,
1808
+ skipHub: true,
1809
+ now: () => new Date("2026-05-31T00:00:00Z"),
1810
+ });
1811
+
1812
+ expect(code).toBe(0);
1813
+ // Service install was attempted with the derived tunnel name + the
1814
+ // config path we wrote.
1815
+ expect(installCalls).toHaveLength(1);
1816
+ expect(installCalls[0]!.tunnelName).toBe(DERIVED);
1817
+ expect(installCalls[0]!.configPath).toBe(env.configPath);
1818
+ // Exactly one connector: the service owns it, so NO transient spawn ran
1819
+ // (connectorPids surfaced the service-spawned pid).
1820
+ expect(seen).toHaveLength(0);
1821
+
1822
+ const state = readCloudflaredState(env.statePath);
1823
+ const rec = findTunnelRecord(state, DERIVED);
1824
+ expect(rec?.serviceManaged).toBe(true);
1825
+ // Recorded pid is the service-spawned connector's pid (from connectorPids).
1826
+ expect(rec?.pid).toBe(93333);
1827
+
1828
+ const joined = logs.join("\n");
1829
+ expect(joined).toContain("Installed launchd LaunchAgent");
1830
+ // Success copy: runs on boot, no "re-run after a reboot" nag.
1831
+ expect(joined).toContain("runs on boot");
1832
+ expect(joined).not.toContain("does NOT survive a reboot");
1833
+ } finally {
1834
+ env.cleanup();
1835
+ }
1836
+ });
1837
+
1838
+ test("service-managed but connector not yet visible → transient spawn keeps a live pid", async () => {
1839
+ const env = makeEnv();
1840
+ try {
1841
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000002";
1842
+ const { runner } = upRunner(uuid);
1843
+ const { spawner, seen } = fakeSpawner(91500);
1844
+ const installService = (): InstallResult => ({
1845
+ outcome: "installed",
1846
+ kind: "systemd-user",
1847
+ servicePath: "/home/op/.config/systemd/user/x.service",
1848
+ messages: [],
1849
+ });
1850
+
1851
+ const code = await exposeCloudflareUp("vault.example.com", {
1852
+ runner,
1853
+ spawner,
1854
+ alive: () => false,
1855
+ kill: () => {},
1856
+ // Service is enabled but its connector isn't visible to pgrep yet.
1857
+ connectorPids: () => [],
1858
+ installService,
1859
+ log: () => {},
1860
+ manifestPath: env.manifestPath,
1861
+ statePath: env.statePath,
1862
+ exposeStatePath: env.exposeStatePath,
1863
+ configPath: env.configPath,
1864
+ logPath: env.logPath,
1865
+ cloudflaredHome: env.cloudflaredHome,
1866
+ configDir: env.configDir,
1867
+ skipHub: true,
1868
+ now: () => new Date("2026-05-31T00:00:00Z"),
1869
+ });
1870
+
1871
+ expect(code).toBe(0);
1872
+ // Belt-and-suspenders: a transient connector was spawned so the state
1873
+ // record carries a live pid + connectivity is immediate. Still marked
1874
+ // serviceManaged (the service takes over on reboot).
1875
+ expect(seen).toHaveLength(1);
1876
+ const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
1877
+ expect(rec?.pid).toBe(91500);
1878
+ expect(rec?.serviceManaged).toBe(true);
1879
+ } finally {
1880
+ env.cleanup();
1881
+ }
1882
+ });
1883
+
1884
+ test("graceful fallback: when install fails, transient spawn + honest reboot caveat", async () => {
1885
+ const env = makeEnv();
1886
+ try {
1887
+ const uuid = "aaaaaaaa-0000-0000-0000-000000000003";
1888
+ const { runner } = upRunner(uuid);
1889
+ const { spawner, seen } = fakeSpawner(92000);
1890
+ const installService = (): InstallResult => ({
1891
+ outcome: "fallback",
1892
+ messages: ["launchctl not found; using a transient connector (won't survive a reboot)."],
1893
+ });
1894
+ const logs: string[] = [];
1895
+
1896
+ const code = await exposeCloudflareUp("vault.example.com", {
1897
+ runner,
1898
+ spawner,
1899
+ alive: () => false,
1900
+ kill: () => {},
1901
+ connectorPids: () => [],
1902
+ installService,
1903
+ log: (l) => logs.push(l),
1904
+ manifestPath: env.manifestPath,
1905
+ statePath: env.statePath,
1906
+ exposeStatePath: env.exposeStatePath,
1907
+ configPath: env.configPath,
1908
+ logPath: env.logPath,
1909
+ cloudflaredHome: env.cloudflaredHome,
1910
+ configDir: env.configDir,
1911
+ skipHub: true,
1912
+ now: () => new Date("2026-05-31T00:00:00Z"),
1913
+ });
1914
+
1915
+ expect(code).toBe(0);
1916
+ // Transient connector spawned (the fallback).
1917
+ expect(seen).toHaveLength(1);
1918
+ expect(seen[0]).toEqual(["cloudflared", "tunnel", "--config", env.configPath, "run"]);
1919
+ const rec = findTunnelRecord(readCloudflaredState(env.statePath), DERIVED);
1920
+ expect(rec?.serviceManaged).toBeUndefined();
1921
+ const joined = logs.join("\n");
1922
+ // The install fallback warning surfaced + the honest reboot caveat.
1923
+ expect(joined).toContain("won't survive a reboot");
1924
+ expect(joined).toContain("does NOT survive a reboot");
1925
+ expect(joined).toContain("parachute expose public --cloudflare --domain vault.example.com");
1926
+ } finally {
1927
+ env.cleanup();
1928
+ }
1929
+ });
1930
+
1931
+ test("re-up is idempotent: install runs again, no duplicate state record", async () => {
1932
+ const env = makeEnv();
1933
+ try {
1934
+ const installCount = { n: 0 };
1935
+ const installService = (): InstallResult => {
1936
+ installCount.n++;
1937
+ return { outcome: "installed", kind: "launchd", servicePath: "/x.plist", messages: [] };
1938
+ };
1939
+ const runOnce = async (uuid: string) => {
1940
+ const { runner } = upRunner(uuid);
1941
+ const { spawner } = fakeSpawner(90000 + installCount.n);
1942
+ return exposeCloudflareUp("vault.example.com", {
1943
+ runner,
1944
+ spawner,
1945
+ alive: () => false,
1946
+ kill: () => {},
1947
+ connectorPids: () => [94000 + installCount.n],
1948
+ installService,
1949
+ log: () => {},
1950
+ manifestPath: env.manifestPath,
1951
+ statePath: env.statePath,
1952
+ exposeStatePath: env.exposeStatePath,
1953
+ configPath: env.configPath,
1954
+ logPath: env.logPath,
1955
+ cloudflaredHome: env.cloudflaredHome,
1956
+ configDir: env.configDir,
1957
+ skipHub: true,
1958
+ now: () => new Date("2026-05-31T00:00:00Z"),
1959
+ });
1960
+ };
1961
+
1962
+ expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
1963
+ expect(await runOnce("aaaaaaaa-0000-0000-0000-000000000004")).toBe(0);
1964
+ // Install attempted on each up (idempotent — the module overwrites + reloads).
1965
+ expect(installCount.n).toBe(2);
1966
+ // Exactly one tunnel record, keyed by name (re-up replaces, not appends).
1967
+ const state = readCloudflaredState(env.statePath);
1968
+ expect(Object.keys(state?.tunnels ?? {})).toEqual([DERIVED]);
1969
+ } finally {
1970
+ env.cleanup();
1971
+ }
1972
+ });
1973
+
1974
+ test("off removes the connector service for the torn-down tunnel", async () => {
1975
+ const env = makeEnv();
1976
+ try {
1977
+ // Seed a service-managed tunnel record.
1978
+ const record: CloudflaredTunnelRecord = {
1979
+ pid: 95000,
1980
+ tunnelUuid: "bbbbbbbb-0000-0000-0000-000000000001",
1981
+ tunnelName: DERIVED,
1982
+ hostname: "vault.example.com",
1983
+ startedAt: "2026-05-31T00:00:00.000Z",
1984
+ configPath: env.configPath,
1985
+ serviceManaged: true,
1986
+ };
1987
+ writeCloudflaredState(withTunnelRecord(undefined, record), env.statePath);
1988
+
1989
+ const removeCalls: string[] = [];
1990
+ const removeService = (args: { tunnelName: string }): RemoveResult => {
1991
+ removeCalls.push(args.tunnelName);
1992
+ return { removed: true, messages: [`Removed launchd LaunchAgent for ${args.tunnelName}.`] };
1993
+ };
1994
+ const killed: number[] = [];
1995
+ const logs: string[] = [];
1996
+
1997
+ const code = await exposeCloudflareOff({
1998
+ statePath: env.statePath,
1999
+ exposeStatePath: env.exposeStatePath,
2000
+ alive: () => true,
2001
+ kill: (pid) => killed.push(pid),
2002
+ connectorPids: () => [],
2003
+ removeService,
2004
+ log: (l) => logs.push(l),
2005
+ });
2006
+
2007
+ expect(code).toBe(0);
2008
+ // The boot service was removed for the torn-down tunnel...
2009
+ expect(removeCalls).toEqual([DERIVED]);
2010
+ // ...AND the connector pid was SIGTERM'd (removal stops the service so it
2011
+ // won't restart it).
2012
+ expect(killed).toContain(95000);
2013
+ expect(existsSync(env.statePath)).toBe(false);
2014
+ expect(logs.join("\n")).toContain("Removed launchd LaunchAgent");
2015
+ } finally {
2016
+ env.cleanup();
2017
+ }
2018
+ });
2019
+ });