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/README.md +130 -0
- package/dist/agent.js +35658 -0
- package/dist/cli.js +36600 -0
- package/dist/index.js +63164 -0
- package/dist/stdio.js +36796 -0
- package/package.json +38 -0
- package/src/agent-config.ts +40 -0
- package/src/agent-core.ts +173 -0
- package/src/agent.ts +16 -0
- package/src/cli-format.ts +57 -0
- package/src/cli-options.ts +459 -0
- package/src/cli.ts +475 -0
- package/src/credential-store.ts +98 -0
- package/src/device-auth.ts +163 -0
- package/src/index.ts +83 -0
- package/src/runner.ts +55 -0
- package/src/security.ts +112 -0
- package/src/server.ts +217 -0
- package/src/stdio.ts +16 -0
- package/test/agent-config.test.ts +32 -0
- package/test/agent-core.test.ts +37 -0
- package/test/cli-options.test.ts +121 -0
- package/test/credential-store.test.ts +44 -0
- package/test/device-auth.test.ts +88 -0
- package/test/runner.test.ts +29 -0
- package/test/security.test.ts +113 -0
- package/test/smoke.mjs +34 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const COMMANDS = [
|
|
6
|
+
"login",
|
|
7
|
+
"connect",
|
|
8
|
+
"status",
|
|
9
|
+
"logout",
|
|
10
|
+
"help",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type Command = (typeof COMMANDS)[number];
|
|
14
|
+
|
|
15
|
+
export type OptionKind = "boolean" | "string" | "list";
|
|
16
|
+
|
|
17
|
+
export interface OptionSpec {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly alias?: string;
|
|
20
|
+
readonly kind: OptionKind;
|
|
21
|
+
readonly env?: string;
|
|
22
|
+
readonly placeholder?: string;
|
|
23
|
+
readonly separator?: string;
|
|
24
|
+
readonly description: string;
|
|
25
|
+
readonly commands: readonly Command[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const COMMAND_SUMMARIES: Record<Exclude<Command, "help">, string> = {
|
|
29
|
+
login: "Sign in using a URL and one-time code",
|
|
30
|
+
connect: "Connect this machine until stopped",
|
|
31
|
+
status: "Show sign-in state and the settings connect would use",
|
|
32
|
+
logout: "Remove locally saved credentials",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const AUTH_COMMANDS = ["login", "connect", "status", "logout"] as const;
|
|
36
|
+
|
|
37
|
+
export const OPTIONS: readonly OptionSpec[] = [
|
|
38
|
+
{
|
|
39
|
+
name: "root",
|
|
40
|
+
alias: "r",
|
|
41
|
+
kind: "list",
|
|
42
|
+
env: "MACHINE_TERMINAL_ROOTS",
|
|
43
|
+
placeholder: "<path>",
|
|
44
|
+
separator: path.delimiter,
|
|
45
|
+
commands: ["connect", "status"],
|
|
46
|
+
description:
|
|
47
|
+
"Approved filesystem root. Repeat the flag, or pass several paths separated by the platform path delimiter. Defaults to the current directory.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "name",
|
|
51
|
+
kind: "string",
|
|
52
|
+
env: "MACHINE_TERMINAL_DEVICE_NAME",
|
|
53
|
+
placeholder: "<name>",
|
|
54
|
+
commands: ["connect", "status"],
|
|
55
|
+
description:
|
|
56
|
+
"Device name shown in the client. Defaults to this machine's hostname.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "device-id",
|
|
60
|
+
kind: "string",
|
|
61
|
+
env: "MACHINE_TERMINAL_DEVICE_ID",
|
|
62
|
+
placeholder: "<id>",
|
|
63
|
+
commands: ["connect", "status"],
|
|
64
|
+
description:
|
|
65
|
+
"Device identifier to report. Defaults to the saved local device ID.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "port",
|
|
69
|
+
kind: "string",
|
|
70
|
+
env: "MACHINE_TERMINAL_PORT",
|
|
71
|
+
placeholder: "<port>",
|
|
72
|
+
commands: ["connect", "status"],
|
|
73
|
+
description: "Port for the local MCP service. Defaults to 3333.",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "host",
|
|
77
|
+
kind: "string",
|
|
78
|
+
env: "MACHINE_TERMINAL_HOST",
|
|
79
|
+
placeholder: "<host>",
|
|
80
|
+
commands: ["connect", "status"],
|
|
81
|
+
description:
|
|
82
|
+
"Bind address for the local MCP service. Defaults to 127.0.0.1.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "gateway-url",
|
|
86
|
+
kind: "string",
|
|
87
|
+
env: "MACHINE_TERMINAL_GATEWAY_URL",
|
|
88
|
+
placeholder: "<url>",
|
|
89
|
+
commands: ["connect", "status"],
|
|
90
|
+
description:
|
|
91
|
+
"Gateway WebSocket URL. Defaults to wss://mcp.terminalcommands.fr/device/connect.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "allow-destructive",
|
|
95
|
+
kind: "boolean",
|
|
96
|
+
env: "MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE",
|
|
97
|
+
commands: ["connect"],
|
|
98
|
+
description:
|
|
99
|
+
"Ask in this terminal before each command and file write. Without it both are refused.",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "allow-shell",
|
|
103
|
+
kind: "boolean",
|
|
104
|
+
env: "MACHINE_TERMINAL_ALLOW_SHELL",
|
|
105
|
+
commands: ["connect"],
|
|
106
|
+
description:
|
|
107
|
+
"Permit shells and privilege tools such as sudo. Leave off unless you need them.",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "auto-approve",
|
|
111
|
+
kind: "boolean",
|
|
112
|
+
env: "MACHINE_TERMINAL_AUTO_APPROVE",
|
|
113
|
+
commands: ["connect"],
|
|
114
|
+
description:
|
|
115
|
+
"Approve every command and file write automatically, without a terminal prompt. Implies --allow-destructive. Use with caution.",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "allowed-program",
|
|
119
|
+
kind: "list",
|
|
120
|
+
env: "MACHINE_TERMINAL_ALLOWED_PROGRAMS",
|
|
121
|
+
placeholder: "<name>",
|
|
122
|
+
separator: ",",
|
|
123
|
+
commands: ["connect"],
|
|
124
|
+
description:
|
|
125
|
+
"Executable allowlist. Repeat the flag or separate names with commas. Defaults to any program that is not blocked.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "allow-public-bind",
|
|
129
|
+
kind: "boolean",
|
|
130
|
+
env: "MACHINE_TERMINAL_ALLOW_PUBLIC_BIND",
|
|
131
|
+
commands: ["connect"],
|
|
132
|
+
description: "Permit a non-loopback bind. Only behind authenticated HTTPS.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "auth-issuer",
|
|
136
|
+
kind: "string",
|
|
137
|
+
env: "MACHINE_TERMINAL_AUTH_ISSUER",
|
|
138
|
+
placeholder: "<url>",
|
|
139
|
+
commands: AUTH_COMMANDS,
|
|
140
|
+
description: "OAuth issuer. Change this only for a self-hosted gateway.",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "auth-client-id",
|
|
144
|
+
kind: "string",
|
|
145
|
+
env: "MACHINE_TERMINAL_AUTH_CLIENT_ID",
|
|
146
|
+
placeholder: "<id>",
|
|
147
|
+
commands: AUTH_COMMANDS,
|
|
148
|
+
description: "OAuth client ID used by the device authorization flow.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "auth-audience",
|
|
152
|
+
kind: "string",
|
|
153
|
+
env: "MACHINE_TERMINAL_AUTH_AUDIENCE",
|
|
154
|
+
placeholder: "<url>",
|
|
155
|
+
commands: AUTH_COMMANDS,
|
|
156
|
+
description: "OAuth audience the access token is issued for.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "dry-run",
|
|
160
|
+
kind: "boolean",
|
|
161
|
+
commands: ["connect"],
|
|
162
|
+
description:
|
|
163
|
+
"Print the resolved settings and exit without starting anything.",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "json",
|
|
167
|
+
kind: "boolean",
|
|
168
|
+
commands: ["connect", "status"],
|
|
169
|
+
description: "Print machine-readable JSON instead of a summary.",
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export interface ParsedCommandLine {
|
|
174
|
+
readonly command: Command;
|
|
175
|
+
readonly help: boolean;
|
|
176
|
+
readonly options: ReadonlyMap<string, string[] | boolean>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findByName(name: string): OptionSpec | undefined {
|
|
180
|
+
return OPTIONS.find((option) => option.name === name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function findByAlias(alias: string): OptionSpec | undefined {
|
|
184
|
+
return OPTIONS.find((option) => option.alias === alias);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function asCommand(token: string): Command {
|
|
188
|
+
const match = COMMANDS.find((name) => name === token);
|
|
189
|
+
if (!match) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Unknown command: ${token}. Expected one of: ${COMMANDS.join(", ")}.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return match;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function splitValues(value: string, separator?: string): string[] {
|
|
198
|
+
const parts = separator ? value.split(separator) : [value];
|
|
199
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readBoolean(value: string, name: string): boolean {
|
|
203
|
+
const normalized = value.trim().toLowerCase();
|
|
204
|
+
if (new Set(["1", "true", "yes", "on"]).has(normalized)) return true;
|
|
205
|
+
if (new Set(["0", "false", "no", "off"]).has(normalized)) return false;
|
|
206
|
+
throw new Error(`--${name} accepts true or false, not "${value}".`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function missingValue(spec: OptionSpec): Error {
|
|
210
|
+
return new Error(
|
|
211
|
+
`Option --${spec.name} needs a value, for example --${spec.name} ${spec.placeholder ?? "<value>"}.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function parseCommandLine(argv: readonly string[]): ParsedCommandLine {
|
|
216
|
+
const options = new Map<string, string[] | boolean>();
|
|
217
|
+
const queue = [...argv];
|
|
218
|
+
let command: Command | undefined;
|
|
219
|
+
let help = false;
|
|
220
|
+
|
|
221
|
+
const record = (spec: OptionSpec, value: string): void => {
|
|
222
|
+
if (spec.kind !== "list") {
|
|
223
|
+
options.set(spec.name, [value]);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const current = options.get(spec.name);
|
|
227
|
+
const existing = Array.isArray(current) ? current : [];
|
|
228
|
+
options.set(spec.name, [
|
|
229
|
+
...existing,
|
|
230
|
+
...splitValues(value, spec.separator),
|
|
231
|
+
]);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const token = queue.shift() as string;
|
|
236
|
+
|
|
237
|
+
if (token === "--help" || token === "-h") {
|
|
238
|
+
help = true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (token.startsWith("--")) {
|
|
243
|
+
const body = token.slice(2);
|
|
244
|
+
if (body.length === 0) {
|
|
245
|
+
throw new Error("A bare `--` is not supported. Pass options directly.");
|
|
246
|
+
}
|
|
247
|
+
const equals = body.indexOf("=");
|
|
248
|
+
const rawName = equals === -1 ? body : body.slice(0, equals);
|
|
249
|
+
const inline = equals === -1 ? undefined : body.slice(equals + 1);
|
|
250
|
+
const negated =
|
|
251
|
+
findByName(rawName) === undefined && rawName.startsWith("no-");
|
|
252
|
+
const name = negated ? rawName.slice(3) : rawName;
|
|
253
|
+
const spec = findByName(name);
|
|
254
|
+
if (!spec) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Unknown option: --${rawName}. Run \`terminal-commands --help\` to see every option.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (spec.kind === "boolean") {
|
|
260
|
+
if (inline === undefined) {
|
|
261
|
+
options.set(spec.name, !negated);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (negated)
|
|
265
|
+
throw new Error(`--no-${spec.name} does not take a value.`);
|
|
266
|
+
options.set(spec.name, readBoolean(inline, spec.name));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (negated)
|
|
270
|
+
throw new Error(`--no-${name} is only available for on/off options.`);
|
|
271
|
+
const value = inline ?? queue.shift();
|
|
272
|
+
if (
|
|
273
|
+
value === undefined ||
|
|
274
|
+
(inline === undefined && value.startsWith("--"))
|
|
275
|
+
) {
|
|
276
|
+
throw missingValue(spec);
|
|
277
|
+
}
|
|
278
|
+
record(spec, value);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (token.startsWith("-") && token.length > 1) {
|
|
283
|
+
const body = token.slice(1);
|
|
284
|
+
const alias = body.slice(0, 1);
|
|
285
|
+
const spec = findByAlias(alias);
|
|
286
|
+
if (!spec) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Unknown option: -${alias}. Run \`terminal-commands --help\` to see every option.`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const attached = body.slice(1).replace(/^=/, "");
|
|
292
|
+
if (spec.kind === "boolean") {
|
|
293
|
+
if (attached.length > 0)
|
|
294
|
+
throw new Error(`-${alias} does not take a value.`);
|
|
295
|
+
options.set(spec.name, true);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const value = attached.length > 0 ? attached : queue.shift();
|
|
299
|
+
if (
|
|
300
|
+
value === undefined ||
|
|
301
|
+
(attached.length === 0 && value.startsWith("--"))
|
|
302
|
+
) {
|
|
303
|
+
throw missingValue(spec);
|
|
304
|
+
}
|
|
305
|
+
record(spec, value);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (command !== undefined) throw new Error(`Unexpected argument: ${token}`);
|
|
310
|
+
command = asCommand(token);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const resolved = command ?? "help";
|
|
314
|
+
if (resolved !== "help") {
|
|
315
|
+
for (const name of options.keys()) {
|
|
316
|
+
const spec = findByName(name);
|
|
317
|
+
if (!spec || spec.commands.includes(resolved)) continue;
|
|
318
|
+
throw new Error(
|
|
319
|
+
`--${name} is not available for \`${resolved}\`. It applies to: ${spec.commands.join(", ")}.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { command: resolved, help: help || resolved === "help", options };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Turn parsed options into environment overrides. A flag replaces the matching
|
|
329
|
+
* environment variable outright, so --root never appends to MACHINE_TERMINAL_ROOTS.
|
|
330
|
+
*/
|
|
331
|
+
export function optionEnvironment(
|
|
332
|
+
parsed: ParsedCommandLine,
|
|
333
|
+
): Record<string, string> {
|
|
334
|
+
const environment: Record<string, string> = {};
|
|
335
|
+
for (const spec of OPTIONS) {
|
|
336
|
+
if (!spec.env) continue;
|
|
337
|
+
const value = parsed.options.get(spec.name);
|
|
338
|
+
if (value === undefined) continue;
|
|
339
|
+
if (typeof value === "boolean") {
|
|
340
|
+
environment[spec.env] = value ? "1" : "0";
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (value.length > 0)
|
|
344
|
+
environment[spec.env] = value.join(spec.separator ?? ",");
|
|
345
|
+
}
|
|
346
|
+
return environment;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function expandHome(value: string, home = os.homedir()): string {
|
|
350
|
+
if (value === "~") return home;
|
|
351
|
+
if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
|
|
352
|
+
return path.join(home, value.slice(2));
|
|
353
|
+
}
|
|
354
|
+
return value;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function splitRootList(raw: string | undefined): string[] {
|
|
358
|
+
if (!raw) return [];
|
|
359
|
+
return raw
|
|
360
|
+
.split(path.delimiter)
|
|
361
|
+
.map((part) => part.trim())
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function normalizeRoots(
|
|
366
|
+
values: readonly string[],
|
|
367
|
+
cwd = process.cwd(),
|
|
368
|
+
): string[] {
|
|
369
|
+
const resolved = values.map((value) => path.resolve(cwd, expandHome(value)));
|
|
370
|
+
return [...new Set(resolved)];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function assertRootsExist(
|
|
374
|
+
roots: readonly string[],
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
const problems: string[] = [];
|
|
377
|
+
for (const root of roots) {
|
|
378
|
+
try {
|
|
379
|
+
const stats = await fs.stat(root);
|
|
380
|
+
if (!stats.isDirectory()) problems.push(`${root} is not a directory`);
|
|
381
|
+
} catch {
|
|
382
|
+
problems.push(`${root} does not exist`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (problems.length > 0) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Cannot approve these roots:\n - ${problems.join("\n - ")}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function flagLabel(spec: OptionSpec): string {
|
|
393
|
+
const alias = spec.alias ? `-${spec.alias}, ` : "";
|
|
394
|
+
const value =
|
|
395
|
+
spec.kind === "boolean" ? "" : ` ${spec.placeholder ?? "<value>"}`;
|
|
396
|
+
return `${alias}--${spec.name}${value}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function renderOptionLines(specs: readonly OptionSpec[]): string[] {
|
|
400
|
+
const labels = specs.map((spec) => flagLabel(spec));
|
|
401
|
+
const width = labels.reduce(
|
|
402
|
+
(widest, label) => Math.max(widest, label.length),
|
|
403
|
+
0,
|
|
404
|
+
);
|
|
405
|
+
return specs.map((spec, index) => {
|
|
406
|
+
const label = (labels[index] ?? "").padEnd(width);
|
|
407
|
+
const environment = spec.env ? ` [${spec.env}]` : "";
|
|
408
|
+
return ` ${label} ${spec.description}${environment}`;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function renderHelp(command: Command = "help"): string {
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
|
|
415
|
+
if (command === "help") {
|
|
416
|
+
lines.push(
|
|
417
|
+
"Terminal Commands CLI",
|
|
418
|
+
"",
|
|
419
|
+
"Usage:",
|
|
420
|
+
" terminal-commands <command> [options]",
|
|
421
|
+
" npm run cli -- <command> [options]",
|
|
422
|
+
"",
|
|
423
|
+
"Commands:",
|
|
424
|
+
);
|
|
425
|
+
for (const [name, summary] of Object.entries(COMMAND_SUMMARIES)) {
|
|
426
|
+
lines.push(` ${name.padEnd(9)}${summary}`);
|
|
427
|
+
}
|
|
428
|
+
lines.push("", "Options:", ...renderOptionLines(OPTIONS));
|
|
429
|
+
} else {
|
|
430
|
+
const relevant = OPTIONS.filter((option) =>
|
|
431
|
+
option.commands.includes(command),
|
|
432
|
+
);
|
|
433
|
+
lines.push(
|
|
434
|
+
`terminal-commands ${command} - ${COMMAND_SUMMARIES[command]}`,
|
|
435
|
+
"",
|
|
436
|
+
"Usage:",
|
|
437
|
+
` terminal-commands ${command} [options]`,
|
|
438
|
+
"",
|
|
439
|
+
);
|
|
440
|
+
if (relevant.length > 0)
|
|
441
|
+
lines.push("Options:", ...renderOptionLines(relevant));
|
|
442
|
+
else lines.push("This command takes no options.");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
lines.push(
|
|
446
|
+
"",
|
|
447
|
+
"Each option also reads the environment variable in brackets. A flag beats the",
|
|
448
|
+
"environment, and the environment beats the default. Use --no-<option> to turn an",
|
|
449
|
+
"on/off option back off when the environment sets it.",
|
|
450
|
+
"",
|
|
451
|
+
"Examples:",
|
|
452
|
+
" terminal-commands connect --root ~/Documents/GitHub --root ~/work",
|
|
453
|
+
" terminal-commands connect --root . --dry-run",
|
|
454
|
+
" terminal-commands connect --root ~/code --name laptop --allow-destructive",
|
|
455
|
+
" npm run cli -- connect --root ~/Documents/GitHub --allowed-program git,node",
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|