@openparachute/hub 0.6.1 → 0.6.2
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/package.json +1 -1
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +272 -0
- package/src/account-home-ui.ts +4 -1
- package/src/cloudflare/connector-service.ts +478 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +143 -10
|
@@ -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
|
+
});
|
package/src/account-home-ui.ts
CHANGED
|
@@ -310,8 +310,11 @@ function renderVaultCard(opts: VaultCardOpts): string {
|
|
|
310
310
|
<p class="vault-notes-cta">
|
|
311
311
|
<a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}"
|
|
312
312
|
target="_blank" rel="noopener" data-testid="open-notes-cta">Open Notes ↗</a>
|
|
313
|
+
<a class="btn btn-secondary" href="https://notes.parachute.computer/import?url=${vaultUrlForAdd}"
|
|
314
|
+
target="_blank" rel="noopener" data-testid="import-notes-cta">Import notes ↗</a>
|
|
313
315
|
<span class="vault-notes-cta-sub">Prefer a browser UI? Open Notes to browse +
|
|
314
|
-
capture in this vault
|
|
316
|
+
capture in this vault — or jump straight to bulk-importing Markdown/Obsidian
|
|
317
|
+
notes into it.</span>
|
|
315
318
|
</p>
|
|
316
319
|
${tokenMintBlock}
|
|
317
320
|
</div>`;
|