terminal-commands 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createInterface } from "node:readline/promises";
7
+
8
+ import { runAgent } from "./agent-core.js";
9
+ import { loadAgentConfig } from "./agent-config.js";
10
+ import {
11
+ assertRootsExist,
12
+ normalizeRoots,
13
+ optionEnvironment,
14
+ parseCommandLine,
15
+ renderHelp,
16
+ splitRootList,
17
+ type ParsedCommandLine,
18
+ } from "./cli-options.js";
19
+ import {
20
+ bold,
21
+ danger,
22
+ dim,
23
+ heading,
24
+ info,
25
+ keyValueBlock,
26
+ muted,
27
+ ok,
28
+ warn,
29
+ } from "./cli-format.js";
30
+ import {
31
+ clearCredentials,
32
+ configDirectory,
33
+ loadCredentials,
34
+ loadOrCreateDeviceId,
35
+ saveCredentials,
36
+ } from "./credential-store.js";
37
+ import {
38
+ pollForDeviceToken,
39
+ refreshDeviceToken,
40
+ requestDeviceCode,
41
+ type DeviceAuthConfig,
42
+ type StoredCredentials,
43
+ } from "./device-auth.js";
44
+
45
+ const DEFAULTS = {
46
+ issuer: "https://auth.terminalcommands.fr/",
47
+ clientId: "ggauJpZPhb1e6qsqUVX2bW7k0V5PdR9B",
48
+ audience: "https://mcp.terminalcommands.fr",
49
+ gatewayUrl: "wss://mcp.terminalcommands.fr/device/connect",
50
+ host: "127.0.0.1",
51
+ port: "3333",
52
+ } as const;
53
+
54
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
55
+ const WILDCARD = new Set(["0.0.0.0", "::"]);
56
+
57
+ interface Settings {
58
+ readonly deviceName: string;
59
+ readonly roots: string[];
60
+ readonly rootsAreDefault: boolean;
61
+ readonly host: string;
62
+ readonly port: number;
63
+ readonly localUrl: string;
64
+ readonly gatewayUrl: string;
65
+ readonly allowDestructive: boolean;
66
+ readonly autoApprove: boolean;
67
+ readonly allowShell: boolean;
68
+ readonly allowedPrograms: string[];
69
+ readonly allowPublicBind: boolean;
70
+ }
71
+
72
+ function resolveAuth(environment: NodeJS.ProcessEnv): DeviceAuthConfig {
73
+ return {
74
+ issuer: environment.MACHINE_TERMINAL_AUTH_ISSUER ?? DEFAULTS.issuer,
75
+ clientId: environment.MACHINE_TERMINAL_AUTH_CLIENT_ID ?? DEFAULTS.clientId,
76
+ audience: environment.MACHINE_TERMINAL_AUTH_AUDIENCE ?? DEFAULTS.audience,
77
+ scope: "openid profile email offline_access terminal:connect",
78
+ };
79
+ }
80
+
81
+ function urlHost(host: string): string {
82
+ if (WILDCARD.has(host)) return "127.0.0.1";
83
+ return host.includes(":") ? `[${host}]` : host;
84
+ }
85
+
86
+ function resolveSettings(environment: NodeJS.ProcessEnv): Settings {
87
+ const port = Number.parseInt(
88
+ environment.MACHINE_TERMINAL_PORT ?? DEFAULTS.port,
89
+ 10,
90
+ );
91
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
92
+ throw new Error("--port must be a TCP port between 1 and 65535.");
93
+ }
94
+
95
+ const requested = splitRootList(environment.MACHINE_TERMINAL_ROOTS);
96
+ const rootsAreDefault = requested.length === 0;
97
+ const host = environment.MACHINE_TERMINAL_HOST ?? DEFAULTS.host;
98
+ const autoApprove = environment.MACHINE_TERMINAL_AUTO_APPROVE === "1";
99
+
100
+ return {
101
+ deviceName: environment.MACHINE_TERMINAL_DEVICE_NAME ?? os.hostname(),
102
+ roots: normalizeRoots(rootsAreDefault ? [process.cwd()] : requested),
103
+ rootsAreDefault,
104
+ host,
105
+ port,
106
+ localUrl: `http://${urlHost(host)}:${port}`,
107
+ gatewayUrl: environment.MACHINE_TERMINAL_GATEWAY_URL ?? DEFAULTS.gatewayUrl,
108
+ allowDestructive:
109
+ autoApprove ||
110
+ environment.MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE === "1",
111
+ autoApprove,
112
+ allowShell: environment.MACHINE_TERMINAL_ALLOW_SHELL === "1",
113
+ allowedPrograms: (environment.MACHINE_TERMINAL_ALLOWED_PROGRAMS ?? "")
114
+ .split(",")
115
+ .map((value) => value.trim())
116
+ .filter(Boolean),
117
+ allowPublicBind: environment.MACHINE_TERMINAL_ALLOW_PUBLIC_BIND === "1",
118
+ };
119
+ }
120
+
121
+ function assertBindable(settings: Settings): void {
122
+ if (LOOPBACK.has(settings.host) || settings.allowPublicBind) return;
123
+ throw new Error(
124
+ `--host ${settings.host} would expose the local service beyond this machine. Keep the default 127.0.0.1, or add --allow-public-bind only behind authenticated HTTPS.`,
125
+ );
126
+ }
127
+
128
+ function describeSettings(settings: Settings, deviceId: string): string {
129
+ const approvedRoots = settings.roots.map((root) => ` ${muted("-")} ${root}`);
130
+ if (settings.rootsAreDefault) {
131
+ approvedRoots.push(
132
+ muted(" (no --root given, so only the current directory is approved)"),
133
+ );
134
+ }
135
+
136
+ const commandsWrites = settings.autoApprove
137
+ ? danger("auto-approved without prompting (--auto-approve)") +
138
+ dim(" — every request runs unattended, only use with a trusted client")
139
+ : settings.allowDestructive
140
+ ? warn("ask in this terminal each time")
141
+ : ok("refused") +
142
+ dim(
143
+ " (add --allow-destructive to approve interactively, or --auto-approve to skip prompts)",
144
+ );
145
+
146
+ const shellPrograms = settings.allowShell
147
+ ? danger("allowed (--allow-shell)") +
148
+ dim(" — shells and privilege tools such as sudo can run")
149
+ : ok("blocked") +
150
+ dim(
151
+ " (add --allow-shell to permit shells and privilege tools such as sudo)",
152
+ );
153
+
154
+ const programAllowlist =
155
+ settings.allowedPrograms.length > 0
156
+ ? ok(`only ${settings.allowedPrograms.join(", ")}`) +
157
+ dim(" (--allowed-program)")
158
+ : warn("any program that is not blocked") +
159
+ dim(
160
+ " (add --allowed-program <name> to restrict to specific executables)",
161
+ );
162
+
163
+ return [
164
+ heading("Terminal Commands"),
165
+ "",
166
+ bold("Device"),
167
+ keyValueBlock([
168
+ ["Name", settings.deviceName],
169
+ ["ID", deviceId],
170
+ ]),
171
+ "",
172
+ bold("Network"),
173
+ keyValueBlock([
174
+ ["Gateway", settings.gatewayUrl],
175
+ ["Local MCP", `${settings.localUrl}/mcp`],
176
+ ]),
177
+ "",
178
+ bold("Approved roots"),
179
+ approvedRoots.join("\n"),
180
+ "",
181
+ bold("Security"),
182
+ keyValueBlock([
183
+ ["Commands, writes", commandsWrites],
184
+ ["Shell programs", shellPrograms],
185
+ ["Program allowlist", programAllowlist],
186
+ ]),
187
+ ].join("\n");
188
+ }
189
+
190
+ function approvalSummary(tool: string, args: Record<string, unknown>): string {
191
+ if (tool === "write_text_file") {
192
+ const bytes = Buffer.byteLength(String(args.contents ?? ""));
193
+ return `Write ${bytes} bytes to ${String(args.path ?? "an unknown path")}${args.overwrite ? " (overwrite)" : ""}`;
194
+ }
195
+ if (tool === "run_program") {
196
+ const command = [
197
+ String(args.program ?? ""),
198
+ ...(Array.isArray(args.args) ? args.args.map(String) : []),
199
+ ];
200
+ return `Run: ${command.join(" ")}`;
201
+ }
202
+ return `Run destructive tool: ${tool}`;
203
+ }
204
+
205
+ function createAutoApprover() {
206
+ return (tool: string, args: Record<string, unknown>): Promise<boolean> => {
207
+ console.log(
208
+ `\n${warn("Approval requested:")} ${approvalSummary(tool, args)} ${dim("(auto-approved)")}`,
209
+ );
210
+ return Promise.resolve(true);
211
+ };
212
+ }
213
+
214
+ function createTerminalApprover() {
215
+ let queue = Promise.resolve(false);
216
+ return (tool: string, args: Record<string, unknown>): Promise<boolean> => {
217
+ queue = queue
218
+ .catch(() => false)
219
+ .then(async () => {
220
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
221
+ const prompt = createInterface({
222
+ input: process.stdin,
223
+ output: process.stdout,
224
+ });
225
+ try {
226
+ console.log(
227
+ `\n${warn("Approval requested:")} ${approvalSummary(tool, args)}`,
228
+ );
229
+ const answer = await prompt.question(
230
+ `${bold("Approve this action?")} [y/N] `,
231
+ );
232
+ return new Set(["y", "yes"]).has(answer.trim().toLowerCase());
233
+ } finally {
234
+ prompt.close();
235
+ }
236
+ });
237
+ return queue;
238
+ };
239
+ }
240
+
241
+ async function login(environment: NodeJS.ProcessEnv): Promise<void> {
242
+ const auth = resolveAuth(environment);
243
+ const code = await requestDeviceCode(auth);
244
+ console.log(`\n${bold("Open this address in any browser:")}`);
245
+ console.log(info(code.verification_uri_complete ?? code.verification_uri));
246
+ console.log(`\n${bold("One-time code:")} ${info(code.user_code)}`);
247
+ console.log(dim("\nWaiting for approval..."));
248
+ const credentials = await pollForDeviceToken(
249
+ auth,
250
+ code.device_code,
251
+ code.expires_in,
252
+ code.interval,
253
+ );
254
+ await saveCredentials(credentials);
255
+ console.log(
256
+ `${ok("Signed in.")} Run \`terminal-commands connect\` to connect this machine.`,
257
+ );
258
+ }
259
+
260
+ async function validCredentials(
261
+ auth: DeviceAuthConfig,
262
+ ): Promise<StoredCredentials> {
263
+ const current = await loadCredentials();
264
+ if (!current)
265
+ throw new Error("Not signed in. Run `terminal-commands login` first.");
266
+ if (current.issuer !== auth.issuer || current.audience !== auth.audience) {
267
+ throw new Error(
268
+ "Saved login belongs to a different Terminal Commands server. Run `terminal-commands login` again.",
269
+ );
270
+ }
271
+ if (current.expiresAt > Date.now() + 60_000) return current;
272
+ if (!current.refreshToken)
273
+ throw new Error("Login expired. Run `terminal-commands login` again.");
274
+ const refreshed = await refreshDeviceToken(auth, current.refreshToken);
275
+ await saveCredentials(refreshed);
276
+ return refreshed;
277
+ }
278
+
279
+ async function waitForHealth(url: string, child: ChildProcess): Promise<void> {
280
+ const deadline = Date.now() + 10_000;
281
+ while (Date.now() < deadline) {
282
+ if (child.exitCode !== null)
283
+ throw new Error("The local terminal service could not start.");
284
+ try {
285
+ const response = await fetch(url);
286
+ if (response.ok) return;
287
+ } catch {}
288
+ await new Promise((resolve) => setTimeout(resolve, 100));
289
+ }
290
+ throw new Error("Timed out while starting the local terminal service.");
291
+ }
292
+
293
+ async function connect(
294
+ environment: NodeJS.ProcessEnv,
295
+ parsed: ParsedCommandLine,
296
+ ): Promise<void> {
297
+ const settings = resolveSettings(environment);
298
+ assertBindable(settings);
299
+ await assertRootsExist(settings.roots);
300
+
301
+ const deviceId =
302
+ environment.MACHINE_TERMINAL_DEVICE_ID ?? (await loadOrCreateDeviceId());
303
+ const asJson = parsed.options.get("json") === true;
304
+
305
+ if (parsed.options.get("dry-run") === true) {
306
+ console.log(
307
+ asJson
308
+ ? JSON.stringify({ ...settings, deviceId, dryRun: true }, null, 2)
309
+ : describeSettings(settings, deviceId),
310
+ );
311
+ return;
312
+ }
313
+
314
+ const auth = resolveAuth(environment);
315
+ const firstCredentials = await validCredentials(auth);
316
+
317
+ console.log(
318
+ asJson
319
+ ? JSON.stringify({ ...settings, deviceId }, null, 2)
320
+ : describeSettings(settings, deviceId),
321
+ );
322
+ if (
323
+ settings.allowDestructive &&
324
+ !settings.autoApprove &&
325
+ !(process.stdin.isTTY && process.stdout.isTTY)
326
+ ) {
327
+ console.error(
328
+ warn(
329
+ "Terminal Commands: approvals need an interactive terminal, so commands and file writes will still be refused here.",
330
+ ),
331
+ );
332
+ }
333
+
334
+ const childEnvironment: NodeJS.ProcessEnv = {
335
+ ...environment,
336
+ MACHINE_TERMINAL_HOST: settings.host,
337
+ MACHINE_TERMINAL_PORT: String(settings.port),
338
+ MACHINE_TERMINAL_ROOTS: settings.roots.join(path.delimiter),
339
+ };
340
+
341
+ const serverEntry = fileURLToPath(new URL("./index.js", import.meta.url));
342
+ const local = spawn(process.execPath, [serverEntry], {
343
+ env: childEnvironment,
344
+ stdio: ["ignore", "inherit", "inherit"],
345
+ });
346
+ const controller = new AbortController();
347
+ const stop = () => controller.abort();
348
+ process.once("SIGINT", stop);
349
+ process.once("SIGTERM", stop);
350
+
351
+ try {
352
+ await waitForHealth(`${settings.localUrl}/health`, local);
353
+ const config = loadAgentConfig({
354
+ ...childEnvironment,
355
+ MACHINE_TERMINAL_GATEWAY_URL: settings.gatewayUrl,
356
+ MACHINE_TERMINAL_DEVICE_TOKEN: firstCredentials.accessToken,
357
+ MACHINE_TERMINAL_DEVICE_ID: deviceId,
358
+ MACHINE_TERMINAL_DEVICE_NAME: settings.deviceName,
359
+ MACHINE_TERMINAL_LOCAL_MCP_URL: `${settings.localUrl}/mcp`,
360
+ MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE: settings.allowDestructive
361
+ ? "1"
362
+ : "0",
363
+ });
364
+ console.log(
365
+ `\n${ok("Connecting")} ${bold(config.MACHINE_TERMINAL_DEVICE_NAME)} securely. ${dim("Press Ctrl+C to stop.")}`,
366
+ );
367
+ await runAgent({
368
+ config,
369
+ signal: controller.signal,
370
+ getToken: async () => (await validCredentials(auth)).accessToken,
371
+ approve: settings.autoApprove
372
+ ? createAutoApprover()
373
+ : createTerminalApprover(),
374
+ log: (message) => console.error(`Terminal Commands: ${message}`),
375
+ });
376
+ } finally {
377
+ local.kill("SIGTERM");
378
+ process.removeListener("SIGINT", stop);
379
+ process.removeListener("SIGTERM", stop);
380
+ }
381
+ }
382
+
383
+ async function status(
384
+ environment: NodeJS.ProcessEnv,
385
+ parsed: ParsedCommandLine,
386
+ ): Promise<void> {
387
+ const auth = resolveAuth(environment);
388
+ const credentials = await loadCredentials();
389
+ const deviceId =
390
+ environment.MACHINE_TERMINAL_DEVICE_ID ?? (await loadOrCreateDeviceId());
391
+ const settings = resolveSettings(environment);
392
+
393
+ const payload = {
394
+ signedIn: credentials !== null,
395
+ deviceId,
396
+ deviceName: settings.deviceName,
397
+ tokenExpiresAt: credentials
398
+ ? new Date(credentials.expiresAt).toISOString()
399
+ : null,
400
+ issuer: auth.issuer,
401
+ audience: auth.audience,
402
+ gatewayUrl: settings.gatewayUrl,
403
+ localUrl: `${settings.localUrl}/mcp`,
404
+ roots: settings.roots,
405
+ rootsAreDefault: settings.rootsAreDefault,
406
+ configDirectory: configDirectory(),
407
+ };
408
+
409
+ if (parsed.options.get("json") === true) {
410
+ console.log(JSON.stringify(payload, null, 2));
411
+ return;
412
+ }
413
+
414
+ const rootLines = payload.roots.map((root) => ` ${muted("-")} ${root}`);
415
+ if (payload.rootsAreDefault) {
416
+ rootLines.push(
417
+ muted(
418
+ " (no --root or MACHINE_TERMINAL_ROOTS set, so this is the current directory)",
419
+ ),
420
+ );
421
+ }
422
+
423
+ console.log(
424
+ [
425
+ heading("Terminal Commands — Status"),
426
+ "",
427
+ bold("Account"),
428
+ keyValueBlock([
429
+ ["Signed in", payload.signedIn ? ok("yes") : danger("no")],
430
+ ["Device ID", payload.deviceId],
431
+ ["Device name", payload.deviceName],
432
+ ...(payload.tokenExpiresAt
433
+ ? ([["Token expires", payload.tokenExpiresAt]] as const)
434
+ : []),
435
+ ]),
436
+ "",
437
+ bold("Network"),
438
+ keyValueBlock([
439
+ ["Gateway", payload.gatewayUrl],
440
+ ["Config folder", payload.configDirectory],
441
+ ]),
442
+ "",
443
+ bold("Approved roots"),
444
+ rootLines.join("\n"),
445
+ ].join("\n"),
446
+ );
447
+ }
448
+
449
+ async function main(): Promise<void> {
450
+ const parsed = parseCommandLine(process.argv.slice(2));
451
+ if (parsed.help) {
452
+ console.log(renderHelp(parsed.command));
453
+ return;
454
+ }
455
+
456
+ const environment: NodeJS.ProcessEnv = {
457
+ ...process.env,
458
+ ...optionEnvironment(parsed),
459
+ };
460
+
461
+ if (parsed.command === "login") return login(environment);
462
+ if (parsed.command === "connect") return connect(environment, parsed);
463
+ if (parsed.command === "status") return status(environment, parsed);
464
+ if (parsed.command === "logout") {
465
+ await clearCredentials();
466
+ console.log(ok("Signed out and removed locally saved credentials."));
467
+ return;
468
+ }
469
+ console.log(renderHelp());
470
+ }
471
+
472
+ void main().catch((error) => {
473
+ console.error(danger(error instanceof Error ? error.message : String(error)));
474
+ process.exitCode = 1;
475
+ });
@@ -0,0 +1,98 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { z } from "zod";
7
+
8
+ import type { StoredCredentials } from "./device-auth.js";
9
+
10
+ const storedSchema = z.object({
11
+ accessToken: z.string().min(1),
12
+ refreshToken: z.string().min(1).optional(),
13
+ expiresAt: z.number().int().positive(),
14
+ scope: z.string().optional(),
15
+ tokenType: z.string().optional(),
16
+ issuer: z.string().url(),
17
+ audience: z.string().min(1),
18
+ });
19
+
20
+ export function configDirectory(
21
+ environment: NodeJS.ProcessEnv = process.env,
22
+ ): string {
23
+ if (process.platform === "win32") {
24
+ return path.join(
25
+ environment.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"),
26
+ "Terminal Commands",
27
+ );
28
+ }
29
+ return path.join(
30
+ environment.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
31
+ "terminal-commands",
32
+ );
33
+ }
34
+
35
+ export function credentialsPath(
36
+ environment: NodeJS.ProcessEnv = process.env,
37
+ ): string {
38
+ return path.join(configDirectory(environment), "credentials.json");
39
+ }
40
+
41
+ export async function saveCredentials(
42
+ credentials: StoredCredentials,
43
+ environment: NodeJS.ProcessEnv = process.env,
44
+ ): Promise<void> {
45
+ const directory = configDirectory(environment);
46
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
47
+ const target = credentialsPath(environment);
48
+ const temporary = `${target}.${process.pid}.tmp`;
49
+ await fs.writeFile(temporary, `${JSON.stringify(credentials)}\n`, {
50
+ mode: 0o600,
51
+ });
52
+ await fs.rename(temporary, target);
53
+ if (process.platform !== "win32") await fs.chmod(target, 0o600);
54
+ }
55
+
56
+ export async function loadCredentials(
57
+ environment: NodeJS.ProcessEnv = process.env,
58
+ ): Promise<StoredCredentials | null> {
59
+ try {
60
+ return storedSchema.parse(
61
+ JSON.parse(await fs.readFile(credentialsPath(environment), "utf8")),
62
+ );
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export async function clearCredentials(
70
+ environment: NodeJS.ProcessEnv = process.env,
71
+ ): Promise<void> {
72
+ try {
73
+ await fs.unlink(credentialsPath(environment));
74
+ } catch (error) {
75
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
76
+ }
77
+ }
78
+
79
+ export async function loadOrCreateDeviceId(
80
+ environment: NodeJS.ProcessEnv = process.env,
81
+ ): Promise<string> {
82
+ const directory = configDirectory(environment);
83
+ const target = path.join(directory, "device-id");
84
+ try {
85
+ const value = (await fs.readFile(target, "utf8")).trim();
86
+ if (value) return value;
87
+ } catch (error) {
88
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
89
+ }
90
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
91
+ const value = randomUUID();
92
+ await fs
93
+ .writeFile(target, `${value}\n`, { mode: 0o600, flag: "wx" })
94
+ .catch(async (error) => {
95
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
96
+ });
97
+ return (await fs.readFile(target, "utf8")).trim();
98
+ }