@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.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/LICENSE +21 -0
- package/README.md +988 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/setup.ts +1151 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/reaper.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// An owner for every process vibe-os starts, and a broom for the ones that
|
|
2
|
+
// slip loose anyway.
|
|
3
|
+
//
|
|
4
|
+
// Three leaks in two days on the VPS (#38, #39) shared one cause: every
|
|
5
|
+
// teardown path reached exactly one pid by matching its argv, and anything
|
|
6
|
+
// below or beside that pid survived — reparented to init, unreachable by name,
|
|
7
|
+
// running for a week in a worktree that no longer existed. The rules this
|
|
8
|
+
// module exists to hold (#40):
|
|
9
|
+
//
|
|
10
|
+
// 1. A session survives disconnect. That is the point of dtach.
|
|
11
|
+
// 2. A client must not survive its terminal.
|
|
12
|
+
// 3. Nothing survives its workspace.
|
|
13
|
+
//
|
|
14
|
+
// The mechanism for 3 is not, as first proposed, a process group. dtach's
|
|
15
|
+
// master creates the pty with forkpty, and forkpty calls setsid in the child —
|
|
16
|
+
// so the harness is a *session* leader of its own kernel session, and the
|
|
17
|
+
// master's pgid never covers a single grandchild. What does cover them, all of
|
|
18
|
+
// them, is the environment: session.ts stamps `VIBE_OS_SOCK=<socket>` onto
|
|
19
|
+
// the create command, every descendant inherits it — across
|
|
20
|
+
// fork, exec, daemonisation, even a second setsid — and /proc/<pid>/environ
|
|
21
|
+
// lets it be read back later. That variable is the owner's name; this module
|
|
22
|
+
// is what reads it.
|
|
23
|
+
//
|
|
24
|
+
// The reaper below is the admission that the rules will still be broken
|
|
25
|
+
// sometimes: a server crash mid-teardown, a session created before the marker
|
|
26
|
+
// existed. Two cheap signals found every leak in the original batch by hand —
|
|
27
|
+
// a cwd that is a deleted path under `.vibe-worktrees/`, and a process tied to
|
|
28
|
+
// a session socket that no longer exists — so those are exactly what it looks
|
|
29
|
+
// for, on server start and in `vibe-os doctor`.
|
|
30
|
+
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { readFile, readlink } from "node:fs/promises";
|
|
34
|
+
import { promisify } from "node:util";
|
|
35
|
+
import type { Config } from "./config.ts";
|
|
36
|
+
import { log } from "./log.ts";
|
|
37
|
+
|
|
38
|
+
const run = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
/** The environment variable that names a process's owning session socket. */
|
|
41
|
+
export const SOCK_VAR = "VIBE_OS_SOCK";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The directory component that marks a workspace worktree.
|
|
45
|
+
*
|
|
46
|
+
* projects.ts owns the real `WORKTREE_DIR` constant. It is repeated here
|
|
47
|
+
* rather than imported because projects.ts imports attach.ts, which imports
|
|
48
|
+
* this file — and if the two ever drift, the reaper misses a class of leak
|
|
49
|
+
* rather than killing anything wrong.
|
|
50
|
+
*/
|
|
51
|
+
const WORKTREE_MARK = "/.vibe-worktrees/";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How old a process must be before a missing socket counts against it.
|
|
55
|
+
*
|
|
56
|
+
* The create path in session.ts is `rm -f <sock>; dtach -n <sock> …`, so a
|
|
57
|
+
* session being born is briefly a dtach whose socket does not exist yet. dtach
|
|
58
|
+
* binds within milliseconds; a minute is three orders of magnitude of slack.
|
|
59
|
+
* The deleted-cwd signal needs no such grace — teardown kills before it
|
|
60
|
+
* removes the worktree, so a live process in a deleted worktree is
|
|
61
|
+
* definitionally a leak.
|
|
62
|
+
*/
|
|
63
|
+
const MIN_AGE_S = 60;
|
|
64
|
+
|
|
65
|
+
/** One process, as much of it as this platform lets us see. */
|
|
66
|
+
export interface ProcInfo {
|
|
67
|
+
pid: number;
|
|
68
|
+
/** Seconds since it started. */
|
|
69
|
+
age: number;
|
|
70
|
+
/** Full argv as one line, as ps prints it. */
|
|
71
|
+
args: string;
|
|
72
|
+
/** readlink of its cwd, ` (deleted)` suffix intact. Linux only. */
|
|
73
|
+
cwd: string | null;
|
|
74
|
+
/** The `VIBE_OS_SOCK` it inherited, if any. Linux only. */
|
|
75
|
+
sock: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Leak {
|
|
79
|
+
pid: number;
|
|
80
|
+
reason: string;
|
|
81
|
+
args: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Pulls the session marker out of a NUL-delimited /proc environ blob. */
|
|
85
|
+
export function markerFromEnviron(environ: string): string | null {
|
|
86
|
+
for (const entry of environ.split("\0")) {
|
|
87
|
+
if (entry.startsWith(`${SOCK_VAR}=`))
|
|
88
|
+
return entry.slice(SOCK_VAR.length + 1) || null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Seconds from a `ps -o etime` value: `[[dd-]hh:]mm:ss`.
|
|
95
|
+
*
|
|
96
|
+
* `etimes` (plain seconds) would skip the parsing but is procps-only; `etime`
|
|
97
|
+
* is in POSIX ps and prints the same way on Linux and macOS. A bare number is
|
|
98
|
+
* accepted too, so a caller with seconds in hand loses nothing.
|
|
99
|
+
*/
|
|
100
|
+
export function parseEtime(value: string): number {
|
|
101
|
+
const m = value.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
102
|
+
if (!m) return Number(value.trim()) || 0;
|
|
103
|
+
const [, d, h, min, s] = m;
|
|
104
|
+
return (
|
|
105
|
+
(Number(d ?? 0) * 24 + Number(h ?? 0)) * 3600 + Number(min) * 60 + Number(s)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pgrep(args: string[]): Promise<number[]> {
|
|
110
|
+
try {
|
|
111
|
+
const { stdout } = await run("pgrep", args, { timeout: 10_000 });
|
|
112
|
+
return stdout
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((line) => Number(line.trim()))
|
|
115
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
116
|
+
} catch {
|
|
117
|
+
// pgrep exits non-zero when nothing matches.
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every process on the box, one ps call plus /proc reads where /proc exists. */
|
|
123
|
+
export async function scanProcesses(): Promise<ProcInfo[]> {
|
|
124
|
+
const { stdout } = await run("ps", ["-A", "-o", "pid=,etime=,args="], {
|
|
125
|
+
timeout: 10_000,
|
|
126
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
127
|
+
}).catch(() => ({ stdout: "" }));
|
|
128
|
+
|
|
129
|
+
const rows: Array<Omit<ProcInfo, "cwd" | "sock">> = [];
|
|
130
|
+
for (const line of stdout.split("\n")) {
|
|
131
|
+
const m = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
|
|
132
|
+
if (m) rows.push({ pid: Number(m[1]), age: parseEtime(m[2]), args: m[3] });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Both reads fail for processes owned by other users; those are not ours to
|
|
136
|
+
// reap, so "could not see" and "not a leak" are the same answer.
|
|
137
|
+
const linux = process.platform === "linux";
|
|
138
|
+
return Promise.all(
|
|
139
|
+
rows.map(async (row) => ({
|
|
140
|
+
...row,
|
|
141
|
+
cwd: linux
|
|
142
|
+
? await readlink(`/proc/${row.pid}/cwd`).catch(() => null)
|
|
143
|
+
: null,
|
|
144
|
+
sock: linux
|
|
145
|
+
? markerFromEnviron(
|
|
146
|
+
await readFile(`/proc/${row.pid}/environ`, "utf8").catch(() => ""),
|
|
147
|
+
)
|
|
148
|
+
: null,
|
|
149
|
+
})),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Is this process a leak? Pure, so the answer is testable without a /proc.
|
|
155
|
+
*
|
|
156
|
+
* Three signals, each sufficient on its own:
|
|
157
|
+
* - its cwd is a deleted path under `.vibe-worktrees/` — the workspace is
|
|
158
|
+
* gone, so rule 3 says the process should be too;
|
|
159
|
+
* - the `VIBE_OS_SOCK` it inherited names a socket that no longer exists;
|
|
160
|
+
* - it is a dtach whose argv names such a socket, which is the same fact for
|
|
161
|
+
* processes older than the marker.
|
|
162
|
+
*/
|
|
163
|
+
export function classifyLeak(
|
|
164
|
+
p: ProcInfo,
|
|
165
|
+
opts: { sessionsDir: string; socketExists: (path: string) => boolean },
|
|
166
|
+
): Leak | null {
|
|
167
|
+
if (p.cwd?.includes(WORKTREE_MARK) && p.cwd.endsWith(" (deleted)"))
|
|
168
|
+
return { pid: p.pid, args: p.args, reason: "its worktree was deleted" };
|
|
169
|
+
|
|
170
|
+
const dir = `${opts.sessionsDir}/`;
|
|
171
|
+
const gone = (sock: string) =>
|
|
172
|
+
sock.startsWith(dir) && p.age > MIN_AGE_S && !opts.socketExists(sock);
|
|
173
|
+
|
|
174
|
+
if (p.sock && gone(p.sock))
|
|
175
|
+
return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
|
|
176
|
+
|
|
177
|
+
const dtach = p.args.match(/^dtach -[nap] (\S+\.sock)\b/);
|
|
178
|
+
if (dtach && gone(dtach[1]))
|
|
179
|
+
return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
|
|
180
|
+
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Finds the leaks, says what they were, ends them.
|
|
186
|
+
*
|
|
187
|
+
* The log line carries the argv because by the time anyone reads it the
|
|
188
|
+
* process is gone, and "reaped 444043" answers nothing.
|
|
189
|
+
*/
|
|
190
|
+
export async function reapLeaks(config: Config): Promise<Leak[]> {
|
|
191
|
+
const leaks = await findLeaks(config);
|
|
192
|
+
for (const leak of leaks)
|
|
193
|
+
log.warn(`reaping ${leak.pid} (${leak.reason}): ${leak.args}`);
|
|
194
|
+
await terminate(leaks.map((leak) => leak.pid));
|
|
195
|
+
return leaks;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Every leaked process this platform can see, scoped to this state dir. */
|
|
199
|
+
export async function findLeaks(config: Config): Promise<Leak[]> {
|
|
200
|
+
const sessionsDir = `${config.stateDir}/sessions`;
|
|
201
|
+
const leaks: Leak[] = [];
|
|
202
|
+
for (const p of await scanProcesses()) {
|
|
203
|
+
if (p.pid <= 1 || p.pid === process.pid) continue;
|
|
204
|
+
const leak = classifyLeak(p, {
|
|
205
|
+
sessionsDir,
|
|
206
|
+
socketExists: (sock) => existsSync(sock),
|
|
207
|
+
});
|
|
208
|
+
if (leak) leaks.push(leak);
|
|
209
|
+
}
|
|
210
|
+
return leaks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Every process belonging to a session, to the last grandchild.
|
|
215
|
+
*
|
|
216
|
+
* Three nets, because no single one reaches everything:
|
|
217
|
+
* - the dtach master itself, by argv — the only thing the old pkill reached;
|
|
218
|
+
* - everything sharing the pty's kernel session: forkpty makes dtach's child
|
|
219
|
+
* a session leader, so the harness and every job it started carry that sid
|
|
220
|
+
* (`ps -o sess` reads it back on Linux and macOS alike), including children
|
|
221
|
+
* already reparented to init;
|
|
222
|
+
* - everything that inherited the `VIBE_OS_SOCK` marker, which is the only
|
|
223
|
+
* net that still holds after a second setsid or after the master has died.
|
|
224
|
+
*/
|
|
225
|
+
export async function sessionProcesses(sock: string): Promise<number[]> {
|
|
226
|
+
const pids = new Set<number>();
|
|
227
|
+
|
|
228
|
+
const masters = await pgrep(["-f", `^dtach -n ${sock}`]);
|
|
229
|
+
for (const pid of masters) pids.add(pid);
|
|
230
|
+
|
|
231
|
+
const leaders = new Set<number>();
|
|
232
|
+
for (const master of masters)
|
|
233
|
+
for (const child of await pgrep(["-P", String(master)])) leaders.add(child);
|
|
234
|
+
|
|
235
|
+
if (leaders.size > 0) {
|
|
236
|
+
const { stdout } = await run("ps", ["-A", "-o", "pid=,sess="], {
|
|
237
|
+
timeout: 10_000,
|
|
238
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
239
|
+
}).catch(() => ({ stdout: "" }));
|
|
240
|
+
for (const line of stdout.split("\n")) {
|
|
241
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
|
|
242
|
+
if (m && leaders.has(Number(m[2]))) pids.add(Number(m[1]));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (process.platform === "linux") {
|
|
247
|
+
for (const p of await scanProcesses()) if (p.sock === sock) pids.add(p.pid);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
pids.delete(process.pid);
|
|
251
|
+
return [...pids];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const isAlive = (pid: number): boolean => {
|
|
255
|
+
try {
|
|
256
|
+
process.kill(pid, 0);
|
|
257
|
+
return true;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
// EPERM is "alive but not ours" — SIGKILL would fail the same way, so
|
|
260
|
+
// there is nothing more to do with it, but it must not count as dead.
|
|
261
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* TERM, a short grace, then KILL for whatever ignored it.
|
|
267
|
+
*
|
|
268
|
+
* Returns the pids actually signalled. The grace is polled rather than slept
|
|
269
|
+
* through so the common case — everything honours TERM at once — costs one
|
|
270
|
+
* tick, not the whole allowance.
|
|
271
|
+
*/
|
|
272
|
+
export async function terminate(
|
|
273
|
+
pids: number[],
|
|
274
|
+
graceMs = 2000,
|
|
275
|
+
): Promise<number[]> {
|
|
276
|
+
const targets = [...new Set(pids)].filter(
|
|
277
|
+
(pid) => pid > 1 && pid !== process.pid,
|
|
278
|
+
);
|
|
279
|
+
const signalled: number[] = [];
|
|
280
|
+
for (const pid of targets) {
|
|
281
|
+
try {
|
|
282
|
+
process.kill(pid, "SIGTERM");
|
|
283
|
+
signalled.push(pid);
|
|
284
|
+
} catch {
|
|
285
|
+
// Already gone, or not ours to signal.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const deadline = Date.now() + graceMs;
|
|
290
|
+
let alive = signalled.filter(isAlive);
|
|
291
|
+
while (alive.length > 0 && Date.now() < deadline) {
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
293
|
+
alive = alive.filter(isAlive);
|
|
294
|
+
}
|
|
295
|
+
for (const pid of alive) {
|
|
296
|
+
try {
|
|
297
|
+
process.kill(pid, "SIGKILL");
|
|
298
|
+
} catch {
|
|
299
|
+
// Died between the poll and now.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return signalled;
|
|
303
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether we are running inside a `bun build --compile` binary.
|
|
6
|
+
*
|
|
7
|
+
* Bun rewrites module paths to a virtual `/$bunfs/` root in standalone
|
|
8
|
+
* executables. That matters because a compiled binary carries the web assets
|
|
9
|
+
* inside it: there is no package directory to read from, nothing to fetch, and
|
|
10
|
+
* the preflight checks that make sense from a git checkout would all fail.
|
|
11
|
+
*/
|
|
12
|
+
export const IS_COMPILED =
|
|
13
|
+
import.meta.url.includes("$bunfs") || import.meta.url.includes("B:/~BUN");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Arguments passed to the CLI, independent of how it was launched.
|
|
17
|
+
*
|
|
18
|
+
* Bun keeps argv the same shape in both modes — [runtime, entry, ...args] —
|
|
19
|
+
* where a compiled binary reports its entry as "/$bunfs/root/<name>". Verified
|
|
20
|
+
* rather than assumed: it is tempting to think a standalone executable drops
|
|
21
|
+
* the entry slot, and slicing 1 there silently turns the entry path into the
|
|
22
|
+
* subcommand.
|
|
23
|
+
*/
|
|
24
|
+
export function cliArgs(): string[] {
|
|
25
|
+
return process.argv.slice(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The package root — one directory above `server/`.
|
|
30
|
+
*
|
|
31
|
+
* Both `cli.ts` and `preflight.ts` derived this the same way from their own
|
|
32
|
+
* `import.meta.url`, which works only because both happen to live one level
|
|
33
|
+
* down. Deriving it here makes that assumption a single fact.
|
|
34
|
+
*/
|
|
35
|
+
export const PKG_ROOT = path.resolve(
|
|
36
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
37
|
+
"..",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** The CLI entry point, for re-invoking ourselves from a checkout. */
|
|
41
|
+
export const ENTRY = path.join(PKG_ROOT, "server", "cli.ts");
|
|
42
|
+
|
|
43
|
+
/** The `fetch-wasm` script, spawned from both the CLI and preflight. */
|
|
44
|
+
export const FETCH_WASM = path.join(PKG_ROOT, "scripts", "fetch-wasm.mjs");
|
|
45
|
+
|
|
46
|
+
/** Directories on a default PATH, where a bare command name resolves. */
|
|
47
|
+
const DEFAULT_PATH = new Set([
|
|
48
|
+
"/usr/local/sbin",
|
|
49
|
+
"/usr/local/bin",
|
|
50
|
+
"/usr/sbin",
|
|
51
|
+
"/usr/bin",
|
|
52
|
+
"/sbin",
|
|
53
|
+
"/bin",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How to run vibe-os again — the string printed into ssh commands people copy,
|
|
58
|
+
* and into the systemd unit. Both used to build it separately.
|
|
59
|
+
*/
|
|
60
|
+
export function invocation(): string {
|
|
61
|
+
if (!IS_COMPILED) return `${process.execPath} ${ENTRY}`;
|
|
62
|
+
const exe = process.execPath;
|
|
63
|
+
return DEFAULT_PATH.has(path.dirname(exe)) ? path.basename(exe) : exe;
|
|
64
|
+
}
|