repoburg 1.3.217 → 1.3.219

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.
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getEnableJob = getEnableJob;
7
+ exports.startEnableJob = startEnableJob;
8
+ exports.runEnable = runEnable;
9
+ /**
10
+ * Deterministic remote-setup orchestration.
11
+ *
12
+ * The daemon itself runs the enable state machine (detect → provision →
13
+ * install → verify) in the background; the modal only polls GET /remote/enable
14
+ * and renders the progress. Idempotent by design:
15
+ * - tunnel already provisioned → provision is skipped,
16
+ * - cloudflared binary already on disk → download is skipped,
17
+ * - service already running with live edge connections → everything is
18
+ * skipped (the job goes straight to ready).
19
+ *
20
+ * When a deterministic step fails, the orchestrator opens a troubleshooting
21
+ * SESSION on the embedded pane (last used provider/model/cwd combo) with the
22
+ * diagnostics inlined — the agent then works the problem in the background
23
+ * while the modal shows "Troubleshooting". No SKILL.md is involved; the
24
+ * troubleshooting playbook lives in this file.
25
+ */
26
+ const os_1 = __importDefault(require("os"));
27
+ const tunnel_state_1 = require("./tunnel-state");
28
+ const provisioner_1 = require("./provisioner");
29
+ const cloudflared_1 = require("./cloudflared");
30
+ const probes_1 = require("./probes");
31
+ const DEFAULT_PANE_BASE_URL = 'http://127.0.0.1:9998';
32
+ const DEFAULT_VERIFY_TIMEOUT_MS = 90000;
33
+ const DEFAULT_VERIFY_INTERVAL_MS = 5000;
34
+ /** The single active (or last finished) job — one enable at a time. */
35
+ let currentJob = null;
36
+ function getEnableJob() {
37
+ return currentJob;
38
+ }
39
+ function log(job, step, message) {
40
+ job.step = step;
41
+ job.logs.push({ at: new Date().toISOString(), step, message });
42
+ }
43
+ /**
44
+ * Kick off the enable job in the background. Throws when a job is already
45
+ * running. Returns the job snapshot the caller can poll.
46
+ */
47
+ function startEnableJob(mahlas, deps = {}) {
48
+ if (currentJob?.state === 'running') {
49
+ throw Object.assign(new Error('An enable job is already running.'), { statusCode: 409 });
50
+ }
51
+ const job = {
52
+ id: `enable-${Date.now()}`,
53
+ mahlas,
54
+ hostname: null,
55
+ state: 'running',
56
+ step: 'detect',
57
+ error: null,
58
+ troubleshootingSessionId: null,
59
+ logs: [],
60
+ startedAt: new Date().toISOString(),
61
+ finishedAt: null,
62
+ };
63
+ currentJob = job;
64
+ void runEnable(job, deps).catch((error) => {
65
+ // Catastrophic path — runEnable should never reject, but never leave
66
+ // the job stuck in 'running'.
67
+ finishFailed(job, error?.message ?? 'Unknown error');
68
+ });
69
+ return job;
70
+ }
71
+ function finishFailed(job, error) {
72
+ job.state = 'failed';
73
+ job.step = 'failed';
74
+ job.error = error;
75
+ job.finishedAt = new Date().toISOString();
76
+ }
77
+ /** The deterministic state machine. */
78
+ async function runEnable(job, depOverrides = {}) {
79
+ const d = {
80
+ loadTunnelState: tunnel_state_1.loadTunnelState,
81
+ provisionTunnel: provisioner_1.provisionTunnel,
82
+ isInstalled: cloudflared_1.isInstalled,
83
+ downloadCloudflared: cloudflared_1.downloadCloudflared,
84
+ installService: cloudflared_1.installService,
85
+ isServiceRunning: cloudflared_1.isServiceRunning,
86
+ isConnected: probes_1.probeTunnelConnected,
87
+ paneBaseUrl: DEFAULT_PANE_BASE_URL,
88
+ fetchImpl: fetch,
89
+ verifyTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
90
+ verifyIntervalMs: DEFAULT_VERIFY_INTERVAL_MS,
91
+ waitImpl: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
92
+ ...depOverrides,
93
+ };
94
+ try {
95
+ // ---- detect: idempotent no-op check ----
96
+ log(job, 'detect', 'Checking the current machine state.');
97
+ const existing = d.loadTunnelState();
98
+ const running = await d.isServiceRunning().catch(() => false);
99
+ const connected = await d.isConnected();
100
+ if (existing && running && connected) {
101
+ job.hostname = existing.hostname;
102
+ log(job, 'ready', `Already remote and connected as ${existing.hostname}. Nothing to do.`);
103
+ job.state = 'ready';
104
+ job.finishedAt = new Date().toISOString();
105
+ return;
106
+ }
107
+ // ---- provision (only when no local state exists) ----
108
+ let state = existing;
109
+ if (!state) {
110
+ log(job, 'provision', `Provisioning the tunnel as "${job.mahlas ?? ''}".`);
111
+ state = await d.provisionTunnel(job.mahlas ?? '');
112
+ job.hostname = state.hostname;
113
+ }
114
+ else {
115
+ job.hostname = state.hostname;
116
+ log(job, 'provision', `Tunnel already provisioned (${state.hostname}) — skipping provisioning.`);
117
+ }
118
+ // ---- install (only when the service is not running) ----
119
+ if (!running) {
120
+ log(job, 'install', 'Installing the cloudflared service.');
121
+ if (d.isInstalled()) {
122
+ log(job, 'install', 'cloudflared binary already present — skipping the download.');
123
+ }
124
+ else {
125
+ log(job, 'install', 'Downloading cloudflared (sha256-verified).');
126
+ await d.downloadCloudflared();
127
+ }
128
+ await d.installService(state.tunnelToken);
129
+ log(job, 'install', 'Service installed and started.');
130
+ }
131
+ else {
132
+ log(job, 'install', 'Service already running — skipping install.');
133
+ }
134
+ // ---- verify: live edge connections with a bounded wait ----
135
+ log(job, 'verify', `Waiting for live edge connections (up to ${Math.round(d.verifyTimeoutMs / 1000)}s).`);
136
+ const deadline = Date.now() + d.verifyTimeoutMs;
137
+ for (;;) {
138
+ const ok = (await d.isConnected()) && (await d.isServiceRunning().catch(() => false));
139
+ if (ok) {
140
+ log(job, 'ready', `Machine is reachable at https://${state.hostname}`);
141
+ job.state = 'ready';
142
+ job.finishedAt = new Date().toISOString();
143
+ return;
144
+ }
145
+ if (Date.now() >= deadline) {
146
+ throw new Error('Tunnel did not connect in time — see the cloudflared service logs.');
147
+ }
148
+ await d.waitImpl(d.verifyIntervalMs);
149
+ }
150
+ }
151
+ catch (error) {
152
+ // Deterministic path failed → background troubleshooting via the pane.
153
+ const message = error?.message ?? 'Unknown error';
154
+ job.error = message;
155
+ log(job, 'troubleshooting', `Deterministic setup failed: ${message}`);
156
+ try {
157
+ await startTroubleshootingSession(job, d);
158
+ }
159
+ catch (troubleshootError) {
160
+ finishFailed(job, `${message} | troubleshooting failed: ${troubleshootError.message}`);
161
+ }
162
+ }
163
+ }
164
+ /**
165
+ * Open a troubleshooting session with the LAST USED provider/model/cwd combo
166
+ * and submit the diagnostics prompt. The agent runs autonomously in the
167
+ * background; the modal surfaces the session.
168
+ */
169
+ async function startTroubleshootingSession(job, d) {
170
+ const base = d.paneBaseUrl;
171
+ const http = d.fetchImpl;
172
+ // The last used agent: newest session wins (GET /api/sessions is newest-first).
173
+ const listRes = await http(`${base}/api/sessions`);
174
+ if (!listRes.ok)
175
+ throw new Error(`pane sessions list failed: ${listRes.status}`);
176
+ const sessions = (await listRes.json());
177
+ const last = sessions[0];
178
+ if (!last) {
179
+ throw new Error('no agent sessions on this machine — nothing to troubleshoot with');
180
+ }
181
+ const createRes = await http(`${base}/api/sessions`, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({
185
+ title: 'Remote Setup Troubleshooting',
186
+ runtime_type: last.runtime_type ?? 'codex',
187
+ model: last.model ?? null,
188
+ model_reasoning_effort: last.model_reasoning_effort ?? null,
189
+ cwd: last.cwd ?? null,
190
+ }),
191
+ });
192
+ if (!createRes.ok)
193
+ throw new Error(`pane session create failed: ${createRes.status}`);
194
+ const session = (await createRes.json());
195
+ const turnRes = await http(`${base}/api/sessions/${session.id}/turns`, {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ user_prompt: buildTroubleshootingPrompt(job) }),
199
+ });
200
+ if (!turnRes.ok)
201
+ throw new Error(`pane turn submit failed: ${turnRes.status}`);
202
+ job.troubleshootingSessionId = session.id;
203
+ job.state = 'troubleshooting';
204
+ job.finishedAt = new Date().toISOString();
205
+ log(job, 'troubleshooting', `Troubleshooting session opened (${session.id}) — the agent is working.`);
206
+ }
207
+ /**
208
+ * Inline diagnostics + playbook for the troubleshooting agent.
209
+ * SECURITY: never include the tunnel token or any other credential here.
210
+ */
211
+ function buildTroubleshootingPrompt(job) {
212
+ const state = (0, tunnel_state_1.loadTunnelState)();
213
+ const tail = job.logs
214
+ .slice(-12)
215
+ .map((entry) => `[${entry.step}] ${entry.message}`)
216
+ .join('\n');
217
+ const platform = os_1.default.platform();
218
+ const isMac = platform === 'darwin';
219
+ return [
220
+ 'The deterministic remote-setup orchestration on this machine FAILED. Fix it autonomously.',
221
+ '',
222
+ `Error: ${job.error ?? 'unknown'}`,
223
+ state
224
+ ? `Machine: mahlas=${state.mahlas}, hostname=${state.hostname}`
225
+ : 'Machine: no tunnel state on disk (provisioning may have failed).',
226
+ `Platform: ${platform}`,
227
+ '',
228
+ 'Setup log:',
229
+ tail,
230
+ '',
231
+ 'Playbook:',
232
+ '1. Snapshot: curl http://localhost:9998/remote/status',
233
+ isMac
234
+ ? '2. Service: launchctl list | grep repoburg ; log: ~/.repoburg/bin/cloudflared.log ; plist: ~/Library/LaunchAgents/com.repoburg.cloudflared.plist'
235
+ : '2. Service: systemctl --user status repoburg-cloudflared ; journalctl --user -u repoburg-cloudflared',
236
+ '3. Fix the root cause yourself:',
237
+ ' - reinstall the service: curl -X POST http://localhost:9998/remote/install',
238
+ ' - broken tunnel: curl -X DELETE http://localhost:9998/remote/uninstall then curl -X POST -H "Content-Type: application/json" -d \'{"mahlas":"<mahlas>"}\' http://localhost:9998/remote/provision',
239
+ ' - the mahlas may be taken (409) — pick a close variant and tell the user.',
240
+ '4. Done when /remote/status reports connection.ok=true and https://<hostname>/health answers.',
241
+ '5. Stop after 3 failed attempts (~2 minutes) and report findings. Do not loop forever.',
242
+ '',
243
+ 'Safety: /remote/* works ONLY from localhost curl. Never edit ~/.repoburg/remote.json by hand and never run cloudflared manually. Never print the tunnel token.',
244
+ ].join('\n');
245
+ }
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Orchestrator spec — drives runEnable with mock deps; startEnableJob is
5
+ * covered by the routes spec (mahlas validation + busy 409).
6
+ */
7
+ const orchestrator_1 = require("./orchestrator");
8
+ jest.mock('./tunnel-state');
9
+ jest.mock('./provisioner');
10
+ jest.mock('./cloudflared');
11
+ jest.mock('./probes');
12
+ const tunnel_state_1 = require("./tunnel-state");
13
+ const cloudflared_1 = require("./cloudflared");
14
+ const probes_1 = require("./probes");
15
+ const mockedLoad = tunnel_state_1.loadTunnelState;
16
+ const mockedIsInstalled = cloudflared_1.isInstalled;
17
+ const mockedConnected = probes_1.probeTunnelConnected;
18
+ const STATE = {
19
+ mahlas: 'zz',
20
+ hostname: 'zz.repoburg.com',
21
+ accountTunnelId: 'acc-1',
22
+ tunnelToken: 'TUNNELSECRET',
23
+ accessTeamDomain: 'team.cloudflareaccess.com',
24
+ accessAud: 'aud',
25
+ ownerEmail: 'o@x.com',
26
+ installedAt: '2026-01-01T00:00:00Z',
27
+ };
28
+ /** Wait for a settled microtask chain (the job loop is async). */
29
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
30
+ function makeJob(mahlas = 'newmach') {
31
+ return {
32
+ id: 'job-1',
33
+ mahlas,
34
+ hostname: null,
35
+ state: 'running',
36
+ step: 'detect',
37
+ error: null,
38
+ troubleshootingSessionId: null,
39
+ logs: [],
40
+ startedAt: new Date().toISOString(),
41
+ finishedAt: null,
42
+ };
43
+ }
44
+ function makeDeps(overrides = {}) {
45
+ return {
46
+ loadTunnelState: mockedLoad,
47
+ provisionTunnel: jest.fn().mockResolvedValue(STATE),
48
+ isInstalled: mockedIsInstalled,
49
+ downloadCloudflared: jest.fn().mockResolvedValue('/bin/cloudflared'),
50
+ installService: jest.fn().mockResolvedValue('/service'),
51
+ isServiceRunning: jest.fn().mockResolvedValue(true),
52
+ isConnected: mockedConnected,
53
+ paneBaseUrl: 'http://127.0.0.1:9998',
54
+ fetchImpl: jest.fn(),
55
+ verifyTimeoutMs: 60,
56
+ verifyIntervalMs: 10,
57
+ waitImpl: jest.fn(async () => undefined),
58
+ ...overrides,
59
+ };
60
+ }
61
+ describe('orchestrator: deterministic happy path', () => {
62
+ beforeEach(() => {
63
+ jest.clearAllMocks();
64
+ });
65
+ it('no-ops when already provisioned, running, and connected', async () => {
66
+ mockedLoad.mockReturnValue(STATE);
67
+ const deps = makeDeps();
68
+ deps.isServiceRunning.mockResolvedValue(true);
69
+ mockedConnected.mockResolvedValue(true);
70
+ const job = makeJob();
71
+ await (0, orchestrator_1.runEnable)(job, deps);
72
+ expect(job.state).toBe('ready');
73
+ expect(deps.provisionTunnel).not.toHaveBeenCalled();
74
+ expect(deps.installService).not.toHaveBeenCalled();
75
+ expect(job.logs.some((l) => l.message.includes('Nothing to do'))).toBe(true);
76
+ });
77
+ it('provisions, installs, and verifies a fresh machine', async () => {
78
+ mockedLoad.mockReturnValue(null);
79
+ mockedIsInstalled.mockReturnValue(false);
80
+ mockedConnected.mockResolvedValue(true);
81
+ // Detect: service down (fresh machine). Verify: service up after install.
82
+ const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
83
+ const job = makeJob('fresh');
84
+ const deps = makeDeps({ isServiceRunning: serviceProbe });
85
+ await (0, orchestrator_1.runEnable)(job, deps);
86
+ expect(deps.provisionTunnel).toHaveBeenCalledWith('fresh');
87
+ expect(deps.downloadCloudflared).toHaveBeenCalledTimes(1);
88
+ expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
89
+ expect(job.state).toBe('ready');
90
+ expect(job.logs.some((l) => l.message.includes('reachable at https://zz.repoburg.com'))).toBe(true);
91
+ });
92
+ it('skips provision when the tunnel state exists and skips download when the binary is present', async () => {
93
+ mockedLoad.mockReturnValue(STATE);
94
+ mockedIsInstalled.mockReturnValue(true);
95
+ mockedConnected.mockResolvedValue(true);
96
+ // Detect: service down → install runs. Verify: service up.
97
+ const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
98
+ const job = makeJob('ignored');
99
+ const deps = makeDeps({ isServiceRunning: serviceProbe });
100
+ await (0, orchestrator_1.runEnable)(job, deps);
101
+ expect(deps.provisionTunnel).not.toHaveBeenCalled();
102
+ expect(deps.downloadCloudflared).not.toHaveBeenCalled();
103
+ expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
104
+ expect(job.state).toBe('ready');
105
+ });
106
+ it('skips install when the service is already running and waits for connections', async () => {
107
+ mockedLoad.mockReturnValue(STATE);
108
+ mockedIsInstalled.mockReturnValue(true);
109
+ // First probe: not connected (service warming up); second: connected.
110
+ mockedConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
111
+ const job = makeJob();
112
+ const deps = makeDeps();
113
+ deps.isServiceRunning.mockResolvedValue(true);
114
+ await (0, orchestrator_1.runEnable)(job, deps);
115
+ expect(deps.installService).not.toHaveBeenCalled();
116
+ expect(job.state).toBe('ready');
117
+ });
118
+ it('fails after the verify deadline', async () => {
119
+ mockedLoad.mockReturnValue(STATE);
120
+ mockedIsInstalled.mockReturnValue(true);
121
+ mockedConnected.mockResolvedValue(false);
122
+ const job = makeJob();
123
+ const deps = makeDeps();
124
+ deps.isServiceRunning.mockResolvedValue(true);
125
+ await (0, orchestrator_1.runEnable)(job, deps);
126
+ expect(job.state).toBe('failed'); // no pane sessions available in the mock
127
+ expect(job.error).toContain('did not connect');
128
+ });
129
+ });
130
+ describe('orchestrator: troubleshooting fallback', () => {
131
+ beforeEach(() => {
132
+ jest.clearAllMocks();
133
+ mockedLoad.mockReturnValue(null);
134
+ mockedIsInstalled.mockReturnValue(false);
135
+ mockedConnected.mockResolvedValue(false);
136
+ });
137
+ function paneFetch(jestFn) {
138
+ // /api/sessions list (newest first), session create, turn submit.
139
+ jestFn.mockImplementation(async (input, init) => {
140
+ const url = String(input);
141
+ if (url.endsWith('/api/sessions') && (!init || init.method === 'GET')) {
142
+ return {
143
+ ok: true,
144
+ json: async () => [
145
+ { id: 's1', runtime_type: 'repoburg', model: 'glm', model_reasoning_effort: null, cwd: '/repo' },
146
+ ],
147
+ };
148
+ }
149
+ if (url.endsWith('/api/sessions') && init?.method === 'POST') {
150
+ return { ok: true, json: async () => ({ id: 'ts-1' }) };
151
+ }
152
+ if (url.includes('/api/sessions/ts-1/turns')) {
153
+ return { ok: true, json: async () => ({ id: 'turn-1' }) };
154
+ }
155
+ throw new Error(`unexpected ${url}`);
156
+ });
157
+ }
158
+ it('opens a troubleshooting session with the last used agent on failure', async () => {
159
+ const fetchImpl = jest.fn();
160
+ paneFetch(fetchImpl);
161
+ const deps = makeDeps({ fetchImpl: fetchImpl });
162
+ const job = makeJob();
163
+ await (0, orchestrator_1.runEnable)(job, deps);
164
+ expect(job.state).toBe('troubleshooting');
165
+ expect(job.troubleshootingSessionId).toBe('ts-1');
166
+ // Session created with the LAST USED combo.
167
+ const createCall = fetchImpl.mock.calls.find((c) => c[1]?.method === 'POST' && String(c[0]).endsWith('/api/sessions'));
168
+ expect(JSON.parse(createCall[1].body)).toMatchObject({
169
+ title: 'Remote Setup Troubleshooting',
170
+ runtime_type: 'repoburg',
171
+ model: 'glm',
172
+ cwd: '/repo',
173
+ });
174
+ // The prompt carries the playbook but never the tunnel token.
175
+ const turnCall = fetchImpl.mock.calls.find((c) => String(c[0]).includes('/api/sessions/ts-1/turns'));
176
+ const prompt = JSON.parse(turnCall[1].body).user_prompt;
177
+ expect(prompt).toContain('localhost:9998/remote/status');
178
+ expect(prompt).not.toContain('TUNNELSECRET');
179
+ });
180
+ it('fails (no agent) when the pane has no sessions', async () => {
181
+ const fetchImpl = jest.fn(async () => ({ ok: true, json: async () => [] }));
182
+ const deps = makeDeps({ fetchImpl: fetchImpl });
183
+ const job = makeJob();
184
+ await (0, orchestrator_1.runEnable)(job, deps);
185
+ expect(job.state).toBe('failed');
186
+ expect(job.troubleshootingSessionId).toBeNull();
187
+ expect(job.error).toContain('no agent sessions');
188
+ });
189
+ it('getEnableJob returns null before any job', () => {
190
+ expect((0, orchestrator_1.getEnableJob)()).toBeNull();
191
+ });
192
+ });
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.probePublicHealth = probePublicHealth;
4
+ exports.probeTunnelConnected = probeTunnelConnected;
5
+ /**
6
+ * Connectivity probes shared by the /remote routes and the enable
7
+ * orchestrator. Both signals are needed: the public health probe only proves
8
+ * the edge knows the hostname (CF Access answers 302 before the tunnel is
9
+ * consulted), while the cloudflared metrics gauge proves live edge
10
+ * connections through the tunnel.
11
+ */
12
+ const tunnel_state_1 = require("./tunnel-state");
13
+ const cloudflared_1 = require("./cloudflared");
14
+ const HEALTH_CHECK_TIMEOUT_MS = 10000;
15
+ function publicHealthUrl() {
16
+ const state = (0, tunnel_state_1.loadTunnelState)();
17
+ return state ? `https://${state.hostname}/health` : null;
18
+ }
19
+ /**
20
+ * Edge reachability probe. Behind CF Access an anonymous request gets a 302
21
+ * to the login page BEFORE the tunnel is ever consulted — so this can only
22
+ * prove the edge knows the hostname, never that the tunnel is connected.
23
+ * `redirect: 'manual'` prevents the old false positive where the login
24
+ * page's 200 was counted as a healthy daemon.
25
+ */
26
+ async function probePublicHealth(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
27
+ const url = publicHealthUrl();
28
+ if (!url)
29
+ return false;
30
+ try {
31
+ const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
32
+ return response.status >= 200 && response.status < 400;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * Tunnel connectivity from cloudflared's LOCAL metrics endpoint. This is the
40
+ * primary "is the tunnel actually serving" signal: `ha_connections` counts
41
+ * live edge connections (0 or unreachable metrics = not connected).
42
+ */
43
+ async function probeTunnelConnected(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
44
+ try {
45
+ const response = await fetch((0, cloudflared_1.cloudflaredMetricsUrl)(), {
46
+ signal: AbortSignal.timeout(timeoutMs),
47
+ });
48
+ if (!response.ok)
49
+ return false;
50
+ const body = await response.text();
51
+ // The gauge may appear label-less (`… 4`) or labeled (`…{region="us"} 4`);
52
+ // ^… anchors past the HELP/TYPE lines.
53
+ const match = body.match(/^cloudflared_tunnel_ha_connections(?:\{[^}]*\})?\s+([0-9.]+)/m);
54
+ return Boolean(match && Number(match[1]) > 0);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProvisioningError = void 0;
4
+ exports.accountServerUrl = accountServerUrl;
5
+ exports.provisionTunnel = provisionTunnel;
6
+ exports.listTunnels = listTunnels;
7
+ exports.uninstallTunnel = uninstallTunnel;
8
+ /**
9
+ * Account-server client for tunnel lifecycle. The daemon authenticates with
10
+ * the device JWT from `repoburg login` (~/.repoburg/token.jwt).
11
+ */
12
+ const authManager_1 = require("../auth/authManager");
13
+ const tunnel_state_1 = require("./tunnel-state");
14
+ const isDevelopment = process.env.NODE_ENV === 'development';
15
+ const DEFAULT_ACCOUNT_SERVER_URL = isDevelopment
16
+ ? 'http://localhost:2999'
17
+ : 'https://repoburg.com/api/account';
18
+ function accountServerUrl() {
19
+ return process.env.ACCOUNT_SERVER_URL ?? DEFAULT_ACCOUNT_SERVER_URL;
20
+ }
21
+ class ProvisioningError extends Error {
22
+ constructor(message, statusCode) {
23
+ super(message);
24
+ this.statusCode = statusCode;
25
+ }
26
+ }
27
+ exports.ProvisioningError = ProvisioningError;
28
+ async function callAccountServer(method, path, token, body) {
29
+ const response = await fetch(`${accountServerUrl()}${path}`, {
30
+ method,
31
+ headers: {
32
+ Authorization: `Bearer ${token}`,
33
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
34
+ },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ });
37
+ const data = (await response.json().catch(() => ({})));
38
+ if (!response.ok) {
39
+ const message = data.message ?? data.error ?? response.statusText;
40
+ throw new ProvisioningError(`Account server: ${message}`, response.status);
41
+ }
42
+ return data;
43
+ }
44
+ /**
45
+ * Provision a new tunnel for this machine and persist the state locally.
46
+ * The response's tunnel_token is the only credential written to disk.
47
+ */
48
+ async function provisionTunnel(mahlas) {
49
+ const token = await authManager_1.authManager.readToken();
50
+ if (!token) {
51
+ throw new ProvisioningError('Not logged in. Run `repoburg login` first.', 401);
52
+ }
53
+ const result = await callAccountServer('POST', '/device/tunnels', token, { mahlas });
54
+ // Access config + owner email arrive in the same response: the tunnel is
55
+ // email-gated from birth, and checkRemoteAuth can pin the owner's email.
56
+ const state = {
57
+ mahlas: result.mahlas,
58
+ hostname: result.hostname,
59
+ accountTunnelId: result.id,
60
+ tunnelToken: result.tunnel_token,
61
+ accessTeamDomain: result.accessTeamDomain,
62
+ accessAud: result.accessAud,
63
+ ownerEmail: result.ownerEmail,
64
+ installedAt: new Date().toISOString(),
65
+ };
66
+ (0, tunnel_state_1.saveTunnelState)(state);
67
+ return state;
68
+ }
69
+ /** List this user's machines (used by the pane machine list when local). */
70
+ async function listTunnels() {
71
+ const token = await authManager_1.authManager.readToken();
72
+ if (!token)
73
+ return [];
74
+ return callAccountServer('GET', '/device/tunnels', token);
75
+ }
76
+ /** Remove the tunnel on the account server and wipe the local state. */
77
+ async function uninstallTunnel() {
78
+ const state = (0, tunnel_state_1.loadTunnelState)();
79
+ if (!state)
80
+ return;
81
+ const token = await authManager_1.authManager.readToken();
82
+ if (token) {
83
+ await callAccountServer('DELETE', `/device/tunnels/${state.accountTunnelId}`, token);
84
+ }
85
+ (0, tunnel_state_1.clearTunnelState)();
86
+ }