@liaisonio/cli 0.1.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 +185 -0
- package/bin/liaison.js +58 -0
- package/package.json +30 -0
- package/scripts/install.js +179 -0
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Liaison Cloud CLI
|
|
2
|
+
|
|
3
|
+
Official command-line interface for [liaison.cloud](https://liaison.cloud), designed
|
|
4
|
+
to be **scripted and agent-friendly**.
|
|
5
|
+
|
|
6
|
+
- JSON output by default — ready for piping into `jq` or parsing by LLM agents
|
|
7
|
+
- `--output table` for humans, `--output yaml` when you prefer it
|
|
8
|
+
- Credentials from env var (`LIAISON_TOKEN`), config file, or explicit `--token` flag
|
|
9
|
+
- Every command has `-h` / `--help` with examples
|
|
10
|
+
- Non-interactive by default: destructive operations require `--yes`
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Pick whichever fits your environment. All paths land at the same versioned binary.
|
|
15
|
+
|
|
16
|
+
### One-line installer (curl, recommended)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -fsSL https://github.com/liaisonio/cli/releases/latest/download/install.sh | sh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Auto-detects OS/arch, verifies SHA256, drops the binary in `~/.local/bin` (or
|
|
23
|
+
`/usr/local/bin` with sudo if `~/.local/bin` is not writable). Pin a version with
|
|
24
|
+
`LIAISON_CLI_VERSION=v0.1.0`.
|
|
25
|
+
|
|
26
|
+
### npx / npm
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Run once without installing
|
|
30
|
+
npx @liaisonio/cli edge list
|
|
31
|
+
|
|
32
|
+
# Or install globally
|
|
33
|
+
npm i -g @liaisonio/cli
|
|
34
|
+
liaison edge list
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The npm wrapper is a thin Node.js shim that downloads the matching native binary
|
|
38
|
+
from the GitHub release on `postinstall`, verifies its SHA256, and execs it.
|
|
39
|
+
|
|
40
|
+
### Go install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
go install github.com/liaisonio/cli/cmd/liaison@latest
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires Go 1.22+. Best for Go developers who already have `$GOPATH/bin` in their PATH.
|
|
47
|
+
|
|
48
|
+
### Build from source
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/liaisonio/cli
|
|
52
|
+
cd cli
|
|
53
|
+
make build # ./bin/liaison (current platform)
|
|
54
|
+
make release # ./dist/liaison-* (all 5 platforms + SHA256SUMS)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Verify
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
liaison version
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Authenticate
|
|
64
|
+
|
|
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
|
+
|
|
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:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
liaison login --token eyJhbGciOi...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This writes `~/.liaison/config.yaml` (mode 0600) and verifies the token against
|
|
78
|
+
`/api/v1/iam/profile_json`.
|
|
79
|
+
|
|
80
|
+
Alternatively, skip the config file entirely and pass the token per-invocation:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
LIAISON_TOKEN=eyJhbGciOi... liaison edge list
|
|
84
|
+
# or
|
|
85
|
+
liaison --token eyJhbGciOi... edge list
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Precedence (highest wins): `--token` flag → `LIAISON_TOKEN` env → config file → built-in default.
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
liaison whoami # who am I logged in as?
|
|
94
|
+
|
|
95
|
+
# Connectors (edges)
|
|
96
|
+
liaison edge list
|
|
97
|
+
liaison edge list --online 1 # only online connectors
|
|
98
|
+
liaison edge list --output table
|
|
99
|
+
liaison edge get 100017
|
|
100
|
+
liaison edge create --name lab-server --description "office lab"
|
|
101
|
+
liaison edge update 100017 --status stopped # disable + kick
|
|
102
|
+
liaison edge update 100017 --status running # re-enable
|
|
103
|
+
liaison edge delete 100017 --yes
|
|
104
|
+
|
|
105
|
+
# Backend applications (IP:port exposed by a connector)
|
|
106
|
+
liaison application list
|
|
107
|
+
liaison application create --name my-ssh --protocol ssh --ip 192.168.1.10 --port 22 --edge-id 100017
|
|
108
|
+
liaison application update 123 --port 2222
|
|
109
|
+
liaison application delete 123 --yes
|
|
110
|
+
|
|
111
|
+
# Entries (public proxies)
|
|
112
|
+
liaison proxy list
|
|
113
|
+
liaison proxy create --name my-ssh-entry --protocol ssh --application-id 123
|
|
114
|
+
liaison proxy update 456 --status stopped
|
|
115
|
+
liaison proxy delete 456 --yes
|
|
116
|
+
|
|
117
|
+
# Devices
|
|
118
|
+
liaison device list
|
|
119
|
+
liaison device get 789
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Global flags
|
|
123
|
+
|
|
124
|
+
| Flag | Env | Description |
|
|
125
|
+
|---------------|------------------|-------------------------------------------------------|
|
|
126
|
+
| `--server` | `LIAISON_SERVER` | Liaison base URL (default `https://liaison.cloud`) |
|
|
127
|
+
| `--token` | `LIAISON_TOKEN` | JWT bearer token |
|
|
128
|
+
| `--config` | | Config file path (default `~/.liaison/config.yaml`) |
|
|
129
|
+
| `--output,-o` | | `json` (default), `yaml`, or `table` |
|
|
130
|
+
| `--insecure` | | Skip TLS verification (self-signed testing only) |
|
|
131
|
+
| `--verbose,-v`| | Print each HTTP request to stderr |
|
|
132
|
+
|
|
133
|
+
## Output formats
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
liaison edge list # pretty JSON (default)
|
|
137
|
+
liaison edge list -o yaml # YAML
|
|
138
|
+
liaison edge list -o table # aligned text table
|
|
139
|
+
liaison edge get 100017 | jq .name # pipe into jq
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Exit codes
|
|
143
|
+
|
|
144
|
+
- `0` — success
|
|
145
|
+
- `1` — any error (auth, network, API error, invalid args)
|
|
146
|
+
|
|
147
|
+
Error messages go to stderr; output goes to stdout — safe to redirect.
|
|
148
|
+
|
|
149
|
+
## Agent guidance
|
|
150
|
+
|
|
151
|
+
If you are an LLM agent, here's the minimal path:
|
|
152
|
+
|
|
153
|
+
**Install** (pick the first one that works in your environment):
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Option 1: npx — no install, works in any node-equipped sandbox
|
|
157
|
+
npx -y @liaisonio/cli@latest <command>
|
|
158
|
+
|
|
159
|
+
# Option 2: curl one-liner — works on any unix shell with curl
|
|
160
|
+
curl -fsSL https://github.com/liaisonio/cli/releases/latest/download/install.sh | sh
|
|
161
|
+
|
|
162
|
+
# Option 3: go install — if Go toolchain is already present
|
|
163
|
+
go install github.com/liaisonio/cli/cmd/liaison@latest
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Authenticate**:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Best: ask the user to put a Personal Access Token in your secrets store as LIAISON_TOKEN.
|
|
170
|
+
# Then every command works without any login flow:
|
|
171
|
+
LIAISON_TOKEN=liaison_pat_... liaison whoami
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Use**:
|
|
175
|
+
|
|
176
|
+
1. Always parse stdout as JSON (it's the default output format).
|
|
177
|
+
2. Discover commands with `liaison --help` and `liaison <resource> --help`. Every flag
|
|
178
|
+
has a description and examples.
|
|
179
|
+
3. Never omit `--yes` for `delete` actions — the CLI refuses to proceed without it.
|
|
180
|
+
4. Don't retry on exit code 1 — read the error message on stderr first.
|
|
181
|
+
5. Errors go to stderr; data goes to stdout. Safe to redirect them separately.
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
Apache 2.0
|
package/bin/liaison.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Entry point listed in package.json "bin". This file is what `npx
|
|
4
|
+
// @liaisonio/cli ...` and the symlinked `liaison` command both call.
|
|
5
|
+
//
|
|
6
|
+
// All it does is locate the platform-specific Go binary that was downloaded
|
|
7
|
+
// during `postinstall` (see scripts/install.js) and exec it, forwarding
|
|
8
|
+
// argv, stdio, and the exit code.
|
|
9
|
+
//
|
|
10
|
+
// We deliberately do NOT use require('child_process').execFile or .exec here
|
|
11
|
+
// because they buffer stdout/stderr — the CLI prints progress lines for
|
|
12
|
+
// `liaison login`, and we want the user to see them in real time.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
|
|
20
|
+
const PLATFORM_BINARY = {
|
|
21
|
+
'darwin-arm64': 'liaison',
|
|
22
|
+
'darwin-x64': 'liaison',
|
|
23
|
+
'linux-arm64': 'liaison',
|
|
24
|
+
'linux-x64': 'liaison',
|
|
25
|
+
'win32-x64': 'liaison.exe',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function fail(msg, code) {
|
|
29
|
+
process.stderr.write(`liaison-cli: ${msg}\n`);
|
|
30
|
+
process.exit(code || 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const key = `${process.platform}-${process.arch}`;
|
|
34
|
+
const binaryName = PLATFORM_BINARY[key];
|
|
35
|
+
if (!binaryName) {
|
|
36
|
+
fail(
|
|
37
|
+
`unsupported platform ${key}. Supported: ${Object.keys(PLATFORM_BINARY).join(', ')}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const binaryPath = path.join(__dirname, '..', 'vendor', binaryName);
|
|
42
|
+
if (!fs.existsSync(binaryPath)) {
|
|
43
|
+
fail(
|
|
44
|
+
`binary not found at ${binaryPath}.\n` +
|
|
45
|
+
'This usually means the postinstall download was skipped or failed.\n' +
|
|
46
|
+
'Try: npm rebuild @liaisonio/cli (or `npm install` again with --foreground-scripts)',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = spawnSync(binaryPath, process.argv.slice(2), {
|
|
51
|
+
stdio: 'inherit',
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (result.error) {
|
|
56
|
+
fail(`failed to run binary: ${result.error.message}`);
|
|
57
|
+
}
|
|
58
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liaisonio/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Liaison Cloud CLI — manage connectors, entries, and applications from the command line",
|
|
5
|
+
"keywords": ["liaison", "liaison-cloud", "cli", "edge", "tunnel", "agent"],
|
|
6
|
+
"homepage": "https://liaison.cloud",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"author": "Liaison Cloud",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/liaisonio/cli.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/liaisonio/cli/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"liaison": "bin/liaison.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"scripts/",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"postinstall": "node scripts/install.js"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=14"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Postinstall hook for @liaisonio/cli.
|
|
4
|
+
//
|
|
5
|
+
// Downloads the platform-specific Go binary from the matching GitHub release
|
|
6
|
+
// and verifies its SHA256 against the published SHA256SUMS file. The binary is
|
|
7
|
+
// dropped at vendor/<liaison|liaison.exe> next to this script's parent.
|
|
8
|
+
//
|
|
9
|
+
// Skipped automatically when:
|
|
10
|
+
// - LIAISON_CLI_SKIP_DOWNLOAD=1 (dev / CI scenarios that don't need the bin)
|
|
11
|
+
// - The user is on an unsupported platform (we exit 0 + warn, NOT fail,
|
|
12
|
+
// so npm install doesn't break for transitive deps)
|
|
13
|
+
//
|
|
14
|
+
// Network failures DO fail the install — silently shipping a broken package
|
|
15
|
+
// is worse than a clear error the user can retry.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const https = require('https');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
const pkg = require('../package.json');
|
|
25
|
+
const VERSION = `v${pkg.version}`;
|
|
26
|
+
const REPO = 'liaisonio/cli';
|
|
27
|
+
const RELEASE_BASE = `https://github.com/${REPO}/releases/download/${VERSION}`;
|
|
28
|
+
|
|
29
|
+
// process.platform-process.arch → release asset GOOS-GOARCH suffix.
|
|
30
|
+
const PLATFORMS = {
|
|
31
|
+
'darwin-arm64': { os: 'darwin', arch: 'arm64', ext: '' },
|
|
32
|
+
'darwin-x64': { os: 'darwin', arch: 'amd64', ext: '' },
|
|
33
|
+
'linux-arm64': { os: 'linux', arch: 'arm64', ext: '' },
|
|
34
|
+
'linux-x64': { os: 'linux', arch: 'amd64', ext: '' },
|
|
35
|
+
'win32-x64': { os: 'windows', arch: 'amd64', ext: '.exe' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function log(msg) {
|
|
39
|
+
process.stdout.write(`liaison-cli: ${msg}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function warn(msg) {
|
|
43
|
+
process.stderr.write(`liaison-cli: ${msg}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function die(msg) {
|
|
47
|
+
warn(msg);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (process.env.LIAISON_CLI_SKIP_DOWNLOAD === '1') {
|
|
52
|
+
log('LIAISON_CLI_SKIP_DOWNLOAD=1, skipping binary download');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const key = `${process.platform}-${process.arch}`;
|
|
57
|
+
const platform = PLATFORMS[key];
|
|
58
|
+
if (!platform) {
|
|
59
|
+
warn(
|
|
60
|
+
`unsupported platform ${key}; package installed without a binary. ` +
|
|
61
|
+
`Use --ignore-scripts or set LIAISON_CLI_SKIP_DOWNLOAD=1 to silence.`,
|
|
62
|
+
);
|
|
63
|
+
// Exit 0 so transitive dependents don't break.
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const filename = `liaison-${VERSION}-${platform.os}-${platform.arch}${platform.ext}`;
|
|
68
|
+
const url = `${RELEASE_BASE}/${filename}`;
|
|
69
|
+
const sumsUrl = `${RELEASE_BASE}/SHA256SUMS`;
|
|
70
|
+
|
|
71
|
+
const vendorDir = path.join(__dirname, '..', 'vendor');
|
|
72
|
+
const destPath = path.join(vendorDir, `liaison${platform.ext}`);
|
|
73
|
+
|
|
74
|
+
fs.mkdirSync(vendorDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
// Followed-redirect HTTP GET that streams to a file.
|
|
77
|
+
function downloadToFile(targetUrl, outPath) {
|
|
78
|
+
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}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
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);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function downloadToString(targetUrl) {
|
|
113
|
+
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;
|
|
118
|
+
}
|
|
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);
|
|
142
|
+
}
|
|
143
|
+
get(targetUrl, 0);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function sha256(filepath) {
|
|
148
|
+
const hash = crypto.createHash('sha256');
|
|
149
|
+
hash.update(fs.readFileSync(filepath));
|
|
150
|
+
return hash.digest('hex');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function main() {
|
|
154
|
+
log(`fetching ${url}`);
|
|
155
|
+
await downloadToFile(url, destPath);
|
|
156
|
+
fs.chmodSync(destPath, 0o755);
|
|
157
|
+
|
|
158
|
+
log('verifying SHA256');
|
|
159
|
+
const sums = await downloadToString(sumsUrl);
|
|
160
|
+
const line = sums
|
|
161
|
+
.split('\n')
|
|
162
|
+
.map((l) => l.trim())
|
|
163
|
+
.find((l) => l.endsWith(filename));
|
|
164
|
+
if (!line) {
|
|
165
|
+
fs.unlinkSync(destPath);
|
|
166
|
+
die(`no SHA256 entry for ${filename} in SHA256SUMS — release may be corrupt`);
|
|
167
|
+
}
|
|
168
|
+
const expected = line.split(/\s+/)[0];
|
|
169
|
+
const actual = sha256(destPath);
|
|
170
|
+
if (expected !== actual) {
|
|
171
|
+
fs.unlinkSync(destPath);
|
|
172
|
+
die(`SHA256 mismatch for ${filename}: expected ${expected}, got ${actual}`);
|
|
173
|
+
}
|
|
174
|
+
log(`installed ${filename} (sha256 ok)`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
die(`download failed: ${err.message}`);
|
|
179
|
+
});
|