@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 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
- - JSON output by default ready for piping into `jq` or parsing by LLM agents
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: destructive operations require `--yes`
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 accepts a JWT bearer token issued by liaison.cloud. For now the slider-captcha
66
- login flow used by the web UI is not supported headlessly — you need to obtain the
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
- 1. Log in to [liaison.cloud](https://liaison.cloud) in your browser.
70
- 2. Open DevTools Application Local Storage → copy the `authorization` value.
71
- 3. Persist it:
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
- ```bash
74
- liaison login --token eyJhbGciOi...
75
- ```
122
+ See `liaison quickstart --help` for the full flag list.
76
123
 
77
- This writes `~/.liaison/config.yaml` (mode 0600) and verifies the token against
78
- `/api/v1/iam/profile_json`.
124
+ ## Agent Skills
79
125
 
80
- Alternatively, skip the config file entirely and pass the token per-invocation:
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
- LIAISON_TOKEN=eyJhbGciOi... liaison edge list
84
- # or
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
- Precedence (highest wins): `--token` flag → `LIAISON_TOKEN` env → config file → built-in default.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liaisonio/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Liaison Cloud CLI — manage connectors, entries, and applications from the command line",
5
5
  "keywords": [
6
6
  "liaison",
@@ -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
- // Followed-redirect HTTP GET that streams to a file.
77
- function downloadToFile(targetUrl, outPath) {
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 file = fs.createWriteStream(outPath);
80
- function get(u, depth) {
81
- if (depth > 5) {
82
- reject(new Error(`too many redirects fetching ${targetUrl}`));
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
- https
86
- .get(u, { headers: { 'User-Agent': 'liaison-cli-installer' } }, (res) => {
87
- if (
88
- res.statusCode &&
89
- res.statusCode >= 300 &&
90
- res.statusCode < 400 &&
91
- res.headers.location
92
- ) {
93
- res.resume();
94
- get(res.headers.location, depth + 1);
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
- function downloadToString(targetUrl) {
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
- function get(u, depth) {
115
- if (depth > 5) {
116
- reject(new Error(`too many redirects fetching ${targetUrl}`));
117
- return;
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
- .get(u, { headers: { 'User-Agent': 'liaison-cli-installer' } }, (res) => {
121
- if (
122
- res.statusCode &&
123
- res.statusCode >= 300 &&
124
- res.statusCode < 400 &&
125
- res.headers.location
126
- ) {
127
- res.resume();
128
- get(res.headers.location, depth + 1);
129
- return;
130
- }
131
- if (res.statusCode !== 200) {
132
- reject(new Error(`HTTP ${res.statusCode} for ${u}`));
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
- get(targetUrl, 0);
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
  });