@liaisonio/cli 0.1.1 → 0.2.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.
- package/README.md +79 -18
- package/package.json +1 -1
- package/scripts/install.js +195 -60
package/README.md
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
Official command-line interface for [liaison.cloud](https://liaison.cloud), designed
|
|
4
4
|
to be **scripted and agent-friendly**.
|
|
5
5
|
|
|
6
|
-
-
|
|
6
|
+
- **One-shot bootstrap** — `liaison quickstart` creates a connector + application + public entry in a single call
|
|
7
|
+
- **5 Agent Skills** — drop-in Skill files for AI agents (Claude/Cursor/etc.), installable via `npx skills add liaisonio/cli`
|
|
8
|
+
- JSON output by default — pipe into `jq` or parse from any LLM agent
|
|
7
9
|
- `--output table` for humans, `--output yaml` when you prefer it
|
|
8
10
|
- Credentials from env var (`LIAISON_TOKEN`), config file, or explicit `--token` flag
|
|
11
|
+
- Browser-based PAT login (or `--no-browser` for headless / SSH)
|
|
9
12
|
- Every command has `-h` / `--help` with examples
|
|
10
|
-
- Non-interactive by default
|
|
13
|
+
- Non-interactive by default — destructive operations require `--yes`
|
|
11
14
|
|
|
12
15
|
## Install
|
|
13
16
|
|
|
@@ -62,30 +65,88 @@ liaison version
|
|
|
62
65
|
|
|
63
66
|
## Authenticate
|
|
64
67
|
|
|
65
|
-
The CLI
|
|
66
|
-
|
|
67
|
-
token out of band:
|
|
68
|
+
The CLI uses long-lived **Personal Access Tokens** (PATs) — `liaison_pat_xxx...`
|
|
69
|
+
issued by the Liaison dashboard. Three ways to provide one:
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
```bash
|
|
72
|
+
# 1) Browser flow (recommended for humans)
|
|
73
|
+
liaison login
|
|
74
|
+
# Opens https://liaison.cloud/dashboard/cli-auth in your default browser,
|
|
75
|
+
# you click "Authorize", a fresh PAT is minted and persisted to ~/.liaison/config.yaml.
|
|
76
|
+
|
|
77
|
+
# 2) SSH / headless / no browser
|
|
78
|
+
liaison login --no-browser
|
|
79
|
+
# Prints the URL — open it on any device that has a browser, click Authorize,
|
|
80
|
+
# the CLI receives the token via a localhost callback.
|
|
81
|
+
|
|
82
|
+
# 3) Already have a token (CI, agent secrets store)
|
|
83
|
+
LIAISON_TOKEN=liaison_pat_a1b2c3... liaison whoami
|
|
84
|
+
# Or: liaison login --token liaison_pat_a1b2c3...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Precedence (highest wins): `--token` flag → `LIAISON_TOKEN` env → `~/.liaison/config.yaml` → no token.
|
|
88
|
+
|
|
89
|
+
Tokens can be revoked any time at **liaison.cloud → Settings → API Tokens**, or by running:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
liaison logout
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Quick Start
|
|
96
|
+
|
|
97
|
+
The fastest way to expose a local service:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# 1) authenticate once
|
|
101
|
+
liaison login
|
|
102
|
+
|
|
103
|
+
# 2) bootstrap a connector + register your service + expose it publicly
|
|
104
|
+
liaison quickstart --name mybox \
|
|
105
|
+
--app-name web --app-ip 127.0.0.1 --app-port 8080 --app-protocol http \
|
|
106
|
+
--expose --wait-online 2m
|
|
107
|
+
|
|
108
|
+
# The output JSON includes:
|
|
109
|
+
# - install_command → run this on your target host (curl|bash one-liner)
|
|
110
|
+
# - entry.port → public TCP port (or entry.domain for http)
|
|
111
|
+
# - online_achieved → whether the connector successfully connected
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`liaison quickstart` is a single command that:
|
|
115
|
+
|
|
116
|
+
1. Creates the connector (and returns the install command for the host)
|
|
117
|
+
2. Optionally runs the install script locally (`--install`, requires sudo)
|
|
118
|
+
3. Optionally polls for the connector to come online (`--wait-online <duration>`)
|
|
119
|
+
4. Optionally registers a backend application (`--app-*` flags)
|
|
120
|
+
5. Optionally exposes it via a public entry (`--expose`)
|
|
72
121
|
|
|
73
|
-
|
|
74
|
-
liaison login --token eyJhbGciOi...
|
|
75
|
-
```
|
|
122
|
+
See `liaison quickstart --help` for the full flag list.
|
|
76
123
|
|
|
77
|
-
|
|
78
|
-
`/api/v1/iam/profile_json`.
|
|
124
|
+
## Agent Skills
|
|
79
125
|
|
|
80
|
-
|
|
126
|
+
This CLI ships **5 [Skill files](./skills/)** so AI agents (Claude, Cursor, Continue, etc.)
|
|
127
|
+
know how to use it without a learning curve. Each skill is a self-contained Markdown
|
|
128
|
+
spec with frontmatter — installable with one command:
|
|
81
129
|
|
|
82
130
|
```bash
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
liaison --token eyJhbGciOi... edge list
|
|
131
|
+
# Install all liaison skills into the agent's skills directory
|
|
132
|
+
npx skills add liaisonio/cli -y -g
|
|
86
133
|
```
|
|
87
134
|
|
|
88
|
-
|
|
135
|
+
| Skill | Purpose |
|
|
136
|
+
|-------|---------|
|
|
137
|
+
| `liaison-shared` | Auth, install, token precedence, error handling, output format (auto-loaded by other skills) |
|
|
138
|
+
| `liaison-quickstart` | One-shot bootstrap: connector + application + entry in a single call |
|
|
139
|
+
| `liaison-connector` | Connector lifecycle: create / list / inspect / enable+disable / delete |
|
|
140
|
+
| `liaison-application` | Backend service metadata: register / list / update / delete |
|
|
141
|
+
| `liaison-entry` | Public exposure: HTTP domains, TCP ports, enable+disable, delete |
|
|
142
|
+
|
|
143
|
+
After installing the skills, point your agent at `liaison.cloud` and ask it
|
|
144
|
+
things like:
|
|
145
|
+
|
|
146
|
+
- "Set up a public SSH endpoint for my home server"
|
|
147
|
+
- "List all my connectors and tell me which ones are offline"
|
|
148
|
+
- "Disable connector 100017 — I'm doing maintenance"
|
|
149
|
+
- "Expose the local Postgres on 5432 via Liaison"
|
|
89
150
|
|
|
90
151
|
## Usage
|
|
91
152
|
|
package/package.json
CHANGED
package/scripts/install.js
CHANGED
|
@@ -11,21 +11,35 @@
|
|
|
11
11
|
// - The user is on an unsupported platform (we exit 0 + warn, NOT fail,
|
|
12
12
|
// so npm install doesn't break for transitive deps)
|
|
13
13
|
//
|
|
14
|
+
// Honours HTTPS_PROXY / HTTP_PROXY env vars for users behind corporate proxies
|
|
15
|
+
// (Node's built-in https.get does NOT honour these out of the box — we handle
|
|
16
|
+
// it manually via a CONNECT tunnel). Retries once on transient network errors.
|
|
17
|
+
//
|
|
14
18
|
// Network failures DO fail the install — silently shipping a broken package
|
|
15
19
|
// is worse than a clear error the user can retry.
|
|
16
20
|
|
|
17
21
|
'use strict';
|
|
18
22
|
|
|
19
23
|
const https = require('https');
|
|
24
|
+
const http = require('http');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
const tls = require('tls');
|
|
20
27
|
const fs = require('fs');
|
|
21
28
|
const path = require('path');
|
|
22
29
|
const crypto = require('crypto');
|
|
30
|
+
const { URL } = require('url');
|
|
23
31
|
|
|
24
32
|
const pkg = require('../package.json');
|
|
25
33
|
const VERSION = `v${pkg.version}`;
|
|
26
34
|
const REPO = 'liaisonio/cli';
|
|
27
35
|
const RELEASE_BASE = `https://github.com/${REPO}/releases/download/${VERSION}`;
|
|
28
36
|
|
|
37
|
+
// How long to wait for a single HTTP round-trip before giving up.
|
|
38
|
+
const SOCKET_TIMEOUT_MS = 30_000;
|
|
39
|
+
// How many times to retry a download after a transient error. Retries are
|
|
40
|
+
// linear-backoff with 2s then 5s gaps.
|
|
41
|
+
const RETRY_DELAYS_MS = [2000, 5000];
|
|
42
|
+
|
|
29
43
|
// process.platform-process.arch → release asset GOOS-GOARCH suffix.
|
|
30
44
|
const PLATFORMS = {
|
|
31
45
|
'darwin-arm64': { os: 'darwin', arch: 'arm64', ext: '' },
|
|
@@ -73,74 +87,186 @@ const destPath = path.join(vendorDir, `liaison${platform.ext}`);
|
|
|
73
87
|
|
|
74
88
|
fs.mkdirSync(vendorDir, { recursive: true });
|
|
75
89
|
|
|
76
|
-
//
|
|
77
|
-
|
|
90
|
+
// ─── proxy support ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// getProxy reads HTTPS_PROXY / HTTP_PROXY / https_proxy / http_proxy, in the
|
|
93
|
+
// order npm / curl do. Returns a parsed URL or null.
|
|
94
|
+
function getProxy() {
|
|
95
|
+
const raw =
|
|
96
|
+
process.env.HTTPS_PROXY ||
|
|
97
|
+
process.env.https_proxy ||
|
|
98
|
+
process.env.HTTP_PROXY ||
|
|
99
|
+
process.env.http_proxy;
|
|
100
|
+
if (!raw) return null;
|
|
101
|
+
try {
|
|
102
|
+
return new URL(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
warn(`ignoring invalid proxy URL: ${raw}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// tunnelThroughProxy opens a TCP connection to the proxy, issues an HTTP
|
|
110
|
+
// CONNECT to the target, and upgrades the socket to TLS. Returns a Promise
|
|
111
|
+
// resolving to an established TLSSocket that can be handed to https.request
|
|
112
|
+
// as `createConnection`. Zero external dependencies.
|
|
113
|
+
function tunnelThroughProxy(proxy, targetHost, targetPort) {
|
|
78
114
|
return new Promise((resolve, reject) => {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
115
|
+
const proxyPort = proxy.port || (proxy.protocol === 'https:' ? 443 : 80);
|
|
116
|
+
const authHeader = proxy.username
|
|
117
|
+
? `Proxy-Authorization: Basic ${Buffer.from(
|
|
118
|
+
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`,
|
|
119
|
+
).toString('base64')}\r\n`
|
|
120
|
+
: '';
|
|
121
|
+
|
|
122
|
+
const proxySocket = net.connect(proxyPort, proxy.hostname, () => {
|
|
123
|
+
proxySocket.write(
|
|
124
|
+
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
|
|
125
|
+
`Host: ${targetHost}:${targetPort}\r\n` +
|
|
126
|
+
authHeader +
|
|
127
|
+
`\r\n`,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let buf = Buffer.alloc(0);
|
|
132
|
+
const onData = (chunk) => {
|
|
133
|
+
buf = Buffer.concat([buf, chunk]);
|
|
134
|
+
const end = buf.indexOf('\r\n\r\n');
|
|
135
|
+
if (end === -1) return;
|
|
136
|
+
const head = buf.slice(0, end).toString('utf8');
|
|
137
|
+
const status = head.split(' ')[1];
|
|
138
|
+
proxySocket.removeListener('data', onData);
|
|
139
|
+
if (status !== '200') {
|
|
140
|
+
reject(new Error(`proxy CONNECT failed: ${head.split('\r\n')[0]}`));
|
|
141
|
+
proxySocket.end();
|
|
83
142
|
return;
|
|
84
143
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (res.statusCode !== 200) {
|
|
98
|
-
reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
|
99
|
-
res.resume();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
res.pipe(file);
|
|
103
|
-
file.on('finish', () => file.close(() => resolve()));
|
|
104
|
-
file.on('error', reject);
|
|
105
|
-
})
|
|
106
|
-
.on('error', reject);
|
|
107
|
-
}
|
|
108
|
-
get(targetUrl, 0);
|
|
144
|
+
// Upgrade to TLS — the CONNECT tunnel is now a raw TCP pipe to target.
|
|
145
|
+
const tlsSocket = tls.connect({
|
|
146
|
+
socket: proxySocket,
|
|
147
|
+
servername: targetHost,
|
|
148
|
+
});
|
|
149
|
+
tlsSocket.once('secureConnect', () => resolve(tlsSocket));
|
|
150
|
+
tlsSocket.once('error', reject);
|
|
151
|
+
};
|
|
152
|
+
proxySocket.on('data', onData);
|
|
153
|
+
proxySocket.once('error', reject);
|
|
109
154
|
});
|
|
110
155
|
}
|
|
111
156
|
|
|
112
|
-
|
|
157
|
+
// httpsGetRaw makes a single HTTPS GET request. If an HTTPS_PROXY is set, it
|
|
158
|
+
// tunnels through it via CONNECT; otherwise it uses stock https.request.
|
|
159
|
+
// Returns a Promise<IncomingMessage>. Caller is responsible for consuming or
|
|
160
|
+
// discarding the body.
|
|
161
|
+
function httpsGetRaw(targetUrl) {
|
|
113
162
|
return new Promise((resolve, reject) => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
const u = new URL(targetUrl);
|
|
164
|
+
const proxy = getProxy();
|
|
165
|
+
|
|
166
|
+
const reqOptions = {
|
|
167
|
+
host: u.hostname,
|
|
168
|
+
port: u.port || 443,
|
|
169
|
+
path: u.pathname + u.search,
|
|
170
|
+
method: 'GET',
|
|
171
|
+
headers: {
|
|
172
|
+
'User-Agent': 'liaison-cli-installer',
|
|
173
|
+
Host: u.host,
|
|
174
|
+
},
|
|
175
|
+
timeout: SOCKET_TIMEOUT_MS,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const runRequest = (agentSocket) => {
|
|
179
|
+
if (agentSocket) {
|
|
180
|
+
reqOptions.createConnection = () => agentSocket;
|
|
118
181
|
}
|
|
119
|
-
https
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
res.resume();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
let body = '';
|
|
137
|
-
res.setEncoding('utf8');
|
|
138
|
-
res.on('data', (chunk) => (body += chunk));
|
|
139
|
-
res.on('end', () => resolve(body));
|
|
140
|
-
})
|
|
141
|
-
.on('error', reject);
|
|
182
|
+
const req = https.request(reqOptions, resolve);
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
req.on('timeout', () => {
|
|
185
|
+
req.destroy(new Error(`timeout after ${SOCKET_TIMEOUT_MS}ms`));
|
|
186
|
+
});
|
|
187
|
+
req.end();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (proxy) {
|
|
191
|
+
tunnelThroughProxy(proxy, u.hostname, u.port || 443)
|
|
192
|
+
.then(runRequest)
|
|
193
|
+
.catch(reject);
|
|
194
|
+
} else {
|
|
195
|
+
runRequest(null);
|
|
142
196
|
}
|
|
143
|
-
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Follow HTTP redirects up to maxRedirects, returning the final IncomingMessage
|
|
201
|
+
// that actually holds the body. Non-2xx status codes at the end of the chain
|
|
202
|
+
// are returned as-is; caller decides whether to treat them as an error.
|
|
203
|
+
async function httpsGet(targetUrl, maxRedirects = 5) {
|
|
204
|
+
let current = targetUrl;
|
|
205
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
206
|
+
const res = await httpsGetRaw(current);
|
|
207
|
+
if (
|
|
208
|
+
res.statusCode >= 300 &&
|
|
209
|
+
res.statusCode < 400 &&
|
|
210
|
+
res.headers.location
|
|
211
|
+
) {
|
|
212
|
+
res.resume();
|
|
213
|
+
current = res.headers.location;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
return res;
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`too many redirects fetching ${targetUrl}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// withRetry wraps an async function so it's tried a few times with backoff
|
|
222
|
+
// on network errors. Does NOT retry on 4xx/5xx from the server — those are
|
|
223
|
+
// deterministic and a retry will just hit the same error.
|
|
224
|
+
async function withRetry(label, fn) {
|
|
225
|
+
let lastErr;
|
|
226
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
227
|
+
try {
|
|
228
|
+
return await fn();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
lastErr = err;
|
|
231
|
+
if (attempt < RETRY_DELAYS_MS.length) {
|
|
232
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
233
|
+
warn(`${label}: ${err.message}, retrying in ${delay}ms...`);
|
|
234
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw lastErr;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function downloadToFile(targetUrl, outPath) {
|
|
243
|
+
return withRetry(`download ${path.basename(outPath)}`, async () => {
|
|
244
|
+
const res = await httpsGet(targetUrl);
|
|
245
|
+
if (res.statusCode !== 200) {
|
|
246
|
+
res.resume();
|
|
247
|
+
throw new Error(`HTTP ${res.statusCode} for ${targetUrl}`);
|
|
248
|
+
}
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
const file = fs.createWriteStream(outPath);
|
|
251
|
+
res.pipe(file);
|
|
252
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
253
|
+
file.on('error', reject);
|
|
254
|
+
res.on('error', reject);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function downloadToString(targetUrl) {
|
|
260
|
+
return withRetry(`fetch ${path.basename(new URL(targetUrl).pathname)}`, async () => {
|
|
261
|
+
const res = await httpsGet(targetUrl);
|
|
262
|
+
if (res.statusCode !== 200) {
|
|
263
|
+
res.resume();
|
|
264
|
+
throw new Error(`HTTP ${res.statusCode} for ${targetUrl}`);
|
|
265
|
+
}
|
|
266
|
+
let body = '';
|
|
267
|
+
res.setEncoding('utf8');
|
|
268
|
+
for await (const chunk of res) body += chunk;
|
|
269
|
+
return body;
|
|
144
270
|
});
|
|
145
271
|
}
|
|
146
272
|
|
|
@@ -151,6 +277,11 @@ function sha256(filepath) {
|
|
|
151
277
|
}
|
|
152
278
|
|
|
153
279
|
async function main() {
|
|
280
|
+
const proxy = getProxy();
|
|
281
|
+
if (proxy) {
|
|
282
|
+
log(`using proxy ${proxy.protocol}//${proxy.hostname}:${proxy.port || ''}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
154
285
|
log(`fetching ${url}`);
|
|
155
286
|
await downloadToFile(url, destPath);
|
|
156
287
|
fs.chmodSync(destPath, 0o755);
|
|
@@ -174,6 +305,10 @@ async function main() {
|
|
|
174
305
|
log(`installed ${filename} (sha256 ok)`);
|
|
175
306
|
}
|
|
176
307
|
|
|
308
|
+
// Suppress the unused `http` import warning — kept for future use if we
|
|
309
|
+
// ever need HTTP-not-HTTPS downloads (CI staging).
|
|
310
|
+
void http;
|
|
311
|
+
|
|
177
312
|
main().catch((err) => {
|
|
178
|
-
die(`download failed: ${err.message}
|
|
313
|
+
die(`download failed: ${err.message}. You can retry with \`npm rebuild @liaisonio/cli\`, or set HTTPS_PROXY if you're behind a corporate proxy.`);
|
|
179
314
|
});
|