@easonwumac/computer-linker 0.1.2
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/CHANGELOG.md +230 -0
- package/LICENSE +21 -0
- package/README.md +539 -0
- package/SECURITY.md +48 -0
- package/dist/api.d.ts +2 -0
- package/dist/api.js +360 -0
- package/dist/audit.d.ts +70 -0
- package/dist/audit.js +102 -0
- package/dist/capabilities.d.ts +98 -0
- package/dist/capabilities.js +718 -0
- package/dist/capability-policy.d.ts +22 -0
- package/dist/capability-policy.js +103 -0
- package/dist/chatgpt.d.ts +167 -0
- package/dist/chatgpt.js +561 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4621 -0
- package/dist/client-smoke.d.ts +44 -0
- package/dist/client-smoke.js +639 -0
- package/dist/client.d.ts +217 -0
- package/dist/client.js +357 -0
- package/dist/codex-runs.d.ts +35 -0
- package/dist/codex-runs.js +66 -0
- package/dist/computer-contract.d.ts +33 -0
- package/dist/computer-contract.js +384 -0
- package/dist/computer-operation-registry.d.ts +45 -0
- package/dist/computer-operation-registry.js +179 -0
- package/dist/config-diagnostics.d.ts +11 -0
- package/dist/config-diagnostics.js +185 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +69 -0
- package/dist/history-insights.d.ts +132 -0
- package/dist/history-insights.js +457 -0
- package/dist/http-auth.d.ts +3 -0
- package/dist/http-auth.js +15 -0
- package/dist/mcp-surface.d.ts +5 -0
- package/dist/mcp-surface.js +25 -0
- package/dist/oauth-provider.d.ts +52 -0
- package/dist/oauth-provider.js +325 -0
- package/dist/package-metadata.d.ts +7 -0
- package/dist/package-metadata.js +24 -0
- package/dist/permissions.d.ts +43 -0
- package/dist/permissions.js +150 -0
- package/dist/platform-shell.d.ts +28 -0
- package/dist/platform-shell.js +124 -0
- package/dist/processes.d.ts +50 -0
- package/dist/processes.js +178 -0
- package/dist/profile.d.ts +159 -0
- package/dist/profile.js +416 -0
- package/dist/screenshot.d.ts +47 -0
- package/dist/screenshot.js +302 -0
- package/dist/search.d.ts +34 -0
- package/dist/search.js +340 -0
- package/dist/security.d.ts +10 -0
- package/dist/security.js +108 -0
- package/dist/sensitive-files.d.ts +4 -0
- package/dist/sensitive-files.js +96 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +713 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.js +486 -0
- package/dist/sessions.d.ts +26 -0
- package/dist/sessions.js +34 -0
- package/dist/tunnels.d.ts +161 -0
- package/dist/tunnels.js +1243 -0
- package/dist/workspace-operations.d.ts +170 -0
- package/dist/workspace-operations.js +3219 -0
- package/dist/workspaces.d.ts +61 -0
- package/dist/workspaces.js +353 -0
- package/docs/agent-instructions.md +65 -0
- package/docs/alpha-evidence.example.json +54 -0
- package/docs/api-compatibility.md +56 -0
- package/docs/architecture.md +561 -0
- package/docs/chatgpt-setup.md +397 -0
- package/docs/client-recipes.md +98 -0
- package/docs/client-sdk.md +163 -0
- package/docs/computer-operation-v1.schema.json +143 -0
- package/docs/manual-test-plan.md +322 -0
- package/docs/product-spec.md +911 -0
- package/docs/release-checklist.md +285 -0
- package/docs/service-mode.md +99 -0
- package/examples/minimal-mcp-client.mjs +114 -0
- package/package.json +87 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4621 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createServer } from "node:net";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
import { expandHomePath } from "./permissions.js";
|
|
8
|
+
import { loadConfig } from "./config.js";
|
|
9
|
+
import { configPath, generateOwnerToken, writeConfig, writeDefaultConfig } from "./config.js";
|
|
10
|
+
import { getLocalPortDoctor } from "./capabilities.js";
|
|
11
|
+
import { chatGptSmoke, chatGptUrl, chatGptVerify, formatChatGptSmoke, formatChatGptUrl, formatChatGptVerify, parseChatGptVerifyMode } from "./chatgpt.js";
|
|
12
|
+
import { formatWorkspaceLinkerClientSmoke, runWorkspaceLinkerMcpClientSmoke } from "./client-smoke.js";
|
|
13
|
+
import { getMcpClientSetup } from "./computer-contract.js";
|
|
14
|
+
import { computerOperationContract, publicComputerOperationRegistry } from "./computer-operation-registry.js";
|
|
15
|
+
import { historyInsight, historyInsightView } from "./history-insights.js";
|
|
16
|
+
import { workspaceLinkerVersion } from "./package-metadata.js";
|
|
17
|
+
import { chatGptAppManifest, chatGptConnectProfile, chatGptConnectorConfig, connectionProfile, parseChatGptProfileMode } from "./profile.js";
|
|
18
|
+
import { defaultServiceOutputDir, formatServicePlan, formatServiceLogs, formatServiceStatus, parseServiceFormat, parseServicePlatform, servicePlan, serviceLogs, serviceProfileOutput, serviceStatus, writeServiceProfileFiles, } from "./service.js";
|
|
19
|
+
import { serveHttp, serveStdio } from "./server.js";
|
|
20
|
+
import { screenshotCapability } from "./screenshot.js";
|
|
21
|
+
import { configuredOpenAiTunnelId, ensureOpenAiTunnelClientInstalled, exposeWithTunnel, listTunnelProcesses, refreshTunnelPublicUrl, startTunnelProcess, tunnelDiagnostics } from "./tunnels.js";
|
|
22
|
+
function permissionPresetFlags(args, commandLabel, options = {}) {
|
|
23
|
+
assertReadOnlyNotMixed(args, commandLabel);
|
|
24
|
+
const readOnly = args.includes("--read-only");
|
|
25
|
+
const fullTrust = args.includes("--full-trust");
|
|
26
|
+
const dev = args.includes("--dev") || args.includes("--coding");
|
|
27
|
+
const defaultCoding = Boolean(options.defaultCoding && !hasExplicitPermissionMode(args));
|
|
28
|
+
const development = dev || fullTrust || defaultCoding;
|
|
29
|
+
return {
|
|
30
|
+
readOnly,
|
|
31
|
+
dev,
|
|
32
|
+
write: !readOnly && (development || args.includes("--write")),
|
|
33
|
+
shell: !readOnly && (development || args.includes("--shell")),
|
|
34
|
+
codex: !readOnly && (fullTrust || args.includes("--codex")),
|
|
35
|
+
screen: !readOnly && (fullTrust || args.includes("--screen")),
|
|
36
|
+
canonicalArgs: canonicalPermissionArgs(args),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function assertReadOnlyNotMixed(args, commandLabel) {
|
|
40
|
+
if (!args.includes("--read-only"))
|
|
41
|
+
return;
|
|
42
|
+
const conflicts = ["--dev", "--coding", "--full-trust", "--write", "--shell", "--codex", "--screen"]
|
|
43
|
+
.filter((flag) => args.includes(flag));
|
|
44
|
+
if (conflicts.length > 0) {
|
|
45
|
+
throw new Error(`${commandLabel} --read-only cannot be combined with ${conflicts.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function hasExplicitPermissionMode(args) {
|
|
49
|
+
return ["--read-only", "--dev", "--coding", "--full-trust", "--write", "--shell"]
|
|
50
|
+
.some((flag) => args.includes(flag));
|
|
51
|
+
}
|
|
52
|
+
function canonicalPermissionArgs(args) {
|
|
53
|
+
if (args.includes("--read-only"))
|
|
54
|
+
return ["--read-only"];
|
|
55
|
+
if (args.includes("--full-trust"))
|
|
56
|
+
return ["--full-trust"];
|
|
57
|
+
const parts = [];
|
|
58
|
+
if (args.includes("--write"))
|
|
59
|
+
parts.push("--write");
|
|
60
|
+
if (args.includes("--shell"))
|
|
61
|
+
parts.push("--shell");
|
|
62
|
+
if (args.includes("--codex"))
|
|
63
|
+
parts.push("--codex");
|
|
64
|
+
if (args.includes("--screen"))
|
|
65
|
+
parts.push("--screen");
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
|
68
|
+
async function main(argv) {
|
|
69
|
+
const [rawCommand, ...args] = argv;
|
|
70
|
+
const command = normalizeCommand(rawCommand);
|
|
71
|
+
switch (command) {
|
|
72
|
+
case "init":
|
|
73
|
+
init(args);
|
|
74
|
+
return;
|
|
75
|
+
case "serve":
|
|
76
|
+
await serve(args);
|
|
77
|
+
return;
|
|
78
|
+
case "start":
|
|
79
|
+
await start(args);
|
|
80
|
+
return;
|
|
81
|
+
case "quickstart":
|
|
82
|
+
quickstart(args);
|
|
83
|
+
return;
|
|
84
|
+
case "status":
|
|
85
|
+
status(args);
|
|
86
|
+
return;
|
|
87
|
+
case "self-test":
|
|
88
|
+
await selfTest(args);
|
|
89
|
+
return;
|
|
90
|
+
case "expose":
|
|
91
|
+
await expose(args);
|
|
92
|
+
return;
|
|
93
|
+
case "tunnel":
|
|
94
|
+
tunnel(args);
|
|
95
|
+
return;
|
|
96
|
+
case "service":
|
|
97
|
+
service(args);
|
|
98
|
+
return;
|
|
99
|
+
case "workspace":
|
|
100
|
+
workspace(args);
|
|
101
|
+
return;
|
|
102
|
+
case "process":
|
|
103
|
+
await processCommand(args);
|
|
104
|
+
return;
|
|
105
|
+
case "screen":
|
|
106
|
+
screen(args);
|
|
107
|
+
return;
|
|
108
|
+
case "doctor":
|
|
109
|
+
doctor(args);
|
|
110
|
+
return;
|
|
111
|
+
case "diagnose":
|
|
112
|
+
await diagnose(args);
|
|
113
|
+
return;
|
|
114
|
+
case "history":
|
|
115
|
+
history(args);
|
|
116
|
+
return;
|
|
117
|
+
case "profile":
|
|
118
|
+
profile(args);
|
|
119
|
+
return;
|
|
120
|
+
case "client":
|
|
121
|
+
await client(args);
|
|
122
|
+
return;
|
|
123
|
+
case "config":
|
|
124
|
+
config(args);
|
|
125
|
+
return;
|
|
126
|
+
case "setup":
|
|
127
|
+
setup(args);
|
|
128
|
+
return;
|
|
129
|
+
case "help":
|
|
130
|
+
printHelp(args);
|
|
131
|
+
return;
|
|
132
|
+
case "version":
|
|
133
|
+
printVersion();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function normalizeCommand(command) {
|
|
138
|
+
if (!command)
|
|
139
|
+
return "help";
|
|
140
|
+
if (command === "serve")
|
|
141
|
+
return "serve";
|
|
142
|
+
if (command === "connect-profile")
|
|
143
|
+
throw new Error("connect-profile was removed; use `computer-linker client chatgpt profile` only when ChatGPT asks for connector-specific fields.");
|
|
144
|
+
if (command === "chatgpt")
|
|
145
|
+
throw new Error("chatgpt was removed; use `computer-linker client chatgpt <subcommand>` only when ChatGPT asks for connector-specific fields.");
|
|
146
|
+
if (command === "init" || command === "start" || command === "quickstart" || command === "status" || command === "self-test" || command === "expose" || command === "tunnel" || command === "service" || command === "workspace" || command === "process" || command === "screen" || command === "doctor" || command === "diagnose" || command === "history" || command === "profile" || command === "client" || command === "config" || command === "setup")
|
|
147
|
+
return command;
|
|
148
|
+
if (command === "version" || command === "--version" || command === "-v")
|
|
149
|
+
return "version";
|
|
150
|
+
if (command === "help" || command === "--help" || command === "-h")
|
|
151
|
+
return "help";
|
|
152
|
+
throw new Error(`Unknown command: ${command}`);
|
|
153
|
+
}
|
|
154
|
+
function status(args) {
|
|
155
|
+
if (hasHelpFlag(args)) {
|
|
156
|
+
printStatusHelp();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const unknown = args.filter((arg) => arg !== "--json" && arg !== "--details");
|
|
160
|
+
if (unknown.length > 0)
|
|
161
|
+
throw new Error(`Unknown status option: ${unknown[0]}`);
|
|
162
|
+
const report = cliStatusReport();
|
|
163
|
+
if (args.includes("--json")) {
|
|
164
|
+
console.log(JSON.stringify(report, null, 2));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
process.stdout.write(args.includes("--details") ? formatDetailedCliStatus(report) : formatCliStatus(report));
|
|
168
|
+
}
|
|
169
|
+
async function selfTest(args) {
|
|
170
|
+
if (hasHelpFlag(args)) {
|
|
171
|
+
printSelfTestHelp();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const unknown = args.filter((arg, index) => (arg !== "--json" &&
|
|
175
|
+
arg !== "--keep-temp" &&
|
|
176
|
+
arg !== "--timeout-ms" &&
|
|
177
|
+
args[index - 1] !== "--timeout-ms"));
|
|
178
|
+
if (unknown.length > 0)
|
|
179
|
+
throw new Error(`Unknown self-test option: ${unknown[0]}`);
|
|
180
|
+
const timeoutMs = readOptionalIntegerOption(args, "--timeout-ms", "self-test --timeout-ms") ?? 15000;
|
|
181
|
+
const keepTemp = args.includes("--keep-temp");
|
|
182
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "computer-linker-self-test-"));
|
|
183
|
+
const configDir = join(tempRoot, "config");
|
|
184
|
+
const workspacePath = join(tempRoot, "workspace");
|
|
185
|
+
const previousWorkspaceConfigDir = process.env.COMPUTER_LINKER_CONFIG_DIR;
|
|
186
|
+
const previousLocalPortConfigDir = process.env.LOCALPORT_CONFIG_DIR;
|
|
187
|
+
let server;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
190
|
+
writeFileSync(join(workspacePath, "README.md"), "# Computer Linker self-test\n\nThis temporary workspace is safe to delete.\n", "utf8");
|
|
191
|
+
const port = await findAvailableLoopbackPort();
|
|
192
|
+
process.env.COMPUTER_LINKER_CONFIG_DIR = configDir;
|
|
193
|
+
delete process.env.LOCALPORT_CONFIG_DIR;
|
|
194
|
+
const config = {
|
|
195
|
+
machineName: "computer-linker-self-test",
|
|
196
|
+
host: "127.0.0.1",
|
|
197
|
+
port,
|
|
198
|
+
ownerToken: generateOwnerToken(),
|
|
199
|
+
workspaces: [
|
|
200
|
+
{
|
|
201
|
+
id: "app",
|
|
202
|
+
name: "Self Test",
|
|
203
|
+
path: workspacePath,
|
|
204
|
+
permissions: { read: true, write: false, shell: false, codex: false },
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
writeConfig(config);
|
|
209
|
+
server = serveHttp();
|
|
210
|
+
await waitForSelfTestServer(`http://127.0.0.1:${port}`, timeoutMs);
|
|
211
|
+
const smoke = await runWorkspaceLinkerMcpClientSmoke(config, {
|
|
212
|
+
url: `http://127.0.0.1:${port}`,
|
|
213
|
+
allowHttp: true,
|
|
214
|
+
timeoutMs,
|
|
215
|
+
});
|
|
216
|
+
const report = {
|
|
217
|
+
kind: "computer-linker-self-test",
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
ready: smoke.ready,
|
|
220
|
+
tempRoot,
|
|
221
|
+
tempKept: keepTemp,
|
|
222
|
+
configDir,
|
|
223
|
+
workspacePath,
|
|
224
|
+
localMcpUrl: `http://127.0.0.1:${port}/mcp`,
|
|
225
|
+
localApiUrl: `http://127.0.0.1:${port}/api/v1`,
|
|
226
|
+
smoke,
|
|
227
|
+
nextActions: smoke.ready
|
|
228
|
+
? ["Installed CLI, local HTTP server, MCP SDK transport, and generic MCP tools are working."]
|
|
229
|
+
: smoke.nextActions,
|
|
230
|
+
};
|
|
231
|
+
if (args.includes("--json")) {
|
|
232
|
+
console.log(JSON.stringify(report, null, 2));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
process.stdout.write(formatSelfTestReport(report));
|
|
236
|
+
}
|
|
237
|
+
if (!report.ready)
|
|
238
|
+
throw new Error("computer-linker self-test failed");
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
if (server)
|
|
242
|
+
server.close();
|
|
243
|
+
if (previousWorkspaceConfigDir === undefined)
|
|
244
|
+
delete process.env.COMPUTER_LINKER_CONFIG_DIR;
|
|
245
|
+
else
|
|
246
|
+
process.env.COMPUTER_LINKER_CONFIG_DIR = previousWorkspaceConfigDir;
|
|
247
|
+
if (previousLocalPortConfigDir === undefined)
|
|
248
|
+
delete process.env.LOCALPORT_CONFIG_DIR;
|
|
249
|
+
else
|
|
250
|
+
process.env.LOCALPORT_CONFIG_DIR = previousLocalPortConfigDir;
|
|
251
|
+
if (!keepTemp)
|
|
252
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function formatSelfTestReport(report) {
|
|
256
|
+
const lines = [
|
|
257
|
+
"Computer Linker self-test",
|
|
258
|
+
`ready: ${report.ready ? "yes" : "no"}`,
|
|
259
|
+
`localMcpUrl: ${report.localMcpUrl}`,
|
|
260
|
+
`localApiUrl: ${report.localApiUrl}`,
|
|
261
|
+
`temp: ${report.tempKept ? report.tempRoot : "removed"}`,
|
|
262
|
+
"checks:",
|
|
263
|
+
...report.smoke.checks.map((check) => ` [${check.status}] ${check.id}: ${check.message}${check.statusCode ? ` (${check.statusCode})` : ""}${check.durationMs !== undefined ? ` ${check.durationMs}ms` : ""}`),
|
|
264
|
+
"next actions:",
|
|
265
|
+
...report.nextActions.map((action) => ` - ${action}`),
|
|
266
|
+
];
|
|
267
|
+
return `${lines.join("\n")}\n`;
|
|
268
|
+
}
|
|
269
|
+
async function findAvailableLoopbackPort() {
|
|
270
|
+
const server = createServer();
|
|
271
|
+
await new Promise((resolvePromise, reject) => {
|
|
272
|
+
server.once("error", reject);
|
|
273
|
+
server.listen(0, "127.0.0.1", () => resolvePromise());
|
|
274
|
+
});
|
|
275
|
+
const address = server.address();
|
|
276
|
+
await new Promise((resolvePromise, reject) => {
|
|
277
|
+
server.close((error) => error ? reject(error) : resolvePromise());
|
|
278
|
+
});
|
|
279
|
+
if (!address || typeof address === "string")
|
|
280
|
+
throw new Error("Unable to allocate a loopback port for self-test.");
|
|
281
|
+
return address.port;
|
|
282
|
+
}
|
|
283
|
+
async function waitForSelfTestServer(origin, timeoutMs) {
|
|
284
|
+
const deadline = Date.now() + timeoutMs;
|
|
285
|
+
let lastError = "";
|
|
286
|
+
while (Date.now() < deadline) {
|
|
287
|
+
try {
|
|
288
|
+
const response = await fetch(`${origin}/healthz`);
|
|
289
|
+
if (response.ok)
|
|
290
|
+
return;
|
|
291
|
+
lastError = `HTTP ${response.status}`;
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
295
|
+
}
|
|
296
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`Self-test server did not become ready: ${lastError || "timeout"}`);
|
|
299
|
+
}
|
|
300
|
+
function cliStatusReport() {
|
|
301
|
+
const config = loadConfig();
|
|
302
|
+
const doctor = getLocalPortDoctor();
|
|
303
|
+
const tunnel = tunnelDiagnostics({
|
|
304
|
+
localPort: config.port ?? 3939,
|
|
305
|
+
publicBaseUrl: config.publicBaseUrl,
|
|
306
|
+
tunnels: listTunnelProcesses(),
|
|
307
|
+
});
|
|
308
|
+
const openAiSecureTunnelActive = tunnel.providers.some((provider) => provider.provider === "openai" && provider.running);
|
|
309
|
+
const publicBaseUrlNotRequired = openAiSecureTunnelActive && !config.publicBaseUrl && !tunnel.effectivePublicUrl;
|
|
310
|
+
const configWarningFindings = doctor.configDiagnostics.findings.filter((finding) => finding.severity === "warning");
|
|
311
|
+
const securityWarningFindings = doctor.security.findings.filter((finding) => (finding.severity === "warning" &&
|
|
312
|
+
!(publicBaseUrlNotRequired && finding.id === "public-base-url-missing")));
|
|
313
|
+
const blockingReasons = uniqueText([
|
|
314
|
+
...doctor.exposure.blockingReasons,
|
|
315
|
+
...doctor.releaseReadiness.blockingReasons,
|
|
316
|
+
...findingSummaries("config", doctor.configDiagnostics.findings, "critical"),
|
|
317
|
+
...findingSummaries("security", doctor.security.findings, "critical"),
|
|
318
|
+
]);
|
|
319
|
+
const warningFindingIds = new Set([
|
|
320
|
+
...configWarningFindings.map((finding) => finding.id),
|
|
321
|
+
...securityWarningFindings.map((finding) => finding.id),
|
|
322
|
+
]);
|
|
323
|
+
const warnings = uniqueText([
|
|
324
|
+
...doctor.exposure.warnings.filter((warning) => (!warningFindingIds.has(warning) &&
|
|
325
|
+
!(publicBaseUrlNotRequired && warning === "public-base-url-missing") &&
|
|
326
|
+
!(publicBaseUrlNotRequired && warning.includes("publicBaseUrl")))),
|
|
327
|
+
...findingSummaries("config", configWarningFindings, "warning"),
|
|
328
|
+
...findingSummaries("security", securityWarningFindings, "warning"),
|
|
329
|
+
]);
|
|
330
|
+
const nextActions = statusNextActions({
|
|
331
|
+
base: doctor.nextActions,
|
|
332
|
+
workspaces: config.workspaces,
|
|
333
|
+
configFindings: doctor.configDiagnostics.findings,
|
|
334
|
+
securityFindings: doctor.security.findings,
|
|
335
|
+
publicBaseUrlNotRequired,
|
|
336
|
+
openAiSecureTunnelActive,
|
|
337
|
+
});
|
|
338
|
+
return {
|
|
339
|
+
kind: "computer-linker-status",
|
|
340
|
+
schemaVersion: 1,
|
|
341
|
+
machine: {
|
|
342
|
+
machineId: doctor.machineId,
|
|
343
|
+
machineName: doctor.machineName,
|
|
344
|
+
},
|
|
345
|
+
configPath: configPath(),
|
|
346
|
+
ready: doctor.startup.ready && doctor.releaseReadiness.ready && doctor.configDiagnostics.criticalCount === 0 && doctor.security.criticalCount === 0,
|
|
347
|
+
status: doctor.releaseReadiness.status,
|
|
348
|
+
urls: {
|
|
349
|
+
localMcpUrl: doctor.runtime.localMcpUrl,
|
|
350
|
+
localApiUrl: doctor.runtime.localApiUrl,
|
|
351
|
+
publicMcpUrl: doctor.exposure.publicMcpUrl,
|
|
352
|
+
publicBaseUrl: doctor.exposure.publicBaseUrl,
|
|
353
|
+
},
|
|
354
|
+
auth: doctor.auth,
|
|
355
|
+
workspaces: {
|
|
356
|
+
total: config.workspaces.length,
|
|
357
|
+
items: config.workspaces.map((workspace) => ({
|
|
358
|
+
id: workspace.id,
|
|
359
|
+
name: workspace.name,
|
|
360
|
+
path: workspace.path,
|
|
361
|
+
permissions: workspace.permissions,
|
|
362
|
+
})),
|
|
363
|
+
},
|
|
364
|
+
tunnel: {
|
|
365
|
+
effectivePublicUrl: tunnel.effectivePublicUrl,
|
|
366
|
+
effectivePublicUrlSource: tunnel.effectivePublicUrlSource,
|
|
367
|
+
openAiSecureTunnelActive,
|
|
368
|
+
running: tunnel.providers
|
|
369
|
+
.filter((provider) => provider.running)
|
|
370
|
+
.map((provider) => ({
|
|
371
|
+
provider: provider.provider,
|
|
372
|
+
publicUrl: provider.publicUrl,
|
|
373
|
+
processId: provider.runningProcessId,
|
|
374
|
+
})),
|
|
375
|
+
},
|
|
376
|
+
readiness: {
|
|
377
|
+
startupReady: doctor.startup.ready,
|
|
378
|
+
releaseStatus: doctor.releaseReadiness.status,
|
|
379
|
+
readyForTunnel: doctor.readyForTunnel,
|
|
380
|
+
blockingReasons,
|
|
381
|
+
warnings,
|
|
382
|
+
configCriticalCount: doctor.configDiagnostics.criticalCount,
|
|
383
|
+
configWarningCount: doctor.configDiagnostics.warningCount,
|
|
384
|
+
securityCriticalCount: doctor.security.criticalCount,
|
|
385
|
+
securityWarningCount: doctor.security.warningCount,
|
|
386
|
+
},
|
|
387
|
+
nextActions,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function formatCliStatus(report) {
|
|
391
|
+
const lines = [
|
|
392
|
+
`Computer Linker status for ${report.machine.machineName}`,
|
|
393
|
+
`ready: ${humanReadiness(report)}`,
|
|
394
|
+
`connect: ${statusConnectionSummary(report)}`,
|
|
395
|
+
`local MCP: ${report.urls.localMcpUrl}`,
|
|
396
|
+
`auth: ${statusAuthSummary(report)}`,
|
|
397
|
+
`workspaces: ${statusWorkspaceSummary(report.workspaces.items)}`,
|
|
398
|
+
`tunnel: ${statusTunnelSummary(report)}`,
|
|
399
|
+
];
|
|
400
|
+
if (report.readiness.blockingReasons.length > 0) {
|
|
401
|
+
lines.push("blocked by:");
|
|
402
|
+
for (const reason of report.readiness.blockingReasons.slice(0, 3))
|
|
403
|
+
lines.push(` - ${formatStatusIssue(reason)}`);
|
|
404
|
+
appendRemainingCount(lines, report.readiness.blockingReasons.length, 3, "blocking reason", "status --details");
|
|
405
|
+
}
|
|
406
|
+
else if (report.readiness.warnings.length > 0) {
|
|
407
|
+
lines.push(`attention: ${report.readiness.warnings.length} warning${report.readiness.warnings.length === 1 ? "" : "s"}; run \`computer-linker status --details\``);
|
|
408
|
+
}
|
|
409
|
+
lines.push("next:");
|
|
410
|
+
const nextActions = report.nextActions.length > 0 ? report.nextActions.slice(0, 3) : ["No action needed."];
|
|
411
|
+
for (const action of nextActions)
|
|
412
|
+
lines.push(` - ${action}`);
|
|
413
|
+
appendRemainingCount(lines, report.nextActions.length, 3, "action", "status --details");
|
|
414
|
+
lines.push("details: computer-linker status --details");
|
|
415
|
+
return `${lines.join("\n")}\n`;
|
|
416
|
+
}
|
|
417
|
+
function formatDetailedCliStatus(report) {
|
|
418
|
+
const tunnelStatus = report.tunnel.effectivePublicUrl
|
|
419
|
+
? `${report.tunnel.effectivePublicUrl}${report.tunnel.effectivePublicUrlSource ? ` (${report.tunnel.effectivePublicUrlSource})` : ""}`
|
|
420
|
+
: report.tunnel.openAiSecureTunnelActive
|
|
421
|
+
? "openai secure MCP tunnel active (no public URL)"
|
|
422
|
+
: "not detected";
|
|
423
|
+
const publicMcpUrl = report.urls.publicMcpUrl
|
|
424
|
+
?? (report.tunnel.openAiSecureTunnelActive ? "not used in OpenAI tunnel mode" : "not configured");
|
|
425
|
+
const lines = [
|
|
426
|
+
`Computer Linker status for ${report.machine.machineName}`,
|
|
427
|
+
`operational: ${report.ready ? "yes" : "no"}`,
|
|
428
|
+
`readiness: ${humanReadiness(report)}`,
|
|
429
|
+
`config: ${report.configPath}`,
|
|
430
|
+
`local MCP URL: ${report.urls.localMcpUrl}`,
|
|
431
|
+
`public MCP URL: ${publicMcpUrl}`,
|
|
432
|
+
`auth: ${humanAuthStatus(report.auth)}`,
|
|
433
|
+
`workspaces: ${report.workspaces.total}`,
|
|
434
|
+
...report.workspaces.items.map((workspace) => (` ${workspace.id}: ${workspace.path} ${permissionSummary(workspace.permissions)}`)),
|
|
435
|
+
`tunnel: ${tunnelStatus}`,
|
|
436
|
+
];
|
|
437
|
+
if (report.tunnel.running.length > 0) {
|
|
438
|
+
lines.push("running tunnels:");
|
|
439
|
+
for (const tunnel of report.tunnel.running) {
|
|
440
|
+
lines.push(` ${tunnel.provider}: ${tunnel.publicUrl ?? tunnel.processId ?? "running"}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (report.readiness.blockingReasons.length > 0) {
|
|
444
|
+
lines.push("blocking reasons:");
|
|
445
|
+
for (const reason of report.readiness.blockingReasons)
|
|
446
|
+
lines.push(` - ${reason}`);
|
|
447
|
+
}
|
|
448
|
+
if (report.readiness.warnings.length > 0) {
|
|
449
|
+
lines.push("warnings:");
|
|
450
|
+
for (const warning of report.readiness.warnings)
|
|
451
|
+
lines.push(` - ${formatStatusIssue(warning)}`);
|
|
452
|
+
}
|
|
453
|
+
lines.push("next actions:");
|
|
454
|
+
for (const action of report.nextActions)
|
|
455
|
+
lines.push(` - ${action}`);
|
|
456
|
+
return `${lines.join("\n")}\n`;
|
|
457
|
+
}
|
|
458
|
+
function appendRemainingCount(lines, total, shown, singularLabel, command) {
|
|
459
|
+
const remaining = total - shown;
|
|
460
|
+
if (remaining <= 0)
|
|
461
|
+
return;
|
|
462
|
+
const label = remaining === 1 ? singularLabel : `${singularLabel}s`;
|
|
463
|
+
const displayCommand = command.startsWith("computer-linker ") || command.startsWith("npm ") ? command : `computer-linker ${command}`;
|
|
464
|
+
lines.push(` - ${remaining} more ${label} in \`${displayCommand}\``);
|
|
465
|
+
}
|
|
466
|
+
function humanReadiness(report) {
|
|
467
|
+
if (!report.ready || report.readiness.blockingReasons.length > 0)
|
|
468
|
+
return "blocked";
|
|
469
|
+
if (report.readiness.warnings.length > 0 || report.status !== "ready")
|
|
470
|
+
return "ready with warnings";
|
|
471
|
+
return "ready";
|
|
472
|
+
}
|
|
473
|
+
function statusConnectionSummary(report) {
|
|
474
|
+
if (report.tunnel.openAiSecureTunnelActive)
|
|
475
|
+
return "OpenAI Tunnel mode; no public URL or pasted bearer token";
|
|
476
|
+
if (report.urls.publicMcpUrl)
|
|
477
|
+
return report.urls.publicMcpUrl;
|
|
478
|
+
if (report.tunnel.effectivePublicUrl)
|
|
479
|
+
return new URL("/mcp", report.tunnel.effectivePublicUrl).href;
|
|
480
|
+
return `local only at ${report.urls.localMcpUrl}`;
|
|
481
|
+
}
|
|
482
|
+
function statusAuthSummary(report) {
|
|
483
|
+
if (report.tunnel.openAiSecureTunnelActive)
|
|
484
|
+
return "handled by local tunnel-client";
|
|
485
|
+
return humanAuthStatus(report.auth);
|
|
486
|
+
}
|
|
487
|
+
function statusWorkspaceSummary(workspaces) {
|
|
488
|
+
if (workspaces.length === 0)
|
|
489
|
+
return "none configured";
|
|
490
|
+
const writeCount = workspaces.filter((workspace) => workspace.permissions.write).length;
|
|
491
|
+
const commandCount = workspaces.filter((workspace) => workspace.permissions.shell || workspace.permissions.codex).length;
|
|
492
|
+
const parts = [`${workspaces.length} configured`];
|
|
493
|
+
if (writeCount > 0)
|
|
494
|
+
parts.push(`${writeCount} write`);
|
|
495
|
+
if (commandCount > 0)
|
|
496
|
+
parts.push(`${commandCount} command`);
|
|
497
|
+
return parts.join(", ");
|
|
498
|
+
}
|
|
499
|
+
function statusTunnelSummary(report) {
|
|
500
|
+
if (report.tunnel.openAiSecureTunnelActive)
|
|
501
|
+
return "OpenAI Secure MCP Tunnel active";
|
|
502
|
+
if (report.tunnel.effectivePublicUrl) {
|
|
503
|
+
const label = report.tunnel.effectivePublicUrlSource === "running-tunnel" ? "public tunnel active" : "public URL configured";
|
|
504
|
+
return `${label} (${report.tunnel.effectivePublicUrl})`;
|
|
505
|
+
}
|
|
506
|
+
if (report.tunnel.running.length > 0)
|
|
507
|
+
return `${report.tunnel.running.length} tunnel process${report.tunnel.running.length === 1 ? "" : "es"} running`;
|
|
508
|
+
return "not active";
|
|
509
|
+
}
|
|
510
|
+
function humanAuthStatus(auth) {
|
|
511
|
+
if (auth.ownerTokenConfigured)
|
|
512
|
+
return "owner token configured";
|
|
513
|
+
if (auth.localOnly)
|
|
514
|
+
return "loopback only; run `computer-linker init` before exposing";
|
|
515
|
+
return auth.mode.replaceAll("-", " ");
|
|
516
|
+
}
|
|
517
|
+
function permissionSummary(permissions) {
|
|
518
|
+
const enabled = [
|
|
519
|
+
permissions.read ? "read" : "",
|
|
520
|
+
permissions.write ? "write" : "",
|
|
521
|
+
permissions.shell ? "shell" : "",
|
|
522
|
+
permissions.codex ? "codex" : "",
|
|
523
|
+
permissions.screen ? "screen" : "",
|
|
524
|
+
].filter(Boolean);
|
|
525
|
+
return `[${enabled.join(",") || "none"}]`;
|
|
526
|
+
}
|
|
527
|
+
function findingSummaries(prefix, findings, severity) {
|
|
528
|
+
return findings
|
|
529
|
+
.filter((finding) => finding.severity === severity)
|
|
530
|
+
.map((finding) => {
|
|
531
|
+
const scope = finding.workspaceId ? `:${finding.workspaceId}` : "";
|
|
532
|
+
return `${prefix}:${finding.id}${scope} - ${finding.title}`;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
function formatStatusIssue(issue) {
|
|
536
|
+
const parsed = /^([a-z]+):([^:\s]+)(?::([^ ]+))? - (.+)$/.exec(issue);
|
|
537
|
+
if (!parsed)
|
|
538
|
+
return issue;
|
|
539
|
+
const [, , id, workspaceId, title] = parsed;
|
|
540
|
+
const workspace = workspaceId ? `workspace ${workspaceId}` : "configuration";
|
|
541
|
+
switch (id) {
|
|
542
|
+
case "workspace-path-duplicate":
|
|
543
|
+
return workspaceId
|
|
544
|
+
? `Duplicate workspace path: ${workspaceId} points at a folder already exposed by another workspace.`
|
|
545
|
+
: "Duplicate workspace path detected.";
|
|
546
|
+
case "workspace-execution-policy-missing":
|
|
547
|
+
return `${capitalize(workspace)} can run local commands but has no execution policy yet.`;
|
|
548
|
+
case "shell-broad-access":
|
|
549
|
+
return `${capitalize(workspace)} has shell access enabled. Review it before exposing this computer to a remote MCP client.`;
|
|
550
|
+
case "command-allowlist-missing":
|
|
551
|
+
return `${capitalize(workspace)} can run commands but has no command allowlist yet.`;
|
|
552
|
+
case "workspace-command-allowlist-missing":
|
|
553
|
+
return `${capitalize(workspace)} can run commands but has no command allowlist yet.`;
|
|
554
|
+
case "public-base-url-missing":
|
|
555
|
+
return "Public base URL is not configured. Local clients can still connect; remote URL-based clients need a tunnel URL.";
|
|
556
|
+
case "public-base-url-not-https":
|
|
557
|
+
return "Public base URL is not HTTPS. Remote cloud MCP clients usually require HTTPS.";
|
|
558
|
+
default:
|
|
559
|
+
return `${title}${workspaceId ? ` (${workspace})` : ""}.`;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function capitalize(value) {
|
|
563
|
+
return value ? `${value[0]?.toUpperCase()}${value.slice(1)}` : value;
|
|
564
|
+
}
|
|
565
|
+
function uniqueText(values) {
|
|
566
|
+
return [...new Set(values.filter((value) => value.trim()))];
|
|
567
|
+
}
|
|
568
|
+
function statusNextActions(input) {
|
|
569
|
+
const actions = [];
|
|
570
|
+
const executionPolicyAction = statusExecutionPolicyAction(input.workspaces, input.configFindings, input.securityFindings);
|
|
571
|
+
if (executionPolicyAction)
|
|
572
|
+
actions.push(executionPolicyAction);
|
|
573
|
+
for (const action of duplicateWorkspacePathActions(input.workspaces, input.configFindings))
|
|
574
|
+
actions.push(action);
|
|
575
|
+
if (input.openAiSecureTunnelActive) {
|
|
576
|
+
actions.push("OpenAI Secure MCP Tunnel is running; use Tunnel mode in the MCP client, no public URL is required.");
|
|
577
|
+
}
|
|
578
|
+
actions.push(...input.base.filter((action) => (!(input.publicBaseUrlNotRequired && action.includes("publicBaseUrl")) &&
|
|
579
|
+
!action.includes("Review releaseReadiness.warnings"))));
|
|
580
|
+
return uniqueText(actions).slice(0, 6);
|
|
581
|
+
}
|
|
582
|
+
function statusExecutionPolicyAction(workspaces, configFindings, securityFindings) {
|
|
583
|
+
const affectedWorkspaceIds = new Set([
|
|
584
|
+
...configFindings
|
|
585
|
+
.filter((finding) => finding.id === "workspace-execution-policy-missing" && finding.workspaceId)
|
|
586
|
+
.map((finding) => finding.workspaceId),
|
|
587
|
+
...securityFindings
|
|
588
|
+
.filter((finding) => finding.id === "command-allowlist-missing" && finding.workspaceId)
|
|
589
|
+
.map((finding) => finding.workspaceId),
|
|
590
|
+
]);
|
|
591
|
+
if (affectedWorkspaceIds.size === 0)
|
|
592
|
+
return undefined;
|
|
593
|
+
const affectedWorkspaces = workspaces.filter((workspace) => affectedWorkspaceIds.has(workspace.id));
|
|
594
|
+
const bootstrapWorkspaces = affectedWorkspaces.filter(isBootstrapDefaultWorkspace);
|
|
595
|
+
const bootstrapWillBeRemoved = bootstrapWorkspaces.length > 0 && workspaces.length > bootstrapWorkspaces.length;
|
|
596
|
+
const affectedNonBootstrap = affectedWorkspaces.filter((workspace) => !isBootstrapDefaultWorkspace(workspace));
|
|
597
|
+
if (bootstrapWillBeRemoved && affectedNonBootstrap.length === 0) {
|
|
598
|
+
return "Run `computer-linker doctor --fix` to remove the default current-directory scope now that explicit workspaces are configured.";
|
|
599
|
+
}
|
|
600
|
+
if (bootstrapWillBeRemoved) {
|
|
601
|
+
return "Run `computer-linker doctor --fix` to remove the default current-directory scope and add default execution policy for remaining shell/Codex scopes.";
|
|
602
|
+
}
|
|
603
|
+
return "Run `computer-linker doctor --fix` to add default execution policy for shell/Codex scopes.";
|
|
604
|
+
}
|
|
605
|
+
function duplicateWorkspacePathActions(workspaces, findings) {
|
|
606
|
+
if (!findings.some((finding) => finding.id === "workspace-path-duplicate"))
|
|
607
|
+
return [];
|
|
608
|
+
const duplicateGroups = workspaceDuplicatePathGroups(workspaces);
|
|
609
|
+
return duplicateGroups.map((group) => {
|
|
610
|
+
const scopeList = group.map((workspace) => `${workspace.id} ${permissionSummary(workspace.permissions)}`).join(", ");
|
|
611
|
+
if (group.every((workspace) => workspaceEquivalentForDuplicateCleanup(workspace, group[0]))) {
|
|
612
|
+
return `Run \`computer-linker doctor --fix\` to remove exact duplicate workspace scopes: ${scopeList}.`;
|
|
613
|
+
}
|
|
614
|
+
return `Duplicate workspace scopes share one folder but have different permissions: ${scopeList}. Keep them only if intentional; otherwise remove the unwanted id with \`computer-linker workspace remove <id>\`.`;
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
function workspaceDuplicatePathGroups(workspaces) {
|
|
618
|
+
const byPath = new Map();
|
|
619
|
+
for (const workspace of workspaces) {
|
|
620
|
+
const key = normalizedWorkspacePathKey(workspace.path);
|
|
621
|
+
if (!key)
|
|
622
|
+
continue;
|
|
623
|
+
const group = byPath.get(key) ?? [];
|
|
624
|
+
group.push(workspace);
|
|
625
|
+
byPath.set(key, group);
|
|
626
|
+
}
|
|
627
|
+
return [...byPath.values()].filter((group) => group.length > 1);
|
|
628
|
+
}
|
|
629
|
+
function tunnel(args) {
|
|
630
|
+
const [subcommand] = args;
|
|
631
|
+
if (subcommand === "help") {
|
|
632
|
+
printTunnelHelpTopic(args.slice(1));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
636
|
+
printTunnelHelp();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (hasHelpFlag(args.slice(1))) {
|
|
640
|
+
printTunnelHelpTopic([subcommand]);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (!subcommand || subcommand === "status") {
|
|
644
|
+
const rest = subcommand ? args.slice(1) : args;
|
|
645
|
+
const unknown = rest.filter((arg) => arg !== "--json");
|
|
646
|
+
if (unknown.length > 0)
|
|
647
|
+
throw new Error(`Unknown tunnel status option: ${unknown[0]}`);
|
|
648
|
+
const config = loadConfig();
|
|
649
|
+
const diagnostics = tunnelDiagnostics({
|
|
650
|
+
localPort: config.port ?? 3939,
|
|
651
|
+
publicBaseUrl: config.publicBaseUrl,
|
|
652
|
+
tunnels: listTunnelProcesses(),
|
|
653
|
+
});
|
|
654
|
+
if (rest.includes("--json")) {
|
|
655
|
+
console.log(JSON.stringify({
|
|
656
|
+
kind: "tunnel-status",
|
|
657
|
+
schemaVersion: 1,
|
|
658
|
+
localPort: config.port ?? 3939,
|
|
659
|
+
...diagnostics,
|
|
660
|
+
}, null, 2));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const openAiSecureTunnelActive = diagnostics.providers.some((provider) => provider.provider === "openai" && provider.running);
|
|
664
|
+
const publicBaseUrlText = diagnostics.publicBaseUrl
|
|
665
|
+
?? (openAiSecureTunnelActive ? "not required for OpenAI Secure MCP Tunnel" : "not configured");
|
|
666
|
+
const effectivePublicUrlText = diagnostics.effectivePublicUrl
|
|
667
|
+
?? (openAiSecureTunnelActive ? "not used in OpenAI Secure MCP Tunnel mode" : "not detected");
|
|
668
|
+
console.log(`publicBaseUrl: ${publicBaseUrlText}`);
|
|
669
|
+
console.log(`effectivePublicUrl: ${effectivePublicUrlText}`);
|
|
670
|
+
if (openAiSecureTunnelActive) {
|
|
671
|
+
console.log("openaiTunnel: active; use Tunnel mode in the MCP client, not a public URL");
|
|
672
|
+
}
|
|
673
|
+
for (const tool of diagnostics.tools) {
|
|
674
|
+
console.log(`${tool.name}: ${tool.available ? "available" : "missing"}${tool.version ? ` (${tool.version})` : ""}`);
|
|
675
|
+
if (tool.status)
|
|
676
|
+
console.log(` status: ${tool.status.split("\n")[0]}`);
|
|
677
|
+
}
|
|
678
|
+
console.log("providers:");
|
|
679
|
+
for (const provider of diagnostics.providers) {
|
|
680
|
+
console.log(` ${provider.provider}: ${provider.available ? "available" : "missing"}${provider.running ? ` running=${provider.runningProcessId ?? "yes"}` : ""}${provider.publicUrl ? ` url=${provider.publicUrl}` : ""}`);
|
|
681
|
+
}
|
|
682
|
+
console.log("commands:");
|
|
683
|
+
for (const command of diagnostics.commands) {
|
|
684
|
+
console.log(` ${command.display}`);
|
|
685
|
+
}
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
throw new Error(`Unknown tunnel command: ${subcommand}`);
|
|
689
|
+
}
|
|
690
|
+
function service(args) {
|
|
691
|
+
const [subcommand] = args;
|
|
692
|
+
if (subcommand === "help") {
|
|
693
|
+
printServiceHelpTopic(args.slice(1));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
697
|
+
printServiceHelp();
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (hasHelpFlag(args.slice(1))) {
|
|
701
|
+
printServiceHelpTopic([subcommand]);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!subcommand || subcommand === "profile") {
|
|
705
|
+
const rest = subcommand ? args.slice(1) : args;
|
|
706
|
+
assertKnownServiceOptions(rest, "--format", "--output-dir");
|
|
707
|
+
const options = serviceOptions(rest);
|
|
708
|
+
const format = parseServiceFormat(readOption(rest, "--format"));
|
|
709
|
+
if (rest.includes("--output-dir")) {
|
|
710
|
+
console.log(JSON.stringify(writeServiceProfileFiles(loadConfig(), options), null, 2));
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
process.stdout.write(serviceProfileOutput(loadConfig(), { ...options, format }));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (subcommand === "status") {
|
|
717
|
+
const rest = args.slice(1);
|
|
718
|
+
assertKnownServiceOptions(rest, "--json");
|
|
719
|
+
const status = serviceStatus(loadConfig(), serviceOptions(rest));
|
|
720
|
+
if (rest.includes("--json")) {
|
|
721
|
+
console.log(JSON.stringify(status, null, 2));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
process.stdout.write(formatServiceStatus(status));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (subcommand === "install" || subcommand === "uninstall") {
|
|
728
|
+
const rest = args.slice(1);
|
|
729
|
+
assertKnownServiceOptions(rest, "--dry-run", "--json", "--yes");
|
|
730
|
+
const options = serviceOptions(rest);
|
|
731
|
+
if (rest.includes("--dry-run") || !rest.includes("--yes")) {
|
|
732
|
+
if (!rest.includes("--dry-run")) {
|
|
733
|
+
throw new Error(`service ${subcommand} requires --yes or --dry-run`);
|
|
734
|
+
}
|
|
735
|
+
const plan = servicePlan(loadConfig(), subcommand, { ...options, dryRun: true });
|
|
736
|
+
if (rest.includes("--json")) {
|
|
737
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
process.stdout.write(formatServicePlan(plan));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const report = applyServiceInstallAction(subcommand, options);
|
|
744
|
+
if (rest.includes("--json")) {
|
|
745
|
+
console.log(JSON.stringify(report, null, 2));
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
process.stdout.write(formatServiceActionReport(report));
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (subcommand === "start" || subcommand === "stop") {
|
|
752
|
+
const rest = args.slice(1);
|
|
753
|
+
assertKnownServiceOptions(rest, "--dry-run", "--json");
|
|
754
|
+
const options = serviceOptions(rest);
|
|
755
|
+
if (rest.includes("--dry-run")) {
|
|
756
|
+
const plan = servicePlan(loadConfig(), subcommand, { ...options, dryRun: true });
|
|
757
|
+
if (rest.includes("--json")) {
|
|
758
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
process.stdout.write(formatServicePlan(plan));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const report = applyServiceControlAction(subcommand, options);
|
|
765
|
+
if (rest.includes("--json")) {
|
|
766
|
+
console.log(JSON.stringify(report, null, 2));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
process.stdout.write(formatServiceActionReport(report));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (subcommand === "logs") {
|
|
773
|
+
const rest = args.slice(1);
|
|
774
|
+
assertKnownServiceOptions(rest, "--json");
|
|
775
|
+
const report = serviceLogs(loadConfig(), {
|
|
776
|
+
...serviceOptions(rest),
|
|
777
|
+
lines: readOptionalIntegerOption(rest, "--lines", "service logs --lines"),
|
|
778
|
+
});
|
|
779
|
+
if (rest.includes("--json")) {
|
|
780
|
+
console.log(JSON.stringify(report, null, 2));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
process.stdout.write(formatServiceLogs(report));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
throw new Error(`Unknown service command: ${subcommand}`);
|
|
787
|
+
}
|
|
788
|
+
function serviceOptions(args) {
|
|
789
|
+
return {
|
|
790
|
+
platform: parseServicePlatform(readOption(args, "--platform")),
|
|
791
|
+
outputDir: readOption(args, "--output-dir"),
|
|
792
|
+
serviceName: readOption(args, "--service-name"),
|
|
793
|
+
nodePath: readOption(args, "--node"),
|
|
794
|
+
cliPath: readOption(args, "--cli"),
|
|
795
|
+
configDirectory: readOption(args, "--config-dir"),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
function assertKnownServiceOptions(args, ...extraFlags) {
|
|
799
|
+
const valueOptions = new Set([
|
|
800
|
+
"--platform",
|
|
801
|
+
"--service-name",
|
|
802
|
+
"--node",
|
|
803
|
+
"--cli",
|
|
804
|
+
"--config-dir",
|
|
805
|
+
"--format",
|
|
806
|
+
"--output-dir",
|
|
807
|
+
"--lines",
|
|
808
|
+
]);
|
|
809
|
+
const flagOptions = new Set(extraFlags);
|
|
810
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
811
|
+
const arg = args[index];
|
|
812
|
+
if (!arg.startsWith("--"))
|
|
813
|
+
continue;
|
|
814
|
+
if (valueOptions.has(arg)) {
|
|
815
|
+
index += 1;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (flagOptions.has(arg))
|
|
819
|
+
continue;
|
|
820
|
+
throw new Error(`Unknown service option: ${arg}`);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function applyServiceInstallAction(action, options) {
|
|
824
|
+
const config = loadConfig();
|
|
825
|
+
const status = serviceStatus(config, options);
|
|
826
|
+
assertServiceExecutionPlatform(status.platform, action);
|
|
827
|
+
const outputDir = defaultServiceOutputDir(options);
|
|
828
|
+
const files = writeServiceProfileFiles(config, { ...options, outputDir });
|
|
829
|
+
const scriptPath = action === "install" ? files.files.install : files.files.uninstall;
|
|
830
|
+
const command = serviceScriptCommand(status.platform, scriptPath);
|
|
831
|
+
const stdout = execFileSync(command.command, command.args, {
|
|
832
|
+
encoding: "utf8",
|
|
833
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
834
|
+
});
|
|
835
|
+
return {
|
|
836
|
+
kind: "computer-linker-service-action",
|
|
837
|
+
schemaVersion: 1,
|
|
838
|
+
action,
|
|
839
|
+
platform: status.platform,
|
|
840
|
+
serviceName: status.serviceName,
|
|
841
|
+
label: status.label,
|
|
842
|
+
command: command.display,
|
|
843
|
+
outputDir,
|
|
844
|
+
files: files.files,
|
|
845
|
+
stdout,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function applyServiceControlAction(action, options) {
|
|
849
|
+
const status = serviceStatus(loadConfig(), options);
|
|
850
|
+
assertServiceExecutionPlatform(status.platform, action);
|
|
851
|
+
const command = serviceControlCommand(status.platform, action, status.serviceName, status.label);
|
|
852
|
+
const stdout = execFileSync(command.command, command.args, {
|
|
853
|
+
encoding: "utf8",
|
|
854
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
855
|
+
});
|
|
856
|
+
return {
|
|
857
|
+
kind: "computer-linker-service-action",
|
|
858
|
+
schemaVersion: 1,
|
|
859
|
+
action,
|
|
860
|
+
platform: status.platform,
|
|
861
|
+
serviceName: status.serviceName,
|
|
862
|
+
label: status.label,
|
|
863
|
+
command: command.display,
|
|
864
|
+
stdout,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
function formatServiceActionReport(report) {
|
|
868
|
+
return [
|
|
869
|
+
`Computer Linker service ${report.action} completed (${report.platform})`,
|
|
870
|
+
`serviceName: ${report.serviceName}`,
|
|
871
|
+
`command: ${report.command}`,
|
|
872
|
+
report.outputDir ? `profileDir: ${report.outputDir}` : undefined,
|
|
873
|
+
report.stdout.trim() ? "output:" : undefined,
|
|
874
|
+
report.stdout.trim() || undefined,
|
|
875
|
+
].filter(Boolean).join("\n") + "\n";
|
|
876
|
+
}
|
|
877
|
+
function serviceScriptCommand(platform, scriptPath) {
|
|
878
|
+
if (platform === "windows") {
|
|
879
|
+
const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath];
|
|
880
|
+
return { command: "powershell.exe", args, display: commandDisplay("powershell.exe", args) };
|
|
881
|
+
}
|
|
882
|
+
const args = [scriptPath];
|
|
883
|
+
return { command: "sh", args, display: commandDisplay("sh", args) };
|
|
884
|
+
}
|
|
885
|
+
function serviceControlCommand(platform, action, serviceName, label) {
|
|
886
|
+
if (platform === "windows") {
|
|
887
|
+
const args = [action, serviceName];
|
|
888
|
+
return { command: "sc.exe", args, display: commandDisplay("sc.exe", args) };
|
|
889
|
+
}
|
|
890
|
+
if (platform === "macos") {
|
|
891
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "$(id -u)";
|
|
892
|
+
const args = action === "start"
|
|
893
|
+
? ["kickstart", "-k", `gui/${uid}/${label}`]
|
|
894
|
+
: ["bootout", `gui/${uid}/${label}`];
|
|
895
|
+
return { command: "launchctl", args, display: commandDisplay("launchctl", args) };
|
|
896
|
+
}
|
|
897
|
+
const args = [action, serviceName];
|
|
898
|
+
return { command: "systemctl", args, display: commandDisplay("systemctl", args) };
|
|
899
|
+
}
|
|
900
|
+
function assertServiceExecutionPlatform(platform, action) {
|
|
901
|
+
const current = currentCliServicePlatform();
|
|
902
|
+
if (platform === current)
|
|
903
|
+
return;
|
|
904
|
+
throw new Error(`service ${action} --platform ${platform} cannot execute on ${current}; use --dry-run for cross-platform plans`);
|
|
905
|
+
}
|
|
906
|
+
function currentCliServicePlatform() {
|
|
907
|
+
if (process.platform === "darwin")
|
|
908
|
+
return "macos";
|
|
909
|
+
if (process.platform === "win32")
|
|
910
|
+
return "windows";
|
|
911
|
+
return "linux";
|
|
912
|
+
}
|
|
913
|
+
function commandDisplay(command, args) {
|
|
914
|
+
return [command, ...args].map((value) => /[\s"]/g.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value).join(" ");
|
|
915
|
+
}
|
|
916
|
+
function workspace(args) {
|
|
917
|
+
const [subcommand, ...rest] = args;
|
|
918
|
+
if (subcommand === "help") {
|
|
919
|
+
printWorkspaceHelpTopic(rest);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
923
|
+
printWorkspaceHelp();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (hasHelpFlag(rest)) {
|
|
927
|
+
printWorkspaceHelpTopic([subcommand]);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (!subcommand || subcommand === "list") {
|
|
931
|
+
const config = loadConfig();
|
|
932
|
+
for (const entry of config.workspaces) {
|
|
933
|
+
console.log(`${entry.id}\t${entry.path}\t${formatPermissions(entry.permissions)}`);
|
|
934
|
+
}
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (subcommand === "add") {
|
|
938
|
+
addWorkspace(rest);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (subcommand === "remove") {
|
|
942
|
+
removeWorkspace(rest);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (subcommand === "update") {
|
|
946
|
+
updateWorkspace(rest);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
throw new Error(`Unknown workspace command: ${subcommand}`);
|
|
950
|
+
}
|
|
951
|
+
async function processCommand(args) {
|
|
952
|
+
const [subcommand = "list", ...rest] = args;
|
|
953
|
+
if (subcommand === "list") {
|
|
954
|
+
const options = parseProcessListOptions(rest);
|
|
955
|
+
const data = await localWorkspaceOperation(options.workspace, "process_list");
|
|
956
|
+
printProcessResult("list", data, options.json);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (subcommand === "read") {
|
|
960
|
+
const options = parseProcessTargetOptions(rest, "read");
|
|
961
|
+
const data = await localWorkspaceOperation(options.workspace, "process_read", options.processId);
|
|
962
|
+
printProcessResult("read", data, options.json);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (subcommand === "stop") {
|
|
966
|
+
const options = parseProcessTargetOptions(rest, "stop");
|
|
967
|
+
const data = await localWorkspaceOperation(options.workspace, "process_stop", options.processId, options.signal ? { signal: options.signal } : {});
|
|
968
|
+
printProcessResult("stop", data, options.json);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
throw new Error("Usage: computer-linker process <list|read|stop> <workspace-id> [process-id] [--signal SIGTERM|SIGINT|SIGKILL] [--json]");
|
|
972
|
+
}
|
|
973
|
+
function parseProcessListOptions(args) {
|
|
974
|
+
const positional = processCommandPositionals(args, new Set(["--json"]));
|
|
975
|
+
if (positional.length !== 1) {
|
|
976
|
+
throw new Error("Usage: computer-linker process list <workspace-id> [--json]");
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
workspace: positional[0],
|
|
980
|
+
json: args.includes("--json"),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function parseProcessTargetOptions(args, command) {
|
|
984
|
+
const flagOptions = new Set(command === "stop" ? ["--json", "--signal"] : ["--json"]);
|
|
985
|
+
const positional = processCommandPositionals(args, flagOptions);
|
|
986
|
+
if (positional.length !== 2) {
|
|
987
|
+
throw new Error(`Usage: computer-linker process ${command} <workspace-id> <process-id>${command === "stop" ? " [--signal SIGTERM|SIGINT|SIGKILL]" : ""} [--json]`);
|
|
988
|
+
}
|
|
989
|
+
const signal = command === "stop" ? readOptionalStringOption(args, "--signal", "process stop --signal") : undefined;
|
|
990
|
+
if (signal && signal !== "SIGTERM" && signal !== "SIGINT" && signal !== "SIGKILL") {
|
|
991
|
+
throw new Error("process stop --signal must be one of: SIGTERM, SIGINT, SIGKILL");
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
workspace: positional[0],
|
|
995
|
+
processId: positional[1],
|
|
996
|
+
signal,
|
|
997
|
+
json: args.includes("--json"),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
function processCommandPositionals(args, flagOptions) {
|
|
1001
|
+
const positional = [];
|
|
1002
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1003
|
+
const arg = args[index];
|
|
1004
|
+
if (!arg.startsWith("--")) {
|
|
1005
|
+
positional.push(arg);
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (!flagOptions.has(arg))
|
|
1009
|
+
throw new Error(`Unknown process option: ${arg}`);
|
|
1010
|
+
if (arg === "--signal") {
|
|
1011
|
+
index += 1;
|
|
1012
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
1013
|
+
throw new Error("process stop --signal requires a value");
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return positional;
|
|
1017
|
+
}
|
|
1018
|
+
async function localWorkspaceOperation(workspace, op, target, input = {}) {
|
|
1019
|
+
return postLocalControl({
|
|
1020
|
+
action: "operation",
|
|
1021
|
+
workspace,
|
|
1022
|
+
op,
|
|
1023
|
+
target,
|
|
1024
|
+
input,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
async function postLocalControl(body) {
|
|
1028
|
+
const config = loadConfig();
|
|
1029
|
+
const host = config.host ?? "127.0.0.1";
|
|
1030
|
+
const port = config.port ?? 3939;
|
|
1031
|
+
const url = `http://${host}:${port}/api/v1/control`;
|
|
1032
|
+
const headers = {
|
|
1033
|
+
"content-type": "application/json",
|
|
1034
|
+
};
|
|
1035
|
+
if (config.ownerToken) {
|
|
1036
|
+
headers.authorization = `Bearer ${config.ownerToken}`;
|
|
1037
|
+
}
|
|
1038
|
+
let response;
|
|
1039
|
+
try {
|
|
1040
|
+
response = await fetch(url, {
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
headers,
|
|
1043
|
+
body: JSON.stringify(body),
|
|
1044
|
+
signal: AbortSignal.timeout(5000),
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
throw new Error(`Local Computer Linker HTTP server is not reachable at ${url}. Start it with \`${invocationCommand("start")}\`. ${error instanceof Error ? error.message : String(error)}`);
|
|
1049
|
+
}
|
|
1050
|
+
const text = await response.text();
|
|
1051
|
+
const payload = parseApiPayload(text);
|
|
1052
|
+
if (!response.ok || payload?.ok === false) {
|
|
1053
|
+
throw new Error(payload?.error ?? `Local API request failed with HTTP ${response.status}`);
|
|
1054
|
+
}
|
|
1055
|
+
return payload?.data;
|
|
1056
|
+
}
|
|
1057
|
+
function parseApiPayload(text) {
|
|
1058
|
+
try {
|
|
1059
|
+
const value = JSON.parse(text);
|
|
1060
|
+
return value && typeof value === "object" ? value : undefined;
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
return undefined;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function printProcessResult(action, data, json) {
|
|
1067
|
+
if (json) {
|
|
1068
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (action === "list") {
|
|
1072
|
+
const processes = processListFromData(data);
|
|
1073
|
+
console.log("Computer Linker managed processes");
|
|
1074
|
+
if (processes.length === 0) {
|
|
1075
|
+
console.log("none");
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
for (const item of processes) {
|
|
1079
|
+
console.log(`${item.processId}\t${item.kind}\t${item.status}\tworkspace=${item.workspaceId}\tpid=${item.pid ?? "n/a"}\t${item.commandPreview}`);
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const item = processFromData(data);
|
|
1084
|
+
console.log(`processId: ${item.processId}`);
|
|
1085
|
+
console.log(`kind: ${item.kind}`);
|
|
1086
|
+
console.log(`status: ${item.status}`);
|
|
1087
|
+
console.log(`workspace: ${item.workspaceId}`);
|
|
1088
|
+
console.log(`pid: ${item.pid ?? "n/a"}`);
|
|
1089
|
+
console.log(`command: ${item.commandPreview}`);
|
|
1090
|
+
console.log(`startedAt: ${item.startedAt}`);
|
|
1091
|
+
if (item.endedAt)
|
|
1092
|
+
console.log(`endedAt: ${item.endedAt}`);
|
|
1093
|
+
console.log(`exitCode: ${item.exitCode ?? "null"}`);
|
|
1094
|
+
if (item.signal)
|
|
1095
|
+
console.log(`signal: ${item.signal}`);
|
|
1096
|
+
if (item.stdout) {
|
|
1097
|
+
console.log("stdout:");
|
|
1098
|
+
process.stdout.write(item.stdout.endsWith("\n") ? item.stdout : `${item.stdout}\n`);
|
|
1099
|
+
}
|
|
1100
|
+
if (item.stderr) {
|
|
1101
|
+
console.log("stderr:");
|
|
1102
|
+
process.stdout.write(item.stderr.endsWith("\n") ? item.stderr : `${item.stderr}\n`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
function processListFromData(data) {
|
|
1106
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.processes))
|
|
1107
|
+
return [];
|
|
1108
|
+
return data.processes.map(processSummaryFromUnknown);
|
|
1109
|
+
}
|
|
1110
|
+
function processFromData(data) {
|
|
1111
|
+
if (!data || typeof data !== "object")
|
|
1112
|
+
throw new Error("Local API did not return a process payload");
|
|
1113
|
+
return processSummaryFromUnknown(data.process);
|
|
1114
|
+
}
|
|
1115
|
+
function processSummaryFromUnknown(value) {
|
|
1116
|
+
if (!value || typeof value !== "object")
|
|
1117
|
+
throw new Error("Local API returned an invalid process payload");
|
|
1118
|
+
const item = value;
|
|
1119
|
+
return {
|
|
1120
|
+
processId: String(item.processId ?? ""),
|
|
1121
|
+
kind: String(item.kind ?? ""),
|
|
1122
|
+
workspaceId: String(item.workspaceId ?? ""),
|
|
1123
|
+
commandPreview: String(item.commandPreview ?? ""),
|
|
1124
|
+
pid: typeof item.pid === "number" ? item.pid : undefined,
|
|
1125
|
+
startedAt: String(item.startedAt ?? ""),
|
|
1126
|
+
endedAt: typeof item.endedAt === "string" ? item.endedAt : undefined,
|
|
1127
|
+
status: String(item.status ?? ""),
|
|
1128
|
+
exitCode: typeof item.exitCode === "number" ? item.exitCode : null,
|
|
1129
|
+
signal: typeof item.signal === "string" ? item.signal : undefined,
|
|
1130
|
+
stdout: typeof item.stdout === "string" ? item.stdout : "",
|
|
1131
|
+
stderr: typeof item.stderr === "string" ? item.stderr : "",
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
function screen(args) {
|
|
1135
|
+
const [subcommand = "status", ...rest] = args;
|
|
1136
|
+
if (subcommand !== "status" && subcommand !== "diagnose") {
|
|
1137
|
+
throw new Error("Usage: computer-linker screen status [--json]");
|
|
1138
|
+
}
|
|
1139
|
+
const unknown = rest.filter((arg) => arg !== "--json");
|
|
1140
|
+
if (unknown.length > 0) {
|
|
1141
|
+
throw new Error(`Unknown screen status option: ${unknown[0]}`);
|
|
1142
|
+
}
|
|
1143
|
+
const config = loadConfig();
|
|
1144
|
+
const capability = screenshotCapability();
|
|
1145
|
+
const screenWorkspaces = config.workspaces
|
|
1146
|
+
.filter((workspace) => Boolean(workspace.permissions.screen))
|
|
1147
|
+
.map((workspace) => ({
|
|
1148
|
+
id: workspace.id,
|
|
1149
|
+
name: workspace.name,
|
|
1150
|
+
path: workspace.path,
|
|
1151
|
+
}));
|
|
1152
|
+
const nextActions = screenNextActions(capability.supported, capability.permission.status, screenWorkspaces.length);
|
|
1153
|
+
const report = {
|
|
1154
|
+
kind: "computer-linker-screen-status",
|
|
1155
|
+
schemaVersion: 1,
|
|
1156
|
+
provider: capability.provider,
|
|
1157
|
+
supported: capability.supported,
|
|
1158
|
+
permission: capability.permission,
|
|
1159
|
+
modes: capability.modes,
|
|
1160
|
+
displays: capability.displays,
|
|
1161
|
+
windows: capability.windows,
|
|
1162
|
+
screenEnabledWorkspaces: screenWorkspaces,
|
|
1163
|
+
nextActions,
|
|
1164
|
+
};
|
|
1165
|
+
if (args.includes("--json")) {
|
|
1166
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
console.log("Computer Linker screen status");
|
|
1170
|
+
console.log(`provider: ${report.provider}`);
|
|
1171
|
+
console.log(`supported: ${report.supported ? "yes" : "no"}`);
|
|
1172
|
+
console.log(`permission: ${report.permission.status}${report.permission.detail ? ` - ${report.permission.detail}` : ""}`);
|
|
1173
|
+
console.log(`modes: ${report.modes.join(", ") || "none"}`);
|
|
1174
|
+
console.log(`displays: ${report.displays.length}`);
|
|
1175
|
+
console.log(`windows: ${report.windows.length}`);
|
|
1176
|
+
console.log("screen-enabled workspaces:");
|
|
1177
|
+
if (screenWorkspaces.length === 0) {
|
|
1178
|
+
console.log(" none");
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
for (const workspace of screenWorkspaces) {
|
|
1182
|
+
console.log(` ${workspace.id} (${workspace.name}) -> ${workspace.path}`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
console.log("next actions:");
|
|
1186
|
+
for (const action of nextActions) {
|
|
1187
|
+
console.log(` - ${action}`);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function screenNextActions(supported, permissionStatus, screenWorkspaceCount) {
|
|
1191
|
+
const actions = [];
|
|
1192
|
+
if (!supported) {
|
|
1193
|
+
actions.push("This platform does not currently have a screenshot capture provider; screen MCP operations will report unsupported.");
|
|
1194
|
+
}
|
|
1195
|
+
else if (permissionStatus === "os_permission_required") {
|
|
1196
|
+
actions.push("Grant OS screen-recording permission before using screen capture operations.");
|
|
1197
|
+
}
|
|
1198
|
+
else if (permissionStatus === "unknown") {
|
|
1199
|
+
actions.push("Run a trusted screen capture once if the OS needs to prompt for screen-recording permission.");
|
|
1200
|
+
}
|
|
1201
|
+
if (screenWorkspaceCount === 0) {
|
|
1202
|
+
actions.push("Enable screen only for scopes that need it: computer-linker workspace update <id> --screen");
|
|
1203
|
+
}
|
|
1204
|
+
if (actions.length === 0) {
|
|
1205
|
+
actions.push("Screen diagnostics are ready; use MCP screen_list before any screen_capture operation.");
|
|
1206
|
+
}
|
|
1207
|
+
return actions;
|
|
1208
|
+
}
|
|
1209
|
+
function doctor(args = []) {
|
|
1210
|
+
if (hasHelpFlag(args)) {
|
|
1211
|
+
printDoctorHelp();
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const unknown = args.filter((arg) => arg !== "--json" && arg !== "--fix" && arg !== "--dry-run");
|
|
1215
|
+
if (unknown.length > 0) {
|
|
1216
|
+
throw new Error(`Unknown doctor option: ${unknown[0]}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (args.includes("--dry-run") && !args.includes("--fix")) {
|
|
1219
|
+
throw new Error("doctor --dry-run requires --fix");
|
|
1220
|
+
}
|
|
1221
|
+
if (args.includes("--fix")) {
|
|
1222
|
+
const repair = repairConfig({ dryRun: args.includes("--dry-run") });
|
|
1223
|
+
if (args.includes("--json")) {
|
|
1224
|
+
console.log(JSON.stringify(repair, null, 2));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
console.log(args.includes("--dry-run") ? "Computer Linker doctor fix dry run" : "Computer Linker doctor fix");
|
|
1228
|
+
console.log(`configPath: ${repair.configPath}`);
|
|
1229
|
+
console.log(`dryRun: ${repair.dryRun ? "yes" : "no"}`);
|
|
1230
|
+
console.log(`changed: ${repair.changed ? "yes" : "no"}${repair.dryRun && repair.changed ? " (not written)" : ""}`);
|
|
1231
|
+
for (const item of repair.repairs) {
|
|
1232
|
+
console.log(`${item.status}: ${item.id}${item.workspaceId ? ` ${item.workspaceId}` : ""} - ${item.detail}`);
|
|
1233
|
+
}
|
|
1234
|
+
if (repair.dryRun && repair.changed)
|
|
1235
|
+
console.log("Run `computer-linker doctor --fix` to apply these repairs.");
|
|
1236
|
+
else if (repair.changed)
|
|
1237
|
+
console.log("Run `computer-linker doctor` again to review remaining warnings.");
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const report = getLocalPortDoctor();
|
|
1241
|
+
if (args.includes("--json")) {
|
|
1242
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
console.log(`Computer Linker doctor for ${report.machineName}`);
|
|
1246
|
+
console.log(`machineId: ${report.machineId ?? "not set"}`);
|
|
1247
|
+
console.log(`runtime: platform=${report.machine.platform} arch=${report.machine.arch} node=${report.machine.nodeVersion} shell=${report.machine.shell ?? "unknown"}`);
|
|
1248
|
+
console.log(`localMcpUrl: ${report.runtime.localMcpUrl}`);
|
|
1249
|
+
console.log(`localApiUrl: ${report.runtime.localApiUrl}`);
|
|
1250
|
+
console.log(`readyForTunnel: ${report.readyForTunnel ? "yes" : "no"}`);
|
|
1251
|
+
console.log(`auth: ${report.auth.mode} ownerToken=${report.auth.ownerTokenConfigured ? "configured" : "missing"}`);
|
|
1252
|
+
console.log(`publicBaseUrl: ${report.exposure.publicBaseUrl ?? "not configured"}`);
|
|
1253
|
+
console.log(`publicMcpUrl: ${report.exposure.publicMcpUrl ?? "not configured"}`);
|
|
1254
|
+
console.log(`workspaces: total=${report.workspaces.total} write=${report.workspaces.writable} shell=${report.workspaces.shellEnabled} codex=${report.workspaces.codexEnabled}`);
|
|
1255
|
+
console.log(`config: critical=${report.configDiagnostics.criticalCount} warning=${report.configDiagnostics.warningCount}`);
|
|
1256
|
+
console.log(`security: critical=${report.security.criticalCount} warning=${report.security.warningCount}`);
|
|
1257
|
+
console.log(`releaseReadiness: status=${report.releaseReadiness.status} ready=${report.releaseReadiness.ready ? "yes" : "no"} gate="${report.releaseReadiness.recommendedGate}"`);
|
|
1258
|
+
console.log(`service: platform=${report.service.platform} name=${report.service.serviceName} manifest=${report.service.manifestExists === null ? "service-manager" : report.service.manifestExists ? "present" : "missing"}`);
|
|
1259
|
+
console.log(`serviceCommand: ${report.service.command}`);
|
|
1260
|
+
console.log(`startup: ready=${report.startup.ready ? "yes" : "no"} recommended=${report.startup.recommendedMode}`);
|
|
1261
|
+
console.log(`toolReadiness: ready=${report.toolReadiness.ready ? "yes" : "no"} requiredMissing=${report.toolReadiness.requiredMissing.join(",") || "none"} recommendedMissing=${report.toolReadiness.recommendedMissing.join(",") || "none"}`);
|
|
1262
|
+
console.log("tunnel tools:");
|
|
1263
|
+
for (const tool of report.tunnels.tools) {
|
|
1264
|
+
console.log(` ${tool.name}: ${tool.available ? "available" : "missing"}${tool.version ? ` (${tool.version})` : ""}`);
|
|
1265
|
+
}
|
|
1266
|
+
console.log("local tools:");
|
|
1267
|
+
for (const tool of report.localTools) {
|
|
1268
|
+
console.log(` ${tool.name}: ${tool.available ? "available" : "missing"}${tool.version ? ` (${tool.version})` : ""}`);
|
|
1269
|
+
}
|
|
1270
|
+
if (report.toolReadiness.installHints.length > 0) {
|
|
1271
|
+
console.log("tool install hints:");
|
|
1272
|
+
for (const hint of report.toolReadiness.installHints) {
|
|
1273
|
+
const install = hint.install?.[report.machine.platform === "darwin" ? "macos" : report.machine.platform === "win32" ? "windows" : "linux"]
|
|
1274
|
+
?? hint.install?.docs
|
|
1275
|
+
?? "see the tool documentation";
|
|
1276
|
+
console.log(` ${hint.name}: ${install}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
console.log("start commands:");
|
|
1280
|
+
console.log(` local http: ${report.runtime.startCommands.start}`);
|
|
1281
|
+
if (report.runtime.startCommands.serveHttp !== report.runtime.startCommands.start) {
|
|
1282
|
+
console.log(` http: ${report.runtime.startCommands.serveHttp}`);
|
|
1283
|
+
}
|
|
1284
|
+
console.log(` stdio: ${report.runtime.startCommands.serveStdio}`);
|
|
1285
|
+
console.log("service commands:");
|
|
1286
|
+
console.log(` profile: ${report.service.profileCommand}`);
|
|
1287
|
+
console.log(` bundle: ${report.service.profileBundleCommand}`);
|
|
1288
|
+
console.log(` install dry-run: ${report.service.installDryRunCommand}`);
|
|
1289
|
+
console.log(` uninstall dry-run: ${report.service.uninstallDryRunCommand}`);
|
|
1290
|
+
for (const command of report.service.statusCommands) {
|
|
1291
|
+
console.log(` status: ${command}`);
|
|
1292
|
+
}
|
|
1293
|
+
console.log("suggested tunnel commands:");
|
|
1294
|
+
for (const command of report.tunnels.commands) {
|
|
1295
|
+
console.log(` ${command.display}`);
|
|
1296
|
+
}
|
|
1297
|
+
if (report.exposure.blockingReasons.length > 0) {
|
|
1298
|
+
console.log("blocking reasons:");
|
|
1299
|
+
for (const reason of report.exposure.blockingReasons) {
|
|
1300
|
+
console.log(` - ${reason}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (report.exposure.warnings.length > 0) {
|
|
1304
|
+
console.log("warnings:");
|
|
1305
|
+
for (const warning of report.exposure.warnings) {
|
|
1306
|
+
console.log(` - ${warning}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
console.log("security findings:");
|
|
1310
|
+
for (const finding of report.security.findings) {
|
|
1311
|
+
const workspace = finding.workspaceId ? ` workspace=${finding.workspaceId}` : "";
|
|
1312
|
+
console.log(` [${finding.severity}] ${finding.id}${workspace}: ${finding.title}`);
|
|
1313
|
+
}
|
|
1314
|
+
console.log("config findings:");
|
|
1315
|
+
for (const finding of report.configDiagnostics.findings) {
|
|
1316
|
+
const workspace = finding.workspaceId ? ` workspace=${finding.workspaceId}` : "";
|
|
1317
|
+
console.log(` [${finding.severity}] ${finding.id}${workspace}: ${finding.title}`);
|
|
1318
|
+
}
|
|
1319
|
+
if (report.releaseReadiness.blockingReasons.length > 0) {
|
|
1320
|
+
console.log("release blocking reasons:");
|
|
1321
|
+
for (const reason of report.releaseReadiness.blockingReasons) {
|
|
1322
|
+
console.log(` - ${reason}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (report.releaseReadiness.warnings.length > 0) {
|
|
1326
|
+
console.log("release warnings:");
|
|
1327
|
+
for (const warning of report.releaseReadiness.warnings) {
|
|
1328
|
+
console.log(` - ${warning}`);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
console.log("next actions:");
|
|
1332
|
+
for (const action of report.nextActions) {
|
|
1333
|
+
console.log(` - ${action}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function repairConfig(options = {}) {
|
|
1337
|
+
const config = loadConfig();
|
|
1338
|
+
const dryRun = Boolean(options.dryRun);
|
|
1339
|
+
const applyStatus = dryRun ? "planned" : "applied";
|
|
1340
|
+
const repairs = [];
|
|
1341
|
+
let changed = false;
|
|
1342
|
+
let workspaces = [...config.workspaces];
|
|
1343
|
+
const bootstrapWorkspaces = workspaces.filter(isBootstrapDefaultWorkspace);
|
|
1344
|
+
if (bootstrapWorkspaces.length > 0 && workspaces.length > bootstrapWorkspaces.length) {
|
|
1345
|
+
workspaces = workspaces.filter((workspace) => !isBootstrapDefaultWorkspace(workspace));
|
|
1346
|
+
changed = true;
|
|
1347
|
+
for (const workspace of bootstrapWorkspaces) {
|
|
1348
|
+
repairs.push({
|
|
1349
|
+
id: "remove-bootstrap-current-workspace",
|
|
1350
|
+
status: applyStatus,
|
|
1351
|
+
workspaceId: workspace.id,
|
|
1352
|
+
detail: "Removed the default current-directory scope after explicit workspaces were configured.",
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
else if (bootstrapWorkspaces.length > 0) {
|
|
1357
|
+
repairs.push({
|
|
1358
|
+
id: "remove-bootstrap-current-workspace",
|
|
1359
|
+
status: "skipped",
|
|
1360
|
+
workspaceId: bootstrapWorkspaces[0].id,
|
|
1361
|
+
detail: "Skipped because it is the only configured workspace. Add an explicit folder with `computer-linker start <folder>` first.",
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
const duplicateRepair = removeExactDuplicateWorkspaces(workspaces);
|
|
1365
|
+
workspaces = duplicateRepair.workspaces;
|
|
1366
|
+
if (duplicateRepair.repairs.length > 0) {
|
|
1367
|
+
changed = true;
|
|
1368
|
+
repairs.push(...duplicateRepair.repairs.map((repair) => ({
|
|
1369
|
+
...repair,
|
|
1370
|
+
status: applyStatus,
|
|
1371
|
+
})));
|
|
1372
|
+
}
|
|
1373
|
+
workspaces = workspaces.map((workspace) => {
|
|
1374
|
+
if (!workspace.permissions.shell && !workspace.permissions.codex)
|
|
1375
|
+
return workspace;
|
|
1376
|
+
const repairedPolicy = repairedExecutionPolicy(workspace.policy, workspace.permissions);
|
|
1377
|
+
if (!policyChanged(workspace.policy, repairedPolicy))
|
|
1378
|
+
return workspace;
|
|
1379
|
+
changed = true;
|
|
1380
|
+
repairs.push({
|
|
1381
|
+
id: workspace.policy ? "complete-execution-policy" : "add-default-execution-policy",
|
|
1382
|
+
status: applyStatus,
|
|
1383
|
+
workspaceId: workspace.id,
|
|
1384
|
+
detail: workspace.policy
|
|
1385
|
+
? "Filled missing execution policy defaults for an execution-enabled scope."
|
|
1386
|
+
: "Added default command allowlist, denylist, runtime cap, and output cap for an execution-enabled scope.",
|
|
1387
|
+
});
|
|
1388
|
+
return {
|
|
1389
|
+
...workspace,
|
|
1390
|
+
policy: repairedPolicy,
|
|
1391
|
+
};
|
|
1392
|
+
});
|
|
1393
|
+
if (repairs.length === 0) {
|
|
1394
|
+
repairs.push({
|
|
1395
|
+
id: "config-repair-not-needed",
|
|
1396
|
+
status: "skipped",
|
|
1397
|
+
detail: "No automatic config repairs were needed.",
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
const writtenPath = changed && !dryRun
|
|
1401
|
+
? writeConfig({ ...config, workspaces })
|
|
1402
|
+
: configPath();
|
|
1403
|
+
return {
|
|
1404
|
+
kind: "computer-linker-config-repair",
|
|
1405
|
+
schemaVersion: 1,
|
|
1406
|
+
configPath: writtenPath,
|
|
1407
|
+
dryRun,
|
|
1408
|
+
changed,
|
|
1409
|
+
repairs,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
function history(args = []) {
|
|
1413
|
+
if (hasHelpFlag(args)) {
|
|
1414
|
+
printHistoryHelp();
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
assertKnownHistoryOptions(args);
|
|
1418
|
+
const view = readHistoryViewOption(args);
|
|
1419
|
+
const limit = readOptionalIntegerOption(args, "--limit", "history --limit");
|
|
1420
|
+
const workspaceId = readOptionalStringOption(args, "--workspace", "history --workspace");
|
|
1421
|
+
const query = readOptionalStringOption(args, "--query", "history --query");
|
|
1422
|
+
const output = readOptionalStringOption(args, "--output", "history --output");
|
|
1423
|
+
const insight = historyInsight({
|
|
1424
|
+
view,
|
|
1425
|
+
limit,
|
|
1426
|
+
workspaceId,
|
|
1427
|
+
query,
|
|
1428
|
+
});
|
|
1429
|
+
if (output) {
|
|
1430
|
+
writeJsonFile(resolve(expandHomePath(output)), insight);
|
|
1431
|
+
}
|
|
1432
|
+
if (args.includes("--json")) {
|
|
1433
|
+
console.log(JSON.stringify(insight, null, 2));
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
console.log(`Computer Linker history (${insight.view})`);
|
|
1437
|
+
console.log(`generatedAt: ${insight.generatedAt}`);
|
|
1438
|
+
console.log(`events: total=${insight.summary.totalEvents} success=${insight.summary.successfulEvents} failed=${insight.summary.failedEvents}`);
|
|
1439
|
+
if (insight.summary.lastWorkspaceOperation) {
|
|
1440
|
+
console.log(`lastWorkspaceOperation: ${insight.summary.lastWorkspaceOperation.operation ?? "unknown"} target=${insight.summary.lastWorkspaceOperation.target ?? insight.summary.lastWorkspaceOperation.path ?? "unknown"}`);
|
|
1441
|
+
}
|
|
1442
|
+
if (insight.failedReplay?.length) {
|
|
1443
|
+
console.log(`failedReplay: ${insight.failedReplay.length}`);
|
|
1444
|
+
}
|
|
1445
|
+
if (insight.sessions?.length) {
|
|
1446
|
+
console.log(`sessions: ${insight.sessions.length}`);
|
|
1447
|
+
}
|
|
1448
|
+
if (insight.connections?.length) {
|
|
1449
|
+
console.log(`connections: ${insight.connections.length}`);
|
|
1450
|
+
}
|
|
1451
|
+
if (output) {
|
|
1452
|
+
console.log(`written: ${resolve(expandHomePath(output))}`);
|
|
1453
|
+
}
|
|
1454
|
+
console.log("next actions:");
|
|
1455
|
+
for (const action of insight.last?.suggestedNextActions ?? ["Use --json for the full redacted history insight payload."]) {
|
|
1456
|
+
console.log(` - ${action}`);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
function assertKnownHistoryOptions(args) {
|
|
1460
|
+
const valueOptions = new Set(["--view", "--workspace", "--query", "--limit", "--output"]);
|
|
1461
|
+
const flagOptions = new Set(["--json"]);
|
|
1462
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1463
|
+
const arg = args[index];
|
|
1464
|
+
if (!arg.startsWith("--"))
|
|
1465
|
+
throw new Error(`Unknown history argument: ${arg}`);
|
|
1466
|
+
if (valueOptions.has(arg)) {
|
|
1467
|
+
index += 1;
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
if (flagOptions.has(arg))
|
|
1471
|
+
continue;
|
|
1472
|
+
throw new Error(`Unknown history option: ${arg}`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
function readHistoryViewOption(args) {
|
|
1476
|
+
const value = readOption(args, "--view");
|
|
1477
|
+
if (args.includes("--view") && (!value || value.startsWith("--"))) {
|
|
1478
|
+
throw new Error("history --view must be one of: summary, last, timeline, sessions, connections, failed_replay, debug_bundle");
|
|
1479
|
+
}
|
|
1480
|
+
const view = historyInsightView(value);
|
|
1481
|
+
if (value && view !== value) {
|
|
1482
|
+
throw new Error("history --view must be one of: summary, last, timeline, sessions, connections, failed_replay, debug_bundle");
|
|
1483
|
+
}
|
|
1484
|
+
return view;
|
|
1485
|
+
}
|
|
1486
|
+
function profile(args) {
|
|
1487
|
+
if (hasHelpFlag(args)) {
|
|
1488
|
+
printProfileHelp();
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (args.includes("--chatgpt")) {
|
|
1492
|
+
throw new Error("profile --chatgpt was removed; use `computer-linker client chatgpt profile` only when ChatGPT asks for connector-specific fields.");
|
|
1493
|
+
}
|
|
1494
|
+
if (args.includes("--mode")) {
|
|
1495
|
+
throw new Error("profile --mode is only supported by `computer-linker client chatgpt profile --mode ...`.");
|
|
1496
|
+
}
|
|
1497
|
+
if (args.includes("--url")) {
|
|
1498
|
+
throw new Error("profile --url is only supported by `computer-linker client chatgpt profile --url ...`.");
|
|
1499
|
+
}
|
|
1500
|
+
const includeSecrets = args.includes("--show-token");
|
|
1501
|
+
const unknown = args.filter((arg) => arg !== "--show-token");
|
|
1502
|
+
if (unknown.length > 0) {
|
|
1503
|
+
throw new Error(`Unknown profile option: ${unknown[0]}`);
|
|
1504
|
+
}
|
|
1505
|
+
console.log(JSON.stringify(connectionProfile(loadConfig(), includeSecrets), null, 2));
|
|
1506
|
+
}
|
|
1507
|
+
async function client(args) {
|
|
1508
|
+
const [clientName, ...rest] = args;
|
|
1509
|
+
if (clientName === "help") {
|
|
1510
|
+
printClientHelpTopic(rest);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
if (clientName === "--help" || clientName === "-h") {
|
|
1514
|
+
printClientHelp();
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (hasHelpFlag(rest)) {
|
|
1518
|
+
printClientHelpTopic([clientName]);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (clientName === "setup") {
|
|
1522
|
+
clientSetup(rest);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (clientName === "smoke") {
|
|
1526
|
+
await clientSmoke(rest);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (clientName === "diagnose") {
|
|
1530
|
+
await diagnoseClient(rest);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (clientName !== "chatgpt") {
|
|
1534
|
+
throw new Error("Usage: computer-linker client <setup|smoke|diagnose|chatgpt>");
|
|
1535
|
+
}
|
|
1536
|
+
await chatGptClient(rest, "client chatgpt");
|
|
1537
|
+
}
|
|
1538
|
+
function clientSetup(args) {
|
|
1539
|
+
const unknown = args.filter((arg) => arg !== "--json" && arg !== "--show-token" && arg !== "--details");
|
|
1540
|
+
if (unknown.length > 0) {
|
|
1541
|
+
throw new Error(`Unknown client setup option: ${unknown[0]}`);
|
|
1542
|
+
}
|
|
1543
|
+
const report = getMcpClientSetup({
|
|
1544
|
+
tunnels: listTunnelProcesses(),
|
|
1545
|
+
includeSecrets: args.includes("--show-token"),
|
|
1546
|
+
});
|
|
1547
|
+
if (args.includes("--json")) {
|
|
1548
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
process.stdout.write(args.includes("--details") ? formatDetailedMcpClientSetup(report) : formatMcpClientSetup(report));
|
|
1552
|
+
}
|
|
1553
|
+
function formatMcpClientSetup(report) {
|
|
1554
|
+
const lines = [
|
|
1555
|
+
"Computer Linker MCP client setup",
|
|
1556
|
+
`ready: ${clientSetupReadySummary(report)}`,
|
|
1557
|
+
`connect: ${clientSetupConnectionSummary(report)}`,
|
|
1558
|
+
`auth: ${clientSetupAuthSummary(report)}`,
|
|
1559
|
+
`tools: ${report.tools?.length ?? 0} stable MCP tools`,
|
|
1560
|
+
];
|
|
1561
|
+
if (clientSetupShouldShowBearerHeader(report) && report.auth?.bearerHeader) {
|
|
1562
|
+
lines.push(`bearer header: ${report.auth.bearerHeader}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (report.remoteBlockingReasons?.length) {
|
|
1565
|
+
lines.push("blocked by:");
|
|
1566
|
+
for (const reason of report.remoteBlockingReasons.slice(0, 3))
|
|
1567
|
+
lines.push(` - ${reason}`);
|
|
1568
|
+
appendRemainingCount(lines, report.remoteBlockingReasons.length, 3, "blocker", invocationCommand("client", "setup", "--details"));
|
|
1569
|
+
}
|
|
1570
|
+
else if (report.warnings?.length) {
|
|
1571
|
+
lines.push(`attention: ${report.warnings.length} warning${report.warnings.length === 1 ? "" : "s"}; run \`${invocationCommand("client", "setup", "--details")}\``);
|
|
1572
|
+
}
|
|
1573
|
+
if (report.connection?.tunnel?.provider === "openai" && report.connection.tunnel.tunnelId) {
|
|
1574
|
+
lines.push(`tunnel id: ${report.connection.tunnel.tunnelId}`);
|
|
1575
|
+
}
|
|
1576
|
+
if (report.nextActions?.length) {
|
|
1577
|
+
lines.push("next:");
|
|
1578
|
+
for (const action of report.nextActions.slice(0, 3))
|
|
1579
|
+
lines.push(` - ${action}`);
|
|
1580
|
+
appendRemainingCount(lines, report.nextActions.length, 3, "action", invocationCommand("client", "setup", "--details"));
|
|
1581
|
+
}
|
|
1582
|
+
lines.push(`details: ${invocationCommand("client", "setup", "--details")}`);
|
|
1583
|
+
return `${lines.join("\n")}\n`;
|
|
1584
|
+
}
|
|
1585
|
+
function formatDetailedMcpClientSetup(report) {
|
|
1586
|
+
const publicMcpUrlText = report.connection?.tunnel?.provider === "openai"
|
|
1587
|
+
? "(not used in OpenAI tunnel mode)"
|
|
1588
|
+
: report.connection?.publicMcpUrl ?? "(not configured)";
|
|
1589
|
+
const lines = [
|
|
1590
|
+
"Computer Linker MCP client setup",
|
|
1591
|
+
`machine: ${report.machineName ?? "unknown"}`,
|
|
1592
|
+
`localReady: ${report.localReady ? "yes" : "no"}`,
|
|
1593
|
+
`remoteReady: ${report.remoteReady ? "yes" : "no"}`,
|
|
1594
|
+
`localMcpUrl: ${report.connection?.localMcpUrl ?? "(unknown)"}`,
|
|
1595
|
+
`publicMcpUrl: ${publicMcpUrlText}`,
|
|
1596
|
+
`auth: ${report.auth?.mode ?? "unknown"}`,
|
|
1597
|
+
];
|
|
1598
|
+
if (report.connection?.tunnel?.provider === "openai") {
|
|
1599
|
+
lines.push("tunnel: OpenAI Secure MCP Tunnel active");
|
|
1600
|
+
lines.push(`tunnelId: ${report.connection.tunnel.tunnelId ?? "(unknown)"}`);
|
|
1601
|
+
lines.push(`tunnelMcpTarget: ${report.connection.tunnel.localMcpTarget ?? report.connection.localMcpUrl ?? "(unknown)"}`);
|
|
1602
|
+
}
|
|
1603
|
+
if (report.auth?.bearerHeader) {
|
|
1604
|
+
lines.push(`bearerHeader: ${report.auth.bearerHeader}`);
|
|
1605
|
+
}
|
|
1606
|
+
if (report.auth?.localBearerHeader && report.auth.localBearerHeader !== report.auth.bearerHeader) {
|
|
1607
|
+
lines.push(`localBearerHeader: ${report.auth.localBearerHeader}`);
|
|
1608
|
+
}
|
|
1609
|
+
if (report.auth?.notes?.length) {
|
|
1610
|
+
lines.push("auth notes:");
|
|
1611
|
+
for (const note of report.auth.notes)
|
|
1612
|
+
lines.push(` - ${note}`);
|
|
1613
|
+
}
|
|
1614
|
+
if (report.connection?.stdio?.command) {
|
|
1615
|
+
lines.push(`stdio: ${[report.connection.stdio.command, ...(report.connection.stdio.args ?? [])].join(" ")}`);
|
|
1616
|
+
}
|
|
1617
|
+
if (report.tools?.length) {
|
|
1618
|
+
lines.push(`tools: ${report.tools.join(", ")}`);
|
|
1619
|
+
}
|
|
1620
|
+
if (report.firstPrompt) {
|
|
1621
|
+
lines.push("first prompt:");
|
|
1622
|
+
lines.push(` ${report.firstPrompt}`);
|
|
1623
|
+
}
|
|
1624
|
+
if (report.agentInstructions?.length) {
|
|
1625
|
+
lines.push("agent instructions:");
|
|
1626
|
+
for (const instruction of report.agentInstructions)
|
|
1627
|
+
lines.push(` ${instruction}`);
|
|
1628
|
+
}
|
|
1629
|
+
if (report.remoteBlockingReasons?.length) {
|
|
1630
|
+
lines.push("remote blockers:");
|
|
1631
|
+
for (const reason of report.remoteBlockingReasons)
|
|
1632
|
+
lines.push(` - ${reason}`);
|
|
1633
|
+
}
|
|
1634
|
+
if (report.warnings?.length) {
|
|
1635
|
+
lines.push("warnings:");
|
|
1636
|
+
for (const warning of report.warnings)
|
|
1637
|
+
lines.push(` - ${warning}`);
|
|
1638
|
+
}
|
|
1639
|
+
if (report.nextActions?.length) {
|
|
1640
|
+
lines.push("next actions:");
|
|
1641
|
+
for (const action of report.nextActions)
|
|
1642
|
+
lines.push(` - ${action}`);
|
|
1643
|
+
}
|
|
1644
|
+
return `${lines.join("\n")}\n`;
|
|
1645
|
+
}
|
|
1646
|
+
function clientSetupReadySummary(report) {
|
|
1647
|
+
if (report.remoteReady)
|
|
1648
|
+
return "yes (remote)";
|
|
1649
|
+
if (report.localReady)
|
|
1650
|
+
return "yes (local only)";
|
|
1651
|
+
return "no";
|
|
1652
|
+
}
|
|
1653
|
+
function clientSetupConnectionSummary(report) {
|
|
1654
|
+
const tunnel = report.connection?.tunnel;
|
|
1655
|
+
if (tunnel?.provider === "openai") {
|
|
1656
|
+
return `OpenAI Tunnel mode${tunnel.tunnelId ? ` (${tunnel.tunnelId})` : ""}`;
|
|
1657
|
+
}
|
|
1658
|
+
if (report.connection?.publicMcpUrl)
|
|
1659
|
+
return report.connection.publicMcpUrl;
|
|
1660
|
+
if (report.connection?.localMcpUrl)
|
|
1661
|
+
return `local only at ${report.connection.localMcpUrl}`;
|
|
1662
|
+
return "not configured";
|
|
1663
|
+
}
|
|
1664
|
+
function clientSetupAuthSummary(report) {
|
|
1665
|
+
if (report.auth?.mode === "openai-secure-tunnel") {
|
|
1666
|
+
return "handled by tunnel-client; do not paste a bearer token into ChatGPT Tunnel mode";
|
|
1667
|
+
}
|
|
1668
|
+
if (report.auth?.bearerHeader) {
|
|
1669
|
+
return report.auth.bearerHeader.includes("<ownerToken>") ? "bearer token configured" : "bearer token shown below";
|
|
1670
|
+
}
|
|
1671
|
+
return report.auth?.mode ?? "unknown";
|
|
1672
|
+
}
|
|
1673
|
+
function clientSetupShouldShowBearerHeader(report) {
|
|
1674
|
+
return Boolean(report.auth?.bearerHeader && !report.auth.bearerHeader.includes("<ownerToken>"));
|
|
1675
|
+
}
|
|
1676
|
+
async function clientSmoke(args) {
|
|
1677
|
+
const unknown = args.filter((arg, index) => (arg !== "--json" &&
|
|
1678
|
+
arg !== "--show-token" &&
|
|
1679
|
+
arg !== "--allow-http" &&
|
|
1680
|
+
arg !== "--url" &&
|
|
1681
|
+
arg !== "--token" &&
|
|
1682
|
+
arg !== "--timeout-ms" &&
|
|
1683
|
+
args[index - 1] !== "--url" &&
|
|
1684
|
+
args[index - 1] !== "--token" &&
|
|
1685
|
+
args[index - 1] !== "--timeout-ms"));
|
|
1686
|
+
if (unknown.length > 0) {
|
|
1687
|
+
throw new Error(`Unknown client smoke option: ${unknown[0]}`);
|
|
1688
|
+
}
|
|
1689
|
+
const timeoutMs = readOptionalIntegerOption(args, "--timeout-ms", "client smoke --timeout-ms");
|
|
1690
|
+
const report = await runWorkspaceLinkerMcpClientSmoke(loadConfig(), {
|
|
1691
|
+
url: readOption(args, "--url"),
|
|
1692
|
+
token: readOption(args, "--token"),
|
|
1693
|
+
includeSecret: args.includes("--show-token"),
|
|
1694
|
+
allowHttp: args.includes("--allow-http"),
|
|
1695
|
+
timeoutMs,
|
|
1696
|
+
});
|
|
1697
|
+
if (args.includes("--json")) {
|
|
1698
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
process.stdout.write(formatWorkspaceLinkerClientSmoke(report));
|
|
1702
|
+
}
|
|
1703
|
+
async function diagnose(args) {
|
|
1704
|
+
const [target = "client", ...rest] = args;
|
|
1705
|
+
if (target === "--help" || target === "-h" || target === "help") {
|
|
1706
|
+
printDiagnoseHelp();
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
if (target !== "client") {
|
|
1710
|
+
throw new Error("Usage: computer-linker diagnose client [--local|--remote|--url https://.../mcp] [--json]");
|
|
1711
|
+
}
|
|
1712
|
+
if (hasHelpFlag(rest)) {
|
|
1713
|
+
printDiagnoseHelp();
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
await diagnoseClient(rest);
|
|
1717
|
+
}
|
|
1718
|
+
async function diagnoseClient(args) {
|
|
1719
|
+
const unknown = args.filter((arg, index) => (arg !== "--json" &&
|
|
1720
|
+
arg !== "--show-token" &&
|
|
1721
|
+
arg !== "--allow-http" &&
|
|
1722
|
+
arg !== "--local" &&
|
|
1723
|
+
arg !== "--remote" &&
|
|
1724
|
+
arg !== "--url" &&
|
|
1725
|
+
arg !== "--token" &&
|
|
1726
|
+
arg !== "--timeout-ms" &&
|
|
1727
|
+
args[index - 1] !== "--url" &&
|
|
1728
|
+
args[index - 1] !== "--token" &&
|
|
1729
|
+
args[index - 1] !== "--timeout-ms"));
|
|
1730
|
+
if (unknown.length > 0) {
|
|
1731
|
+
throw new Error(`Unknown client diagnose option: ${unknown[0]}`);
|
|
1732
|
+
}
|
|
1733
|
+
const explicitTargets = [args.includes("--local"), args.includes("--remote"), args.includes("--url")].filter(Boolean).length;
|
|
1734
|
+
if (explicitTargets > 1) {
|
|
1735
|
+
throw new Error("client diagnose accepts only one target: --local, --remote, or --url");
|
|
1736
|
+
}
|
|
1737
|
+
const config = loadConfig();
|
|
1738
|
+
const urlOption = readOption(args, "--url");
|
|
1739
|
+
const target = urlOption ? "url" : args.includes("--remote") ? "remote" : "local";
|
|
1740
|
+
const localUrl = `http://${config.host ?? "127.0.0.1"}:${config.port ?? 3939}/mcp`;
|
|
1741
|
+
const smokeUrl = target === "local" ? localUrl : target === "url" ? urlOption : undefined;
|
|
1742
|
+
const timeoutMs = readOptionalIntegerOption(args, "--timeout-ms", "client diagnose --timeout-ms");
|
|
1743
|
+
const setup = getMcpClientSetup({
|
|
1744
|
+
tunnels: listTunnelProcesses(),
|
|
1745
|
+
includeSecrets: args.includes("--show-token"),
|
|
1746
|
+
});
|
|
1747
|
+
const smoke = await runWorkspaceLinkerMcpClientSmoke(config, {
|
|
1748
|
+
url: smokeUrl,
|
|
1749
|
+
token: readOption(args, "--token"),
|
|
1750
|
+
includeSecret: args.includes("--show-token"),
|
|
1751
|
+
allowHttp: target === "local" || args.includes("--allow-http"),
|
|
1752
|
+
timeoutMs,
|
|
1753
|
+
clientName: "computer-linker-client-diagnose",
|
|
1754
|
+
});
|
|
1755
|
+
const historyConnections = historyInsight({ view: "connections", limit: 20 });
|
|
1756
|
+
const historyLast = historyInsight({ view: "last", limit: 20 });
|
|
1757
|
+
const report = buildClientDiagnosisReport({
|
|
1758
|
+
target,
|
|
1759
|
+
url: smokeUrl ?? setup.connection?.publicMcpUrl ?? null,
|
|
1760
|
+
setup,
|
|
1761
|
+
smoke,
|
|
1762
|
+
connections: historyConnections,
|
|
1763
|
+
last: historyLast,
|
|
1764
|
+
});
|
|
1765
|
+
if (args.includes("--json")) {
|
|
1766
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
process.stdout.write(formatClientDiagnosis(report));
|
|
1770
|
+
}
|
|
1771
|
+
function buildClientDiagnosisReport(input) {
|
|
1772
|
+
const blockingReasons = [...input.smoke.blockingReasons];
|
|
1773
|
+
const warnings = [...input.smoke.warnings, ...(input.setup.warnings ?? [])];
|
|
1774
|
+
if (input.target === "remote" && !input.setup.remoteReady) {
|
|
1775
|
+
blockingReasons.push(...(input.setup.remoteBlockingReasons ?? ["Remote MCP client setup is not ready."]));
|
|
1776
|
+
}
|
|
1777
|
+
const hasConnectionHistory = (input.connections.connections?.length ?? 0) > 0;
|
|
1778
|
+
const nextActions = new Set(input.smoke.nextActions);
|
|
1779
|
+
if (input.target === "remote" && !input.setup.remoteReady) {
|
|
1780
|
+
for (const action of input.setup.nextActions ?? [])
|
|
1781
|
+
nextActions.add(action);
|
|
1782
|
+
}
|
|
1783
|
+
if (!hasConnectionHistory) {
|
|
1784
|
+
nextActions.add("After an external MCP client connects, run `computer-linker history --view connections` to verify incoming traffic.");
|
|
1785
|
+
}
|
|
1786
|
+
if (input.smoke.ready && (input.target !== "remote" || input.setup.remoteReady)) {
|
|
1787
|
+
nextActions.add("Use `computer-linker client setup --details` for the agent prompt and stable tool contract.");
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
kind: "computer-linker-client-diagnosis",
|
|
1791
|
+
schemaVersion: 1,
|
|
1792
|
+
target: input.target,
|
|
1793
|
+
url: input.url,
|
|
1794
|
+
generatedAt: new Date().toISOString(),
|
|
1795
|
+
setup: input.setup,
|
|
1796
|
+
smoke: input.smoke,
|
|
1797
|
+
history: {
|
|
1798
|
+
connections: input.connections,
|
|
1799
|
+
last: input.last,
|
|
1800
|
+
},
|
|
1801
|
+
diagnosis: {
|
|
1802
|
+
ready: blockingReasons.length === 0,
|
|
1803
|
+
blockingReasons,
|
|
1804
|
+
warnings,
|
|
1805
|
+
nextActions: [...nextActions],
|
|
1806
|
+
},
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
function formatClientDiagnosis(report) {
|
|
1810
|
+
const lines = [
|
|
1811
|
+
"Computer Linker client diagnosis",
|
|
1812
|
+
`target: ${report.target}${report.url ? ` ${report.url}` : ""}`,
|
|
1813
|
+
`ready: ${report.diagnosis.ready ? "yes" : "no"}`,
|
|
1814
|
+
`setup: local=${report.setup.localReady ? "ready" : "not-ready"} remote=${report.setup.remoteReady ? "ready" : "not-ready"}`,
|
|
1815
|
+
`smoke: ${report.smoke.ready ? "pass" : "fail"} (${report.smoke.checks.filter((check) => check.status === "pass").length}/${report.smoke.checks.length} checks passed)`,
|
|
1816
|
+
`traffic: ${(report.history.connections.connections?.length ?? 0)} recent connection group${(report.history.connections.connections?.length ?? 0) === 1 ? "" : "s"}`,
|
|
1817
|
+
];
|
|
1818
|
+
if (report.diagnosis.blockingReasons.length > 0) {
|
|
1819
|
+
lines.push("blocked by:");
|
|
1820
|
+
for (const reason of report.diagnosis.blockingReasons.slice(0, 5))
|
|
1821
|
+
lines.push(` - ${reason}`);
|
|
1822
|
+
appendRemainingCount(lines, report.diagnosis.blockingReasons.length, 5, "blocker", invocationCommand("diagnose", "client", "--json"));
|
|
1823
|
+
}
|
|
1824
|
+
if (report.diagnosis.warnings.length > 0) {
|
|
1825
|
+
lines.push("warnings:");
|
|
1826
|
+
for (const warning of report.diagnosis.warnings.slice(0, 5))
|
|
1827
|
+
lines.push(` - ${warning}`);
|
|
1828
|
+
appendRemainingCount(lines, report.diagnosis.warnings.length, 5, "warning", invocationCommand("diagnose", "client", "--json"));
|
|
1829
|
+
}
|
|
1830
|
+
lines.push("next:");
|
|
1831
|
+
for (const action of report.diagnosis.nextActions.slice(0, 5))
|
|
1832
|
+
lines.push(` - ${action}`);
|
|
1833
|
+
appendRemainingCount(lines, report.diagnosis.nextActions.length, 5, "action", invocationCommand("diagnose", "client", "--json"));
|
|
1834
|
+
return `${lines.join("\n")}\n`;
|
|
1835
|
+
}
|
|
1836
|
+
async function chatGptClient(args, commandPrefix) {
|
|
1837
|
+
const [subcommand] = args;
|
|
1838
|
+
if (subcommand === "url") {
|
|
1839
|
+
const rest = args.slice(1);
|
|
1840
|
+
const unknown = rest.filter((arg) => arg !== "--json" && arg !== "--show-token");
|
|
1841
|
+
if (unknown.length > 0) {
|
|
1842
|
+
throw new Error(`Unknown ${commandPrefix} url option: ${unknown[0]}`);
|
|
1843
|
+
}
|
|
1844
|
+
const report = chatGptUrl(loadConfig(), rest.includes("--show-token"), {
|
|
1845
|
+
tunnels: listTunnelProcesses(),
|
|
1846
|
+
});
|
|
1847
|
+
if (rest.includes("--json")) {
|
|
1848
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
process.stdout.write(formatChatGptUrl(report));
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (subcommand === "smoke") {
|
|
1855
|
+
const rest = args.slice(1);
|
|
1856
|
+
const unknown = rest.filter((arg, index) => (arg !== "--json" &&
|
|
1857
|
+
arg !== "--show-token" &&
|
|
1858
|
+
arg !== "--allow-http" &&
|
|
1859
|
+
arg !== "--url" &&
|
|
1860
|
+
arg !== "--token" &&
|
|
1861
|
+
arg !== "--timeout-ms" &&
|
|
1862
|
+
rest[index - 1] !== "--url" &&
|
|
1863
|
+
rest[index - 1] !== "--token" &&
|
|
1864
|
+
rest[index - 1] !== "--timeout-ms"));
|
|
1865
|
+
if (unknown.length > 0) {
|
|
1866
|
+
throw new Error(`Unknown ${commandPrefix} smoke option: ${unknown[0]}`);
|
|
1867
|
+
}
|
|
1868
|
+
const timeoutMs = readOptionalIntegerOption(rest, "--timeout-ms", `${commandPrefix} smoke --timeout-ms`);
|
|
1869
|
+
const report = await chatGptSmoke(loadConfig(), {
|
|
1870
|
+
url: readOption(rest, "--url"),
|
|
1871
|
+
token: readOption(rest, "--token"),
|
|
1872
|
+
includeSecret: rest.includes("--show-token"),
|
|
1873
|
+
allowHttp: rest.includes("--allow-http"),
|
|
1874
|
+
timeoutMs,
|
|
1875
|
+
});
|
|
1876
|
+
if (rest.includes("--json")) {
|
|
1877
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
process.stdout.write(formatChatGptSmoke(report));
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (!subcommand || subcommand === "verify") {
|
|
1884
|
+
const rest = subcommand ? args.slice(1) : args;
|
|
1885
|
+
const unknown = rest.filter((arg, index) => (arg !== "--json" &&
|
|
1886
|
+
arg !== "--mode" &&
|
|
1887
|
+
rest[index - 1] !== "--mode"));
|
|
1888
|
+
if (unknown.length > 0) {
|
|
1889
|
+
throw new Error(`Unknown ${commandPrefix} verify option: ${unknown[0]}`);
|
|
1890
|
+
}
|
|
1891
|
+
const modeValue = readOption(rest, "--mode");
|
|
1892
|
+
if (rest.includes("--mode") && (!modeValue || modeValue.startsWith("--"))) {
|
|
1893
|
+
throw new Error(`${commandPrefix} verify --mode must be one of: safe, coding, full`);
|
|
1894
|
+
}
|
|
1895
|
+
const report = chatGptVerify(loadConfig(), parseChatGptVerifyMode(modeValue));
|
|
1896
|
+
if (rest.includes("--json")) {
|
|
1897
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
process.stdout.write(formatChatGptVerify(report));
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (subcommand === "profile" || subcommand === "manifest" || subcommand === "connector" || subcommand === "files") {
|
|
1904
|
+
const rest = args.slice(1);
|
|
1905
|
+
const output = chatGptProfileCommand(subcommand === "files" && !rest.includes("--output-dir") ? ["--output-dir", ...rest] : rest, `${commandPrefix} ${subcommand}`, subcommand);
|
|
1906
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
throw new Error(`Unknown ${commandPrefix} command: ${subcommand}`);
|
|
1910
|
+
}
|
|
1911
|
+
function chatGptProfileCommand(args, commandPrefix, forcedFormat) {
|
|
1912
|
+
const format = readOption(args, "--format") ?? "profile";
|
|
1913
|
+
const hasOutputDir = args.includes("--output-dir");
|
|
1914
|
+
const outputDir = readOption(args, "--output-dir");
|
|
1915
|
+
const unknown = args.filter((arg, index) => (arg !== "--show-token" &&
|
|
1916
|
+
arg !== "--format" &&
|
|
1917
|
+
arg !== "--output-dir" &&
|
|
1918
|
+
arg !== "--mode" &&
|
|
1919
|
+
arg !== "--url" &&
|
|
1920
|
+
args[index - 1] !== "--format" &&
|
|
1921
|
+
args[index - 1] !== "--output-dir" &&
|
|
1922
|
+
args[index - 1] !== "--url" &&
|
|
1923
|
+
args[index - 1] !== "--mode"));
|
|
1924
|
+
if (unknown.length > 0) {
|
|
1925
|
+
throw new Error(`Unknown ${commandPrefix} option: ${unknown[0]}`);
|
|
1926
|
+
}
|
|
1927
|
+
if (forcedFormat && args.includes("--format")) {
|
|
1928
|
+
throw new Error(`${commandPrefix} does not accept --format; use client chatgpt profile, manifest, connector, or files`);
|
|
1929
|
+
}
|
|
1930
|
+
const selectedFormat = forcedFormat ?? format;
|
|
1931
|
+
if (selectedFormat !== "profile" && selectedFormat !== "manifest" && selectedFormat !== "connector" && selectedFormat !== "files") {
|
|
1932
|
+
throw new Error(`${commandPrefix} --format must be one of: profile, manifest, connector, files`);
|
|
1933
|
+
}
|
|
1934
|
+
const config = loadConfig();
|
|
1935
|
+
const includeSecrets = args.includes("--show-token");
|
|
1936
|
+
const mode = readChatGptModeOption(args, `${commandPrefix} --mode`);
|
|
1937
|
+
const publicBaseUrl = readPublicUrlOption(args, `${commandPrefix} --url`);
|
|
1938
|
+
if (hasOutputDir || selectedFormat === "files") {
|
|
1939
|
+
return writeChatGptProfileFiles(config, includeSecrets, outputDir, mode, { publicBaseUrl }, commandPrefix);
|
|
1940
|
+
}
|
|
1941
|
+
return selectedFormat === "manifest"
|
|
1942
|
+
? chatGptAppManifest(config, mode, { publicBaseUrl })
|
|
1943
|
+
: selectedFormat === "connector"
|
|
1944
|
+
? chatGptConnectorConfig(config, includeSecrets, mode, { publicBaseUrl })
|
|
1945
|
+
: chatGptConnectProfile(config, includeSecrets, mode, { publicBaseUrl });
|
|
1946
|
+
}
|
|
1947
|
+
function writeChatGptProfileFiles(config, includeSecrets, outputDir, mode, options = {}, commandPrefix = "client chatgpt files") {
|
|
1948
|
+
if (!outputDir || outputDir.startsWith("--")) {
|
|
1949
|
+
throw new Error(`${commandPrefix} --output-dir requires a directory path`);
|
|
1950
|
+
}
|
|
1951
|
+
const directory = expandHomePath(outputDir);
|
|
1952
|
+
mkdirSync(directory, { recursive: true });
|
|
1953
|
+
const profilePath = join(directory, "chatgpt-profile.json");
|
|
1954
|
+
const manifestPath = join(directory, "chatgpt-app-manifest.json");
|
|
1955
|
+
const connectorPath = join(directory, "chatgpt-connector-config.json");
|
|
1956
|
+
const operationRegistryPath = join(directory, "operation-registry.json");
|
|
1957
|
+
const indexPath = join(directory, "chatgpt-index.json");
|
|
1958
|
+
const files = {
|
|
1959
|
+
profile: profilePath,
|
|
1960
|
+
manifest: manifestPath,
|
|
1961
|
+
connector: connectorPath,
|
|
1962
|
+
operationRegistry: operationRegistryPath,
|
|
1963
|
+
index: indexPath,
|
|
1964
|
+
};
|
|
1965
|
+
const profile = chatGptConnectProfile(config, includeSecrets, mode, options);
|
|
1966
|
+
const manifest = chatGptAppManifest(config, mode, options);
|
|
1967
|
+
const connector = chatGptConnectorConfig(config, includeSecrets, mode, options);
|
|
1968
|
+
const operations = publicComputerOperationRegistry();
|
|
1969
|
+
const operationRegistry = {
|
|
1970
|
+
kind: "operation-registry",
|
|
1971
|
+
schemaVersion: 1,
|
|
1972
|
+
contract: computerOperationContract,
|
|
1973
|
+
count: operations.length,
|
|
1974
|
+
operations,
|
|
1975
|
+
};
|
|
1976
|
+
const index = {
|
|
1977
|
+
kind: "chatgpt-config-files",
|
|
1978
|
+
schemaVersion: 1,
|
|
1979
|
+
mode,
|
|
1980
|
+
appName: manifest.appName,
|
|
1981
|
+
mcpServerUrl: connector.mcpServerUrl,
|
|
1982
|
+
files,
|
|
1983
|
+
nextSteps: [
|
|
1984
|
+
"Use chatgpt-app-manifest.json when ChatGPT asks for app metadata.",
|
|
1985
|
+
"Use chatgpt-connector-config.json when ChatGPT asks for direct connector fields.",
|
|
1986
|
+
"Use chatgpt-profile.json for the full setup profile and GPT instructions.",
|
|
1987
|
+
"Use operation-registry.json when ChatGPT needs the exact operation names, permissions, payload fields, and safety boundaries.",
|
|
1988
|
+
],
|
|
1989
|
+
};
|
|
1990
|
+
writeJsonFile(profilePath, profile);
|
|
1991
|
+
writeJsonFile(manifestPath, manifest);
|
|
1992
|
+
writeJsonFile(connectorPath, connector);
|
|
1993
|
+
writeJsonFile(operationRegistryPath, operationRegistry);
|
|
1994
|
+
writeJsonFile(indexPath, index);
|
|
1995
|
+
return {
|
|
1996
|
+
kind: "chatgpt-config-files",
|
|
1997
|
+
outputDir: directory,
|
|
1998
|
+
files,
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
function writeJsonFile(path, value) {
|
|
2002
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
2003
|
+
}
|
|
2004
|
+
function config(args) {
|
|
2005
|
+
const [subcommand, value] = args;
|
|
2006
|
+
if (subcommand === "help") {
|
|
2007
|
+
printConfigHelpTopic(args.slice(1));
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
2011
|
+
printConfigHelp();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (hasHelpFlag(args.slice(1))) {
|
|
2015
|
+
printConfigHelpTopic([subcommand]);
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (!subcommand || subcommand === "path") {
|
|
2019
|
+
console.log(configPath());
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (subcommand === "show") {
|
|
2023
|
+
const rest = args.slice(1);
|
|
2024
|
+
const unknown = rest.filter((arg) => arg !== "--show-token");
|
|
2025
|
+
if (unknown.length > 0) {
|
|
2026
|
+
throw new Error(`Unknown config show option: ${unknown[0]}`);
|
|
2027
|
+
}
|
|
2028
|
+
console.log(JSON.stringify(redactedConfig(loadConfig(), rest.includes("--show-token")), null, 2));
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (subcommand === "validate") {
|
|
2032
|
+
validateConfig(args.slice(1));
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (subcommand === "policy") {
|
|
2036
|
+
configPolicy(args.slice(1));
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
if (subcommand === "token") {
|
|
2040
|
+
configToken(args.slice(1));
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (subcommand === "set-public-url" || subcommand === "set-public-base-url") {
|
|
2044
|
+
const publicBaseUrl = requireHttpsUrl(value, "public URL");
|
|
2045
|
+
const writtenPath = writeConfig({
|
|
2046
|
+
...loadConfig(),
|
|
2047
|
+
publicBaseUrl,
|
|
2048
|
+
});
|
|
2049
|
+
console.log(`Updated publicBaseUrl in ${writtenPath}`);
|
|
2050
|
+
console.log(`Public MCP URL: ${new URL("/mcp", publicBaseUrl).href}`);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (subcommand === "clear-public-url" || subcommand === "clear-public-base-url") {
|
|
2054
|
+
const current = loadConfig();
|
|
2055
|
+
const writtenPath = writeConfig({
|
|
2056
|
+
...current,
|
|
2057
|
+
publicBaseUrl: undefined,
|
|
2058
|
+
});
|
|
2059
|
+
console.log(`Cleared publicBaseUrl in ${writtenPath}`);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
throw new Error(`Unknown config command: ${subcommand}`);
|
|
2063
|
+
}
|
|
2064
|
+
function redactedConfig(config, includeSecrets) {
|
|
2065
|
+
if (includeSecrets || !config.ownerToken)
|
|
2066
|
+
return config;
|
|
2067
|
+
return {
|
|
2068
|
+
...config,
|
|
2069
|
+
ownerToken: "<ownerToken>",
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
function configToken(args) {
|
|
2073
|
+
const action = args.find((arg) => !arg.startsWith("--")) ?? "status";
|
|
2074
|
+
const flags = args.filter((arg) => arg.startsWith("--"));
|
|
2075
|
+
const positional = args.filter((arg) => !arg.startsWith("--"));
|
|
2076
|
+
for (const flag of flags) {
|
|
2077
|
+
if (flag !== "--json" && flag !== "--show-token") {
|
|
2078
|
+
throw new Error(`Unknown config token option: ${flag}`);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if (positional.length > 1 || (action !== "status" && action !== "rotate")) {
|
|
2082
|
+
throw new Error("Usage: computer-linker config token [rotate] [--show-token] [--json]");
|
|
2083
|
+
}
|
|
2084
|
+
const includeSecret = args.includes("--show-token");
|
|
2085
|
+
const config = loadConfig();
|
|
2086
|
+
let ownerToken = config.ownerToken;
|
|
2087
|
+
let writtenPath = configPath();
|
|
2088
|
+
let rotated = false;
|
|
2089
|
+
if (action === "rotate") {
|
|
2090
|
+
ownerToken = generateOwnerToken();
|
|
2091
|
+
writtenPath = writeConfig({
|
|
2092
|
+
...config,
|
|
2093
|
+
ownerToken,
|
|
2094
|
+
});
|
|
2095
|
+
rotated = true;
|
|
2096
|
+
}
|
|
2097
|
+
const tokenConfigured = Boolean(ownerToken);
|
|
2098
|
+
const authHeader = tokenConfigured
|
|
2099
|
+
? `Authorization: Bearer ${includeSecret ? ownerToken : "<ownerToken>"}`
|
|
2100
|
+
: null;
|
|
2101
|
+
const nextActions = tokenConfigured
|
|
2102
|
+
? rotated
|
|
2103
|
+
? [
|
|
2104
|
+
"Update MCP clients with the new Authorization bearer token.",
|
|
2105
|
+
"Restart the HTTP server after token-state changes when using OAuth clients.",
|
|
2106
|
+
]
|
|
2107
|
+
: ["Run `computer-linker config token rotate --show-token` when you need to replace the owner token."]
|
|
2108
|
+
: ["Run `computer-linker config token rotate --show-token` before exposing Computer Linker through a tunnel."];
|
|
2109
|
+
const report = {
|
|
2110
|
+
kind: "computer-linker-owner-token",
|
|
2111
|
+
schemaVersion: 1,
|
|
2112
|
+
configPath: writtenPath,
|
|
2113
|
+
tokenConfigured,
|
|
2114
|
+
rotated,
|
|
2115
|
+
authHeader,
|
|
2116
|
+
ownerToken: includeSecret ? ownerToken : undefined,
|
|
2117
|
+
nextActions,
|
|
2118
|
+
};
|
|
2119
|
+
if (args.includes("--json")) {
|
|
2120
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
console.log("Computer Linker owner token");
|
|
2124
|
+
console.log(`configPath: ${report.configPath}`);
|
|
2125
|
+
console.log(`tokenConfigured: ${report.tokenConfigured ? "yes" : "no"}`);
|
|
2126
|
+
console.log(`rotated: ${report.rotated ? "yes" : "no"}`);
|
|
2127
|
+
if (report.authHeader)
|
|
2128
|
+
console.log(`authHeader: ${report.authHeader}`);
|
|
2129
|
+
if (includeSecret && ownerToken)
|
|
2130
|
+
console.log(`ownerToken: ${ownerToken}`);
|
|
2131
|
+
console.log("next actions:");
|
|
2132
|
+
for (const actionText of nextActions) {
|
|
2133
|
+
console.log(` - ${actionText}`);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
function setup(args) {
|
|
2137
|
+
const [subcommand, ...rest] = args;
|
|
2138
|
+
if (subcommand === "help") {
|
|
2139
|
+
printSetupHelpTopic(rest);
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
2143
|
+
printSetupHelp();
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (hasHelpFlag(rest)) {
|
|
2147
|
+
printSetupHelpTopic([subcommand]);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
if (subcommand === "mcp-only" || subcommand === "cloudflare-mcp") {
|
|
2151
|
+
setupMcpOnly(rest, "setup mcp-only");
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (!subcommand) {
|
|
2155
|
+
throw new Error(setupMcpOnlyUsage());
|
|
2156
|
+
}
|
|
2157
|
+
setupMcpOnly(args, "setup");
|
|
2158
|
+
}
|
|
2159
|
+
function setupMcpOnly(args, commandLabel = "setup", outputMode = "full") {
|
|
2160
|
+
const options = parseSetupMcpOnlyOptions(args, commandLabel);
|
|
2161
|
+
const config = loadConfig();
|
|
2162
|
+
const ownerToken = config.ownerToken ?? generateOwnerToken();
|
|
2163
|
+
const initiallyRemovedBootstrapWorkspaces = !config.ownerToken && options.workspacePath
|
|
2164
|
+
? config.workspaces.filter(isBootstrapDefaultWorkspace)
|
|
2165
|
+
: [];
|
|
2166
|
+
const nextConfig = {
|
|
2167
|
+
...config,
|
|
2168
|
+
ownerToken,
|
|
2169
|
+
publicBaseUrl: options.publicBaseUrl ?? config.publicBaseUrl,
|
|
2170
|
+
publicMcpOnly: true,
|
|
2171
|
+
workspaces: config.ownerToken ? [...config.workspaces] : config.workspaces.filter((workspace) => !isBootstrapDefaultWorkspace(workspace)),
|
|
2172
|
+
};
|
|
2173
|
+
let workspaceSummary;
|
|
2174
|
+
if (options.workspaceId && options.workspacePath) {
|
|
2175
|
+
const workspacePath = expandHomePath(options.workspacePath);
|
|
2176
|
+
let index = nextConfig.workspaces.findIndex((workspace) => workspace.id === options.workspaceId);
|
|
2177
|
+
if (index === -1 && !options.workspaceIdExplicit && options.reuseExistingPath) {
|
|
2178
|
+
const pathKey = normalizedWorkspacePathKey(workspacePath);
|
|
2179
|
+
if (pathKey) {
|
|
2180
|
+
index = nextConfig.workspaces.findIndex((workspace) => normalizedWorkspacePathKey(workspace.path) === pathKey);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
const existing = index === -1 ? undefined : nextConfig.workspaces[index];
|
|
2184
|
+
const permissions = {
|
|
2185
|
+
read: true,
|
|
2186
|
+
write: options.readOnly ? false : options.write ? true : existing?.permissions.write ?? false,
|
|
2187
|
+
shell: options.readOnly ? false : options.shell ? true : existing?.permissions.shell ?? false,
|
|
2188
|
+
codex: options.readOnly ? false : options.codex ? true : existing?.permissions.codex ?? false,
|
|
2189
|
+
screen: options.readOnly ? false : options.screen ? true : existing?.permissions.screen ?? false,
|
|
2190
|
+
};
|
|
2191
|
+
const workspace = {
|
|
2192
|
+
id: existing?.id ?? options.workspaceId,
|
|
2193
|
+
name: options.workspaceName ?? existing?.name ?? workspaceNameFromPath(options.workspacePath),
|
|
2194
|
+
path: workspacePath,
|
|
2195
|
+
permissions,
|
|
2196
|
+
policy: existing?.policy ?? defaultExecutionPolicyForPermissions(permissions),
|
|
2197
|
+
};
|
|
2198
|
+
if (index === -1)
|
|
2199
|
+
nextConfig.workspaces.push(workspace);
|
|
2200
|
+
else
|
|
2201
|
+
nextConfig.workspaces[index] = workspace;
|
|
2202
|
+
workspaceSummary = {
|
|
2203
|
+
id: workspace.id,
|
|
2204
|
+
name: workspace.name,
|
|
2205
|
+
path: resolve(expandHomePath(workspace.path)),
|
|
2206
|
+
created: index === -1,
|
|
2207
|
+
permissions,
|
|
2208
|
+
policy: workspace.policy,
|
|
2209
|
+
policyCreated: !existing?.policy && Boolean(workspace.policy),
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
const bootstrapCleanup = options.workspacePath
|
|
2213
|
+
? removeBootstrapDefaultWorkspacesAfterExplicitSetup(nextConfig.workspaces)
|
|
2214
|
+
: { workspaces: nextConfig.workspaces, removed: [] };
|
|
2215
|
+
nextConfig.workspaces = bootstrapCleanup.workspaces;
|
|
2216
|
+
const removedBootstrapWorkspaces = uniqueWorkspaceSummaries([
|
|
2217
|
+
...initiallyRemovedBootstrapWorkspaces,
|
|
2218
|
+
...bootstrapCleanup.removed,
|
|
2219
|
+
]);
|
|
2220
|
+
const writtenPath = writeConfig(nextConfig);
|
|
2221
|
+
const mcpUrl = options.publicBaseUrl ? new URL("/mcp", options.publicBaseUrl).href : undefined;
|
|
2222
|
+
const host = options.publicBaseUrl ? new URL(options.publicBaseUrl).host : undefined;
|
|
2223
|
+
const localTarget = `http://${nextConfig.host ?? "127.0.0.1"}:${nextConfig.port ?? 3939}`;
|
|
2224
|
+
const wafExpression = host ? `(http.host eq "${host}" and http.request.uri.path ne "/mcp")` : undefined;
|
|
2225
|
+
const startCommandParts = [...invocationCommandParts(), "start"];
|
|
2226
|
+
if (options.tunnelProvider)
|
|
2227
|
+
startCommandParts.push("--tunnel", options.tunnelProvider);
|
|
2228
|
+
if (options.tunnelProvider === "openai")
|
|
2229
|
+
startCommandParts.push("--tunnel-id", options.openaiTunnelId ?? "tunnel_...");
|
|
2230
|
+
const startCommand = formatCliCommand(startCommandParts);
|
|
2231
|
+
const result = {
|
|
2232
|
+
kind: "computer-linker-mcp-only-setup",
|
|
2233
|
+
schemaVersion: 1,
|
|
2234
|
+
configPath: writtenPath,
|
|
2235
|
+
publicBaseUrl: options.publicBaseUrl ?? null,
|
|
2236
|
+
publicMcpUrl: mcpUrl,
|
|
2237
|
+
publicMcpOnly: true,
|
|
2238
|
+
tunnel: options.tunnelProvider ?? null,
|
|
2239
|
+
openaiTunnelId: options.openaiTunnelId ?? null,
|
|
2240
|
+
localTunnelTarget: localTarget,
|
|
2241
|
+
authHeader: options.showToken ? `Authorization: Bearer ${ownerToken}` : "Authorization: Bearer <ownerToken>",
|
|
2242
|
+
ownerToken: options.showToken ? ownerToken : undefined,
|
|
2243
|
+
ownerTokenCreated: !config.ownerToken,
|
|
2244
|
+
workspace: workspaceSummary,
|
|
2245
|
+
removedBootstrapWorkspaces,
|
|
2246
|
+
cloudflare: {
|
|
2247
|
+
tunnelPublicHostname: host,
|
|
2248
|
+
tunnelService: localTarget,
|
|
2249
|
+
wafRuleName: "Optional defense-in-depth: block non-MCP paths",
|
|
2250
|
+
wafExpression,
|
|
2251
|
+
wafAction: "Block",
|
|
2252
|
+
},
|
|
2253
|
+
commands: {
|
|
2254
|
+
start: startCommand,
|
|
2255
|
+
startLocalOnly: invocationCommand("start"),
|
|
2256
|
+
showToken: invocationCommand("profile", "--show-token"),
|
|
2257
|
+
},
|
|
2258
|
+
};
|
|
2259
|
+
if (options.json) {
|
|
2260
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
if (outputMode === "startup") {
|
|
2264
|
+
printStartupSetupSummary(result);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
printSetupSummary(result);
|
|
2268
|
+
}
|
|
2269
|
+
function printStartupSetupSummary(result) {
|
|
2270
|
+
const lines = ["Computer Linker auto setup"];
|
|
2271
|
+
if (result.ownerTokenCreated)
|
|
2272
|
+
lines.push("owner token: created");
|
|
2273
|
+
if (result.ownerToken)
|
|
2274
|
+
lines.push(`owner token: ${result.ownerToken}`);
|
|
2275
|
+
if (result.workspace) {
|
|
2276
|
+
lines.push(`workspace: ${result.workspace.created ? "created" : "updated"} ${result.workspace.id} (${result.workspace.name})`);
|
|
2277
|
+
lines.push(`path: ${result.workspace.path}`);
|
|
2278
|
+
lines.push(`access: ${setupAccessSummary(result.workspace.permissions)}`);
|
|
2279
|
+
if (result.workspace.policy)
|
|
2280
|
+
lines.push(`command policy: ${result.workspace.policyCreated ? "default limits" : "configured"}`);
|
|
2281
|
+
}
|
|
2282
|
+
if (result.removedBootstrapWorkspaces.length > 0) {
|
|
2283
|
+
lines.push(`removed bootstrap workspace: ${result.removedBootstrapWorkspaces.map((workspace) => workspace.id).join(", ")}`);
|
|
2284
|
+
}
|
|
2285
|
+
console.log(lines.join("\n"));
|
|
2286
|
+
}
|
|
2287
|
+
function printSetupSummary(result) {
|
|
2288
|
+
const lines = [
|
|
2289
|
+
"Computer Linker setup",
|
|
2290
|
+
`connect: ${setupConnectionSummary(result)}`,
|
|
2291
|
+
"public access: MCP endpoint only",
|
|
2292
|
+
`auth: ${result.ownerToken ? "bearer token shown below" : "bearer token configured"}`,
|
|
2293
|
+
];
|
|
2294
|
+
if (result.ownerToken)
|
|
2295
|
+
lines.push(`auth header: ${result.authHeader}`);
|
|
2296
|
+
if (result.workspace) {
|
|
2297
|
+
lines.push(`workspace: ${result.workspace.created ? "created" : "updated"} ${result.workspace.id} (${result.workspace.name})`);
|
|
2298
|
+
lines.push(`path: ${result.workspace.path}`);
|
|
2299
|
+
lines.push(`access: ${setupAccessSummary(result.workspace.permissions)}`);
|
|
2300
|
+
if (result.workspace.policy)
|
|
2301
|
+
lines.push(`command policy: ${result.workspace.policyCreated ? "default limits" : "configured"}`);
|
|
2302
|
+
}
|
|
2303
|
+
if (result.removedBootstrapWorkspaces.length > 0) {
|
|
2304
|
+
lines.push(`removed bootstrap workspace: ${result.removedBootstrapWorkspaces.map((workspace) => workspace.id).join(", ")}`);
|
|
2305
|
+
}
|
|
2306
|
+
lines.push("next:");
|
|
2307
|
+
for (const action of setupNextActions(result))
|
|
2308
|
+
lines.push(` - ${action}`);
|
|
2309
|
+
lines.push("details: rerun the same setup command with --json for policy/WAF details");
|
|
2310
|
+
console.log(lines.join("\n"));
|
|
2311
|
+
}
|
|
2312
|
+
function setupConnectionSummary(result) {
|
|
2313
|
+
if (result.tunnel === "openai") {
|
|
2314
|
+
return `OpenAI Tunnel mode${result.openaiTunnelId ? ` (${result.openaiTunnelId})` : ""}`;
|
|
2315
|
+
}
|
|
2316
|
+
if (result.publicMcpUrl)
|
|
2317
|
+
return result.publicMcpUrl;
|
|
2318
|
+
if (result.tunnel)
|
|
2319
|
+
return `${result.tunnel} tunnel URL will be detected when start runs`;
|
|
2320
|
+
return "local only";
|
|
2321
|
+
}
|
|
2322
|
+
function setupAccessSummary(permissions) {
|
|
2323
|
+
const parts = [permissions.write ? "read/write" : "read-only"];
|
|
2324
|
+
if (permissions.shell)
|
|
2325
|
+
parts.push("commands");
|
|
2326
|
+
if (permissions.codex)
|
|
2327
|
+
parts.push("codex");
|
|
2328
|
+
if (permissions.screen)
|
|
2329
|
+
parts.push("screen");
|
|
2330
|
+
return parts.join(", ");
|
|
2331
|
+
}
|
|
2332
|
+
function setupNextActions(result) {
|
|
2333
|
+
if (result.tunnel === "openai") {
|
|
2334
|
+
return [
|
|
2335
|
+
`Start server and tunnel: ${result.commands.start}`,
|
|
2336
|
+
"In ChatGPT connector settings, choose Tunnel mode and use the tunnel id.",
|
|
2337
|
+
`Use ${invocationCommand("client", "setup")} for other MCP clients.`,
|
|
2338
|
+
];
|
|
2339
|
+
}
|
|
2340
|
+
if (result.tunnel) {
|
|
2341
|
+
return [
|
|
2342
|
+
`Start server and tunnel: ${result.commands.start}`,
|
|
2343
|
+
`Use ${invocationCommand("client", "setup")} after the tunnel is running.`,
|
|
2344
|
+
"Keep the start terminal open while the workspace is in use.",
|
|
2345
|
+
];
|
|
2346
|
+
}
|
|
2347
|
+
if (result.publicMcpUrl) {
|
|
2348
|
+
return [
|
|
2349
|
+
`Start server: ${result.commands.start}`,
|
|
2350
|
+
`Use ${invocationCommand("client", "setup")} for auth and first-prompt guidance.`,
|
|
2351
|
+
"Optional network WAF details are available in setup --json.",
|
|
2352
|
+
];
|
|
2353
|
+
}
|
|
2354
|
+
return [
|
|
2355
|
+
`Start server: ${result.commands.start}`,
|
|
2356
|
+
`Use ${invocationCommand("client", "setup")} to connect a local MCP client.`,
|
|
2357
|
+
`For remote ChatGPT access, use \`${invocationCommand("setup")} <folder> --tunnel openai --tunnel-id tunnel_...\`.`,
|
|
2358
|
+
];
|
|
2359
|
+
}
|
|
2360
|
+
function parseSetupMcpOnlyOptions(args, commandLabel = "setup") {
|
|
2361
|
+
const valueOptions = new Set(["--url", "--id", "--name", "--tunnel", "--tunnel-id"]);
|
|
2362
|
+
const flagOptions = new Set(["--dev", "--coding", "--full-trust", "--write", "--shell", "--codex", "--screen", "--read-only", "--show-token", "--json"]);
|
|
2363
|
+
const positional = [];
|
|
2364
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2365
|
+
const arg = args[index];
|
|
2366
|
+
if (!arg.startsWith("--")) {
|
|
2367
|
+
positional.push(arg);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
if (valueOptions.has(arg)) {
|
|
2371
|
+
index += 1;
|
|
2372
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
2373
|
+
throw new Error(`${commandLabel} ${arg} requires a value`);
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
if (flagOptions.has(arg))
|
|
2377
|
+
continue;
|
|
2378
|
+
throw new Error(`Unknown ${commandLabel} option: ${arg}`);
|
|
2379
|
+
}
|
|
2380
|
+
const explicitUrl = readOptionalStringOption(args, "--url", `${commandLabel} --url`);
|
|
2381
|
+
const explicitWorkspaceId = readOptionalStringOption(args, "--id", `${commandLabel} --id`);
|
|
2382
|
+
const tunnelProviderValue = readOptionalStringOption(args, "--tunnel", `${commandLabel} --tunnel`);
|
|
2383
|
+
const tunnelProvider = tunnelProviderValue ? parseTunnelProvider(tunnelProviderValue, `${commandLabel} --tunnel`) : undefined;
|
|
2384
|
+
const openaiTunnelId = readOptionalStringOption(args, "--tunnel-id", `${commandLabel} --tunnel-id`);
|
|
2385
|
+
if (openaiTunnelId && tunnelProvider !== "openai") {
|
|
2386
|
+
throw new Error(`${commandLabel} --tunnel-id is only valid with --tunnel openai`);
|
|
2387
|
+
}
|
|
2388
|
+
const firstPositionalIsUrl = typeof positional[0] === "string" && /^https?:\/\//i.test(positional[0]);
|
|
2389
|
+
const positionalUrl = explicitUrl ? undefined : firstPositionalIsUrl ? positional[0] : undefined;
|
|
2390
|
+
const positionalWorkspaceArgs = positionalUrl ? positional.slice(1) : positional;
|
|
2391
|
+
const [firstWorkspaceArg, secondWorkspaceArg, ...extra] = positionalWorkspaceArgs;
|
|
2392
|
+
if (extra.length > 0) {
|
|
2393
|
+
throw new Error(setupMcpOnlyUsage());
|
|
2394
|
+
}
|
|
2395
|
+
if (explicitUrl && typeof firstWorkspaceArg === "string" && /^https?:\/\//i.test(firstWorkspaceArg)) {
|
|
2396
|
+
throw new Error(`${commandLabel} accepts either <https-url> or --url, not both`);
|
|
2397
|
+
}
|
|
2398
|
+
if (explicitWorkspaceId && !firstWorkspaceArg) {
|
|
2399
|
+
throw new Error(`${commandLabel} --id requires a workspace path`);
|
|
2400
|
+
}
|
|
2401
|
+
if (explicitWorkspaceId && secondWorkspaceArg) {
|
|
2402
|
+
throw new Error(`${commandLabel} accepts either --id with <workspace-path> or legacy <workspace-id workspace-path>`);
|
|
2403
|
+
}
|
|
2404
|
+
const url = explicitUrl ?? positionalUrl;
|
|
2405
|
+
const workspacePath = secondWorkspaceArg ?? firstWorkspaceArg;
|
|
2406
|
+
if (!url && !workspacePath && tunnelProvider !== "tailscale" && tunnelProvider !== "openai") {
|
|
2407
|
+
throw new Error(setupMcpOnlyUsage());
|
|
2408
|
+
}
|
|
2409
|
+
const workspaceId = secondWorkspaceArg ? firstWorkspaceArg : explicitWorkspaceId ?? (workspacePath ? workspaceIdFromPath(workspacePath) : undefined);
|
|
2410
|
+
const permissionFlags = permissionPresetFlags(args, commandLabel, { defaultCoding: Boolean(workspacePath) });
|
|
2411
|
+
return {
|
|
2412
|
+
publicBaseUrl: url ? requireHttpsUrl(url, `${commandLabel} URL`, setupMcpOnlyUsage()) : undefined,
|
|
2413
|
+
tunnelProvider,
|
|
2414
|
+
openaiTunnelId,
|
|
2415
|
+
workspaceId,
|
|
2416
|
+
workspaceIdExplicit: Boolean(secondWorkspaceArg || explicitWorkspaceId),
|
|
2417
|
+
reuseExistingPath: commandLabel !== "setup mcp-only",
|
|
2418
|
+
workspacePath,
|
|
2419
|
+
workspaceName: readOptionalStringOption(args, "--name", `${commandLabel} --name`),
|
|
2420
|
+
write: permissionFlags.write,
|
|
2421
|
+
shell: permissionFlags.shell,
|
|
2422
|
+
codex: permissionFlags.codex,
|
|
2423
|
+
screen: permissionFlags.screen,
|
|
2424
|
+
readOnly: permissionFlags.readOnly,
|
|
2425
|
+
dev: permissionFlags.dev,
|
|
2426
|
+
showToken: args.includes("--show-token"),
|
|
2427
|
+
json: args.includes("--json"),
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function setupMcpOnlyUsage() {
|
|
2431
|
+
return [
|
|
2432
|
+
"Usage: computer-linker setup <workspace-path> [--read-only|--full-trust] [--tunnel cloudflare|tailscale|openai] [--tunnel-id tunnel_...] [--id workspace-id] [--name name] [--write] [--shell] [--codex] [--screen] [--show-token] [--json]",
|
|
2433
|
+
" computer-linker setup <https-url> [workspace-path] [--write] [--screen] [--show-token]",
|
|
2434
|
+
"Legacy: computer-linker setup mcp-only <https-url|workspace-path> [workspace-path] [...]",
|
|
2435
|
+
].join("\n");
|
|
2436
|
+
}
|
|
2437
|
+
function isBootstrapDefaultWorkspace(workspace) {
|
|
2438
|
+
return workspace.id === "current" &&
|
|
2439
|
+
workspace.name === "Current directory" &&
|
|
2440
|
+
resolve(expandHomePath(workspace.path)) === resolve(process.cwd()) &&
|
|
2441
|
+
workspace.permissions.read === true &&
|
|
2442
|
+
workspace.permissions.write === true &&
|
|
2443
|
+
workspace.permissions.shell === true &&
|
|
2444
|
+
workspace.permissions.codex === false &&
|
|
2445
|
+
Boolean(workspace.permissions.screen) === false &&
|
|
2446
|
+
!workspace.policy;
|
|
2447
|
+
}
|
|
2448
|
+
function removeBootstrapDefaultWorkspacesAfterExplicitSetup(workspaces) {
|
|
2449
|
+
const removed = workspaces.filter(isBootstrapDefaultWorkspace);
|
|
2450
|
+
if (removed.length === 0 || workspaces.length <= removed.length) {
|
|
2451
|
+
return { workspaces, removed: [] };
|
|
2452
|
+
}
|
|
2453
|
+
return {
|
|
2454
|
+
workspaces: workspaces.filter((workspace) => !isBootstrapDefaultWorkspace(workspace)),
|
|
2455
|
+
removed,
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
function uniqueWorkspaceSummaries(workspaces) {
|
|
2459
|
+
const seen = new Set();
|
|
2460
|
+
const summaries = [];
|
|
2461
|
+
for (const workspace of workspaces) {
|
|
2462
|
+
if (seen.has(workspace.id))
|
|
2463
|
+
continue;
|
|
2464
|
+
seen.add(workspace.id);
|
|
2465
|
+
summaries.push({
|
|
2466
|
+
id: workspace.id,
|
|
2467
|
+
name: workspace.name,
|
|
2468
|
+
path: resolve(expandHomePath(workspace.path)),
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
return summaries;
|
|
2472
|
+
}
|
|
2473
|
+
function defaultExecutionPolicyForPermissions(permissions) {
|
|
2474
|
+
if (!permissions.shell && !permissions.codex)
|
|
2475
|
+
return undefined;
|
|
2476
|
+
const allowedCommands = [
|
|
2477
|
+
"npm *",
|
|
2478
|
+
"pnpm *",
|
|
2479
|
+
"yarn *",
|
|
2480
|
+
"bun *",
|
|
2481
|
+
"node *",
|
|
2482
|
+
"npx *",
|
|
2483
|
+
"git *",
|
|
2484
|
+
];
|
|
2485
|
+
if (permissions.codex)
|
|
2486
|
+
allowedCommands.push("codex *");
|
|
2487
|
+
return {
|
|
2488
|
+
maxRuntimeSeconds: permissions.codex ? 1800 : 600,
|
|
2489
|
+
maxOutputBytes: 200000,
|
|
2490
|
+
allowedCommands,
|
|
2491
|
+
deniedCommands: ["rm -rf *", "del /s *", "rmdir /s *", "format *", "shutdown *"],
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
function repairedExecutionPolicy(policy, permissions) {
|
|
2495
|
+
const defaults = defaultExecutionPolicyForPermissions(permissions);
|
|
2496
|
+
if (!defaults)
|
|
2497
|
+
return policy;
|
|
2498
|
+
if (!policy)
|
|
2499
|
+
return defaults;
|
|
2500
|
+
return {
|
|
2501
|
+
...policy,
|
|
2502
|
+
maxRuntimeSeconds: policy.maxRuntimeSeconds ?? defaults.maxRuntimeSeconds,
|
|
2503
|
+
maxOutputBytes: policy.maxOutputBytes ?? defaults.maxOutputBytes,
|
|
2504
|
+
allowedCommands: policy.allowedCommands?.length ? policy.allowedCommands : defaults.allowedCommands,
|
|
2505
|
+
deniedCommands: mergePolicyList(policy.deniedCommands, defaults.deniedCommands ?? []),
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
function policyChanged(before, after) {
|
|
2509
|
+
return JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
|
|
2510
|
+
}
|
|
2511
|
+
function removeExactDuplicateWorkspaces(workspaces) {
|
|
2512
|
+
const kept = [];
|
|
2513
|
+
const firstByPath = new Map();
|
|
2514
|
+
const repairs = [];
|
|
2515
|
+
for (const workspace of workspaces) {
|
|
2516
|
+
const pathKey = normalizedWorkspacePathKey(workspace.path);
|
|
2517
|
+
const first = pathKey ? firstByPath.get(pathKey) : undefined;
|
|
2518
|
+
if (first && workspaceEquivalentForDuplicateCleanup(first, workspace)) {
|
|
2519
|
+
repairs.push({
|
|
2520
|
+
id: "remove-exact-duplicate-workspace",
|
|
2521
|
+
status: "applied",
|
|
2522
|
+
workspaceId: workspace.id,
|
|
2523
|
+
detail: `Removed duplicate scope ${workspace.id}; it points at the same folder with the same permissions and policy as ${first.id}.`,
|
|
2524
|
+
});
|
|
2525
|
+
continue;
|
|
2526
|
+
}
|
|
2527
|
+
kept.push(workspace);
|
|
2528
|
+
if (pathKey && !firstByPath.has(pathKey))
|
|
2529
|
+
firstByPath.set(pathKey, workspace);
|
|
2530
|
+
}
|
|
2531
|
+
return { workspaces: kept, repairs };
|
|
2532
|
+
}
|
|
2533
|
+
function workspaceEquivalentForDuplicateCleanup(a, b) {
|
|
2534
|
+
return normalizedWorkspacePathKey(a.path) === normalizedWorkspacePathKey(b.path) &&
|
|
2535
|
+
JSON.stringify(normalizedPermissionShape(a.permissions)) === JSON.stringify(normalizedPermissionShape(b.permissions)) &&
|
|
2536
|
+
JSON.stringify(a.policy ?? null) === JSON.stringify(b.policy ?? null);
|
|
2537
|
+
}
|
|
2538
|
+
function normalizedPermissionShape(permissions) {
|
|
2539
|
+
return {
|
|
2540
|
+
read: Boolean(permissions.read),
|
|
2541
|
+
write: Boolean(permissions.write),
|
|
2542
|
+
shell: Boolean(permissions.shell),
|
|
2543
|
+
codex: Boolean(permissions.codex),
|
|
2544
|
+
screen: Boolean(permissions.screen),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
function normalizedWorkspacePathKey(path) {
|
|
2548
|
+
const text = path?.trim();
|
|
2549
|
+
if (!text)
|
|
2550
|
+
return undefined;
|
|
2551
|
+
const resolved = resolve(expandHomePath(text));
|
|
2552
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
2553
|
+
}
|
|
2554
|
+
function workspaceIdFromPath(path) {
|
|
2555
|
+
const name = basename(resolve(expandHomePath(path)));
|
|
2556
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
|
|
2557
|
+
}
|
|
2558
|
+
function workspaceNameFromPath(path) {
|
|
2559
|
+
return basename(resolve(expandHomePath(path))) || "Workspace";
|
|
2560
|
+
}
|
|
2561
|
+
function validateConfig(args) {
|
|
2562
|
+
const unknown = args.filter((arg) => arg !== "--json");
|
|
2563
|
+
if (unknown.length > 0) {
|
|
2564
|
+
throw new Error(`Unknown config validate option: ${unknown[0]}`);
|
|
2565
|
+
}
|
|
2566
|
+
const doctor = getLocalPortDoctor();
|
|
2567
|
+
const report = {
|
|
2568
|
+
kind: "computer-linker-config-validation",
|
|
2569
|
+
schemaVersion: 1,
|
|
2570
|
+
configPath: configPath(),
|
|
2571
|
+
ready: doctor.releaseReadiness.ready,
|
|
2572
|
+
status: doctor.releaseReadiness.status,
|
|
2573
|
+
configDiagnostics: doctor.configDiagnostics,
|
|
2574
|
+
security: doctor.security,
|
|
2575
|
+
releaseReadiness: doctor.releaseReadiness,
|
|
2576
|
+
};
|
|
2577
|
+
if (args.includes("--json")) {
|
|
2578
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2579
|
+
}
|
|
2580
|
+
else {
|
|
2581
|
+
console.log("Computer Linker config validation");
|
|
2582
|
+
console.log(`configPath: ${report.configPath}`);
|
|
2583
|
+
console.log(`status: ${report.status} ready=${report.ready ? "yes" : "no"}`);
|
|
2584
|
+
console.log(`config: critical=${report.configDiagnostics.criticalCount} warning=${report.configDiagnostics.warningCount}`);
|
|
2585
|
+
console.log(`security: critical=${report.security.criticalCount} warning=${report.security.warningCount}`);
|
|
2586
|
+
if (doctor.releaseReadiness.blockingReasons.length > 0) {
|
|
2587
|
+
console.log("blocking reasons:");
|
|
2588
|
+
for (const reason of doctor.releaseReadiness.blockingReasons) {
|
|
2589
|
+
console.log(` - ${reason}`);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (doctor.releaseReadiness.warnings.length > 0) {
|
|
2593
|
+
console.log("warnings:");
|
|
2594
|
+
for (const warning of doctor.releaseReadiness.warnings) {
|
|
2595
|
+
console.log(` - ${warning}`);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
if (doctor.releaseReadiness.status === "blocked") {
|
|
2600
|
+
process.exitCode = 1;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
function configPolicy(args) {
|
|
2604
|
+
const [workspaceId] = args;
|
|
2605
|
+
if (!workspaceId || workspaceId.startsWith("--")) {
|
|
2606
|
+
throw new Error("Usage: computer-linker config policy <workspace-id> [--json] [--allow pattern] [--deny pattern] [--max-runtime-seconds n] [--max-output-bytes n] [--clear|--clear-allowed|--clear-denied]");
|
|
2607
|
+
}
|
|
2608
|
+
assertKnownConfigPolicyOptions(args.slice(1));
|
|
2609
|
+
const config = loadConfig();
|
|
2610
|
+
const index = config.workspaces.findIndex((workspace) => workspace.id === workspaceId);
|
|
2611
|
+
if (index === -1) {
|
|
2612
|
+
throw new Error(`Unknown workspace: ${workspaceId}`);
|
|
2613
|
+
}
|
|
2614
|
+
const rest = args.slice(1);
|
|
2615
|
+
const current = config.workspaces[index];
|
|
2616
|
+
const updates = configPolicyUpdates(rest);
|
|
2617
|
+
const hasUpdates = policyHasUpdates(updates);
|
|
2618
|
+
if (!hasUpdates) {
|
|
2619
|
+
printConfigPolicy(current.id, current.policy, rest.includes("--json"));
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
const nextPolicy = applyPolicyUpdates(current.policy, updates);
|
|
2623
|
+
config.workspaces[index] = {
|
|
2624
|
+
...current,
|
|
2625
|
+
policy: nextPolicy,
|
|
2626
|
+
};
|
|
2627
|
+
const writtenPath = writeConfig(config);
|
|
2628
|
+
const updated = loadConfig().workspaces.find((workspace) => workspace.id === workspaceId);
|
|
2629
|
+
if (rest.includes("--json")) {
|
|
2630
|
+
console.log(JSON.stringify({
|
|
2631
|
+
kind: "computer-linker-config-policy",
|
|
2632
|
+
workspaceId,
|
|
2633
|
+
configPath: writtenPath,
|
|
2634
|
+
policy: updated?.policy ?? {},
|
|
2635
|
+
}, null, 2));
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
console.log(`Updated policy for workspace ${workspaceId} in ${writtenPath}`);
|
|
2639
|
+
printPolicyLines(updated?.policy);
|
|
2640
|
+
}
|
|
2641
|
+
function assertKnownConfigPolicyOptions(args) {
|
|
2642
|
+
const valueOptions = new Set(["--allow", "--deny", "--max-runtime-seconds", "--max-output-bytes"]);
|
|
2643
|
+
const flagOptions = new Set(["--json", "--clear", "--clear-allowed", "--clear-denied"]);
|
|
2644
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2645
|
+
const arg = args[index];
|
|
2646
|
+
if (!arg.startsWith("--"))
|
|
2647
|
+
throw new Error(`Unknown config policy argument: ${arg}`);
|
|
2648
|
+
if (valueOptions.has(arg)) {
|
|
2649
|
+
index += 1;
|
|
2650
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
2651
|
+
throw new Error(`config policy ${arg} requires a value`);
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
if (flagOptions.has(arg))
|
|
2655
|
+
continue;
|
|
2656
|
+
throw new Error(`Unknown config policy option: ${arg}`);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
function configPolicyUpdates(args) {
|
|
2660
|
+
return {
|
|
2661
|
+
clear: args.includes("--clear"),
|
|
2662
|
+
clearAllowed: args.includes("--clear-allowed"),
|
|
2663
|
+
clearDenied: args.includes("--clear-denied"),
|
|
2664
|
+
allowedCommands: readRepeatedOptions(args, "--allow", "config policy --allow"),
|
|
2665
|
+
deniedCommands: readRepeatedOptions(args, "--deny", "config policy --deny"),
|
|
2666
|
+
maxRuntimeSeconds: readOptionalIntegerOption(args, "--max-runtime-seconds", "config policy --max-runtime-seconds"),
|
|
2667
|
+
maxOutputBytes: readOptionalIntegerOption(args, "--max-output-bytes", "config policy --max-output-bytes"),
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
function policyHasUpdates(updates) {
|
|
2671
|
+
return (updates.clear ||
|
|
2672
|
+
updates.clearAllowed ||
|
|
2673
|
+
updates.clearDenied ||
|
|
2674
|
+
updates.allowedCommands.length > 0 ||
|
|
2675
|
+
updates.deniedCommands.length > 0 ||
|
|
2676
|
+
updates.maxRuntimeSeconds !== undefined ||
|
|
2677
|
+
updates.maxOutputBytes !== undefined);
|
|
2678
|
+
}
|
|
2679
|
+
function applyPolicyUpdates(policy, updates) {
|
|
2680
|
+
const next = updates.clear ? {} : { ...(policy ?? {}) };
|
|
2681
|
+
if (updates.clearAllowed)
|
|
2682
|
+
delete next.allowedCommands;
|
|
2683
|
+
if (updates.clearDenied)
|
|
2684
|
+
delete next.deniedCommands;
|
|
2685
|
+
if (updates.maxRuntimeSeconds !== undefined)
|
|
2686
|
+
next.maxRuntimeSeconds = updates.maxRuntimeSeconds;
|
|
2687
|
+
if (updates.maxOutputBytes !== undefined)
|
|
2688
|
+
next.maxOutputBytes = updates.maxOutputBytes;
|
|
2689
|
+
if (updates.allowedCommands.length > 0) {
|
|
2690
|
+
next.allowedCommands = mergePolicyList(next.allowedCommands, updates.allowedCommands);
|
|
2691
|
+
}
|
|
2692
|
+
if (updates.deniedCommands.length > 0) {
|
|
2693
|
+
next.deniedCommands = mergePolicyList(next.deniedCommands, updates.deniedCommands);
|
|
2694
|
+
}
|
|
2695
|
+
return Object.keys(next).length > 0 ? next : undefined;
|
|
2696
|
+
}
|
|
2697
|
+
function mergePolicyList(current, next) {
|
|
2698
|
+
const seen = new Set();
|
|
2699
|
+
const merged = [];
|
|
2700
|
+
for (const item of [...(current ?? []), ...next]) {
|
|
2701
|
+
const text = item.trim().replace(/\s+/g, " ");
|
|
2702
|
+
if (!text || seen.has(text))
|
|
2703
|
+
continue;
|
|
2704
|
+
seen.add(text);
|
|
2705
|
+
merged.push(text);
|
|
2706
|
+
}
|
|
2707
|
+
return merged;
|
|
2708
|
+
}
|
|
2709
|
+
function printConfigPolicy(workspaceId, policy, json) {
|
|
2710
|
+
if (json) {
|
|
2711
|
+
console.log(JSON.stringify({
|
|
2712
|
+
kind: "computer-linker-config-policy",
|
|
2713
|
+
workspaceId,
|
|
2714
|
+
configPath: configPath(),
|
|
2715
|
+
policy: policy ?? {},
|
|
2716
|
+
}, null, 2));
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
console.log(`Computer Linker policy for ${workspaceId}`);
|
|
2720
|
+
printPolicyLines(policy);
|
|
2721
|
+
}
|
|
2722
|
+
function printPolicyLines(policy) {
|
|
2723
|
+
console.log(`maxRuntimeSeconds: ${policy?.maxRuntimeSeconds ?? "not set"}`);
|
|
2724
|
+
console.log(`maxOutputBytes: ${policy?.maxOutputBytes ?? "not set"}`);
|
|
2725
|
+
console.log(`allowedCommands: ${policy?.allowedCommands?.join(", ") || "not set"}`);
|
|
2726
|
+
console.log(`deniedCommands: ${policy?.deniedCommands?.join(", ") || "not set"}`);
|
|
2727
|
+
}
|
|
2728
|
+
function formatPermissions(permissions) {
|
|
2729
|
+
return [
|
|
2730
|
+
`read=${permissions.read}`,
|
|
2731
|
+
`write=${permissions.write}`,
|
|
2732
|
+
`shell=${permissions.shell}`,
|
|
2733
|
+
`codex=${permissions.codex}`,
|
|
2734
|
+
`screen=${Boolean(permissions.screen)}`,
|
|
2735
|
+
].join(" ");
|
|
2736
|
+
}
|
|
2737
|
+
function addWorkspace(args) {
|
|
2738
|
+
const options = parseWorkspaceAddOptions(args);
|
|
2739
|
+
const config = loadConfig();
|
|
2740
|
+
if (config.workspaces.some((entry) => entry.id === options.id)) {
|
|
2741
|
+
throw new Error(`Workspace already exists: ${options.id}`);
|
|
2742
|
+
}
|
|
2743
|
+
config.workspaces.push({
|
|
2744
|
+
id: options.id,
|
|
2745
|
+
name: options.name,
|
|
2746
|
+
path: expandHomePath(options.path),
|
|
2747
|
+
permissions: {
|
|
2748
|
+
read: true,
|
|
2749
|
+
write: options.write,
|
|
2750
|
+
shell: options.shell,
|
|
2751
|
+
codex: options.codex,
|
|
2752
|
+
screen: options.screen,
|
|
2753
|
+
},
|
|
2754
|
+
});
|
|
2755
|
+
const writtenPath = writeConfig(config);
|
|
2756
|
+
console.log(`Added workspace ${options.id} (${options.name}) -> ${resolve(expandHomePath(options.path))} to ${writtenPath}`);
|
|
2757
|
+
}
|
|
2758
|
+
function parseWorkspaceAddOptions(args) {
|
|
2759
|
+
const usage = "Usage: computer-linker workspace add <path> [--id workspace-id] [--name name] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen]\nLegacy: computer-linker workspace add <id> <path> [--name name] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen]";
|
|
2760
|
+
const valueOptions = new Set(["--id", "--name"]);
|
|
2761
|
+
const flagOptions = new Set(["--dev", "--coding", "--full-trust", "--read-only", "--write", "--shell", "--codex", "--screen"]);
|
|
2762
|
+
const positional = [];
|
|
2763
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
2764
|
+
const arg = args[index];
|
|
2765
|
+
if (!arg.startsWith("--")) {
|
|
2766
|
+
positional.push(arg);
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
if (valueOptions.has(arg)) {
|
|
2770
|
+
index += 1;
|
|
2771
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
2772
|
+
throw new Error(`workspace add ${arg} requires a value`);
|
|
2773
|
+
continue;
|
|
2774
|
+
}
|
|
2775
|
+
if (flagOptions.has(arg))
|
|
2776
|
+
continue;
|
|
2777
|
+
throw new Error(`Unknown workspace add option: ${arg}`);
|
|
2778
|
+
}
|
|
2779
|
+
const explicitId = readOptionalStringOption(args, "--id", "workspace add --id");
|
|
2780
|
+
const explicitName = readOptionalStringOption(args, "--name", "workspace add --name");
|
|
2781
|
+
const [first, second, ...extra] = positional;
|
|
2782
|
+
if (!first || extra.length > 0)
|
|
2783
|
+
throw new Error(usage);
|
|
2784
|
+
if (explicitId && second) {
|
|
2785
|
+
throw new Error("workspace add accepts either --id with <path> or legacy <id> <path>");
|
|
2786
|
+
}
|
|
2787
|
+
const path = second ?? first;
|
|
2788
|
+
const id = second ? first : explicitId ?? workspaceIdFromPath(path);
|
|
2789
|
+
const permissionFlags = permissionPresetFlags(args, "workspace add");
|
|
2790
|
+
return {
|
|
2791
|
+
id,
|
|
2792
|
+
name: explicitName ?? workspaceNameFromPath(path),
|
|
2793
|
+
path,
|
|
2794
|
+
write: permissionFlags.write,
|
|
2795
|
+
shell: permissionFlags.shell,
|
|
2796
|
+
codex: permissionFlags.codex,
|
|
2797
|
+
screen: permissionFlags.screen,
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
function updateWorkspace(args) {
|
|
2801
|
+
const [id] = args;
|
|
2802
|
+
if (!id) {
|
|
2803
|
+
throw new Error("Usage: computer-linker workspace update <id> [--name name] [--path path] [--read-only|--full-trust] [--write|--no-write] [--shell|--no-shell] [--codex|--no-codex] [--screen|--no-screen]");
|
|
2804
|
+
}
|
|
2805
|
+
assertReadOnlyNotMixed(args, "workspace update");
|
|
2806
|
+
const config = loadConfig();
|
|
2807
|
+
const index = config.workspaces.findIndex((entry) => entry.id === id);
|
|
2808
|
+
if (index === -1) {
|
|
2809
|
+
throw new Error(`Unknown workspace: ${id}`);
|
|
2810
|
+
}
|
|
2811
|
+
const current = config.workspaces[index];
|
|
2812
|
+
const readOnly = args.includes("--read-only");
|
|
2813
|
+
const fullTrust = args.includes("--full-trust");
|
|
2814
|
+
const coding = args.includes("--dev") || args.includes("--coding") || fullTrust;
|
|
2815
|
+
config.workspaces[index] = {
|
|
2816
|
+
...current,
|
|
2817
|
+
name: readOption(args, "--name") ?? current.name,
|
|
2818
|
+
path: readOption(args, "--path") ? expandHomePath(readOption(args, "--path") ?? current.path) : current.path,
|
|
2819
|
+
permissions: {
|
|
2820
|
+
read: true,
|
|
2821
|
+
write: readOnly ? false : coding ? true : booleanFlag(args, "write", current.permissions.write),
|
|
2822
|
+
shell: readOnly ? false : coding ? true : booleanFlag(args, "shell", current.permissions.shell),
|
|
2823
|
+
codex: readOnly ? false : fullTrust ? true : booleanFlag(args, "codex", current.permissions.codex),
|
|
2824
|
+
screen: readOnly ? false : fullTrust ? true : booleanFlag(args, "screen", Boolean(current.permissions.screen)),
|
|
2825
|
+
},
|
|
2826
|
+
};
|
|
2827
|
+
const writtenPath = writeConfig(config);
|
|
2828
|
+
console.log(`Updated workspace ${id} in ${writtenPath}`);
|
|
2829
|
+
}
|
|
2830
|
+
function removeWorkspace(args) {
|
|
2831
|
+
const [id] = args;
|
|
2832
|
+
if (!id) {
|
|
2833
|
+
throw new Error("Usage: computer-linker workspace remove <id>");
|
|
2834
|
+
}
|
|
2835
|
+
const config = loadConfig();
|
|
2836
|
+
const nextWorkspaces = config.workspaces.filter((entry) => entry.id !== id);
|
|
2837
|
+
if (nextWorkspaces.length === config.workspaces.length) {
|
|
2838
|
+
throw new Error(`Unknown workspace: ${id}`);
|
|
2839
|
+
}
|
|
2840
|
+
writeConfig({
|
|
2841
|
+
...config,
|
|
2842
|
+
workspaces: nextWorkspaces,
|
|
2843
|
+
});
|
|
2844
|
+
console.log(`Removed workspace ${id}`);
|
|
2845
|
+
}
|
|
2846
|
+
async function serve(args) {
|
|
2847
|
+
if (hasHelpFlag(args)) {
|
|
2848
|
+
printServeHelp();
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const transport = readOption(args, "--transport") ?? "stdio";
|
|
2852
|
+
if (transport === "stdio") {
|
|
2853
|
+
await serveStdio();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
if (transport !== "http") {
|
|
2857
|
+
throw new Error(`Unknown transport: ${transport}`);
|
|
2858
|
+
}
|
|
2859
|
+
const config = loadConfig();
|
|
2860
|
+
const server = serveHttp();
|
|
2861
|
+
console.log(`Computer Linker HTTP MCP server listening at ${server.url}`);
|
|
2862
|
+
console.log(startupPublicMcpUrlLine(config, undefined, server.publicUrl));
|
|
2863
|
+
console.log(`Local API: ${server.apiUrl}`);
|
|
2864
|
+
printHttpAuthHint();
|
|
2865
|
+
await waitForShutdown(server.close);
|
|
2866
|
+
}
|
|
2867
|
+
async function start(args) {
|
|
2868
|
+
if (hasHelpFlag(args)) {
|
|
2869
|
+
printStartHelp();
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
const options = parseStartOptions(args);
|
|
2873
|
+
if (options.workspacePath) {
|
|
2874
|
+
setupMcpOnly(startSetupArgs(options), "start", "startup");
|
|
2875
|
+
}
|
|
2876
|
+
let config = loadConfig();
|
|
2877
|
+
const localPort = config.port ?? 3939;
|
|
2878
|
+
let openAiClientPath;
|
|
2879
|
+
let openAiTunnelId;
|
|
2880
|
+
if (options.tunnelProvider) {
|
|
2881
|
+
assertExposeAuthConfigured(config, invocationCommand("start"));
|
|
2882
|
+
if (options.tunnelProvider === "openai") {
|
|
2883
|
+
openAiTunnelId = assertOpenAiTunnelConfigured(options.openaiTunnelId);
|
|
2884
|
+
const install = await ensureOpenAiTunnelClientInstalled({ clientPath: options.openaiClientPath });
|
|
2885
|
+
openAiClientPath = install.path;
|
|
2886
|
+
const source = install.source === "downloaded"
|
|
2887
|
+
? `downloaded ${install.releaseTag ?? "latest official release"}`
|
|
2888
|
+
: install.source;
|
|
2889
|
+
console.log(`OpenAI tunnel-client: ready (${source})`);
|
|
2890
|
+
}
|
|
2891
|
+
else {
|
|
2892
|
+
assertTunnelToolAvailable(options.tunnelProvider, localPort, config.publicBaseUrl);
|
|
2893
|
+
}
|
|
2894
|
+
config = ensurePublicMcpOnlyForTunnel(config, options.tunnelProvider);
|
|
2895
|
+
}
|
|
2896
|
+
const server = serveHttp();
|
|
2897
|
+
const startupCheck = await runStartupCheck(config, server.url);
|
|
2898
|
+
try {
|
|
2899
|
+
if (options.tunnelProvider) {
|
|
2900
|
+
const tunnel = startTunnelProcess({
|
|
2901
|
+
provider: options.tunnelProvider,
|
|
2902
|
+
localPort,
|
|
2903
|
+
tailscaleMode: options.tailscaleMode,
|
|
2904
|
+
openaiTunnelId: openAiTunnelId,
|
|
2905
|
+
openaiClientPath: openAiClientPath,
|
|
2906
|
+
ownerToken: config.ownerToken,
|
|
2907
|
+
});
|
|
2908
|
+
if (options.tunnelProvider === "openai") {
|
|
2909
|
+
const readyTunnel = await waitForTunnelStartup(tunnel.id, Math.min(options.tunnelTimeoutMs, 3000));
|
|
2910
|
+
if (readyTunnel?.status === "exited") {
|
|
2911
|
+
throw new Error(`OpenAI tunnel-client exited before staying connected.${readyTunnel.stderr ? ` ${readyTunnel.stderr.trim()}` : ""}`);
|
|
2912
|
+
}
|
|
2913
|
+
printStartSummary({
|
|
2914
|
+
config,
|
|
2915
|
+
options,
|
|
2916
|
+
server,
|
|
2917
|
+
startupCheck,
|
|
2918
|
+
tunnel: {
|
|
2919
|
+
provider: "openai",
|
|
2920
|
+
display: tunnel.display,
|
|
2921
|
+
openAiTunnelId,
|
|
2922
|
+
status: "running",
|
|
2923
|
+
},
|
|
2924
|
+
});
|
|
2925
|
+
await waitForShutdown(server.close);
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
const readyTunnel = await waitForTunnelPublicUrl(tunnel.id, options.tunnelTimeoutMs);
|
|
2929
|
+
if (readyTunnel?.publicUrl) {
|
|
2930
|
+
const savedPath = saveDetectedTunnelPublicBaseUrl(options.tunnelProvider, readyTunnel.publicUrl);
|
|
2931
|
+
printStartSummary({
|
|
2932
|
+
config: savedPath ? loadConfig() : config,
|
|
2933
|
+
options,
|
|
2934
|
+
server,
|
|
2935
|
+
startupCheck,
|
|
2936
|
+
tunnel: {
|
|
2937
|
+
provider: options.tunnelProvider,
|
|
2938
|
+
display: tunnel.display,
|
|
2939
|
+
status: "running",
|
|
2940
|
+
publicUrl: readyTunnel.publicUrl,
|
|
2941
|
+
savedConfigPath: savedPath,
|
|
2942
|
+
},
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
else if (readyTunnel?.status === "exited") {
|
|
2946
|
+
throw new Error(`Tunnel exited before a public URL was detected.${readyTunnel.stderr ? ` ${readyTunnel.stderr.trim()}` : ""}`);
|
|
2947
|
+
}
|
|
2948
|
+
else {
|
|
2949
|
+
printStartSummary({
|
|
2950
|
+
config,
|
|
2951
|
+
options,
|
|
2952
|
+
server,
|
|
2953
|
+
startupCheck,
|
|
2954
|
+
tunnel: {
|
|
2955
|
+
provider: options.tunnelProvider,
|
|
2956
|
+
display: tunnel.display,
|
|
2957
|
+
status: "pending",
|
|
2958
|
+
},
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
else {
|
|
2963
|
+
printStartSummary({ config, options, server, startupCheck });
|
|
2964
|
+
}
|
|
2965
|
+
await waitForShutdown(server.close);
|
|
2966
|
+
}
|
|
2967
|
+
catch (error) {
|
|
2968
|
+
server.close();
|
|
2969
|
+
throw error;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
function startupPublicMcpUrlLine(config, tunnelProvider, serverPublicUrl) {
|
|
2973
|
+
if (tunnelProvider === "openai") {
|
|
2974
|
+
return "Public MCP URL: not used in OpenAI tunnel mode";
|
|
2975
|
+
}
|
|
2976
|
+
if (config.publicBaseUrl) {
|
|
2977
|
+
return `Public MCP URL: ${serverPublicUrl}`;
|
|
2978
|
+
}
|
|
2979
|
+
if (tunnelProvider) {
|
|
2980
|
+
return "Public MCP URL: pending tunnel detection";
|
|
2981
|
+
}
|
|
2982
|
+
return "Public MCP URL: not configured; local-only";
|
|
2983
|
+
}
|
|
2984
|
+
async function runStartupCheck(config, localMcpUrl) {
|
|
2985
|
+
const command = invocationCommand("client", "smoke", "--allow-http", "--url", localMcpUrl);
|
|
2986
|
+
try {
|
|
2987
|
+
await waitForSelfTestServer(new URL(localMcpUrl).origin, 10000);
|
|
2988
|
+
const report = await runWorkspaceLinkerMcpClientSmoke(config, {
|
|
2989
|
+
url: localMcpUrl,
|
|
2990
|
+
allowHttp: true,
|
|
2991
|
+
timeoutMs: 10000,
|
|
2992
|
+
clientName: "computer-linker-startup-check",
|
|
2993
|
+
});
|
|
2994
|
+
const passed = report.checks.filter((check) => check.status === "pass").length;
|
|
2995
|
+
const total = report.checks.length;
|
|
2996
|
+
if (report.ready) {
|
|
2997
|
+
return { status: "ready", passed, total, blockingReasons: [], command };
|
|
2998
|
+
}
|
|
2999
|
+
return { status: "needs_attention", passed, total, blockingReasons: report.blockingReasons, command };
|
|
3000
|
+
}
|
|
3001
|
+
catch (error) {
|
|
3002
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3003
|
+
return { status: "skipped", passed: 0, total: 0, blockingReasons: [], detail, command };
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
function printStartSummary(input) {
|
|
3007
|
+
const { config, options, server, startupCheck, tunnel } = input;
|
|
3008
|
+
const localMcpUrl = server.url;
|
|
3009
|
+
const lines = [
|
|
3010
|
+
"Computer Linker started",
|
|
3011
|
+
"server: running",
|
|
3012
|
+
`local MCP: ${localMcpUrl}`,
|
|
3013
|
+
`connect: ${startConnectionSummary(config, options, server, tunnel)}`,
|
|
3014
|
+
`auth: ${startAuthSummary(config, options.tunnelProvider)}`,
|
|
3015
|
+
`startup check: ${formatStartupCheckStatus(startupCheck)}`,
|
|
3016
|
+
`tunnel: ${startTunnelLine(options, tunnel)}`,
|
|
3017
|
+
];
|
|
3018
|
+
if (startupCheck.status === "needs_attention") {
|
|
3019
|
+
lines.push("startup issues:");
|
|
3020
|
+
for (const reason of startupCheck.blockingReasons.slice(0, 3))
|
|
3021
|
+
lines.push(` - ${reason}`);
|
|
3022
|
+
appendRemainingCount(lines, startupCheck.blockingReasons.length, 3, "startup issue", startupCheck.command);
|
|
3023
|
+
}
|
|
3024
|
+
else if (startupCheck.status === "skipped") {
|
|
3025
|
+
lines.push(`startup detail: ${startupCheck.detail}`);
|
|
3026
|
+
}
|
|
3027
|
+
if (tunnel?.provider === "openai") {
|
|
3028
|
+
lines.push(`tunnel id: ${tunnel.openAiTunnelId ?? "(not configured)"}`);
|
|
3029
|
+
}
|
|
3030
|
+
if (tunnel?.publicUrl) {
|
|
3031
|
+
lines.push(`public MCP: ${new URL("/mcp", tunnel.publicUrl).href}`);
|
|
3032
|
+
if (tunnel.savedConfigPath)
|
|
3033
|
+
lines.push(`saved public URL: ${tunnel.publicUrl}`);
|
|
3034
|
+
}
|
|
3035
|
+
lines.push("next:");
|
|
3036
|
+
for (const action of startNextActions(config, options, tunnel))
|
|
3037
|
+
lines.push(` - ${action}`);
|
|
3038
|
+
lines.push(`details: ${invocationCommand("status", "--details")}`);
|
|
3039
|
+
console.log(lines.join("\n"));
|
|
3040
|
+
}
|
|
3041
|
+
function formatStartupCheckStatus(check) {
|
|
3042
|
+
if (check.status === "ready")
|
|
3043
|
+
return `ready (${check.passed}/${check.total})`;
|
|
3044
|
+
if (check.status === "needs_attention")
|
|
3045
|
+
return `needs attention (${check.passed}/${check.total})`;
|
|
3046
|
+
return "skipped";
|
|
3047
|
+
}
|
|
3048
|
+
function startConnectionSummary(config, options, server, tunnel) {
|
|
3049
|
+
if (options.tunnelProvider === "openai") {
|
|
3050
|
+
return `OpenAI Tunnel mode${tunnel?.openAiTunnelId ? ` (${tunnel.openAiTunnelId})` : ""}`;
|
|
3051
|
+
}
|
|
3052
|
+
if (tunnel?.publicUrl)
|
|
3053
|
+
return new URL("/mcp", tunnel.publicUrl).href;
|
|
3054
|
+
if (config.publicBaseUrl && !options.tunnelProvider)
|
|
3055
|
+
return server.publicUrl;
|
|
3056
|
+
if (options.tunnelProvider)
|
|
3057
|
+
return "waiting for tunnel public URL";
|
|
3058
|
+
return "local only";
|
|
3059
|
+
}
|
|
3060
|
+
function startAuthSummary(config, tunnelProvider) {
|
|
3061
|
+
if (!config.ownerToken)
|
|
3062
|
+
return "loopback only; run computer-linker init before exposing";
|
|
3063
|
+
if (tunnelProvider === "openai")
|
|
3064
|
+
return "handled by tunnel-client; do not paste a bearer token into ChatGPT";
|
|
3065
|
+
return `bearer token configured; setup command: ${invocationCommand("client", "setup")}`;
|
|
3066
|
+
}
|
|
3067
|
+
function startTunnelLine(options, tunnel) {
|
|
3068
|
+
if (!options.tunnelProvider) {
|
|
3069
|
+
return options.noTunnelExplicit
|
|
3070
|
+
? "disabled by --no-tunnel"
|
|
3071
|
+
: "disabled; restart with --tunnel openai, tailscale, or cloudflare for remote access";
|
|
3072
|
+
}
|
|
3073
|
+
if (!tunnel)
|
|
3074
|
+
return "starting";
|
|
3075
|
+
if (tunnel.status === "pending")
|
|
3076
|
+
return `${tunnel.provider} pending public URL`;
|
|
3077
|
+
if (tunnel.provider === "openai")
|
|
3078
|
+
return "OpenAI Secure MCP Tunnel active";
|
|
3079
|
+
return `${tunnel.provider} active`;
|
|
3080
|
+
}
|
|
3081
|
+
function startNextActions(config, options, tunnel) {
|
|
3082
|
+
if (options.tunnelProvider === "openai") {
|
|
3083
|
+
return [
|
|
3084
|
+
"In ChatGPT connector settings, choose Tunnel mode and select or paste the tunnel id above.",
|
|
3085
|
+
"Keep this terminal running while ChatGPT uses the workspace.",
|
|
3086
|
+
`Use ${invocationCommand("client", "setup")} if another MCP client needs setup instructions.`,
|
|
3087
|
+
];
|
|
3088
|
+
}
|
|
3089
|
+
if (tunnel?.publicUrl) {
|
|
3090
|
+
return [
|
|
3091
|
+
`Use ${new URL("/mcp", tunnel.publicUrl).href} as the remote MCP URL.`,
|
|
3092
|
+
`Use ${invocationCommand("client", "setup")} for auth and first-prompt guidance.`,
|
|
3093
|
+
"Keep this terminal running while the tunnel is in use.",
|
|
3094
|
+
];
|
|
3095
|
+
}
|
|
3096
|
+
if (options.tunnelProvider) {
|
|
3097
|
+
return [
|
|
3098
|
+
`Run ${invocationCommand("tunnel", "status")} to inspect tunnel output.`,
|
|
3099
|
+
"Keep this terminal running while the tunnel is starting.",
|
|
3100
|
+
`Use ${invocationCommand("status", "--details")} for full readiness details.`,
|
|
3101
|
+
];
|
|
3102
|
+
}
|
|
3103
|
+
return [
|
|
3104
|
+
`Use ${invocationCommand("client", "setup")} to connect a local MCP client.`,
|
|
3105
|
+
"For ChatGPT remote access, restart with `computer-linker start <workspace-path> --tunnel openai --tunnel-id tunnel_...`.",
|
|
3106
|
+
config.ownerToken ? "Keep this terminal running while the client is connected." : `Run ${invocationCommand("init")} before exposing this computer.`,
|
|
3107
|
+
];
|
|
3108
|
+
}
|
|
3109
|
+
function ensurePublicMcpOnlyForTunnel(config, provider) {
|
|
3110
|
+
if (config.publicMcpOnly)
|
|
3111
|
+
return config;
|
|
3112
|
+
const nextConfig = {
|
|
3113
|
+
...config,
|
|
3114
|
+
publicMcpOnly: true,
|
|
3115
|
+
};
|
|
3116
|
+
writeConfig(nextConfig);
|
|
3117
|
+
console.log(`public access: MCP endpoint only for ${provider} tunnel.`);
|
|
3118
|
+
return nextConfig;
|
|
3119
|
+
}
|
|
3120
|
+
function saveDetectedTunnelPublicBaseUrl(provider, publicUrl) {
|
|
3121
|
+
if (provider !== "tailscale")
|
|
3122
|
+
return undefined;
|
|
3123
|
+
const publicBaseUrl = requireHttpsUrl(publicUrl, "detected tunnel URL");
|
|
3124
|
+
const config = loadConfig();
|
|
3125
|
+
if (config.publicBaseUrl === publicBaseUrl)
|
|
3126
|
+
return undefined;
|
|
3127
|
+
return writeConfig({
|
|
3128
|
+
...config,
|
|
3129
|
+
publicBaseUrl,
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
function startSetupArgs(options) {
|
|
3133
|
+
const setupArgs = [];
|
|
3134
|
+
if (options.publicBaseUrl)
|
|
3135
|
+
setupArgs.push("--url", options.publicBaseUrl);
|
|
3136
|
+
if (options.workspacePath)
|
|
3137
|
+
setupArgs.push(options.workspacePath);
|
|
3138
|
+
if (options.workspaceId)
|
|
3139
|
+
setupArgs.push("--id", options.workspaceId);
|
|
3140
|
+
if (options.workspaceName)
|
|
3141
|
+
setupArgs.push("--name", options.workspaceName);
|
|
3142
|
+
if (options.tunnelProvider)
|
|
3143
|
+
setupArgs.push("--tunnel", options.tunnelProvider);
|
|
3144
|
+
if (options.openaiTunnelId)
|
|
3145
|
+
setupArgs.push("--tunnel-id", options.openaiTunnelId);
|
|
3146
|
+
setupArgs.push(...options.permissionArgs);
|
|
3147
|
+
if (options.showToken)
|
|
3148
|
+
setupArgs.push("--show-token");
|
|
3149
|
+
return setupArgs;
|
|
3150
|
+
}
|
|
3151
|
+
async function expose(args) {
|
|
3152
|
+
const provider = args[0];
|
|
3153
|
+
if (args[0] === "help") {
|
|
3154
|
+
printExposeHelpTopic(args.slice(1));
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
3158
|
+
printExposeHelp();
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
if (hasHelpFlag(args.slice(1))) {
|
|
3162
|
+
printExposeHelpTopic([args[0]]);
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (provider !== "cloudflare" && provider !== "tailscale") {
|
|
3166
|
+
throw new Error("Usage: computer-linker expose <cloudflare|tailscale> [--mode funnel]");
|
|
3167
|
+
}
|
|
3168
|
+
let config = loadConfig();
|
|
3169
|
+
assertExposeAuthConfigured(config, invocationCommand("expose"));
|
|
3170
|
+
const localPort = config.port ?? 3939;
|
|
3171
|
+
const mode = readOption(args, "--mode");
|
|
3172
|
+
if (mode && provider !== "tailscale") {
|
|
3173
|
+
throw new Error("expose --mode is only valid with tailscale");
|
|
3174
|
+
}
|
|
3175
|
+
const tailscaleMode = provider === "tailscale"
|
|
3176
|
+
? parseTailscaleMode(mode ?? "funnel", "expose --mode")
|
|
3177
|
+
: undefined;
|
|
3178
|
+
config = ensurePublicMcpOnlyForTunnel(config, provider);
|
|
3179
|
+
const server = serveHttp();
|
|
3180
|
+
console.log(`Computer Linker HTTP MCP server listening at ${server.url}`);
|
|
3181
|
+
console.log(startupPublicMcpUrlLine(config, provider, server.publicUrl));
|
|
3182
|
+
console.log(`Local API: ${server.apiUrl}`);
|
|
3183
|
+
printHttpAuthHint(provider);
|
|
3184
|
+
console.log("Expose mode starts a tunnel to the local HTTP MCP endpoint.");
|
|
3185
|
+
console.log("Use Tailscale ACLs, Cloudflare Access, or equivalent network controls for another layer of protection.");
|
|
3186
|
+
try {
|
|
3187
|
+
await exposeWithTunnel({
|
|
3188
|
+
provider,
|
|
3189
|
+
localPort,
|
|
3190
|
+
tailscaleMode,
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
finally {
|
|
3194
|
+
server.close();
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
function parseStartOptions(args) {
|
|
3198
|
+
const valueOptions = new Set(["--tunnel", "--mode", "--tunnel-timeout-ms", "--tunnel-id", "--tunnel-client", "--url", "--id", "--name"]);
|
|
3199
|
+
const flagOptions = new Set(["--no-tunnel", "--dev", "--coding", "--full-trust", "--write", "--shell", "--codex", "--screen", "--read-only", "--show-token"]);
|
|
3200
|
+
const positional = [];
|
|
3201
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3202
|
+
const arg = args[index];
|
|
3203
|
+
if (!arg.startsWith("--")) {
|
|
3204
|
+
positional.push(arg);
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
if (valueOptions.has(arg)) {
|
|
3208
|
+
index += 1;
|
|
3209
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
3210
|
+
throw new Error(`start ${arg} requires a value`);
|
|
3211
|
+
continue;
|
|
3212
|
+
}
|
|
3213
|
+
if (flagOptions.has(arg))
|
|
3214
|
+
continue;
|
|
3215
|
+
throw new Error(`Unknown start option: ${arg}`);
|
|
3216
|
+
}
|
|
3217
|
+
if (positional.length > 1) {
|
|
3218
|
+
throw new Error("Usage: computer-linker start [workspace-path] [--no-tunnel|--tunnel cloudflare|tailscale|openai] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen]");
|
|
3219
|
+
}
|
|
3220
|
+
const workspacePath = positional[0];
|
|
3221
|
+
const setupOnlyOptions = ["--url", "--id", "--name", "--dev", "--coding", "--full-trust", "--write", "--shell", "--codex", "--screen", "--read-only", "--show-token"];
|
|
3222
|
+
const setupOnlyOption = setupOnlyOptions.find((option) => args.includes(option));
|
|
3223
|
+
if (setupOnlyOption && !workspacePath) {
|
|
3224
|
+
throw new Error(`start ${setupOnlyOption} is only valid when start is given a workspace path`);
|
|
3225
|
+
}
|
|
3226
|
+
if (args.includes("--no-tunnel") && args.includes("--tunnel")) {
|
|
3227
|
+
throw new Error("start accepts either --tunnel or --no-tunnel, not both");
|
|
3228
|
+
}
|
|
3229
|
+
const noTunnelExplicit = args.includes("--no-tunnel");
|
|
3230
|
+
const tunnelOption = readOptionalStringOption(args, "--tunnel", "start --tunnel");
|
|
3231
|
+
const tunnelProvider = noTunnelExplicit || !tunnelOption
|
|
3232
|
+
? undefined
|
|
3233
|
+
: parseTunnelProvider(tunnelOption, "start --tunnel");
|
|
3234
|
+
const mode = readOptionalStringOption(args, "--mode", "start --mode");
|
|
3235
|
+
if (mode && tunnelProvider !== "tailscale") {
|
|
3236
|
+
throw new Error("start --mode is only valid with --tunnel tailscale");
|
|
3237
|
+
}
|
|
3238
|
+
const openaiTunnelId = readOptionalStringOption(args, "--tunnel-id", "start --tunnel-id");
|
|
3239
|
+
const openaiClientPath = readOptionalStringOption(args, "--tunnel-client", "start --tunnel-client");
|
|
3240
|
+
if ((openaiTunnelId || openaiClientPath) && tunnelProvider !== "openai") {
|
|
3241
|
+
throw new Error("start --tunnel-id and --tunnel-client are only valid with --tunnel openai");
|
|
3242
|
+
}
|
|
3243
|
+
const permissionFlags = permissionPresetFlags(args, "start", { defaultCoding: Boolean(workspacePath) });
|
|
3244
|
+
return {
|
|
3245
|
+
workspacePath,
|
|
3246
|
+
workspaceId: readOptionalStringOption(args, "--id", "start --id"),
|
|
3247
|
+
workspaceName: readOptionalStringOption(args, "--name", "start --name"),
|
|
3248
|
+
publicBaseUrl: readOptionalStringOption(args, "--url", "start --url"),
|
|
3249
|
+
dev: permissionFlags.dev,
|
|
3250
|
+
write: permissionFlags.write,
|
|
3251
|
+
shell: permissionFlags.shell,
|
|
3252
|
+
codex: permissionFlags.codex,
|
|
3253
|
+
screen: permissionFlags.screen,
|
|
3254
|
+
readOnly: permissionFlags.readOnly,
|
|
3255
|
+
showToken: args.includes("--show-token"),
|
|
3256
|
+
noTunnelExplicit,
|
|
3257
|
+
tunnelProvider,
|
|
3258
|
+
tailscaleMode: tunnelProvider === "tailscale" ? parseTailscaleMode(mode ?? "funnel", "start --mode") : undefined,
|
|
3259
|
+
openaiTunnelId,
|
|
3260
|
+
openaiClientPath,
|
|
3261
|
+
tunnelTimeoutMs: readOptionalIntegerOption(args, "--tunnel-timeout-ms", "start --tunnel-timeout-ms") ?? 8000,
|
|
3262
|
+
permissionArgs: permissionFlags.canonicalArgs,
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
function parseTunnelProvider(value, command) {
|
|
3266
|
+
if (value === "cloudflare" || value === "tailscale" || value === "openai")
|
|
3267
|
+
return value;
|
|
3268
|
+
throw new Error(`${command} must be one of: cloudflare, tailscale, openai`);
|
|
3269
|
+
}
|
|
3270
|
+
function parseTailscaleMode(value, command) {
|
|
3271
|
+
if (value === "funnel")
|
|
3272
|
+
return value;
|
|
3273
|
+
throw new Error(`${command} must be funnel`);
|
|
3274
|
+
}
|
|
3275
|
+
function assertTunnelToolAvailable(provider, localPort, publicBaseUrl) {
|
|
3276
|
+
const status = tunnelDiagnostics({ localPort, publicBaseUrl, tunnels: listTunnelProcesses() })
|
|
3277
|
+
.providers.find((item) => item.provider === provider);
|
|
3278
|
+
if (status?.available)
|
|
3279
|
+
return;
|
|
3280
|
+
const tool = provider === "cloudflare" ? "cloudflared" : provider === "tailscale" ? "tailscale" : "tunnel-client";
|
|
3281
|
+
throw new Error(`${tool} is not available. Install ${tool}, choose another tunnel with --tunnel, or run ${invocationCommand("start")} without --tunnel.`);
|
|
3282
|
+
}
|
|
3283
|
+
function assertOpenAiTunnelConfigured(tunnelIdOption) {
|
|
3284
|
+
const tunnelId = tunnelIdOption ?? configuredOpenAiTunnelId();
|
|
3285
|
+
if (!tunnelId) {
|
|
3286
|
+
throw new Error("start --tunnel openai requires --tunnel-id tunnel_... or COMPUTER_LINKER_OPENAI_TUNNEL_ID.");
|
|
3287
|
+
}
|
|
3288
|
+
if (!/^tunnel_[A-Za-z0-9_-]+$/.test(tunnelId)) {
|
|
3289
|
+
throw new Error("OpenAI tunnel id must look like tunnel_...");
|
|
3290
|
+
}
|
|
3291
|
+
if (!process.env.CONTROL_PLANE_API_KEY && !process.env.OPENAI_API_KEY) {
|
|
3292
|
+
throw new Error(`OpenAI Secure MCP Tunnel requires CONTROL_PLANE_API_KEY (preferred) or OPENAI_API_KEY with Tunnels Read+Use permissions. Set it before starting. ${openAiTunnelApiKeyHint()}`);
|
|
3293
|
+
}
|
|
3294
|
+
return tunnelId;
|
|
3295
|
+
}
|
|
3296
|
+
function openAiTunnelApiKeyHint() {
|
|
3297
|
+
return "PowerShell: $env:CONTROL_PLANE_API_KEY = \"sk-...\"";
|
|
3298
|
+
}
|
|
3299
|
+
async function waitForTunnelStartup(id, timeoutMs) {
|
|
3300
|
+
const deadline = Date.now() + timeoutMs;
|
|
3301
|
+
let latest;
|
|
3302
|
+
while (Date.now() < deadline) {
|
|
3303
|
+
latest = listTunnelProcesses().find((tunnel) => tunnel.id === id);
|
|
3304
|
+
if (latest?.status === "exited")
|
|
3305
|
+
return latest;
|
|
3306
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3307
|
+
}
|
|
3308
|
+
return listTunnelProcesses().find((tunnel) => tunnel.id === id) ?? latest;
|
|
3309
|
+
}
|
|
3310
|
+
async function waitForTunnelPublicUrl(id, timeoutMs) {
|
|
3311
|
+
const deadline = Date.now() + timeoutMs;
|
|
3312
|
+
let latest;
|
|
3313
|
+
while (Date.now() < deadline) {
|
|
3314
|
+
latest = listTunnelProcesses().find((tunnel) => tunnel.id === id);
|
|
3315
|
+
if (latest?.publicUrl)
|
|
3316
|
+
return latest;
|
|
3317
|
+
if (latest?.status === "exited")
|
|
3318
|
+
return latest;
|
|
3319
|
+
if (latest?.provider === "tailscale" && Date.now() - new Date(latest.startedAt).getTime() > 500) {
|
|
3320
|
+
const refreshed = refreshTunnelPublicUrl(latest.id);
|
|
3321
|
+
if (refreshed?.publicUrl)
|
|
3322
|
+
return refreshed;
|
|
3323
|
+
}
|
|
3324
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3325
|
+
}
|
|
3326
|
+
return listTunnelProcesses().find((tunnel) => tunnel.id === id) ?? latest;
|
|
3327
|
+
}
|
|
3328
|
+
function assertExposeAuthConfigured(config, command) {
|
|
3329
|
+
if (config.ownerToken)
|
|
3330
|
+
return;
|
|
3331
|
+
throw new Error(`Refusing to expose Computer Linker without an owner token. Run \`${invocationCommand("init")}\` or set COMPUTER_LINKER_OWNER_TOKEN before using \`${command}\`.`);
|
|
3332
|
+
}
|
|
3333
|
+
function printHttpAuthHint(tunnelProvider) {
|
|
3334
|
+
const config = loadConfig();
|
|
3335
|
+
if (!config.ownerToken) {
|
|
3336
|
+
console.log("HTTP auth: local loopback only because ownerToken is not configured.");
|
|
3337
|
+
console.log(`Run \`${invocationCommand("init")}\` or set COMPUTER_LINKER_OWNER_TOKEN before exposing this server.`);
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
if (tunnelProvider === "openai") {
|
|
3341
|
+
console.log("HTTP auth: OpenAI tunnel-client forwards the owner token to the local MCP server.");
|
|
3342
|
+
console.log("ChatGPT Tunnel mode: select or paste the tunnel id; do not paste a bearer token into ChatGPT.");
|
|
3343
|
+
console.log(`Show token for local debugging only: ${invocationCommand("profile", "--show-token")}`);
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
console.log("OAuth: enabled. MCP clients can discover OAuth metadata from the public base URL.");
|
|
3347
|
+
console.log("HTTP auth: send this header from your MCP client:");
|
|
3348
|
+
console.log("Authorization: Bearer <ownerToken>");
|
|
3349
|
+
console.log(`Show token on a trusted local setup screen: ${invocationCommand("profile", "--show-token")}`);
|
|
3350
|
+
}
|
|
3351
|
+
function init(args = []) {
|
|
3352
|
+
if (hasHelpFlag(args)) {
|
|
3353
|
+
printInitHelp();
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
const unknown = args.filter((arg) => arg !== "--show-token");
|
|
3357
|
+
if (unknown.length > 0) {
|
|
3358
|
+
throw new Error(`Unknown init option: ${unknown[0]}`);
|
|
3359
|
+
}
|
|
3360
|
+
const showToken = args.includes("--show-token");
|
|
3361
|
+
const path = configPath();
|
|
3362
|
+
if (existsSync(path)) {
|
|
3363
|
+
const config = loadConfig();
|
|
3364
|
+
if (!config.ownerToken) {
|
|
3365
|
+
const ownerToken = generateOwnerToken();
|
|
3366
|
+
writeConfig({
|
|
3367
|
+
...config,
|
|
3368
|
+
ownerToken,
|
|
3369
|
+
});
|
|
3370
|
+
console.log(`Updated Computer Linker config with owner token: ${path}`);
|
|
3371
|
+
printOwnerTokenSetup(ownerToken, showToken, true);
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
console.log(`Computer Linker config already exists: ${path}`);
|
|
3375
|
+
printOwnerTokenSetup(config.ownerToken, showToken, false);
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
const createdPath = writeDefaultConfig();
|
|
3379
|
+
const config = JSON.parse(readFileSync(createdPath, "utf8"));
|
|
3380
|
+
console.log(`Created Computer Linker config: ${createdPath}`);
|
|
3381
|
+
if (config.ownerToken) {
|
|
3382
|
+
printOwnerTokenSetup(config.ownerToken, showToken, true);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
function printOwnerTokenSetup(ownerToken, showToken, created) {
|
|
3386
|
+
console.log(`ownerToken: ${created ? "created" : "configured"}`);
|
|
3387
|
+
console.log(`authHeader: Authorization: Bearer ${showToken ? ownerToken : "<ownerToken>"}`);
|
|
3388
|
+
if (showToken) {
|
|
3389
|
+
console.log(`ownerTokenValue: ${ownerToken}`);
|
|
3390
|
+
}
|
|
3391
|
+
else {
|
|
3392
|
+
console.log(`showToken: ${invocationCommand("profile", "--show-token")}`);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
function quickstart(args = []) {
|
|
3396
|
+
if (hasHelpFlag(args)) {
|
|
3397
|
+
printQuickstartHelp();
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
const options = parseQuickstartOptions(args);
|
|
3401
|
+
const report = buildQuickstartReport(options);
|
|
3402
|
+
if (options.json) {
|
|
3403
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
printQuickstartReport(report);
|
|
3407
|
+
}
|
|
3408
|
+
function parseQuickstartOptions(args) {
|
|
3409
|
+
const valueOptions = new Set(["--tunnel", "--tunnel-id", "--url"]);
|
|
3410
|
+
const flagOptions = new Set(["--dev", "--coding", "--full-trust", "--read-only", "--write", "--shell", "--codex", "--screen", "--json"]);
|
|
3411
|
+
const positional = [];
|
|
3412
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
3413
|
+
const arg = args[index];
|
|
3414
|
+
if (!arg.startsWith("--")) {
|
|
3415
|
+
positional.push(arg);
|
|
3416
|
+
continue;
|
|
3417
|
+
}
|
|
3418
|
+
if (valueOptions.has(arg)) {
|
|
3419
|
+
index += 1;
|
|
3420
|
+
if (!args[index] || args[index].startsWith("--"))
|
|
3421
|
+
throw new Error(`quickstart ${arg} requires a value`);
|
|
3422
|
+
continue;
|
|
3423
|
+
}
|
|
3424
|
+
if (flagOptions.has(arg))
|
|
3425
|
+
continue;
|
|
3426
|
+
throw new Error(`Unknown quickstart option: ${arg}`);
|
|
3427
|
+
}
|
|
3428
|
+
if (positional.length > 1) {
|
|
3429
|
+
throw new Error("Usage: computer-linker quickstart [workspace-path] [--tunnel cloudflare|tailscale|openai] [--tunnel-id tunnel_...] [--url https://...] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen] [--json]");
|
|
3430
|
+
}
|
|
3431
|
+
const tunnelOption = readOptionalStringOption(args, "--tunnel", "quickstart --tunnel");
|
|
3432
|
+
const tunnelProvider = tunnelOption ? parseTunnelProvider(tunnelOption, "quickstart --tunnel") : undefined;
|
|
3433
|
+
const openaiTunnelId = readOptionalStringOption(args, "--tunnel-id", "quickstart --tunnel-id");
|
|
3434
|
+
const publicBaseUrl = args.includes("--url")
|
|
3435
|
+
? requireHttpsUrl(readOptionalStringOption(args, "--url", "quickstart --url"), "quickstart --url", "computer-linker quickstart [workspace-path] --url <https-url>")
|
|
3436
|
+
: undefined;
|
|
3437
|
+
if (openaiTunnelId && tunnelProvider !== "openai") {
|
|
3438
|
+
throw new Error("quickstart --tunnel-id is only valid with --tunnel openai");
|
|
3439
|
+
}
|
|
3440
|
+
if (publicBaseUrl && tunnelProvider === "openai") {
|
|
3441
|
+
throw new Error("quickstart --url is not used with --tunnel openai");
|
|
3442
|
+
}
|
|
3443
|
+
const permissionFlags = permissionPresetFlags(args, "quickstart", { defaultCoding: true });
|
|
3444
|
+
return {
|
|
3445
|
+
workspacePath: positional[0],
|
|
3446
|
+
publicBaseUrl,
|
|
3447
|
+
tunnelProvider,
|
|
3448
|
+
openaiTunnelId,
|
|
3449
|
+
dev: permissionFlags.dev,
|
|
3450
|
+
write: permissionFlags.write,
|
|
3451
|
+
shell: permissionFlags.shell,
|
|
3452
|
+
codex: permissionFlags.codex,
|
|
3453
|
+
screen: permissionFlags.screen,
|
|
3454
|
+
readOnly: permissionFlags.readOnly,
|
|
3455
|
+
json: args.includes("--json"),
|
|
3456
|
+
permissionArgs: permissionFlags.canonicalArgs,
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
function buildQuickstartReport(options) {
|
|
3460
|
+
const placeholderWorkspacePath = process.platform === "win32" ? "C:\\Projects\\my-app" : "~/work/my-app";
|
|
3461
|
+
const workspacePath = options.workspacePath ?? placeholderWorkspacePath;
|
|
3462
|
+
const localMcpUrl = "http://127.0.0.1:3939/mcp";
|
|
3463
|
+
const commandParts = quickstartCommandParts();
|
|
3464
|
+
const commandPrefix = formatCliCommand(commandParts);
|
|
3465
|
+
const startParts = [...commandParts, "start", workspacePath];
|
|
3466
|
+
if (options.publicBaseUrl)
|
|
3467
|
+
startParts.push("--url", options.publicBaseUrl);
|
|
3468
|
+
startParts.push(...options.permissionArgs);
|
|
3469
|
+
if (options.tunnelProvider)
|
|
3470
|
+
startParts.push("--tunnel", options.tunnelProvider);
|
|
3471
|
+
if (options.tunnelProvider === "openai")
|
|
3472
|
+
startParts.push("--tunnel-id", options.openaiTunnelId ?? "tunnel_...");
|
|
3473
|
+
const tunnelStatus = options.tunnelProvider ? formatCliCommand([...commandParts, "tunnel", "status"]) : undefined;
|
|
3474
|
+
const historyConnections = options.tunnelProvider ? formatCliCommand([...commandParts, "history", "--view", "connections"]) : undefined;
|
|
3475
|
+
const mcpUrl = options.publicBaseUrl
|
|
3476
|
+
? `${options.publicBaseUrl}/mcp`
|
|
3477
|
+
: options.tunnelProvider === "openai"
|
|
3478
|
+
? `OpenAI tunnel ${options.openaiTunnelId ?? "tunnel_..."} uses the local MCP target ${localMcpUrl}`
|
|
3479
|
+
: options.tunnelProvider
|
|
3480
|
+
? "Use the public tunnel URL printed by start, plus /mcp"
|
|
3481
|
+
: localMcpUrl;
|
|
3482
|
+
const nextActions = [
|
|
3483
|
+
"Run the start command and keep it open while the MCP client is connected.",
|
|
3484
|
+
"Use the startup check printed by start as the first readiness signal.",
|
|
3485
|
+
"Run client setup --show-token only on a trusted local screen when configuring client auth.",
|
|
3486
|
+
"Use client setup --details when the MCP client or agent needs full setup instructions.",
|
|
3487
|
+
"Use self-test only when you want an isolated install check.",
|
|
3488
|
+
];
|
|
3489
|
+
if (options.tunnelProvider) {
|
|
3490
|
+
nextActions.push("Use tunnel status and connection history to confirm traffic reaches /mcp only.");
|
|
3491
|
+
}
|
|
3492
|
+
return {
|
|
3493
|
+
kind: "computer-linker-quickstart",
|
|
3494
|
+
schemaVersion: 1,
|
|
3495
|
+
commandPrefix,
|
|
3496
|
+
workspacePath: options.workspacePath ?? null,
|
|
3497
|
+
placeholderWorkspacePath,
|
|
3498
|
+
permissions: {
|
|
3499
|
+
write: options.write,
|
|
3500
|
+
shell: options.shell,
|
|
3501
|
+
codex: options.codex,
|
|
3502
|
+
screen: options.screen,
|
|
3503
|
+
},
|
|
3504
|
+
tunnel: {
|
|
3505
|
+
provider: options.tunnelProvider ?? null,
|
|
3506
|
+
publicBaseUrl: options.publicBaseUrl ?? null,
|
|
3507
|
+
openaiTunnelId: options.tunnelProvider === "openai" ? options.openaiTunnelId ?? "tunnel_..." : null,
|
|
3508
|
+
},
|
|
3509
|
+
commands: {
|
|
3510
|
+
selfTest: formatCliCommand([...commandParts, "self-test"]),
|
|
3511
|
+
start: formatCliCommand(startParts),
|
|
3512
|
+
status: formatCliCommand([...commandParts, "status"]),
|
|
3513
|
+
token: formatCliCommand([...commandParts, "client", "setup", "--show-token"]),
|
|
3514
|
+
clientSetup: formatCliCommand([...commandParts, "client", "setup"]),
|
|
3515
|
+
localSmoke: formatCliCommand([...commandParts, "client", "smoke", "--allow-http", "--url", localMcpUrl]),
|
|
3516
|
+
...(tunnelStatus ? { tunnelStatus } : {}),
|
|
3517
|
+
...(historyConnections ? { historyConnections } : {}),
|
|
3518
|
+
},
|
|
3519
|
+
connection: {
|
|
3520
|
+
localMcpUrl,
|
|
3521
|
+
mcpUrl,
|
|
3522
|
+
authHeader: "Authorization: Bearer <ownerToken>",
|
|
3523
|
+
},
|
|
3524
|
+
prerequisites: options.tunnelProvider === "openai"
|
|
3525
|
+
? [
|
|
3526
|
+
"OpenAI Secure MCP Tunnel requires CONTROL_PLANE_API_KEY or OPENAI_API_KEY with Tunnels Read+Use permissions.",
|
|
3527
|
+
openAiTunnelApiKeyHint(),
|
|
3528
|
+
]
|
|
3529
|
+
: [],
|
|
3530
|
+
terminalHint: "Keep the start command running. Run client setup and verify commands in another terminal.",
|
|
3531
|
+
nextActions,
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
function printQuickstartReport(report) {
|
|
3535
|
+
console.log("Computer Linker quickstart");
|
|
3536
|
+
console.log("");
|
|
3537
|
+
if (!report.workspacePath) {
|
|
3538
|
+
console.log(`workspace path: not provided; example uses ${report.placeholderWorkspacePath}`);
|
|
3539
|
+
}
|
|
3540
|
+
else {
|
|
3541
|
+
console.log(`workspace path: ${report.workspacePath}`);
|
|
3542
|
+
}
|
|
3543
|
+
console.log("");
|
|
3544
|
+
console.log("1. Start Computer Linker:");
|
|
3545
|
+
if (report.prerequisites.length > 0) {
|
|
3546
|
+
console.log(` Prerequisite: ${report.prerequisites[0]}`);
|
|
3547
|
+
for (const prerequisite of report.prerequisites.slice(1)) {
|
|
3548
|
+
console.log(` ${prerequisite}`);
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
console.log(` ${report.commands.start}`);
|
|
3552
|
+
console.log(` ${report.terminalHint}`);
|
|
3553
|
+
console.log("2. Configure MCP client:");
|
|
3554
|
+
console.log(` MCP URL: ${report.connection.mcpUrl}`);
|
|
3555
|
+
if (report.tunnel.provider === "openai") {
|
|
3556
|
+
console.log(" Auth: handled by OpenAI tunnel-client; do not paste a bearer token into ChatGPT Tunnel mode.");
|
|
3557
|
+
console.log(` ChatGPT connector: choose Tunnel and select or paste ${report.tunnel.openaiTunnelId ?? "tunnel_..."}.`);
|
|
3558
|
+
}
|
|
3559
|
+
else {
|
|
3560
|
+
console.log(` Auth: ${report.connection.authHeader}`);
|
|
3561
|
+
console.log(` Token: ${report.commands.token}`);
|
|
3562
|
+
}
|
|
3563
|
+
console.log(` Agent instructions: ${report.commands.clientSetup}`);
|
|
3564
|
+
console.log("3. Verify:");
|
|
3565
|
+
console.log(` ${report.commands.status}`);
|
|
3566
|
+
console.log(` ${report.commands.localSmoke}`);
|
|
3567
|
+
if (report.commands.tunnelStatus)
|
|
3568
|
+
console.log(` ${report.commands.tunnelStatus}`);
|
|
3569
|
+
if (report.commands.historyConnections)
|
|
3570
|
+
console.log(` ${report.commands.historyConnections}`);
|
|
3571
|
+
console.log(` optional isolated install check: ${report.commands.selfTest}`);
|
|
3572
|
+
}
|
|
3573
|
+
function quickstartCommandParts() {
|
|
3574
|
+
return invocationCommandParts();
|
|
3575
|
+
}
|
|
3576
|
+
function invocationCommand(...args) {
|
|
3577
|
+
return formatCliCommand([...invocationCommandParts(), ...args]);
|
|
3578
|
+
}
|
|
3579
|
+
function invocationCommandParts() {
|
|
3580
|
+
if (isNpmDevCliInvocation()) {
|
|
3581
|
+
return ["npm", "run", "dev", "--"];
|
|
3582
|
+
}
|
|
3583
|
+
const scriptArg = process.argv[1];
|
|
3584
|
+
const invokedPath = scriptArg ? resolve(scriptArg) : "";
|
|
3585
|
+
const checkoutDistCliPath = resolve(process.cwd(), "dist", "cli.js");
|
|
3586
|
+
const normalizedInvokedPath = invokedPath.replaceAll("\\", "/").toLowerCase();
|
|
3587
|
+
if (normalizedInvokedPath.endsWith("/dist/cli.js") &&
|
|
3588
|
+
!isInstalledPackageCliPath(normalizedInvokedPath)) {
|
|
3589
|
+
if (invokedPath === checkoutDistCliPath) {
|
|
3590
|
+
return ["node", process.platform === "win32" ? "dist\\cli.js" : "dist/cli.js"];
|
|
3591
|
+
}
|
|
3592
|
+
return ["node", invokedPath];
|
|
3593
|
+
}
|
|
3594
|
+
return ["computer-linker"];
|
|
3595
|
+
}
|
|
3596
|
+
function isInstalledPackageCliPath(normalizedInvokedPath) {
|
|
3597
|
+
return /\/node_modules\/(?:@[^/]+\/)?computer-linker\/dist\/cli\.js$/.test(normalizedInvokedPath);
|
|
3598
|
+
}
|
|
3599
|
+
function isNpmDevCliInvocation() {
|
|
3600
|
+
return (process.env.npm_lifecycle_event === "dev" &&
|
|
3601
|
+
typeof process.env.npm_lifecycle_script === "string" &&
|
|
3602
|
+
/\btsx(?:\s+|$)/.test(process.env.npm_lifecycle_script) &&
|
|
3603
|
+
/src[\\/]+cli\.ts\b/.test(process.env.npm_lifecycle_script));
|
|
3604
|
+
}
|
|
3605
|
+
function formatCliCommand(parts) {
|
|
3606
|
+
return parts.map((part) => quoteCliPart(part)).join(" ");
|
|
3607
|
+
}
|
|
3608
|
+
function quoteCliPart(part) {
|
|
3609
|
+
if (part === "")
|
|
3610
|
+
return "\"\"";
|
|
3611
|
+
if (process.platform === "win32") {
|
|
3612
|
+
if (!/[\s"&|<>^()%!:\\/]/.test(part))
|
|
3613
|
+
return part;
|
|
3614
|
+
return `"${part.replaceAll("\"", "\"\"")}"`;
|
|
3615
|
+
}
|
|
3616
|
+
if (!/[\s"'\\$`!&|;<>(){}[\]*?]/.test(part))
|
|
3617
|
+
return part;
|
|
3618
|
+
return `'${part.replaceAll("'", "'\\''")}'`;
|
|
3619
|
+
}
|
|
3620
|
+
function printHelp(args = []) {
|
|
3621
|
+
if (args.length === 0) {
|
|
3622
|
+
printCoreHelp();
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
const [topic, ...rest] = args;
|
|
3626
|
+
if ((topic === "advanced" || topic === "all" || topic === "--advanced") && rest.length === 0) {
|
|
3627
|
+
printAdvancedHelp();
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
if (topic === "init" && rest.length === 0) {
|
|
3631
|
+
printInitHelp();
|
|
3632
|
+
return;
|
|
3633
|
+
}
|
|
3634
|
+
if (topic === "serve" && rest.length === 0) {
|
|
3635
|
+
printServeHelp();
|
|
3636
|
+
return;
|
|
3637
|
+
}
|
|
3638
|
+
if ((topic === "chatgpt" || topic === "client-chatgpt") && rest.length === 0) {
|
|
3639
|
+
printChatGptHelp();
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
if (topic === "client") {
|
|
3643
|
+
printClientHelpTopic(rest);
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
if (topic === "profile" && rest.length === 0) {
|
|
3647
|
+
printProfileHelp();
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (topic === "setup") {
|
|
3651
|
+
printSetupHelpTopic(rest);
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (topic === "expose") {
|
|
3655
|
+
printExposeHelpTopic(rest);
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
if (topic === "start" && rest.length === 0) {
|
|
3659
|
+
printStartHelp();
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
if (topic === "quickstart" && rest.length === 0) {
|
|
3663
|
+
printQuickstartHelp();
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
if (topic === "status" && rest.length === 0) {
|
|
3667
|
+
printStatusHelp();
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3670
|
+
if ((topic === "self-test" || topic === "selftest") && rest.length === 0) {
|
|
3671
|
+
printSelfTestHelp();
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
if (topic === "doctor" && rest.length === 0) {
|
|
3675
|
+
printDoctorHelp();
|
|
3676
|
+
return;
|
|
3677
|
+
}
|
|
3678
|
+
if (topic === "diagnose" && rest.length === 0) {
|
|
3679
|
+
printDiagnoseHelp();
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
if (topic === "history" && rest.length === 0) {
|
|
3683
|
+
printHistoryHelp();
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
if (topic === "config") {
|
|
3687
|
+
printConfigHelpTopic(rest);
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
if (topic === "tunnel") {
|
|
3691
|
+
printTunnelHelpTopic(rest);
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
if (topic === "service") {
|
|
3695
|
+
printServiceHelpTopic(rest);
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
if (topic === "workspace") {
|
|
3699
|
+
printWorkspaceHelpTopic(rest);
|
|
3700
|
+
return;
|
|
3701
|
+
}
|
|
3702
|
+
throw new Error(`Unknown help topic: ${topic}`);
|
|
3703
|
+
}
|
|
3704
|
+
function hasHelpFlag(args) {
|
|
3705
|
+
return args.includes("--help") || args.includes("-h");
|
|
3706
|
+
}
|
|
3707
|
+
function printVersion() {
|
|
3708
|
+
console.log(`computer-linker ${workspaceLinkerVersion()}`);
|
|
3709
|
+
}
|
|
3710
|
+
function printCliHelp(lines) {
|
|
3711
|
+
console.log(formatCliHelp(lines.join("\n")));
|
|
3712
|
+
}
|
|
3713
|
+
function formatCliHelp(text) {
|
|
3714
|
+
if (!isNpmDevCliInvocation())
|
|
3715
|
+
return text;
|
|
3716
|
+
return text
|
|
3717
|
+
.replace(/\bcomputer-linker\b/g, "npm run dev --")
|
|
3718
|
+
.replace(/npm run dev -- --version/g, "npm run dev -- version");
|
|
3719
|
+
}
|
|
3720
|
+
function printInitHelp() {
|
|
3721
|
+
printCliHelp([
|
|
3722
|
+
"Computer Linker init",
|
|
3723
|
+
"",
|
|
3724
|
+
"Usage:",
|
|
3725
|
+
" computer-linker init [--show-token]",
|
|
3726
|
+
"",
|
|
3727
|
+
"What it does:",
|
|
3728
|
+
" Creates the local config and owner token if they do not exist.",
|
|
3729
|
+
" Use --show-token only on a trusted local setup screen.",
|
|
3730
|
+
"",
|
|
3731
|
+
"Example:",
|
|
3732
|
+
" computer-linker init",
|
|
3733
|
+
]);
|
|
3734
|
+
}
|
|
3735
|
+
function printServeHelp() {
|
|
3736
|
+
printCliHelp([
|
|
3737
|
+
"Computer Linker serve",
|
|
3738
|
+
"",
|
|
3739
|
+
"Usage:",
|
|
3740
|
+
" computer-linker serve",
|
|
3741
|
+
" computer-linker serve --transport http",
|
|
3742
|
+
" computer-linker serve --transport stdio",
|
|
3743
|
+
"",
|
|
3744
|
+
"What it does:",
|
|
3745
|
+
" Starts the MCP server without changing workspace config.",
|
|
3746
|
+
" For daily use, prefer `computer-linker start <folder>` so setup and server start happen together.",
|
|
3747
|
+
]);
|
|
3748
|
+
}
|
|
3749
|
+
function printCoreHelp() {
|
|
3750
|
+
printCliHelp([
|
|
3751
|
+
"Computer Linker",
|
|
3752
|
+
"",
|
|
3753
|
+
"Usage:",
|
|
3754
|
+
" computer-linker start <workspace-path>",
|
|
3755
|
+
" computer-linker start <workspace-path> --tunnel openai|tailscale|cloudflare",
|
|
3756
|
+
" computer-linker client setup",
|
|
3757
|
+
" computer-linker status",
|
|
3758
|
+
" computer-linker help advanced",
|
|
3759
|
+
"",
|
|
3760
|
+
"First run:",
|
|
3761
|
+
" 1. Start local: computer-linker start C:\\Projects\\my-app",
|
|
3762
|
+
" 2. Connect client: computer-linker client setup",
|
|
3763
|
+
" 3. Check state: computer-linker status",
|
|
3764
|
+
"",
|
|
3765
|
+
"Cloud client:",
|
|
3766
|
+
" computer-linker start C:\\Projects\\my-app --tunnel openai --tunnel-id tunnel_...",
|
|
3767
|
+
" computer-linker start C:\\Projects\\my-app --tunnel tailscale",
|
|
3768
|
+
" computer-linker start C:\\Projects\\my-app --tunnel cloudflare",
|
|
3769
|
+
"",
|
|
3770
|
+
"Before changing config:",
|
|
3771
|
+
" computer-linker quickstart C:\\Projects\\my-app",
|
|
3772
|
+
"",
|
|
3773
|
+
"Notes:",
|
|
3774
|
+
" <workspace-path> is the folder to expose.",
|
|
3775
|
+
" start creates config, token, and a workspace entry when needed, then runs a local startup check.",
|
|
3776
|
+
" Workspace names default to the folder name.",
|
|
3777
|
+
" By default, start allows file edits and approved project commands for normal development work.",
|
|
3778
|
+
" Use --read-only to inspect only; use --full-trust only when Codex and screen capture are intended.",
|
|
3779
|
+
" Tokens stay hidden by default; use client setup --show-token only on a trusted local setup screen.",
|
|
3780
|
+
" Details: computer-linker help start | computer-linker help client setup | computer-linker help advanced",
|
|
3781
|
+
]);
|
|
3782
|
+
}
|
|
3783
|
+
function printStartHelp() {
|
|
3784
|
+
printCliHelp([
|
|
3785
|
+
"Computer Linker start",
|
|
3786
|
+
"",
|
|
3787
|
+
"Usage:",
|
|
3788
|
+
" computer-linker start <workspace-path> [--codex] [--screen]",
|
|
3789
|
+
" computer-linker start <workspace-path> --read-only",
|
|
3790
|
+
" computer-linker start <workspace-path> --full-trust",
|
|
3791
|
+
" computer-linker start <workspace-path> --tunnel openai --tunnel-id tunnel_...",
|
|
3792
|
+
" computer-linker start <workspace-path> --tunnel tailscale",
|
|
3793
|
+
" computer-linker start <workspace-path> --tunnel cloudflare",
|
|
3794
|
+
" computer-linker start",
|
|
3795
|
+
"",
|
|
3796
|
+
"What it does:",
|
|
3797
|
+
" Creates config, owner token, and a workspace entry when needed.",
|
|
3798
|
+
" Uses the folder name as the workspace name unless --name is provided.",
|
|
3799
|
+
" Starts the local HTTP MCP server, runs a local startup check, and keeps running until you stop it.",
|
|
3800
|
+
" A new workspace defaults to coding mode: file edits plus approved project commands.",
|
|
3801
|
+
"",
|
|
3802
|
+
"Common options:",
|
|
3803
|
+
" --read-only Read/search/history only.",
|
|
3804
|
+
" --full-trust Writes, approved commands, Codex operations, and screen capture.",
|
|
3805
|
+
" --write Allow file edits in this workspace.",
|
|
3806
|
+
" --shell Allow approved local commands and package scripts.",
|
|
3807
|
+
" --codex Allow Codex operations in this workspace.",
|
|
3808
|
+
" --screen Allow screen capture operations.",
|
|
3809
|
+
" --tunnel openai|tailscale|cloudflare",
|
|
3810
|
+
" --show-token Print the owner token on this trusted local screen.",
|
|
3811
|
+
" OpenAI tunnel requires CONTROL_PLANE_API_KEY or OPENAI_API_KEY with Tunnels Read+Use permissions.",
|
|
3812
|
+
"",
|
|
3813
|
+
"Examples:",
|
|
3814
|
+
" computer-linker start C:\\Projects\\my-app",
|
|
3815
|
+
" computer-linker start C:\\Projects\\my-app --tunnel openai --tunnel-id tunnel_...",
|
|
3816
|
+
" computer-linker start C:\\Projects\\my-app --tunnel tailscale",
|
|
3817
|
+
]);
|
|
3818
|
+
}
|
|
3819
|
+
function printQuickstartHelp() {
|
|
3820
|
+
printCliHelp([
|
|
3821
|
+
"Computer Linker quickstart",
|
|
3822
|
+
"",
|
|
3823
|
+
"Usage:",
|
|
3824
|
+
" computer-linker quickstart [workspace-path]",
|
|
3825
|
+
" computer-linker quickstart [workspace-path] --tunnel openai --tunnel-id tunnel_...",
|
|
3826
|
+
" computer-linker quickstart [workspace-path] --tunnel tailscale",
|
|
3827
|
+
" computer-linker quickstart [workspace-path] --tunnel cloudflare",
|
|
3828
|
+
"",
|
|
3829
|
+
"What it does:",
|
|
3830
|
+
" Prints the exact commands to test, start, configure, and verify Computer Linker.",
|
|
3831
|
+
" Does not read or write config.",
|
|
3832
|
+
"",
|
|
3833
|
+
"Common options:",
|
|
3834
|
+
" --read-only Read/search/history only.",
|
|
3835
|
+
" --full-trust Include write, shell, Codex, and screen permission.",
|
|
3836
|
+
" --write Include write permission in the generated start command.",
|
|
3837
|
+
" --shell Include shell/package command permission in the generated start command.",
|
|
3838
|
+
" --codex Include Codex permission in the generated start command.",
|
|
3839
|
+
" --screen Include screen capture permission in the generated start command.",
|
|
3840
|
+
" --json Print the quickstart plan as JSON.",
|
|
3841
|
+
"",
|
|
3842
|
+
"Examples:",
|
|
3843
|
+
" computer-linker quickstart C:\\Projects\\my-app",
|
|
3844
|
+
" computer-linker quickstart C:\\Projects\\my-app --tunnel openai --tunnel-id tunnel_...",
|
|
3845
|
+
]);
|
|
3846
|
+
}
|
|
3847
|
+
function printProfileHelp() {
|
|
3848
|
+
printCliHelp([
|
|
3849
|
+
"Computer Linker profile",
|
|
3850
|
+
"",
|
|
3851
|
+
"Usage:",
|
|
3852
|
+
" computer-linker profile [--show-token]",
|
|
3853
|
+
"",
|
|
3854
|
+
"What it does:",
|
|
3855
|
+
" Prints MCP connection profile JSON for local setup screens and clients.",
|
|
3856
|
+
" Tokens are redacted unless --show-token is passed on a trusted local screen.",
|
|
3857
|
+
"",
|
|
3858
|
+
"Example:",
|
|
3859
|
+
" computer-linker profile",
|
|
3860
|
+
]);
|
|
3861
|
+
}
|
|
3862
|
+
function printClientHelpTopic(args) {
|
|
3863
|
+
const [topic, ...rest] = args;
|
|
3864
|
+
if (!topic) {
|
|
3865
|
+
printClientHelp();
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
if (topic === "setup" && rest.length === 0) {
|
|
3869
|
+
printClientSetupHelp();
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
if (topic === "smoke" && rest.length === 0) {
|
|
3873
|
+
printClientSmokeHelp();
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
if (topic === "diagnose" && rest.length === 0) {
|
|
3877
|
+
printClientDiagnoseHelp();
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
if (topic === "chatgpt" && rest.length === 0) {
|
|
3881
|
+
printChatGptHelp();
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
throw new Error(`Unknown client help topic: ${args.join(" ")}`);
|
|
3885
|
+
}
|
|
3886
|
+
function printClientHelp() {
|
|
3887
|
+
printCliHelp([
|
|
3888
|
+
"Computer Linker client",
|
|
3889
|
+
"",
|
|
3890
|
+
"Usage:",
|
|
3891
|
+
" computer-linker client setup [--details] [--show-token] [--json]",
|
|
3892
|
+
" computer-linker client smoke [--url https://.../mcp] [--token token] [--allow-http] [--show-token] [--json]",
|
|
3893
|
+
" computer-linker client diagnose [--local|--remote|--url https://.../mcp] [--json]",
|
|
3894
|
+
" computer-linker client chatgpt <subcommand>",
|
|
3895
|
+
"",
|
|
3896
|
+
"What it does:",
|
|
3897
|
+
" Prints generic MCP client setup details and runs connection smoke tests.",
|
|
3898
|
+
" ChatGPT-specific exports are compatibility helpers; prefer generic setup first.",
|
|
3899
|
+
"",
|
|
3900
|
+
"More help:",
|
|
3901
|
+
" computer-linker client help setup",
|
|
3902
|
+
" computer-linker client help smoke",
|
|
3903
|
+
" computer-linker client help diagnose",
|
|
3904
|
+
]);
|
|
3905
|
+
}
|
|
3906
|
+
function printClientSetupHelp() {
|
|
3907
|
+
printCliHelp([
|
|
3908
|
+
"Computer Linker client setup",
|
|
3909
|
+
"",
|
|
3910
|
+
"Usage:",
|
|
3911
|
+
" computer-linker client setup [--details] [--show-token] [--json]",
|
|
3912
|
+
"",
|
|
3913
|
+
"What it does:",
|
|
3914
|
+
" Prints a short MCP client connection summary by default.",
|
|
3915
|
+
" Use --details for tool names, first prompt, and copy-pasteable agent instructions.",
|
|
3916
|
+
" Use --show-token only on a trusted local setup screen when the client needs a bearer token.",
|
|
3917
|
+
"",
|
|
3918
|
+
"Examples:",
|
|
3919
|
+
" computer-linker client setup",
|
|
3920
|
+
" computer-linker client setup --details",
|
|
3921
|
+
" computer-linker client setup --show-token",
|
|
3922
|
+
]);
|
|
3923
|
+
}
|
|
3924
|
+
function printClientSmokeHelp() {
|
|
3925
|
+
printCliHelp([
|
|
3926
|
+
"Computer Linker client smoke",
|
|
3927
|
+
"",
|
|
3928
|
+
"Usage:",
|
|
3929
|
+
" computer-linker client smoke [--url https://.../mcp] [--token token] [--allow-http] [--show-token] [--json] [--timeout-ms ms]",
|
|
3930
|
+
"",
|
|
3931
|
+
"What it does:",
|
|
3932
|
+
" Runs a small MCP client smoke test against the configured or provided MCP URL.",
|
|
3933
|
+
" Use --allow-http only for trusted local loopback tests.",
|
|
3934
|
+
"",
|
|
3935
|
+
"Example:",
|
|
3936
|
+
" computer-linker client smoke --allow-http --url http://127.0.0.1:3939/mcp",
|
|
3937
|
+
]);
|
|
3938
|
+
}
|
|
3939
|
+
function printClientDiagnoseHelp() {
|
|
3940
|
+
printCliHelp([
|
|
3941
|
+
"Computer Linker client diagnose",
|
|
3942
|
+
"",
|
|
3943
|
+
"Usage:",
|
|
3944
|
+
" computer-linker client diagnose [--local|--remote|--url https://.../mcp] [--json] [--timeout-ms ms]",
|
|
3945
|
+
" computer-linker diagnose client [--local|--remote|--url https://.../mcp] [--json] [--timeout-ms ms]",
|
|
3946
|
+
"",
|
|
3947
|
+
"What it does:",
|
|
3948
|
+
" Runs MCP client setup checks, a minimal MCP client smoke test, and redacted connection-history inspection.",
|
|
3949
|
+
" Defaults to local loopback. Use --remote for the configured public URL or --url for one explicit endpoint.",
|
|
3950
|
+
"",
|
|
3951
|
+
"Examples:",
|
|
3952
|
+
" computer-linker diagnose client",
|
|
3953
|
+
" computer-linker diagnose client --remote",
|
|
3954
|
+
" computer-linker diagnose client --url https://example.com/mcp",
|
|
3955
|
+
]);
|
|
3956
|
+
}
|
|
3957
|
+
function printDiagnoseHelp() {
|
|
3958
|
+
printClientDiagnoseHelp();
|
|
3959
|
+
}
|
|
3960
|
+
function printSetupHelpTopic(args) {
|
|
3961
|
+
const [topic, ...rest] = args;
|
|
3962
|
+
if (!topic || topic === "mcp-only" || topic === "cloudflare-mcp") {
|
|
3963
|
+
if (rest.length > 0)
|
|
3964
|
+
throw new Error(`Unknown setup help topic: ${args.join(" ")}`);
|
|
3965
|
+
printSetupHelp();
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
throw new Error(`Unknown setup help topic: ${args.join(" ")}`);
|
|
3969
|
+
}
|
|
3970
|
+
function printSetupHelp() {
|
|
3971
|
+
printCliHelp([
|
|
3972
|
+
"Computer Linker setup",
|
|
3973
|
+
"",
|
|
3974
|
+
"Usage:",
|
|
3975
|
+
" computer-linker setup <workspace-path> [--read-only|--full-trust]",
|
|
3976
|
+
" computer-linker setup <workspace-path> --tunnel openai --tunnel-id tunnel_...",
|
|
3977
|
+
" computer-linker setup <workspace-path> --tunnel tailscale",
|
|
3978
|
+
" computer-linker setup <workspace-path> --tunnel cloudflare",
|
|
3979
|
+
"",
|
|
3980
|
+
"What it does:",
|
|
3981
|
+
" Creates or updates config, owner token, public MCP-only mode, and one workspace entry without starting the server.",
|
|
3982
|
+
" Workspace names default to the folder name.",
|
|
3983
|
+
" For one-command daily use, prefer `computer-linker start <workspace-path>`.",
|
|
3984
|
+
" New setup entries default to coding mode: file edits plus approved project commands.",
|
|
3985
|
+
" Use --read-only to inspect only; use --full-trust only when Codex and screen capture are intended.",
|
|
3986
|
+
"",
|
|
3987
|
+
"Example:",
|
|
3988
|
+
" computer-linker setup C:\\Projects\\my-app",
|
|
3989
|
+
]);
|
|
3990
|
+
}
|
|
3991
|
+
function printExposeHelpTopic(args) {
|
|
3992
|
+
const [topic, ...rest] = args;
|
|
3993
|
+
if (!topic) {
|
|
3994
|
+
printExposeHelp();
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
if ((topic === "tailscale" || topic === "cloudflare") && rest.length === 0) {
|
|
3998
|
+
printExposeProviderHelp(topic);
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
throw new Error(`Unknown expose help topic: ${args.join(" ")}`);
|
|
4002
|
+
}
|
|
4003
|
+
function printExposeHelp() {
|
|
4004
|
+
console.log([
|
|
4005
|
+
"Computer Linker expose",
|
|
4006
|
+
"",
|
|
4007
|
+
"Usage:",
|
|
4008
|
+
" computer-linker expose tailscale [--mode funnel]",
|
|
4009
|
+
" computer-linker expose cloudflare",
|
|
4010
|
+
"",
|
|
4011
|
+
"What it does:",
|
|
4012
|
+
" Starts an HTTP MCP server and opens a tunnel to it.",
|
|
4013
|
+
" `start <workspace-path> --tunnel ...` is the simpler development path.",
|
|
4014
|
+
"",
|
|
4015
|
+
"More help:",
|
|
4016
|
+
" computer-linker expose help tailscale",
|
|
4017
|
+
" computer-linker expose help cloudflare",
|
|
4018
|
+
].join("\n"));
|
|
4019
|
+
}
|
|
4020
|
+
function printExposeProviderHelp(provider) {
|
|
4021
|
+
console.log([
|
|
4022
|
+
`Computer Linker expose ${provider}`,
|
|
4023
|
+
"",
|
|
4024
|
+
"Usage:",
|
|
4025
|
+
provider === "tailscale"
|
|
4026
|
+
? " computer-linker expose tailscale [--mode funnel]"
|
|
4027
|
+
: " computer-linker expose cloudflare",
|
|
4028
|
+
"",
|
|
4029
|
+
"What it does:",
|
|
4030
|
+
provider === "tailscale"
|
|
4031
|
+
? " Opens a Tailscale Funnel tunnel to the local HTTP MCP server."
|
|
4032
|
+
: " Opens a Cloudflare tunnel to the local HTTP MCP server.",
|
|
4033
|
+
" Public host requests are restricted to the MCP endpoint by Computer Linker.",
|
|
4034
|
+
].join("\n"));
|
|
4035
|
+
}
|
|
4036
|
+
function printStatusHelp() {
|
|
4037
|
+
console.log([
|
|
4038
|
+
"Computer Linker status",
|
|
4039
|
+
"",
|
|
4040
|
+
"Usage:",
|
|
4041
|
+
" computer-linker status [--details] [--json]",
|
|
4042
|
+
"",
|
|
4043
|
+
"What it does:",
|
|
4044
|
+
" Prints the daily readiness summary: connection mode, local MCP URL, workspace/tunnel counts, and the next few actions.",
|
|
4045
|
+
" Use --details for warnings, workspace rows, running tunnel rows, and all next actions.",
|
|
4046
|
+
"",
|
|
4047
|
+
"Example:",
|
|
4048
|
+
" computer-linker status",
|
|
4049
|
+
].join("\n"));
|
|
4050
|
+
}
|
|
4051
|
+
function printSelfTestHelp() {
|
|
4052
|
+
console.log([
|
|
4053
|
+
"Computer Linker self-test",
|
|
4054
|
+
"",
|
|
4055
|
+
"Usage:",
|
|
4056
|
+
" computer-linker self-test [--json] [--keep-temp] [--timeout-ms ms]",
|
|
4057
|
+
"",
|
|
4058
|
+
"What it does:",
|
|
4059
|
+
" Starts a temporary local MCP server, runs a safe client smoke test, then cleans up.",
|
|
4060
|
+
" It does not use your configured workspaces unless --keep-temp leaves the temporary files for inspection.",
|
|
4061
|
+
"",
|
|
4062
|
+
"Example:",
|
|
4063
|
+
" computer-linker self-test",
|
|
4064
|
+
].join("\n"));
|
|
4065
|
+
}
|
|
4066
|
+
function printDoctorHelp() {
|
|
4067
|
+
console.log([
|
|
4068
|
+
"Computer Linker doctor",
|
|
4069
|
+
"",
|
|
4070
|
+
"Usage:",
|
|
4071
|
+
" computer-linker doctor [--json]",
|
|
4072
|
+
" computer-linker doctor --fix [--dry-run] [--json]",
|
|
4073
|
+
"",
|
|
4074
|
+
"What it does:",
|
|
4075
|
+
" Checks config, auth, tunnel tools, local tools, startup readiness, and release readiness.",
|
|
4076
|
+
" --fix applies low-risk config repairs, such as removing exact duplicate scopes and filling execution policy defaults.",
|
|
4077
|
+
"",
|
|
4078
|
+
"Examples:",
|
|
4079
|
+
" computer-linker doctor",
|
|
4080
|
+
" computer-linker doctor --fix --dry-run",
|
|
4081
|
+
].join("\n"));
|
|
4082
|
+
}
|
|
4083
|
+
function printHistoryHelp() {
|
|
4084
|
+
console.log([
|
|
4085
|
+
"Computer Linker history",
|
|
4086
|
+
"",
|
|
4087
|
+
"Usage:",
|
|
4088
|
+
" computer-linker history [--view summary|last|timeline|sessions|connections|failed_replay|debug_bundle] [--workspace id] [--query text] [--limit n] [--json] [--output file]",
|
|
4089
|
+
"",
|
|
4090
|
+
"What it does:",
|
|
4091
|
+
" Reads redacted local operation history for troubleshooting MCP client behavior.",
|
|
4092
|
+
"",
|
|
4093
|
+
"Examples:",
|
|
4094
|
+
" computer-linker history --view last",
|
|
4095
|
+
" computer-linker history --view connections",
|
|
4096
|
+
].join("\n"));
|
|
4097
|
+
}
|
|
4098
|
+
function printConfigHelpTopic(args) {
|
|
4099
|
+
const [topic, ...rest] = args;
|
|
4100
|
+
if (!topic || topic === "path") {
|
|
4101
|
+
if (rest.length > 0)
|
|
4102
|
+
throw new Error(`Unknown config help topic: ${args.join(" ")}`);
|
|
4103
|
+
printConfigHelp();
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
4106
|
+
if (topic === "show" && rest.length === 0) {
|
|
4107
|
+
printConfigShowHelp();
|
|
4108
|
+
return;
|
|
4109
|
+
}
|
|
4110
|
+
if (topic === "validate" && rest.length === 0) {
|
|
4111
|
+
printConfigValidateHelp();
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
4114
|
+
if (topic === "token" && rest.length === 0) {
|
|
4115
|
+
printConfigTokenHelp();
|
|
4116
|
+
return;
|
|
4117
|
+
}
|
|
4118
|
+
if (topic === "policy" && rest.length === 0) {
|
|
4119
|
+
printConfigPolicyHelp();
|
|
4120
|
+
return;
|
|
4121
|
+
}
|
|
4122
|
+
if ((topic === "set-public-url" || topic === "set-public-base-url") && rest.length === 0) {
|
|
4123
|
+
printConfigPublicUrlHelp();
|
|
4124
|
+
return;
|
|
4125
|
+
}
|
|
4126
|
+
if ((topic === "clear-public-url" || topic === "clear-public-base-url") && rest.length === 0) {
|
|
4127
|
+
printConfigClearPublicUrlHelp();
|
|
4128
|
+
return;
|
|
4129
|
+
}
|
|
4130
|
+
throw new Error(`Unknown config help topic: ${args.join(" ")}`);
|
|
4131
|
+
}
|
|
4132
|
+
function printConfigHelp() {
|
|
4133
|
+
console.log([
|
|
4134
|
+
"Computer Linker config",
|
|
4135
|
+
"",
|
|
4136
|
+
"Usage:",
|
|
4137
|
+
" computer-linker config path",
|
|
4138
|
+
" computer-linker config show [--show-token]",
|
|
4139
|
+
" computer-linker config validate [--json]",
|
|
4140
|
+
" computer-linker config token [rotate] [--show-token] [--json]",
|
|
4141
|
+
" computer-linker config policy <workspace-id> [--json] [--allow pattern] [--deny pattern]",
|
|
4142
|
+
" computer-linker config set-public-url <https-url>",
|
|
4143
|
+
" computer-linker config clear-public-url",
|
|
4144
|
+
"",
|
|
4145
|
+
"What it does:",
|
|
4146
|
+
" Inspects and updates the local Computer Linker config file.",
|
|
4147
|
+
" Tokens are redacted unless --show-token is explicitly passed on a trusted local screen.",
|
|
4148
|
+
"",
|
|
4149
|
+
"More help:",
|
|
4150
|
+
" computer-linker config help token",
|
|
4151
|
+
" computer-linker config help policy",
|
|
4152
|
+
].join("\n"));
|
|
4153
|
+
}
|
|
4154
|
+
function printConfigShowHelp() {
|
|
4155
|
+
console.log([
|
|
4156
|
+
"Computer Linker config show",
|
|
4157
|
+
"",
|
|
4158
|
+
"Usage:",
|
|
4159
|
+
" computer-linker config show [--show-token]",
|
|
4160
|
+
"",
|
|
4161
|
+
"What it does:",
|
|
4162
|
+
" Prints the local config as JSON. The owner token is redacted unless --show-token is passed.",
|
|
4163
|
+
].join("\n"));
|
|
4164
|
+
}
|
|
4165
|
+
function printConfigValidateHelp() {
|
|
4166
|
+
console.log([
|
|
4167
|
+
"Computer Linker config validate",
|
|
4168
|
+
"",
|
|
4169
|
+
"Usage:",
|
|
4170
|
+
" computer-linker config validate [--json]",
|
|
4171
|
+
"",
|
|
4172
|
+
"What it does:",
|
|
4173
|
+
" Checks config and security diagnostics without modifying the config.",
|
|
4174
|
+
].join("\n"));
|
|
4175
|
+
}
|
|
4176
|
+
function printConfigTokenHelp() {
|
|
4177
|
+
console.log([
|
|
4178
|
+
"Computer Linker config token",
|
|
4179
|
+
"",
|
|
4180
|
+
"Usage:",
|
|
4181
|
+
" computer-linker config token [rotate] [--show-token] [--json]",
|
|
4182
|
+
"",
|
|
4183
|
+
"What it does:",
|
|
4184
|
+
" Shows token status or rotates the owner token.",
|
|
4185
|
+
" Use --show-token only on a trusted local setup screen.",
|
|
4186
|
+
].join("\n"));
|
|
4187
|
+
}
|
|
4188
|
+
function printConfigPolicyHelp() {
|
|
4189
|
+
console.log([
|
|
4190
|
+
"Computer Linker config policy",
|
|
4191
|
+
"",
|
|
4192
|
+
"Usage:",
|
|
4193
|
+
" computer-linker config policy <workspace-id> [--json]",
|
|
4194
|
+
" computer-linker config policy <workspace-id> [--allow pattern] [--deny pattern] [--max-runtime-seconds n] [--max-output-bytes n]",
|
|
4195
|
+
"",
|
|
4196
|
+
"What it does:",
|
|
4197
|
+
" Reads or updates command policy for shell/Codex-enabled workspaces.",
|
|
4198
|
+
].join("\n"));
|
|
4199
|
+
}
|
|
4200
|
+
function printConfigPublicUrlHelp() {
|
|
4201
|
+
console.log([
|
|
4202
|
+
"Computer Linker config set-public-url",
|
|
4203
|
+
"",
|
|
4204
|
+
"Usage:",
|
|
4205
|
+
" computer-linker config set-public-url <https-url>",
|
|
4206
|
+
"",
|
|
4207
|
+
"What it does:",
|
|
4208
|
+
" Stores the public HTTPS base URL used by URL-based remote MCP clients.",
|
|
4209
|
+
].join("\n"));
|
|
4210
|
+
}
|
|
4211
|
+
function printConfigClearPublicUrlHelp() {
|
|
4212
|
+
console.log([
|
|
4213
|
+
"Computer Linker config clear-public-url",
|
|
4214
|
+
"",
|
|
4215
|
+
"Usage:",
|
|
4216
|
+
" computer-linker config clear-public-url",
|
|
4217
|
+
"",
|
|
4218
|
+
"What it does:",
|
|
4219
|
+
" Removes the configured public base URL. This does not stop any running tunnel.",
|
|
4220
|
+
].join("\n"));
|
|
4221
|
+
}
|
|
4222
|
+
function printTunnelHelpTopic(args) {
|
|
4223
|
+
const [topic, ...rest] = args;
|
|
4224
|
+
if (!topic || topic === "status") {
|
|
4225
|
+
if (rest.length > 0)
|
|
4226
|
+
throw new Error(`Unknown tunnel help topic: ${args.join(" ")}`);
|
|
4227
|
+
printTunnelHelp();
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
throw new Error(`Unknown tunnel help topic: ${args.join(" ")}`);
|
|
4231
|
+
}
|
|
4232
|
+
function printTunnelHelp() {
|
|
4233
|
+
console.log([
|
|
4234
|
+
"Computer Linker tunnel",
|
|
4235
|
+
"",
|
|
4236
|
+
"Usage:",
|
|
4237
|
+
" computer-linker tunnel status [--json]",
|
|
4238
|
+
"",
|
|
4239
|
+
"What it does:",
|
|
4240
|
+
" Shows detected tunnel tools, running tunnel processes, effective public URL, and suggested commands.",
|
|
4241
|
+
" OpenAI Secure MCP Tunnel mode reports a tunnel id, not a public URL.",
|
|
4242
|
+
"",
|
|
4243
|
+
"Example:",
|
|
4244
|
+
" computer-linker tunnel status",
|
|
4245
|
+
].join("\n"));
|
|
4246
|
+
}
|
|
4247
|
+
function printServiceHelpTopic(args) {
|
|
4248
|
+
const [topic, ...rest] = args;
|
|
4249
|
+
if (!topic || topic === "profile") {
|
|
4250
|
+
if (rest.length > 0)
|
|
4251
|
+
throw new Error(`Unknown service help topic: ${args.join(" ")}`);
|
|
4252
|
+
printServiceHelp();
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
if (topic === "status" && rest.length === 0) {
|
|
4256
|
+
printServiceStatusHelp();
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
if ((topic === "install" || topic === "uninstall") && rest.length === 0) {
|
|
4260
|
+
printServiceInstallHelp(topic);
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
if ((topic === "start" || topic === "stop") && rest.length === 0) {
|
|
4264
|
+
printServiceControlHelp(topic);
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
4267
|
+
if (topic === "logs" && rest.length === 0) {
|
|
4268
|
+
printServiceLogsHelp();
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
throw new Error(`Unknown service help topic: ${args.join(" ")}`);
|
|
4272
|
+
}
|
|
4273
|
+
function printServiceHelp() {
|
|
4274
|
+
console.log([
|
|
4275
|
+
"Computer Linker service",
|
|
4276
|
+
"",
|
|
4277
|
+
"Usage:",
|
|
4278
|
+
" computer-linker service profile [--platform linux|macos|windows] [--format profile|manifest]",
|
|
4279
|
+
" computer-linker service profile --output-dir ./service-profile [--platform linux|macos|windows]",
|
|
4280
|
+
" computer-linker service status [--platform linux|macos|windows] [--json]",
|
|
4281
|
+
" computer-linker service install --dry-run [--platform linux|macos|windows] [--json]",
|
|
4282
|
+
" computer-linker service install --yes [--platform linux|macos|windows] [--json]",
|
|
4283
|
+
" computer-linker service uninstall --yes [--platform linux|macos|windows] [--json]",
|
|
4284
|
+
" computer-linker service start|stop [--platform linux|macos|windows] [--json]",
|
|
4285
|
+
" computer-linker service logs [--lines 100] [--platform linux|macos|windows] [--json]",
|
|
4286
|
+
"",
|
|
4287
|
+
"What it does:",
|
|
4288
|
+
" Generates service-manager profiles and controls the local background service.",
|
|
4289
|
+
" Install and uninstall require --yes; use --dry-run to preview without changing the OS.",
|
|
4290
|
+
"",
|
|
4291
|
+
"More help:",
|
|
4292
|
+
" computer-linker service help status",
|
|
4293
|
+
" computer-linker service help install",
|
|
4294
|
+
" computer-linker service help logs",
|
|
4295
|
+
].join("\n"));
|
|
4296
|
+
}
|
|
4297
|
+
function printServiceStatusHelp() {
|
|
4298
|
+
console.log([
|
|
4299
|
+
"Computer Linker service status",
|
|
4300
|
+
"",
|
|
4301
|
+
"Usage:",
|
|
4302
|
+
" computer-linker service status [--platform linux|macos|windows] [--json]",
|
|
4303
|
+
"",
|
|
4304
|
+
"What it does:",
|
|
4305
|
+
" Prints service-manager status metadata, daily start/stop commands, and log locations.",
|
|
4306
|
+
].join("\n"));
|
|
4307
|
+
}
|
|
4308
|
+
function printServiceInstallHelp(action) {
|
|
4309
|
+
console.log([
|
|
4310
|
+
`Computer Linker service ${action}`,
|
|
4311
|
+
"",
|
|
4312
|
+
"Usage:",
|
|
4313
|
+
` computer-linker service ${action} --dry-run [--platform linux|macos|windows] [--json]`,
|
|
4314
|
+
` computer-linker service ${action} --yes [--platform linux|macos|windows] [--json]`,
|
|
4315
|
+
"",
|
|
4316
|
+
"What it does:",
|
|
4317
|
+
` Prints the ${action} plan with --dry-run, or applies it with --yes.`,
|
|
4318
|
+
].join("\n"));
|
|
4319
|
+
}
|
|
4320
|
+
function printServiceControlHelp(action) {
|
|
4321
|
+
console.log([
|
|
4322
|
+
`Computer Linker service ${action}`,
|
|
4323
|
+
"",
|
|
4324
|
+
"Usage:",
|
|
4325
|
+
` computer-linker service ${action} [--platform linux|macos|windows] [--json]`,
|
|
4326
|
+
` computer-linker service ${action} --dry-run [--platform linux|macos|windows] [--json]`,
|
|
4327
|
+
"",
|
|
4328
|
+
"What it does:",
|
|
4329
|
+
` ${action === "start" ? "Starts" : "Stops"} the installed service on the current platform.`,
|
|
4330
|
+
].join("\n"));
|
|
4331
|
+
}
|
|
4332
|
+
function printServiceLogsHelp() {
|
|
4333
|
+
console.log([
|
|
4334
|
+
"Computer Linker service logs",
|
|
4335
|
+
"",
|
|
4336
|
+
"Usage:",
|
|
4337
|
+
" computer-linker service logs [--lines 100] [--platform linux|macos|windows] [--json]",
|
|
4338
|
+
"",
|
|
4339
|
+
"What it does:",
|
|
4340
|
+
" Reads generated service stdout/stderr logs when available and prints the native log command.",
|
|
4341
|
+
].join("\n"));
|
|
4342
|
+
}
|
|
4343
|
+
function printWorkspaceHelpTopic(args) {
|
|
4344
|
+
const [topic, ...rest] = args;
|
|
4345
|
+
if (!topic || topic === "list") {
|
|
4346
|
+
if (rest.length > 0)
|
|
4347
|
+
throw new Error(`Unknown workspace help topic: ${args.join(" ")}`);
|
|
4348
|
+
printWorkspaceHelp();
|
|
4349
|
+
return;
|
|
4350
|
+
}
|
|
4351
|
+
if (topic === "add" && rest.length === 0) {
|
|
4352
|
+
printWorkspaceAddHelp();
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
if (topic === "update" && rest.length === 0) {
|
|
4356
|
+
printWorkspaceUpdateHelp();
|
|
4357
|
+
return;
|
|
4358
|
+
}
|
|
4359
|
+
if (topic === "remove" && rest.length === 0) {
|
|
4360
|
+
printWorkspaceRemoveHelp();
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
throw new Error(`Unknown workspace help topic: ${args.join(" ")}`);
|
|
4364
|
+
}
|
|
4365
|
+
function printWorkspaceHelp() {
|
|
4366
|
+
console.log([
|
|
4367
|
+
"Computer Linker workspace",
|
|
4368
|
+
"",
|
|
4369
|
+
"Usage:",
|
|
4370
|
+
" computer-linker workspace list",
|
|
4371
|
+
" computer-linker workspace add <path> [--id workspace-id] [--name name] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen]",
|
|
4372
|
+
" computer-linker workspace update <id> [--name name] [--path path] [--read-only|--full-trust] [--write|--no-write] [--shell|--no-shell] [--codex|--no-codex] [--screen|--no-screen]",
|
|
4373
|
+
" computer-linker workspace remove <id>",
|
|
4374
|
+
"",
|
|
4375
|
+
"What it does:",
|
|
4376
|
+
" Manages the local list of folders exposed to MCP clients.",
|
|
4377
|
+
" Direct workspace add entries are read-only by default; add --write/--shell only when needed.",
|
|
4378
|
+
" For daily setup, prefer `computer-linker start <path>`; it creates a normal coding workspace automatically.",
|
|
4379
|
+
" Workspace names default to the folder name when omitted.",
|
|
4380
|
+
" This does not delete the folder on disk when removing a workspace entry.",
|
|
4381
|
+
"",
|
|
4382
|
+
"Examples:",
|
|
4383
|
+
" computer-linker workspace add C:\\Projects\\my-app --write --shell",
|
|
4384
|
+
" computer-linker workspace update my-app --no-shell",
|
|
4385
|
+
" computer-linker workspace remove my-app",
|
|
4386
|
+
"",
|
|
4387
|
+
"More help:",
|
|
4388
|
+
" computer-linker workspace help add",
|
|
4389
|
+
" computer-linker workspace help update",
|
|
4390
|
+
" computer-linker workspace help remove",
|
|
4391
|
+
].join("\n"));
|
|
4392
|
+
}
|
|
4393
|
+
function printWorkspaceAddHelp() {
|
|
4394
|
+
console.log([
|
|
4395
|
+
"Computer Linker workspace add",
|
|
4396
|
+
"",
|
|
4397
|
+
"Usage:",
|
|
4398
|
+
" computer-linker workspace add <path> [--id workspace-id] [--name name] [--read-only|--full-trust] [--write] [--shell] [--codex] [--screen]",
|
|
4399
|
+
"",
|
|
4400
|
+
"What it does:",
|
|
4401
|
+
" Adds one folder to the local MCP workspace list.",
|
|
4402
|
+
" If --id is omitted, the id is derived from the folder name.",
|
|
4403
|
+
" If --name is omitted, the workspace name is the folder name.",
|
|
4404
|
+
"",
|
|
4405
|
+
"Common options:",
|
|
4406
|
+
" --read-only Read/search/history only.",
|
|
4407
|
+
" --full-trust Allow writes, local commands, Codex operations, and screen capture.",
|
|
4408
|
+
" --write Allow file edits in this workspace.",
|
|
4409
|
+
" --shell Allow local commands and package scripts.",
|
|
4410
|
+
" --codex Allow Codex operations in this workspace.",
|
|
4411
|
+
" --screen Allow screen capture operations.",
|
|
4412
|
+
"",
|
|
4413
|
+
"Example:",
|
|
4414
|
+
" computer-linker workspace add C:\\Projects\\my-app --write --shell",
|
|
4415
|
+
].join("\n"));
|
|
4416
|
+
}
|
|
4417
|
+
function printWorkspaceUpdateHelp() {
|
|
4418
|
+
console.log([
|
|
4419
|
+
"Computer Linker workspace update",
|
|
4420
|
+
"",
|
|
4421
|
+
"Usage:",
|
|
4422
|
+
" computer-linker workspace update <id> [--name name] [--path path] [--read-only|--full-trust] [--write|--no-write] [--shell|--no-shell] [--codex|--no-codex] [--screen|--no-screen]",
|
|
4423
|
+
"",
|
|
4424
|
+
"What it does:",
|
|
4425
|
+
" Updates an existing workspace entry without changing unrelated entries.",
|
|
4426
|
+
"",
|
|
4427
|
+
"Examples:",
|
|
4428
|
+
" computer-linker workspace update my-app --write --shell",
|
|
4429
|
+
" computer-linker workspace update my-app --no-shell",
|
|
4430
|
+
].join("\n"));
|
|
4431
|
+
}
|
|
4432
|
+
function printWorkspaceRemoveHelp() {
|
|
4433
|
+
console.log([
|
|
4434
|
+
"Computer Linker workspace remove",
|
|
4435
|
+
"",
|
|
4436
|
+
"Usage:",
|
|
4437
|
+
" computer-linker workspace remove <id>",
|
|
4438
|
+
"",
|
|
4439
|
+
"What it does:",
|
|
4440
|
+
" Removes one workspace entry from the local MCP workspace list.",
|
|
4441
|
+
" This does not delete the folder on disk.",
|
|
4442
|
+
"",
|
|
4443
|
+
"Example:",
|
|
4444
|
+
" computer-linker workspace remove my-app",
|
|
4445
|
+
].join("\n"));
|
|
4446
|
+
}
|
|
4447
|
+
function printAdvancedHelp() {
|
|
4448
|
+
printCliHelp([
|
|
4449
|
+
"Computer Linker",
|
|
4450
|
+
"",
|
|
4451
|
+
"Advanced Usage:",
|
|
4452
|
+
" computer-linker init [--show-token]",
|
|
4453
|
+
" computer-linker --version",
|
|
4454
|
+
" computer-linker quickstart [workspace-path] [--tunnel cloudflare|tailscale|openai] [--tunnel-id tunnel_...] [--url https://...] [--write] [--shell] [--codex] [--screen] [--read-only|--full-trust] [--json]",
|
|
4455
|
+
" computer-linker serve Start the stdio MCP server",
|
|
4456
|
+
" computer-linker serve --transport http",
|
|
4457
|
+
" computer-linker start [workspace-path] [--write] [--shell] [--codex] [--screen] [--read-only|--full-trust]",
|
|
4458
|
+
" Configure a workspace when provided, then start the HTTP MCP server",
|
|
4459
|
+
" computer-linker start Start local HTTP MCP server",
|
|
4460
|
+
" computer-linker start --tunnel cloudflare",
|
|
4461
|
+
" computer-linker start --no-tunnel",
|
|
4462
|
+
" computer-linker start --tunnel tailscale",
|
|
4463
|
+
" computer-linker start --tunnel openai --tunnel-id tunnel_...",
|
|
4464
|
+
" computer-linker status [--details] [--json]",
|
|
4465
|
+
" computer-linker self-test [--json] [--keep-temp] [--timeout-ms ms]",
|
|
4466
|
+
" computer-linker process list <workspace-id> [--json]",
|
|
4467
|
+
" computer-linker process read <workspace-id> <process-id> [--json]",
|
|
4468
|
+
" computer-linker process stop <workspace-id> <process-id> [--signal SIGTERM|SIGINT|SIGKILL] [--json]",
|
|
4469
|
+
" computer-linker screen status [--json]",
|
|
4470
|
+
" computer-linker expose cloudflare",
|
|
4471
|
+
" computer-linker expose tailscale --mode funnel",
|
|
4472
|
+
" computer-linker tunnel status [--json]",
|
|
4473
|
+
" computer-linker service profile [--platform linux|macos|windows] [--format profile|manifest]",
|
|
4474
|
+
" computer-linker service profile --output-dir ./service-profile [--platform linux|macos|windows]",
|
|
4475
|
+
" computer-linker service status [--platform linux|macos|windows] [--json]",
|
|
4476
|
+
" computer-linker service install --dry-run [--platform linux|macos|windows] [--json]",
|
|
4477
|
+
" computer-linker service install --yes [--platform linux|macos|windows] [--json]",
|
|
4478
|
+
" computer-linker service uninstall --dry-run [--platform linux|macos|windows] [--json]",
|
|
4479
|
+
" computer-linker service uninstall --yes [--platform linux|macos|windows] [--json]",
|
|
4480
|
+
" computer-linker service start|stop [--platform linux|macos|windows] [--json]",
|
|
4481
|
+
" computer-linker service logs [--lines 100] [--platform linux|macos|windows] [--json]",
|
|
4482
|
+
" computer-linker doctor",
|
|
4483
|
+
" computer-linker doctor --json",
|
|
4484
|
+
" computer-linker doctor --fix [--dry-run] [--json]",
|
|
4485
|
+
" computer-linker diagnose client [--local|--remote|--url https://.../mcp] [--json]",
|
|
4486
|
+
" computer-linker profile [--show-token]",
|
|
4487
|
+
" computer-linker client setup [--details] [--show-token] [--json]",
|
|
4488
|
+
" computer-linker client smoke [--url https://.../mcp] [--token token] [--allow-http] [--show-token] [--json]",
|
|
4489
|
+
" computer-linker client diagnose [--local|--remote|--url https://.../mcp] [--json]",
|
|
4490
|
+
" computer-linker setup <workspace-path> [--tunnel cloudflare|tailscale|openai] [--tunnel-id tunnel_...] [--id workspace-id] [--name name] [--write] [--shell] [--codex] [--screen] [--read-only|--full-trust] [--show-token] [--json]",
|
|
4491
|
+
" computer-linker history [--view summary|last|timeline|sessions|connections|failed_replay|debug_bundle] [--workspace id] [--query text] [--limit n] [--json] [--output file]",
|
|
4492
|
+
" computer-linker config path",
|
|
4493
|
+
" computer-linker config show [--show-token]",
|
|
4494
|
+
" computer-linker config validate [--json]",
|
|
4495
|
+
" computer-linker config token [rotate] [--show-token] [--json]",
|
|
4496
|
+
" computer-linker config policy <workspace-id> [--json]",
|
|
4497
|
+
" computer-linker config policy <workspace-id> [--allow pattern] [--deny pattern] [--max-runtime-seconds n] [--max-output-bytes n]",
|
|
4498
|
+
" computer-linker config set-public-url <https-url>",
|
|
4499
|
+
" computer-linker config clear-public-url",
|
|
4500
|
+
" computer-linker workspace list",
|
|
4501
|
+
" computer-linker workspace add <path> [--id workspace-id] [--name name] [--write] [--shell] [--codex] [--screen] [--read-only|--full-trust]",
|
|
4502
|
+
" computer-linker workspace update <id> [--name name] [--path path] [--write|--no-write] [--shell|--no-shell] [--codex|--no-codex] [--screen|--no-screen] [--read-only|--full-trust]",
|
|
4503
|
+
" computer-linker workspace remove <id>",
|
|
4504
|
+
" computer-linker help",
|
|
4505
|
+
" computer-linker help chatgpt",
|
|
4506
|
+
"",
|
|
4507
|
+
"Client-specific helpers are compatibility exports layered over the generic MCP contract.",
|
|
4508
|
+
"Compatibility: LOCALPORT_* env vars and x-localport-token still work for existing configs.",
|
|
4509
|
+
]);
|
|
4510
|
+
}
|
|
4511
|
+
function printChatGptHelp() {
|
|
4512
|
+
printCliHelp([
|
|
4513
|
+
"Computer Linker ChatGPT Compatibility Helpers",
|
|
4514
|
+
"",
|
|
4515
|
+
"ChatGPT is one MCP client, not the product axis. Prefer the generic setup commands first:",
|
|
4516
|
+
" computer-linker client setup",
|
|
4517
|
+
" computer-linker client smoke [--url https://.../mcp] [--token token] [--allow-http]",
|
|
4518
|
+
"",
|
|
4519
|
+
"Use these only when ChatGPT asks for connector-specific fields or files:",
|
|
4520
|
+
" computer-linker client chatgpt url [--show-token] [--json]",
|
|
4521
|
+
" computer-linker client chatgpt smoke [--url https://.../mcp] [--token token] [--allow-http] [--show-token] [--json]",
|
|
4522
|
+
" computer-linker client chatgpt verify [--mode safe|coding|full] [--json]",
|
|
4523
|
+
" computer-linker client chatgpt profile [--mode safe|coding|full] [--url https://...] [--show-token]",
|
|
4524
|
+
" computer-linker client chatgpt manifest [--mode safe|coding|full] [--url https://...]",
|
|
4525
|
+
" computer-linker client chatgpt connector [--mode safe|coding|full] [--url https://...] [--show-token]",
|
|
4526
|
+
" computer-linker client chatgpt files ./chatgpt-config [--mode safe|coding|full] [--url https://...] [--show-token]",
|
|
4527
|
+
"",
|
|
4528
|
+
"For OpenAI Secure MCP Tunnel, start with:",
|
|
4529
|
+
" computer-linker start <workspace-path> --tunnel openai --tunnel-id tunnel_...",
|
|
4530
|
+
]);
|
|
4531
|
+
}
|
|
4532
|
+
function readOption(args, name) {
|
|
4533
|
+
const index = args.indexOf(name);
|
|
4534
|
+
if (index === -1)
|
|
4535
|
+
return undefined;
|
|
4536
|
+
return args[index + 1];
|
|
4537
|
+
}
|
|
4538
|
+
function readRepeatedOptions(args, name, command) {
|
|
4539
|
+
const values = [];
|
|
4540
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
4541
|
+
if (args[index] !== name)
|
|
4542
|
+
continue;
|
|
4543
|
+
const value = args[index + 1];
|
|
4544
|
+
if (!value || value.startsWith("--")) {
|
|
4545
|
+
throw new Error(`${command} requires a value`);
|
|
4546
|
+
}
|
|
4547
|
+
values.push(value);
|
|
4548
|
+
index += 1;
|
|
4549
|
+
}
|
|
4550
|
+
return values;
|
|
4551
|
+
}
|
|
4552
|
+
function readOptionalStringOption(args, name, command) {
|
|
4553
|
+
const value = readOption(args, name);
|
|
4554
|
+
if (!args.includes(name))
|
|
4555
|
+
return undefined;
|
|
4556
|
+
if (!value || value.startsWith("--")) {
|
|
4557
|
+
throw new Error(`${command} requires a value`);
|
|
4558
|
+
}
|
|
4559
|
+
return value;
|
|
4560
|
+
}
|
|
4561
|
+
function readOptionalIntegerOption(args, name, command) {
|
|
4562
|
+
const value = readOption(args, name);
|
|
4563
|
+
if (!args.includes(name))
|
|
4564
|
+
return undefined;
|
|
4565
|
+
if (!value || value.startsWith("--")) {
|
|
4566
|
+
throw new Error(`${command} requires a positive integer`);
|
|
4567
|
+
}
|
|
4568
|
+
const parsed = Number.parseInt(value, 10);
|
|
4569
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
4570
|
+
throw new Error(`${command} requires a positive integer`);
|
|
4571
|
+
}
|
|
4572
|
+
return parsed;
|
|
4573
|
+
}
|
|
4574
|
+
function readChatGptModeOption(args, command) {
|
|
4575
|
+
const value = readOption(args, "--mode");
|
|
4576
|
+
if (args.includes("--mode") && (!value || value.startsWith("--"))) {
|
|
4577
|
+
throw new Error(`${command} must be one of: safe, coding, full`);
|
|
4578
|
+
}
|
|
4579
|
+
return parseChatGptProfileMode(value, command);
|
|
4580
|
+
}
|
|
4581
|
+
function readPublicUrlOption(args, command) {
|
|
4582
|
+
if (!args.includes("--url"))
|
|
4583
|
+
return undefined;
|
|
4584
|
+
return requireHttpsUrl(readOption(args, "--url"), command, `computer-linker ${command} <https-url>`);
|
|
4585
|
+
}
|
|
4586
|
+
function booleanFlag(args, name, current) {
|
|
4587
|
+
if (args.includes(`--${name}`))
|
|
4588
|
+
return true;
|
|
4589
|
+
if (args.includes(`--no-${name}`))
|
|
4590
|
+
return false;
|
|
4591
|
+
return current;
|
|
4592
|
+
}
|
|
4593
|
+
function requireHttpsUrl(value, name, usage = "computer-linker config set-public-url <https-url>") {
|
|
4594
|
+
if (!value)
|
|
4595
|
+
throw new Error(`Usage: ${usage}`);
|
|
4596
|
+
let parsed;
|
|
4597
|
+
try {
|
|
4598
|
+
parsed = new URL(value);
|
|
4599
|
+
}
|
|
4600
|
+
catch {
|
|
4601
|
+
throw new Error(`${name} must be a valid HTTPS URL`);
|
|
4602
|
+
}
|
|
4603
|
+
if (parsed.protocol !== "https:") {
|
|
4604
|
+
throw new Error(`${name} must use https://`);
|
|
4605
|
+
}
|
|
4606
|
+
return parsed.origin;
|
|
4607
|
+
}
|
|
4608
|
+
async function waitForShutdown(close) {
|
|
4609
|
+
await new Promise((resolve) => {
|
|
4610
|
+
const shutdown = () => {
|
|
4611
|
+
close();
|
|
4612
|
+
resolve();
|
|
4613
|
+
};
|
|
4614
|
+
process.once("SIGINT", shutdown);
|
|
4615
|
+
process.once("SIGTERM", shutdown);
|
|
4616
|
+
});
|
|
4617
|
+
}
|
|
4618
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
4619
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
4620
|
+
process.exit(1);
|
|
4621
|
+
});
|