@socrates-ai/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 +9 -0
- package/bin/socrates.mjs +7 -0
- package/package.json +30 -0
- package/src/cli.mjs +98 -0
- package/src/runtime.mjs +314 -0
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Socrates CLI
|
|
2
|
+
|
|
3
|
+
Run Socrates as a local-first web app:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @socrates-ai/cli
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The CLI downloads the matching runtime bundle from GitHub Releases, stores it under `~/.Socrates/runtimes/`, starts local services on `127.0.0.1`, and opens the browser.
|
package/bin/socrates.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@socrates-ai/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Launch Socrates as a local-first AI workspace from npm.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"socrates": "bin/socrates.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src/cli.mjs",
|
|
12
|
+
"src/runtime.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Ayushbh6/Socrates.git",
|
|
27
|
+
"directory": "apps/cli"
|
|
28
|
+
},
|
|
29
|
+
"license": "UNLICENSED"
|
|
30
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
availablePort,
|
|
8
|
+
defaultSocratesHome,
|
|
9
|
+
ensureRuntime,
|
|
10
|
+
openBrowser,
|
|
11
|
+
parseArgs,
|
|
12
|
+
platformArchFor,
|
|
13
|
+
runRuntime,
|
|
14
|
+
} from "./runtime.mjs";
|
|
15
|
+
|
|
16
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
18
|
+
|
|
19
|
+
export const runCli = async (argv) => {
|
|
20
|
+
const options = parseArgs(argv);
|
|
21
|
+
|
|
22
|
+
if (options.help) {
|
|
23
|
+
console.log(helpText());
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (options.version) {
|
|
28
|
+
console.log(packageJson.version);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
assertNodeVersion(process.versions.node);
|
|
33
|
+
|
|
34
|
+
const platformArch = platformArchFor(process.platform, process.arch);
|
|
35
|
+
const socratesHome = path.resolve(options.home ?? defaultSocratesHome());
|
|
36
|
+
const backendPort = options.backendPort ?? (await availablePort());
|
|
37
|
+
let webPort = options.webPort ?? (await availablePort());
|
|
38
|
+
while (!options.webPort && webPort === backendPort) {
|
|
39
|
+
webPort = await availablePort();
|
|
40
|
+
}
|
|
41
|
+
if (webPort === backendPort) {
|
|
42
|
+
throw new Error("Backend and web ports must be different.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log("Socrates is starting...");
|
|
46
|
+
console.log(`Data: ${socratesHome}`);
|
|
47
|
+
|
|
48
|
+
const runtime = await ensureRuntime({
|
|
49
|
+
home: socratesHome,
|
|
50
|
+
platformArch,
|
|
51
|
+
version: options.runtimeVersion,
|
|
52
|
+
reset: options.resetRuntime,
|
|
53
|
+
log: (message) => console.log(message),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`Runtime: ${runtime.runtimeDir}`);
|
|
57
|
+
console.log(`Backend: http://127.0.0.1:${backendPort}`);
|
|
58
|
+
console.log(`App: http://127.0.0.1:${webPort}`);
|
|
59
|
+
|
|
60
|
+
await runRuntime({
|
|
61
|
+
runtimeDir: runtime.runtimeDir,
|
|
62
|
+
socratesHome,
|
|
63
|
+
backendPort,
|
|
64
|
+
webPort,
|
|
65
|
+
nodePath: process.execPath,
|
|
66
|
+
onReady: async (ready) => {
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(`Socrates is ready: ${ready.webUrl}`);
|
|
69
|
+
console.log("Press Ctrl+C to stop.");
|
|
70
|
+
if (!options.noOpen) {
|
|
71
|
+
await openBrowser(ready.webUrl, { spawn });
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const assertNodeVersion = (version) => {
|
|
78
|
+
const major = Number(version.split(".")[0]);
|
|
79
|
+
if (!Number.isInteger(major) || major < 20) {
|
|
80
|
+
throw new Error(`Socrates requires Node.js 20 or newer. Current Node.js version is ${version}.`);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const helpText = () => `Socrates ${packageJson.version}
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
socrates [options]
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
--version Print the CLI version.
|
|
91
|
+
--no-open Start Socrates without opening the browser.
|
|
92
|
+
--home <path> Use a custom Socrates data directory.
|
|
93
|
+
--backend-port <port> Use a fixed backend port.
|
|
94
|
+
--web-port <port> Use a fixed web port.
|
|
95
|
+
--runtime-version <tag> Use a specific GitHub Release tag, e.g. v0.1.0.
|
|
96
|
+
--reset-runtime Redownload and extract the runtime bundle.
|
|
97
|
+
--help Show this help.
|
|
98
|
+
`;
|
package/src/runtime.mjs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
|
|
10
|
+
const defaultRepo = "Ayushbh6/Socrates";
|
|
11
|
+
const host = "127.0.0.1";
|
|
12
|
+
|
|
13
|
+
export const defaultSocratesHome = () => path.join(os.homedir(), ".Socrates");
|
|
14
|
+
|
|
15
|
+
export const parseArgs = (argv) => {
|
|
16
|
+
const options = {};
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const arg = argv[index];
|
|
19
|
+
switch (arg) {
|
|
20
|
+
case "--version":
|
|
21
|
+
case "-v":
|
|
22
|
+
options.version = true;
|
|
23
|
+
break;
|
|
24
|
+
case "--help":
|
|
25
|
+
case "-h":
|
|
26
|
+
options.help = true;
|
|
27
|
+
break;
|
|
28
|
+
case "--no-open":
|
|
29
|
+
options.noOpen = true;
|
|
30
|
+
break;
|
|
31
|
+
case "--reset-runtime":
|
|
32
|
+
options.resetRuntime = true;
|
|
33
|
+
break;
|
|
34
|
+
case "--home":
|
|
35
|
+
options.home = requiredValue(argv, (index += 1), arg);
|
|
36
|
+
break;
|
|
37
|
+
case "--backend-port":
|
|
38
|
+
options.backendPort = parsePort(requiredValue(argv, (index += 1), arg), arg);
|
|
39
|
+
break;
|
|
40
|
+
case "--web-port":
|
|
41
|
+
options.webPort = parsePort(requiredValue(argv, (index += 1), arg), arg);
|
|
42
|
+
break;
|
|
43
|
+
case "--runtime-version":
|
|
44
|
+
options.runtimeVersion = requiredValue(argv, (index += 1), arg);
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return options;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const platformArchFor = (platform, arch) => {
|
|
54
|
+
if (platform === "darwin" && arch === "arm64") return "darwin-arm64";
|
|
55
|
+
if (platform === "darwin" && arch === "x64") return "darwin-x64";
|
|
56
|
+
if (platform === "win32" && arch === "x64") return "win32-x64";
|
|
57
|
+
throw new Error(`Unsupported platform: ${platform}-${arch}. Socrates currently supports macOS arm64, macOS x64, and Windows x64.`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const runtimeAssetName = (platformArch) => `socrates-runtime-${platformArch}.zip`;
|
|
61
|
+
|
|
62
|
+
export const runtimeRoot = (home) => path.join(home, "runtimes");
|
|
63
|
+
|
|
64
|
+
export const runtimeCacheDir = (home) => path.join(home, "cache");
|
|
65
|
+
|
|
66
|
+
export const runtimeDirFor = (home, version, platformArch) => path.join(runtimeRoot(home), version, platformArch);
|
|
67
|
+
|
|
68
|
+
export const availablePort = () =>
|
|
69
|
+
new Promise((resolve, reject) => {
|
|
70
|
+
const server = http.createServer();
|
|
71
|
+
server.listen(0, host, () => {
|
|
72
|
+
const address = server.address();
|
|
73
|
+
server.close(() => {
|
|
74
|
+
if (typeof address === "object" && address?.port) {
|
|
75
|
+
resolve(address.port);
|
|
76
|
+
} else {
|
|
77
|
+
reject(new Error("Could not reserve a local port."));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
server.once("error", reject);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const parseSha256Sums = (content) => {
|
|
85
|
+
const entries = new Map();
|
|
86
|
+
for (const line of content.split(/\r?\n/)) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed) continue;
|
|
89
|
+
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
90
|
+
if (match) {
|
|
91
|
+
entries.set(path.basename(match[2]), match[1].toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return entries;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const sha256File = (filePath) =>
|
|
98
|
+
new Promise((resolve, reject) => {
|
|
99
|
+
const hash = crypto.createHash("sha256");
|
|
100
|
+
const stream = fs.createReadStream(filePath);
|
|
101
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
102
|
+
stream.on("error", reject);
|
|
103
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const verifyChecksum = async (filePath, sumsContent) => {
|
|
107
|
+
const expected = parseSha256Sums(sumsContent).get(path.basename(filePath));
|
|
108
|
+
if (!expected) {
|
|
109
|
+
throw new Error(`SHA256SUMS does not include ${path.basename(filePath)}.`);
|
|
110
|
+
}
|
|
111
|
+
const actual = await sha256File(filePath);
|
|
112
|
+
if (actual !== expected) {
|
|
113
|
+
throw new Error(`Checksum verification failed for ${path.basename(filePath)}.`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const ensureRuntime = async ({ home, platformArch, version, reset = false, log = () => undefined }) => {
|
|
118
|
+
fs.mkdirSync(home, { recursive: true });
|
|
119
|
+
const release = await fetchRelease(version);
|
|
120
|
+
const resolvedVersion = release.tagName;
|
|
121
|
+
const runtimeDir = runtimeDirFor(home, resolvedVersion, platformArch);
|
|
122
|
+
const launcher = path.join(runtimeDir, "launcher.mjs");
|
|
123
|
+
|
|
124
|
+
if (!reset && fs.existsSync(launcher)) {
|
|
125
|
+
return { version: resolvedVersion, runtimeDir };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const assetName = runtimeAssetName(platformArch);
|
|
129
|
+
const runtimeAsset = selectAsset(release.assets, assetName);
|
|
130
|
+
const sumsAsset = selectAsset(release.assets, "SHA256SUMS");
|
|
131
|
+
const cacheDir = path.join(runtimeCacheDir(home), resolvedVersion);
|
|
132
|
+
const archivePath = path.join(cacheDir, assetName);
|
|
133
|
+
const sumsPath = path.join(cacheDir, "SHA256SUMS");
|
|
134
|
+
|
|
135
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
136
|
+
log(`Downloading ${assetName}...`);
|
|
137
|
+
await downloadFile(runtimeAsset.url, archivePath);
|
|
138
|
+
await downloadFile(sumsAsset.url, sumsPath);
|
|
139
|
+
await verifyChecksum(archivePath, fs.readFileSync(sumsPath, "utf8"));
|
|
140
|
+
|
|
141
|
+
const tempDir = `${runtimeDir}.tmp-${Date.now()}`;
|
|
142
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
143
|
+
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
|
144
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
145
|
+
await extractZip(archivePath, tempDir);
|
|
146
|
+
fs.mkdirSync(path.dirname(runtimeDir), { recursive: true });
|
|
147
|
+
fs.renameSync(tempDir, runtimeDir);
|
|
148
|
+
|
|
149
|
+
if (!fs.existsSync(launcher)) {
|
|
150
|
+
throw new Error(`Runtime archive did not contain launcher.mjs.`);
|
|
151
|
+
}
|
|
152
|
+
return { version: resolvedVersion, runtimeDir };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const runRuntime = ({ runtimeDir, socratesHome, backendPort, webPort, nodePath, onReady }) =>
|
|
156
|
+
new Promise((resolve, reject) => {
|
|
157
|
+
const launcher = path.join(runtimeDir, "launcher.mjs");
|
|
158
|
+
const child = defaultSpawn(nodePath, [launcher], {
|
|
159
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
160
|
+
env: {
|
|
161
|
+
...process.env,
|
|
162
|
+
SOCRATES_RUNTIME_DIR: runtimeDir,
|
|
163
|
+
SOCRATES_HOME: socratesHome,
|
|
164
|
+
SOCRATES_BACKEND_PORT: String(backendPort),
|
|
165
|
+
SOCRATES_WEB_PORT: String(webPort),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let ready = false;
|
|
170
|
+
let buffer = "";
|
|
171
|
+
|
|
172
|
+
const shutdown = () => {
|
|
173
|
+
child.kill();
|
|
174
|
+
};
|
|
175
|
+
process.once("SIGINT", shutdown);
|
|
176
|
+
process.once("SIGTERM", shutdown);
|
|
177
|
+
|
|
178
|
+
child.stdout.on("data", (chunk) => {
|
|
179
|
+
buffer += chunk.toString();
|
|
180
|
+
const lines = buffer.split(/\r?\n/);
|
|
181
|
+
buffer = lines.pop() ?? "";
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
if (!line.trim()) continue;
|
|
184
|
+
const parsed = parseReadyLine(line);
|
|
185
|
+
if (parsed) {
|
|
186
|
+
ready = true;
|
|
187
|
+
void onReady(parsed).catch(reject);
|
|
188
|
+
} else {
|
|
189
|
+
console.log(line);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
child.once("error", reject);
|
|
195
|
+
child.once("exit", (code, signal) => {
|
|
196
|
+
process.off("SIGINT", shutdown);
|
|
197
|
+
process.off("SIGTERM", shutdown);
|
|
198
|
+
if (!ready && code !== 0) {
|
|
199
|
+
reject(new Error(`Socrates runtime exited before becoming ready with code ${code ?? signal}.`));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
resolve();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export const openBrowser = async (url, { spawn = defaultSpawn } = {}) => {
|
|
207
|
+
if (process.platform === "darwin") {
|
|
208
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (process.platform === "win32") {
|
|
212
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export const selectAsset = (assets, name) => {
|
|
219
|
+
const asset = assets.find((item) => item.name === name);
|
|
220
|
+
if (!asset) {
|
|
221
|
+
throw new Error(`GitHub Release is missing ${name}.`);
|
|
222
|
+
}
|
|
223
|
+
return asset;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const fetchRelease = async (version) => {
|
|
227
|
+
const repo = process.env.SOCRATES_RELEASE_REPO ?? defaultRepo;
|
|
228
|
+
const pathPart = version ? `releases/tags/${encodeURIComponent(version)}` : "releases/latest";
|
|
229
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/${pathPart}`, {
|
|
230
|
+
headers: {
|
|
231
|
+
accept: "application/vnd.github+json",
|
|
232
|
+
"user-agent": "@socrates-ai/cli",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
throw new Error(`Could not fetch Socrates release metadata: HTTP ${response.status}.`);
|
|
237
|
+
}
|
|
238
|
+
const json = await response.json();
|
|
239
|
+
return {
|
|
240
|
+
tagName: json.tag_name,
|
|
241
|
+
assets: (json.assets ?? []).map((asset) => ({
|
|
242
|
+
name: asset.name,
|
|
243
|
+
url: asset.browser_download_url,
|
|
244
|
+
})),
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const downloadFile = async (url, target) => {
|
|
249
|
+
const response = await fetch(url, {
|
|
250
|
+
headers: {
|
|
251
|
+
"user-agent": "@socrates-ai/cli",
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
if (!response.ok || !response.body) {
|
|
255
|
+
throw new Error(`Could not download ${url}: HTTP ${response.status}.`);
|
|
256
|
+
}
|
|
257
|
+
const tempTarget = `${target}.tmp`;
|
|
258
|
+
fs.rmSync(tempTarget, { force: true });
|
|
259
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(tempTarget));
|
|
260
|
+
fs.renameSync(tempTarget, target);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const extractZip = async (archivePath, targetDir) => {
|
|
264
|
+
if (process.platform === "win32") {
|
|
265
|
+
await run("powershell.exe", [
|
|
266
|
+
"-NoProfile",
|
|
267
|
+
"-Command",
|
|
268
|
+
`Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${targetDir}' -Force`,
|
|
269
|
+
]);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await run("unzip", ["-q", archivePath, "-d", targetDir]);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const run = (command, args) =>
|
|
276
|
+
new Promise((resolve, reject) => {
|
|
277
|
+
const child = defaultSpawn(command, args, { stdio: "ignore" });
|
|
278
|
+
child.once("exit", (code) => {
|
|
279
|
+
if (code === 0) {
|
|
280
|
+
resolve();
|
|
281
|
+
} else {
|
|
282
|
+
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
child.once("error", reject);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const parseReadyLine = (line) => {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(line);
|
|
291
|
+
if (parsed?.type === "socrates.runtime.ready" && typeof parsed.webUrl === "string") {
|
|
292
|
+
return parsed;
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const requiredValue = (argv, index, flag) => {
|
|
301
|
+
const value = argv[index];
|
|
302
|
+
if (!value || value.startsWith("--")) {
|
|
303
|
+
throw new Error(`${flag} requires a value.`);
|
|
304
|
+
}
|
|
305
|
+
return value;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const parsePort = (value, flag) => {
|
|
309
|
+
const port = Number(value);
|
|
310
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
311
|
+
throw new Error(`${flag} requires a valid TCP port.`);
|
|
312
|
+
}
|
|
313
|
+
return port;
|
|
314
|
+
};
|