@desplega.ai/qa-use 2.14.0 → 2.15.0
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 +23 -0
- package/dist/lib/env/index.d.ts +13 -0
- package/dist/lib/env/index.d.ts.map +1 -1
- package/dist/lib/env/index.js +35 -0
- package/dist/lib/env/index.js.map +1 -1
- package/dist/lib/env/localhost.d.ts +22 -0
- package/dist/lib/env/localhost.d.ts.map +1 -0
- package/dist/lib/env/localhost.js +49 -0
- package/dist/lib/env/localhost.js.map +1 -0
- package/dist/lib/env/paths.d.ts +27 -0
- package/dist/lib/env/paths.d.ts.map +1 -0
- package/dist/lib/env/paths.js +42 -0
- package/dist/lib/env/paths.js.map +1 -0
- package/dist/lib/env/sessions.d.ts +55 -0
- package/dist/lib/env/sessions.d.ts.map +1 -0
- package/dist/lib/env/sessions.js +128 -0
- package/dist/lib/env/sessions.js.map +1 -0
- package/dist/lib/tunnel/errors.d.ts +61 -0
- package/dist/lib/tunnel/errors.d.ts.map +1 -0
- package/dist/lib/tunnel/errors.js +152 -0
- package/dist/lib/tunnel/errors.js.map +1 -0
- package/dist/lib/tunnel/index.d.ts.map +1 -1
- package/dist/lib/tunnel/index.js +26 -11
- package/dist/lib/tunnel/index.js.map +1 -1
- package/dist/lib/tunnel/registry.d.ts +182 -0
- package/dist/lib/tunnel/registry.d.ts.map +1 -0
- package/dist/lib/tunnel/registry.js +561 -0
- package/dist/lib/tunnel/registry.js.map +1 -0
- package/dist/package.json +1 -1
- package/dist/src/cli/commands/browser/_detached.d.ts +27 -0
- package/dist/src/cli/commands/browser/_detached.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/_detached.js +422 -0
- package/dist/src/cli/commands/browser/_detached.js.map +1 -0
- package/dist/src/cli/commands/browser/close.d.ts +7 -0
- package/dist/src/cli/commands/browser/close.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/close.js +101 -5
- package/dist/src/cli/commands/browser/close.js.map +1 -1
- package/dist/src/cli/commands/browser/create.d.ts +7 -0
- package/dist/src/cli/commands/browser/create.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/create.js +233 -25
- package/dist/src/cli/commands/browser/create.js.map +1 -1
- package/dist/src/cli/commands/browser/index.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/index.js +3 -0
- package/dist/src/cli/commands/browser/index.js.map +1 -1
- package/dist/src/cli/commands/browser/run.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/run.js +13 -6
- package/dist/src/cli/commands/browser/run.js.map +1 -1
- package/dist/src/cli/commands/browser/status.d.ts +4 -0
- package/dist/src/cli/commands/browser/status.d.ts.map +1 -1
- package/dist/src/cli/commands/browser/status.js +85 -3
- package/dist/src/cli/commands/browser/status.js.map +1 -1
- package/dist/src/cli/commands/doctor.d.ts +45 -0
- package/dist/src/cli/commands/doctor.d.ts.map +1 -0
- package/dist/src/cli/commands/doctor.js +267 -0
- package/dist/src/cli/commands/doctor.js.map +1 -0
- package/dist/src/cli/commands/test/run.d.ts.map +1 -1
- package/dist/src/cli/commands/test/run.js +29 -18
- package/dist/src/cli/commands/test/run.js.map +1 -1
- package/dist/src/cli/commands/tunnel/close.d.ts +18 -0
- package/dist/src/cli/commands/tunnel/close.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/close.js +154 -0
- package/dist/src/cli/commands/tunnel/close.js.map +1 -0
- package/dist/src/cli/commands/tunnel/index.d.ts +6 -0
- package/dist/src/cli/commands/tunnel/index.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/index.js +17 -0
- package/dist/src/cli/commands/tunnel/index.js.map +1 -0
- package/dist/src/cli/commands/tunnel/ls.d.ts +10 -0
- package/dist/src/cli/commands/tunnel/ls.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/ls.js +89 -0
- package/dist/src/cli/commands/tunnel/ls.js.map +1 -0
- package/dist/src/cli/commands/tunnel/start.d.ts +15 -0
- package/dist/src/cli/commands/tunnel/start.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/start.js +65 -0
- package/dist/src/cli/commands/tunnel/start.js.map +1 -0
- package/dist/src/cli/commands/tunnel/status.d.ts +8 -0
- package/dist/src/cli/commands/tunnel/status.d.ts.map +1 -0
- package/dist/src/cli/commands/tunnel/status.js +58 -0
- package/dist/src/cli/commands/tunnel/status.js.map +1 -0
- package/dist/src/cli/generated/docs-content.d.ts +1 -1
- package/dist/src/cli/generated/docs-content.d.ts.map +1 -1
- package/dist/src/cli/generated/docs-content.js +157 -100
- package/dist/src/cli/generated/docs-content.js.map +1 -1
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/lib/browser.d.ts +25 -9
- package/dist/src/cli/lib/browser.d.ts.map +1 -1
- package/dist/src/cli/lib/browser.js +73 -42
- package/dist/src/cli/lib/browser.js.map +1 -1
- package/dist/src/cli/lib/cli-entry.d.ts +40 -0
- package/dist/src/cli/lib/cli-entry.d.ts.map +1 -0
- package/dist/src/cli/lib/cli-entry.js +65 -0
- package/dist/src/cli/lib/cli-entry.js.map +1 -0
- package/dist/src/cli/lib/config.d.ts.map +1 -1
- package/dist/src/cli/lib/config.js +8 -4
- package/dist/src/cli/lib/config.js.map +1 -1
- package/dist/src/cli/lib/startup-sweep.d.ts +45 -0
- package/dist/src/cli/lib/startup-sweep.d.ts.map +1 -0
- package/dist/src/cli/lib/startup-sweep.js +246 -0
- package/dist/src/cli/lib/startup-sweep.js.map +1 -0
- package/dist/src/cli/lib/tunnel-banner.d.ts +33 -0
- package/dist/src/cli/lib/tunnel-banner.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-banner.js +55 -0
- package/dist/src/cli/lib/tunnel-banner.js.map +1 -0
- package/dist/src/cli/lib/tunnel-error-hint.d.ts +20 -0
- package/dist/src/cli/lib/tunnel-error-hint.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-error-hint.js +48 -0
- package/dist/src/cli/lib/tunnel-error-hint.js.map +1 -0
- package/dist/src/cli/lib/tunnel-option.d.ts +27 -0
- package/dist/src/cli/lib/tunnel-option.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-option.js +77 -0
- package/dist/src/cli/lib/tunnel-option.js.map +1 -0
- package/dist/src/cli/lib/tunnel-resolve.d.ts +42 -0
- package/dist/src/cli/lib/tunnel-resolve.d.ts.map +1 -0
- package/dist/src/cli/lib/tunnel-resolve.js +72 -0
- package/dist/src/cli/lib/tunnel-resolve.js.map +1 -0
- package/lib/env/index.ts +51 -0
- package/lib/env/localhost.test.ts +63 -0
- package/lib/env/localhost.ts +51 -0
- package/lib/env/paths.ts +46 -0
- package/lib/env/sessions.test.ts +109 -0
- package/lib/env/sessions.ts +155 -0
- package/lib/tunnel/errors.test.ts +105 -0
- package/lib/tunnel/errors.ts +169 -0
- package/lib/tunnel/index.ts +26 -11
- package/lib/tunnel/registry.test.ts +420 -0
- package/lib/tunnel/registry.ts +646 -0
- package/package.json +1 -1
package/lib/env/paths.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem paths used by qa-use for CLI-side state.
|
|
3
|
+
*
|
|
4
|
+
* All paths are relative to `os.homedir()` and created lazily on first
|
|
5
|
+
* write. Reading these directories does not create them — that's important
|
|
6
|
+
* for commands like `tunnel ls` that report an empty state cleanly when
|
|
7
|
+
* nothing has ever been written.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Base dir for all qa-use CLI-side state. Defaults to `~/.qa-use` but may
|
|
16
|
+
* be overridden via `QA_USE_HOME` (test-friendly).
|
|
17
|
+
*/
|
|
18
|
+
export function qaUseDir(): string {
|
|
19
|
+
const override = process.env.QA_USE_HOME;
|
|
20
|
+
if (override && override.length > 0) {
|
|
21
|
+
return override;
|
|
22
|
+
}
|
|
23
|
+
return path.join(os.homedir(), '.qa-use');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where persisted tunnel registry entries live. Each active tunnel is a
|
|
28
|
+
* single JSON file named `<sha256(target)[0..10]>.json`.
|
|
29
|
+
*/
|
|
30
|
+
export function tunnelsDir(): string {
|
|
31
|
+
return path.join(qaUseDir(), 'tunnels');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where detached browser-session PID files live (used by Phase 4 onward).
|
|
36
|
+
*/
|
|
37
|
+
export function sessionsDir(): string {
|
|
38
|
+
return path.join(qaUseDir(), 'sessions');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure a directory exists (recursively). No-op if it already does.
|
|
43
|
+
*/
|
|
44
|
+
export function ensureDir(dir: string): void {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
type DetachedSessionRecord,
|
|
7
|
+
listSessionRecords,
|
|
8
|
+
removeSessionRecord,
|
|
9
|
+
sessionFilePath,
|
|
10
|
+
writeSessionRecord,
|
|
11
|
+
} from './sessions.js';
|
|
12
|
+
|
|
13
|
+
// Redirect QA_USE_HOME so `paths.ts` resolves to a throwaway dir per test.
|
|
14
|
+
let originalQaUseHome: string | undefined;
|
|
15
|
+
let tmpHome: string;
|
|
16
|
+
|
|
17
|
+
function makeRecord(overrides: Partial<DetachedSessionRecord> = {}): DetachedSessionRecord {
|
|
18
|
+
return {
|
|
19
|
+
id: 'qa-test-1',
|
|
20
|
+
pid: 99999999,
|
|
21
|
+
target: 'http://localhost:3000',
|
|
22
|
+
publicUrl: 'https://example.tunnel',
|
|
23
|
+
startedAt: '2026-04-21T00:00:00.000Z',
|
|
24
|
+
ttlExpiresAt: Date.now() + 60_000,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('sessions.ts', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
originalQaUseHome = process.env.QA_USE_HOME;
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qa-use-sessions-'));
|
|
33
|
+
process.env.QA_USE_HOME = tmpHome;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (originalQaUseHome !== undefined) {
|
|
38
|
+
process.env.QA_USE_HOME = originalQaUseHome;
|
|
39
|
+
} else {
|
|
40
|
+
delete process.env.QA_USE_HOME;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
/* best-effort */
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('removeSessionRecord', () => {
|
|
50
|
+
test('fast path: removes <id>.json when filename matches id', () => {
|
|
51
|
+
const record = makeRecord({ id: 'qa-fast-1' });
|
|
52
|
+
writeSessionRecord(record);
|
|
53
|
+
const p = sessionFilePath('qa-fast-1');
|
|
54
|
+
expect(fs.existsSync(p)).toBe(true);
|
|
55
|
+
|
|
56
|
+
removeSessionRecord('qa-fast-1');
|
|
57
|
+
expect(fs.existsSync(p)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('fallback: removes file when filename drifts from internal id', () => {
|
|
61
|
+
// Simulate corrupted/drifted state: filename qa-stale-2.json, internal id qa-stale-1.
|
|
62
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
const driftedPath = path.join(dir, 'qa-stale-2.json');
|
|
65
|
+
const content = JSON.stringify(makeRecord({ id: 'qa-stale-1' }));
|
|
66
|
+
fs.writeFileSync(driftedPath, content);
|
|
67
|
+
|
|
68
|
+
// No file at sessionFilePath('qa-stale-1'), but a file with id=qa-stale-1 exists as qa-stale-2.json
|
|
69
|
+
expect(fs.existsSync(sessionFilePath('qa-stale-1'))).toBe(false);
|
|
70
|
+
expect(fs.existsSync(driftedPath)).toBe(true);
|
|
71
|
+
|
|
72
|
+
removeSessionRecord('qa-stale-1');
|
|
73
|
+
|
|
74
|
+
expect(fs.existsSync(driftedPath)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('no-op when no matching record exists', () => {
|
|
78
|
+
// Create an unrelated record — scan should not touch it.
|
|
79
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
81
|
+
const unrelatedPath = path.join(dir, 'other.json');
|
|
82
|
+
fs.writeFileSync(unrelatedPath, JSON.stringify(makeRecord({ id: 'other' })));
|
|
83
|
+
|
|
84
|
+
removeSessionRecord('not-present');
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(unrelatedPath)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('no-op when sessions dir is missing', () => {
|
|
90
|
+
// Should not throw even when the dir doesn't exist.
|
|
91
|
+
expect(() => removeSessionRecord('missing')).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('listSessionRecords', () => {
|
|
96
|
+
test('returns parsed records regardless of filename drift', () => {
|
|
97
|
+
const dir = path.join(tmpHome, 'sessions');
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(dir, 'drifted.json'),
|
|
101
|
+
JSON.stringify(makeRecord({ id: 'actual-id' }))
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const records = listSessionRecords();
|
|
105
|
+
expect(records).toHaveLength(1);
|
|
106
|
+
expect(records[0]!.id).toBe('actual-id');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached browser-session PID files.
|
|
3
|
+
*
|
|
4
|
+
* Each detached `browser create` writes a JSON file under
|
|
5
|
+
* `~/.qa-use/sessions/<session-id>.json` describing the running child
|
|
6
|
+
* process. The file schema is documented in `DetachedSessionRecord`.
|
|
7
|
+
*
|
|
8
|
+
* Readers (tunnel close, browser status, doctor) cross-reference the
|
|
9
|
+
* `pid` field via `process.kill(pid, 0)` to detect stale entries.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { ensureDir, sessionsDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* On-disk schema for a detached browser session.
|
|
18
|
+
*
|
|
19
|
+
* The file is created by the detached child (`__browser-detach`) shortly
|
|
20
|
+
* after it starts, and removed on clean exit. Partial writes are
|
|
21
|
+
* avoided by atomic rename (write `.tmp` + `rename`).
|
|
22
|
+
*/
|
|
23
|
+
export interface DetachedSessionRecord {
|
|
24
|
+
/** Backend session id (also the filename base). */
|
|
25
|
+
id: string;
|
|
26
|
+
/** PID of the detached child process. */
|
|
27
|
+
pid: number;
|
|
28
|
+
/** Tunnel target (canonical origin of the browser WS URL). */
|
|
29
|
+
target: string;
|
|
30
|
+
/** Public URL for the tunnel (from registry handle). `null` when no tunnel. */
|
|
31
|
+
publicUrl: string | null;
|
|
32
|
+
/** ISO timestamp of child startup. */
|
|
33
|
+
startedAt: string;
|
|
34
|
+
/** Epoch ms at which the backend session TTL expires. */
|
|
35
|
+
ttlExpiresAt: number;
|
|
36
|
+
/**
|
|
37
|
+
* True when the registry handle was an attach (another process owns
|
|
38
|
+
* the in-process `TunnelManager`). Informational — `browser close`
|
|
39
|
+
* prints this in diagnostics.
|
|
40
|
+
*/
|
|
41
|
+
crossProcessTunnel?: boolean;
|
|
42
|
+
/** Optional: subdomain used for the tunnel. */
|
|
43
|
+
subdomain?: string;
|
|
44
|
+
/** Optional: viewport for display/debug. */
|
|
45
|
+
viewport?: string;
|
|
46
|
+
/** Optional: headless flag for display/debug. */
|
|
47
|
+
headless?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sessionFilePath(sessionId: string): string {
|
|
51
|
+
return path.join(sessionsDir(), `${sessionId}.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeSessionRecord(record: DetachedSessionRecord): void {
|
|
55
|
+
const dir = sessionsDir();
|
|
56
|
+
ensureDir(dir);
|
|
57
|
+
const finalPath = sessionFilePath(record.id);
|
|
58
|
+
const tmp = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
|
59
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
|
|
60
|
+
fs.renameSync(tmp, finalPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readSessionRecord(sessionId: string): DetachedSessionRecord | null {
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(sessionFilePath(sessionId), 'utf8');
|
|
66
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
67
|
+
if (typeof parsed.id !== 'string' || typeof parsed.pid !== 'number') return null;
|
|
68
|
+
return parsed;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeSessionRecord(sessionId: string): void {
|
|
75
|
+
try {
|
|
76
|
+
fs.unlinkSync(sessionFilePath(sessionId));
|
|
77
|
+
return;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Fast path missed — fall through to dir-scan fallback only on ENOENT.
|
|
80
|
+
// Any other error (EPERM, EACCES, EBUSY, etc.) is propagated by
|
|
81
|
+
// simply returning: callers treat removal as best-effort and surfacing
|
|
82
|
+
// an error here would change the public contract.
|
|
83
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Fallback: filename may have drifted from the internal `id` (corrupted
|
|
89
|
+
// state, manual edits, legacy data). Scan the dir and unlink the file
|
|
90
|
+
// whose parsed content has a matching `id`.
|
|
91
|
+
const dir = sessionsDir();
|
|
92
|
+
let files: string[];
|
|
93
|
+
try {
|
|
94
|
+
files = fs.readdirSync(dir);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const name of files) {
|
|
99
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp')) continue;
|
|
100
|
+
const fullPath = path.join(dir, name);
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(fullPath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
104
|
+
if (parsed.id === sessionId) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(fullPath);
|
|
107
|
+
} catch {
|
|
108
|
+
/* already gone */
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* skip unreadable */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List all persisted detached-session records. Does NOT reconcile
|
|
120
|
+
* against PIDs — callers decide how to handle stale entries.
|
|
121
|
+
*/
|
|
122
|
+
export function listSessionRecords(): DetachedSessionRecord[] {
|
|
123
|
+
const dir = sessionsDir();
|
|
124
|
+
let files: string[];
|
|
125
|
+
try {
|
|
126
|
+
files = fs.readdirSync(dir);
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const out: DetachedSessionRecord[] = [];
|
|
131
|
+
for (const name of files) {
|
|
132
|
+
if (!name.endsWith('.json') || name.endsWith('.tmp')) continue;
|
|
133
|
+
try {
|
|
134
|
+
const raw = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
135
|
+
const parsed = JSON.parse(raw) as DetachedSessionRecord;
|
|
136
|
+
if (typeof parsed.id !== 'string' || typeof parsed.pid !== 'number') continue;
|
|
137
|
+
out.push(parsed);
|
|
138
|
+
} catch {
|
|
139
|
+
/* skip unreadable */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Cheap liveness check — `kill(pid, 0)` returns true if the pid exists. */
|
|
146
|
+
export function isPidAlive(pid: number): boolean {
|
|
147
|
+
if (!pid || pid <= 0) return false;
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
classifyTunnelFailure,
|
|
4
|
+
TunnelAuthError,
|
|
5
|
+
TunnelError,
|
|
6
|
+
TunnelNetworkError,
|
|
7
|
+
TunnelQuotaError,
|
|
8
|
+
TunnelUnknownError,
|
|
9
|
+
} from './errors.js';
|
|
10
|
+
|
|
11
|
+
describe('TunnelError', () => {
|
|
12
|
+
test('base class carries target + provider + cause', () => {
|
|
13
|
+
const cause = new Error('boom');
|
|
14
|
+
const err = new TunnelError('msg', {
|
|
15
|
+
target: 'http://localhost:3000',
|
|
16
|
+
provider: 'localtunnel',
|
|
17
|
+
cause,
|
|
18
|
+
});
|
|
19
|
+
expect(err).toBeInstanceOf(Error);
|
|
20
|
+
expect(err.target).toBe('http://localhost:3000');
|
|
21
|
+
expect(err.provider).toBe('localtunnel');
|
|
22
|
+
expect(err.cause).toBe(cause);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('provider defaults to "localtunnel"', () => {
|
|
26
|
+
const err = new TunnelError('msg', { target: 'x' });
|
|
27
|
+
expect(err.provider).toBe('localtunnel');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('classifyTunnelFailure', () => {
|
|
32
|
+
test('classifies Node errno codes as network errors', () => {
|
|
33
|
+
const nodeErr = Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' });
|
|
34
|
+
const out = classifyTunnelFailure(nodeErr);
|
|
35
|
+
expect(out).toBeInstanceOf(TunnelNetworkError);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('classifies ETIMEDOUT as network', () => {
|
|
39
|
+
const err = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' });
|
|
40
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('classifies ENOTFOUND as network', () => {
|
|
44
|
+
const err = Object.assign(new Error('getaddrinfo ENOTFOUND'), { code: 'ENOTFOUND' });
|
|
45
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('classifies message-based timeout text as network', () => {
|
|
49
|
+
const err = new Error('Connection timed out after 5s');
|
|
50
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelNetworkError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('classifies HTTP 401 as auth error', () => {
|
|
54
|
+
const err = Object.assign(new Error('Unauthorized'), { statusCode: 401 });
|
|
55
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('classifies HTTP 403 as auth error', () => {
|
|
59
|
+
const err = Object.assign(new Error('Forbidden'), { statusCode: 403 });
|
|
60
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('classifies "unauthorized" message as auth error', () => {
|
|
64
|
+
const err = new Error('Request rejected: unauthorized');
|
|
65
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('classifies HTTP 429 as quota error', () => {
|
|
69
|
+
const err = Object.assign(new Error('Too Many Requests'), { statusCode: 429 });
|
|
70
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('classifies "rate limit" message as quota error', () => {
|
|
74
|
+
const err = new Error('rate limit exceeded');
|
|
75
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('classifies "subdomain already in use" as quota error', () => {
|
|
79
|
+
const err = new Error('subdomain qa-use-foo is already in use');
|
|
80
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelQuotaError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('classifies nested response.status', () => {
|
|
84
|
+
const err = { message: 'boom', response: { status: 401 } };
|
|
85
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelAuthError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('falls back to TunnelUnknownError on unrecognised shape', () => {
|
|
89
|
+
const err = new Error('something exploded');
|
|
90
|
+
expect(classifyTunnelFailure(err)).toBeInstanceOf(TunnelUnknownError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('preserves target and cause in the returned error', () => {
|
|
94
|
+
const cause = new Error('root');
|
|
95
|
+
const out = classifyTunnelFailure(cause, { target: 'http://localhost:3000' });
|
|
96
|
+
expect(out.target).toBe('http://localhost:3000');
|
|
97
|
+
expect(out.cause).toBe(cause);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('handles non-Error values', () => {
|
|
101
|
+
expect(classifyTunnelFailure('bare string')).toBeInstanceOf(TunnelUnknownError);
|
|
102
|
+
expect(classifyTunnelFailure(undefined)).toBeInstanceOf(TunnelUnknownError);
|
|
103
|
+
expect(classifyTunnelFailure(null)).toBeInstanceOf(TunnelUnknownError);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured tunnel error classes.
|
|
3
|
+
*
|
|
4
|
+
* The CLI uses these to render triage-hint error messages on tunnel
|
|
5
|
+
* failure. `classifyTunnelFailure` inspects a thrown error from the
|
|
6
|
+
* underlying `@desplega.ai/localtunnel` provider and returns the most
|
|
7
|
+
* specific subclass we can identify.
|
|
8
|
+
*
|
|
9
|
+
* Zero retries live in this layer — classification is strictly about
|
|
10
|
+
* picking the right error shape to hand back up the stack.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TunnelErrorContext {
|
|
14
|
+
/** Human-readable target the user was trying to tunnel (URL or host:port). */
|
|
15
|
+
target?: string;
|
|
16
|
+
/** Identifier of the tunnel provider (kept open for future providers). */
|
|
17
|
+
provider?: string;
|
|
18
|
+
/** Original underlying error, preserved for logs / debugging. */
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base class for all tunnel-layer failures surfaced to the CLI.
|
|
24
|
+
*
|
|
25
|
+
* Do not throw `TunnelError` directly — throw one of the subclasses
|
|
26
|
+
* below so the CLI can pick the right hint.
|
|
27
|
+
*/
|
|
28
|
+
export class TunnelError extends Error {
|
|
29
|
+
readonly target?: string;
|
|
30
|
+
readonly provider?: string;
|
|
31
|
+
readonly cause?: unknown;
|
|
32
|
+
|
|
33
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'TunnelError';
|
|
36
|
+
this.target = context.target;
|
|
37
|
+
this.provider = context.provider ?? 'localtunnel';
|
|
38
|
+
this.cause = context.cause;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Network / connectivity failure (DNS, timeout, ECONNREFUSED, etc.). */
|
|
43
|
+
export class TunnelNetworkError extends TunnelError {
|
|
44
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
45
|
+
super(message, context);
|
|
46
|
+
this.name = 'TunnelNetworkError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Auth failure (bad/expired API key, 401/403 from provider). */
|
|
51
|
+
export class TunnelAuthError extends TunnelError {
|
|
52
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
53
|
+
super(message, context);
|
|
54
|
+
this.name = 'TunnelAuthError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Quota / rate-limit / subdomain-clash failure. */
|
|
59
|
+
export class TunnelQuotaError extends TunnelError {
|
|
60
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
61
|
+
super(message, context);
|
|
62
|
+
this.name = 'TunnelQuotaError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Fallback when no classification matched. */
|
|
67
|
+
export class TunnelUnknownError extends TunnelError {
|
|
68
|
+
constructor(message: string, context: TunnelErrorContext = {}) {
|
|
69
|
+
super(message, context);
|
|
70
|
+
this.name = 'TunnelUnknownError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extractStatusCode(err: unknown): number | undefined {
|
|
75
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
76
|
+
const obj = err as Record<string, unknown>;
|
|
77
|
+
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
|
78
|
+
if (typeof obj.status === 'number') return obj.status;
|
|
79
|
+
// Some providers nest the status in a `response` object.
|
|
80
|
+
const response = obj.response;
|
|
81
|
+
if (response && typeof response === 'object') {
|
|
82
|
+
const respObj = response as Record<string, unknown>;
|
|
83
|
+
if (typeof respObj.status === 'number') return respObj.status;
|
|
84
|
+
if (typeof respObj.statusCode === 'number') return respObj.statusCode;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractErrorCode(err: unknown): string | undefined {
|
|
90
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
91
|
+
const obj = err as Record<string, unknown>;
|
|
92
|
+
if (typeof obj.code === 'string') return obj.code;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractMessage(err: unknown): string {
|
|
97
|
+
if (err instanceof Error) return err.message;
|
|
98
|
+
if (typeof err === 'string') return err;
|
|
99
|
+
if (err && typeof err === 'object') {
|
|
100
|
+
const obj = err as Record<string, unknown>;
|
|
101
|
+
if (typeof obj.message === 'string') return obj.message;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return String(err);
|
|
105
|
+
} catch {
|
|
106
|
+
return 'unknown tunnel error';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const NETWORK_CODES = new Set([
|
|
111
|
+
'ECONNREFUSED',
|
|
112
|
+
'ECONNRESET',
|
|
113
|
+
'ETIMEDOUT',
|
|
114
|
+
'ENOTFOUND',
|
|
115
|
+
'EAI_AGAIN',
|
|
116
|
+
'EHOSTUNREACH',
|
|
117
|
+
'ENETUNREACH',
|
|
118
|
+
'EPIPE',
|
|
119
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
120
|
+
'UND_ERR_SOCKET',
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Inspect a thrown tunnel error and return the most specific
|
|
125
|
+
* `TunnelError` subclass we can identify.
|
|
126
|
+
*
|
|
127
|
+
* Heuristics:
|
|
128
|
+
* - Node / undici error codes (`ECONNREFUSED`, `ETIMEDOUT`, …) →
|
|
129
|
+
* `TunnelNetworkError`
|
|
130
|
+
* - HTTP 401/403 → `TunnelAuthError`
|
|
131
|
+
* - HTTP 429 or messages about quota / rate-limit / subdomain in use →
|
|
132
|
+
* `TunnelQuotaError`
|
|
133
|
+
* - Everything else → `TunnelUnknownError`
|
|
134
|
+
*/
|
|
135
|
+
export function classifyTunnelFailure(err: unknown, context: TunnelErrorContext = {}): TunnelError {
|
|
136
|
+
const ctx: TunnelErrorContext = {
|
|
137
|
+
...context,
|
|
138
|
+
cause: context.cause ?? err,
|
|
139
|
+
};
|
|
140
|
+
const message = extractMessage(err);
|
|
141
|
+
const code = extractErrorCode(err);
|
|
142
|
+
const status = extractStatusCode(err);
|
|
143
|
+
const lowered = message.toLowerCase();
|
|
144
|
+
|
|
145
|
+
if (code && NETWORK_CODES.has(code)) {
|
|
146
|
+
return new TunnelNetworkError(message, ctx);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (/timeout|timed out|econnrefused|econnreset|enotfound|network/i.test(lowered)) {
|
|
150
|
+
return new TunnelNetworkError(message, ctx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
status === 401 ||
|
|
155
|
+
status === 403 ||
|
|
156
|
+
/\b(401|403|unauthori[sz]ed|forbidden)\b/i.test(lowered)
|
|
157
|
+
) {
|
|
158
|
+
return new TunnelAuthError(message, ctx);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
status === 429 ||
|
|
163
|
+
/quota|rate.?limit|too many|subdomain.*(in use|taken|already)/i.test(lowered)
|
|
164
|
+
) {
|
|
165
|
+
return new TunnelQuotaError(message, ctx);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new TunnelUnknownError(message || 'tunnel failed', ctx);
|
|
169
|
+
}
|
package/lib/tunnel/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import https from 'node:https';
|
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
import localtunnel from '@desplega.ai/localtunnel';
|
|
5
5
|
import { getEnv } from '../env/index.js';
|
|
6
|
+
import { classifyTunnelFailure, TunnelError } from './errors.js';
|
|
6
7
|
|
|
7
8
|
export interface TunnelSession {
|
|
8
9
|
tunnel: localtunnel.Tunnel;
|
|
@@ -69,24 +70,38 @@ export class TunnelManager {
|
|
|
69
70
|
options.apiKey,
|
|
70
71
|
options.sessionIndex
|
|
71
72
|
);
|
|
72
|
-
console.
|
|
73
|
+
console.error(`Using deterministic subdomain: ${subdomain}`);
|
|
73
74
|
} else if (!subdomain) {
|
|
74
75
|
// Fallback to timestamp-based random subdomain
|
|
75
76
|
subdomain = `qa-use-${Date.now().toString().slice(-6)}`;
|
|
76
|
-
console.
|
|
77
|
+
console.error(`Using random subdomain: ${subdomain}`);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
console.
|
|
80
|
+
console.error(`Starting tunnel on port ${port} with host ${host} in region ${region}`);
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
let tunnel: localtunnel.Tunnel;
|
|
83
|
+
try {
|
|
84
|
+
tunnel = await localtunnel({
|
|
85
|
+
port,
|
|
86
|
+
host,
|
|
87
|
+
subdomain,
|
|
88
|
+
local_host: options.localHost || 'localhost',
|
|
89
|
+
auth: true,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Classify provider errors into structured TunnelError subclasses so
|
|
93
|
+
// the CLI can render a triage-hint message. Zero retries here —
|
|
94
|
+
// surface the failure immediately.
|
|
95
|
+
const classified =
|
|
96
|
+
err instanceof TunnelError
|
|
97
|
+
? err
|
|
98
|
+
: classifyTunnelFailure(err, {
|
|
99
|
+
target: `${options.localHost || 'localhost'}:${port}`,
|
|
100
|
+
});
|
|
101
|
+
throw classified;
|
|
102
|
+
}
|
|
88
103
|
|
|
89
|
-
console.
|
|
104
|
+
console.error(`Tunnel started at ${tunnel.url}`);
|
|
90
105
|
|
|
91
106
|
this.session = {
|
|
92
107
|
tunnel,
|