vmsan 0.1.0-alpha.14 → 0.1.0-alpha.16

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.
@@ -1,6 +1,196 @@
1
1
  import { createGzip } from "node:zlib";
2
2
  import { Readable } from "node:stream";
3
3
  import { pack } from "tar-stream";
4
+ /**
5
+ * Lightweight async iterable queue for pushing events and yielding them to consumers.
6
+ */
7
+ var AsyncQueue = class {
8
+ _buffer = [];
9
+ _waiting = [];
10
+ _closed = false;
11
+ push(item) {
12
+ if (this._closed) return;
13
+ if (this._waiting.length > 0) this._waiting.shift()({
14
+ value: item,
15
+ done: false
16
+ });
17
+ else this._buffer.push(item);
18
+ }
19
+ close() {
20
+ this._closed = true;
21
+ for (const resolve of this._waiting) resolve({
22
+ value: void 0,
23
+ done: true
24
+ });
25
+ this._waiting.length = 0;
26
+ }
27
+ async *[Symbol.asyncIterator]() {
28
+ while (true) if (this._buffer.length > 0) yield this._buffer.shift();
29
+ else if (this._closed) return;
30
+ else {
31
+ const item = await new Promise((resolve) => {
32
+ this._waiting.push(resolve);
33
+ });
34
+ if (item.done) return;
35
+ yield item.value;
36
+ }
37
+ }
38
+ };
39
+ var CommandFinished = class {
40
+ cmdId;
41
+ exitCode;
42
+ stdout;
43
+ stderr;
44
+ output;
45
+ timedOut;
46
+ startedAt;
47
+ constructor(opts) {
48
+ this.cmdId = opts.cmdId;
49
+ this.exitCode = opts.exitCode;
50
+ this.stdout = opts.stdout;
51
+ this.stderr = opts.stderr;
52
+ this.output = opts.output;
53
+ this.timedOut = opts.timedOut;
54
+ this.startedAt = opts.startedAt;
55
+ }
56
+ get ok() {
57
+ return this.exitCode === 0;
58
+ }
59
+ };
60
+ const MAX_LOG_ENTRIES = 1e5;
61
+ var Command = class {
62
+ cmdId;
63
+ _startedAt;
64
+ _exitCode = null;
65
+ _timedOut = false;
66
+ _logEntries = [];
67
+ _logTruncated = false;
68
+ _eventQueue = new AsyncQueue();
69
+ _completion;
70
+ _stdoutPromise = null;
71
+ _stderrPromise = null;
72
+ _outputPromise = null;
73
+ _agent;
74
+ constructor(init) {
75
+ const { agent, cmdId, startedAt, stream, signal, onStdout, onStderr } = init;
76
+ this._agent = agent;
77
+ this.cmdId = cmdId;
78
+ this._startedAt = startedAt;
79
+ let resolveCompletion;
80
+ let rejectCompletion;
81
+ this._completion = new Promise((resolve, reject) => {
82
+ resolveCompletion = resolve;
83
+ rejectCompletion = reject;
84
+ });
85
+ let onAbort;
86
+ if (signal) {
87
+ onAbort = () => {
88
+ this.kill().catch(() => {});
89
+ };
90
+ if (signal.aborted) this.kill().catch(() => {});
91
+ else signal.addEventListener("abort", onAbort, { once: true });
92
+ }
93
+ (async () => {
94
+ try {
95
+ for await (const event of stream) {
96
+ this._eventQueue.push(event);
97
+ switch (event.type) {
98
+ case "stdout":
99
+ case "stderr":
100
+ if (event.data !== void 0) {
101
+ if (this._logEntries.length < MAX_LOG_ENTRIES) this._logEntries.push({
102
+ stream: event.type,
103
+ data: event.data
104
+ });
105
+ else this._logTruncated = true;
106
+ (event.type === "stdout" ? onStdout : onStderr)?.(event.data);
107
+ }
108
+ break;
109
+ case "exit":
110
+ this._exitCode = event.exitCode ?? 1;
111
+ break;
112
+ case "timeout":
113
+ this._timedOut = true;
114
+ this._exitCode = 124;
115
+ break;
116
+ case "error":
117
+ this._exitCode = 1;
118
+ break;
119
+ }
120
+ }
121
+ this._eventQueue.close();
122
+ resolveCompletion(new CommandFinished({
123
+ cmdId,
124
+ exitCode: this._exitCode ?? 1,
125
+ stdout: this._logEntries.filter((e) => e.stream === "stdout").map((e) => e.data).join("\n"),
126
+ stderr: this._logEntries.filter((e) => e.stream === "stderr").map((e) => e.data).join("\n"),
127
+ output: this._logEntries.map((e) => e.data).join("\n"),
128
+ timedOut: this._timedOut,
129
+ startedAt
130
+ }));
131
+ this._logEntries.length = 0;
132
+ } catch (err) {
133
+ this._eventQueue.close();
134
+ const error = err instanceof Error ? err : new Error(String(err));
135
+ rejectCompletion(error);
136
+ } finally {
137
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
138
+ }
139
+ })();
140
+ }
141
+ get startedAt() {
142
+ return this._startedAt;
143
+ }
144
+ get exitCode() {
145
+ return this._exitCode;
146
+ }
147
+ async *logs(opts) {
148
+ const signal = opts?.signal;
149
+ if (signal?.aborted) return;
150
+ for await (const event of this._eventQueue) {
151
+ if (signal?.aborted) return;
152
+ if (event.type === "stdout" || event.type === "stderr") yield {
153
+ stream: event.type,
154
+ data: event.data ?? ""
155
+ };
156
+ }
157
+ }
158
+ stdout(opts) {
159
+ if (!this._stdoutPromise) this._stdoutPromise = this._completion.then((r) => r.stdout);
160
+ return this._withSignal(this._stdoutPromise, opts?.signal);
161
+ }
162
+ stderr(opts) {
163
+ if (!this._stderrPromise) this._stderrPromise = this._completion.then((r) => r.stderr);
164
+ return this._withSignal(this._stderrPromise, opts?.signal);
165
+ }
166
+ output(stream, opts) {
167
+ if (stream === "stdout") return this.stdout(opts);
168
+ if (stream === "stderr") return this.stderr(opts);
169
+ if (!this._outputPromise) this._outputPromise = this._completion.then((r) => r.output);
170
+ return this._withSignal(this._outputPromise, opts?.signal);
171
+ }
172
+ wait(opts) {
173
+ return this._withSignal(this._completion, opts?.signal);
174
+ }
175
+ async kill(signal, opts) {
176
+ await this._agent.killCommand(this.cmdId, signal, opts?.abortSignal);
177
+ }
178
+ _withSignal(promise, signal) {
179
+ if (!signal) return promise;
180
+ if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted.", "AbortError"));
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => reject(new DOMException("The operation was aborted.", "AbortError"));
183
+ signal.addEventListener("abort", onAbort, { once: true });
184
+ promise.then((value) => {
185
+ signal.removeEventListener("abort", onAbort);
186
+ resolve(value);
187
+ }, (err) => {
188
+ signal.removeEventListener("abort", onAbort);
189
+ reject(err);
190
+ });
191
+ });
192
+ }
193
+ };
4
194
  var AgentClient = class {
5
195
  constructor(baseUrl, token) {
6
196
  this.baseUrl = baseUrl;
@@ -11,14 +201,15 @@ var AgentClient = class {
11
201
  if (!res.ok) throw new Error(`Agent health check failed: ${res.status}`);
12
202
  return res.json();
13
203
  }
14
- async *run(params) {
204
+ async *run(params, signal) {
15
205
  const res = await fetch(`${this.baseUrl}/exec`, {
16
206
  method: "POST",
17
207
  headers: {
18
208
  "Content-Type": "application/json",
19
209
  Authorization: `Bearer ${this.token}`
20
210
  },
21
- body: JSON.stringify(params)
211
+ body: JSON.stringify(params),
212
+ signal
22
213
  });
23
214
  if (!res.ok) {
24
215
  const text = await res.text();
@@ -41,7 +232,7 @@ var AgentClient = class {
41
232
  }
42
233
  if (buffer.trim()) yield JSON.parse(buffer.trim());
43
234
  }
44
- async killCommand(cmdId, signal) {
235
+ async killCommand(cmdId, signal, abortSignal) {
45
236
  const url = `${this.baseUrl}/exec/${cmdId}/kill`;
46
237
  const res = await fetch(url, {
47
238
  method: "POST",
@@ -49,7 +240,8 @@ var AgentClient = class {
49
240
  "Content-Type": "application/json",
50
241
  Authorization: `Bearer ${this.token}`
51
242
  },
52
- body: signal ? JSON.stringify({ signal }) : void 0
243
+ body: signal ? JSON.stringify({ signal }) : void 0,
244
+ signal: abortSignal
53
245
  });
54
246
  if (!res.ok) {
55
247
  const text = await res.text();
@@ -94,6 +286,41 @@ var AgentClient = class {
94
286
  throw new Error(`Agent killShellSession failed (${res.status}): ${text}`);
95
287
  }
96
288
  }
289
+ async exec(params, opts) {
290
+ const stream = this.run(params, opts?.signal);
291
+ const first = await stream.next();
292
+ if (first.done) throw new Error("Stream ended without 'started' event");
293
+ if (first.value.type !== "started") throw new Error(`Expected 'started' event, got '${first.value.type}'`);
294
+ const cmdId = first.value.id;
295
+ if (!cmdId) throw new Error("'started' event missing 'id' field");
296
+ const startedAt = first.value.ts ? new Date(first.value.ts) : /* @__PURE__ */ new Date();
297
+ return new Command({
298
+ agent: this,
299
+ cmdId,
300
+ startedAt,
301
+ stream,
302
+ signal: opts?.signal,
303
+ onStdout: opts?.onStdout,
304
+ onStderr: opts?.onStderr
305
+ });
306
+ }
307
+ async runCommand(cmdOrParams, args, opts) {
308
+ let params;
309
+ if (typeof cmdOrParams === "string") params = {
310
+ cmd: cmdOrParams,
311
+ args,
312
+ signal: opts?.signal
313
+ };
314
+ else params = cmdOrParams;
315
+ const { signal, onStdout, onStderr, ...runParams } = params;
316
+ const command = await this.exec(runParams, {
317
+ signal,
318
+ onStdout,
319
+ onStderr
320
+ });
321
+ if (params.detached) return command;
322
+ return command.wait({ signal });
323
+ }
97
324
  async readFile(path) {
98
325
  const res = await fetch(`${this.baseUrl}/files/read`, {
99
326
  method: "POST",
@@ -1,13 +1,57 @@
1
- import { l as agentTimeoutError } from "./errors.mjs";
2
- async function waitForAgent(guestIp, port, timeoutMs = 6e4) {
3
- const start = Date.now();
4
- const url = `http://${guestIp}:${port}/health`;
5
- while (Date.now() - start < timeoutMs) {
1
+ import { n as vmsanPaths } from "./paths.mjs";
2
+ import { t as handleCommandError } from "./errors.mjs";
3
+ import { t as createCommandLogger } from "./logger.mjs";
4
+ import "./vm-state.mjs";
5
+ import { n as waitForAgent, t as resolveVmState } from "./vm-context.mjs";
6
+ import { t as ShellSession } from "./shell.mjs";
7
+ import { consola } from "consola";
8
+ import { defineCommand } from "citty";
9
+ const connectCommand = defineCommand({
10
+ meta: {
11
+ name: "connect",
12
+ description: "Connect to a running VM"
13
+ },
14
+ args: {
15
+ vmId: {
16
+ type: "positional",
17
+ description: "VM ID to connect to",
18
+ required: true
19
+ },
20
+ session: {
21
+ type: "string",
22
+ alias: "s",
23
+ description: "Attach to an existing shell session ID",
24
+ required: false
25
+ }
26
+ },
27
+ async run({ args }) {
28
+ const cmdLog = createCommandLogger("connect");
29
+ const paths = vmsanPaths();
6
30
  try {
7
- if ((await fetch(url, { signal: AbortSignal.timeout(2e3) })).ok) return;
8
- } catch {}
9
- await new Promise((r) => setTimeout(r, 500));
31
+ const { state, guestIp, port } = resolveVmState(args.vmId, paths);
32
+ const log = consola.withTag(args.vmId);
33
+ consola.debug(`Agent endpoint: ${guestIp}:${port}`);
34
+ log.start("Waiting for agent to become ready...");
35
+ await waitForAgent(guestIp, port);
36
+ log.success("Agent is ready. Connecting via PTY shell...");
37
+ const shell = new ShellSession({
38
+ host: guestIp,
39
+ port,
40
+ token: state.agentToken,
41
+ sessionId: args.session
42
+ });
43
+ const closeInfo = await shell.connect();
44
+ cmdLog.set({
45
+ vmId: args.vmId,
46
+ method: "pty"
47
+ });
48
+ cmdLog.emit();
49
+ if (!closeInfo.sessionDestroyed && shell.sessionId) process.stderr.write(`\nResume this session with:\n vmsan connect ${args.vmId} --session ${shell.sessionId}\n`);
50
+ process.exit(0);
51
+ } catch (error) {
52
+ handleCommandError(error, cmdLog);
53
+ process.exit(1);
54
+ }
10
55
  }
11
- throw agentTimeoutError(guestIp, timeoutMs);
12
- }
13
- export { waitForAgent as t };
56
+ });
57
+ export { connectCommand as default };
@@ -1,10 +1,11 @@
1
1
  import { n as vmsanPaths } from "./paths.mjs";
2
- import { H as VmsanError, S as vmNotStoppedError, _ as chrootNotFoundError, a as noKernelDirError, b as vmNotFoundError, d as socketTimeoutError, i as noExt4RootfsError, o as noKernelError, p as defaultInterfaceNotFoundError, r as missingBinaryError, s as noRootfsDirError, u as lockTimeoutError, x as vmNotRunningError, y as snapshotNotFoundError, z as mutuallyExclusiveFlagsError } from "./errors.mjs";
2
+ import { B as mutuallyExclusiveFlagsError, C as vmNotStoppedError, S as vmNotRunningError, U as VmsanError, _ as chrootNotFoundError, a as noKernelDirError, d as socketTimeoutError, i as noExt4RootfsError, o as noKernelError, p as defaultInterfaceNotFoundError, r as missingBinaryError, s as noRootfsDirError, u as lockTimeoutError, x as vmNotFoundError, y as snapshotNotFoundError } from "./errors.mjs";
3
3
  import { c as safeKill, f as toError, i as generateVmId, t as FileVmStateStore } from "./vm-state.mjs";
4
4
  import { t as FirecrackerClient } from "./firecracker.mjs";
5
+ import { t as spawnTimeoutKiller } from "./timeout-killer.mjs";
5
6
  import { createHooks } from "hookable";
6
7
  import { dirname, join } from "node:path";
7
- import { execFileSync, execSync, spawn } from "node:child_process";
8
+ import { execFileSync, execSync } from "node:child_process";
8
9
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
9
10
  import { consola } from "consola";
10
11
  import { randomBytes } from "node:crypto";
@@ -1988,21 +1989,12 @@ var VMService = class {
1988
1989
  pid
1989
1990
  });
1990
1991
  log.success(`VM ${vmId} is running (PID: ${pid || "unknown"})`);
1991
- if (timeoutMs && pid) {
1992
- const stateFile = join(paths.vmsDir, `${vmId}.json`);
1993
- spawn("bash", ["-c", [
1994
- `sleep ${Math.ceil(timeoutMs / 1e3)}`,
1995
- `STATE=$(cat "${stateFile}" 2>/dev/null) || exit 0`,
1996
- `echo "$STATE" | grep -q '"status":"running"' || exit 0`,
1997
- `echo "$STATE" | grep -q '"pid":${pid}' || exit 0`,
1998
- `[ -d /proc/${pid} ] || exit 0`,
1999
- `grep -q "${vmId}" /proc/${pid}/cmdline 2>/dev/null || exit 0`,
2000
- `kill ${pid} 2>/dev/null`
2001
- ].join(" && ")], {
2002
- detached: true,
2003
- stdio: "ignore"
2004
- }).unref();
2005
- }
1992
+ if (timeoutMs && pid) spawnTimeoutKiller({
1993
+ vmId,
1994
+ pid,
1995
+ timeoutMs,
1996
+ stateFile: join(paths.vmsDir, `${vmId}.json`)
1997
+ });
2006
1998
  const finalState = this.store.load(vmId);
2007
1999
  await hooks.callHook("vm:afterCreate", finalState);
2008
2000
  return {
@@ -4,10 +4,11 @@ import { i as initVmsanLogger, n as createScopedLogger, t as createCommandLogger
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
8
+ import { n as waitForAgent } from "./vm-context.mjs";
7
9
  import { t as ShellSession } from "./shell.mjs";
8
10
  import { a as parseImageReference, t as parseBandwidth } from "./validation.mjs";
9
11
  import { n as parseCreateInput, t as buildCreateSummaryLines } from "./summary.mjs";
10
- import { t as waitForAgent } from "./connect.mjs";
11
12
  import { join } from "node:path";
12
13
  import { consola } from "consola";
13
14
  import { defineCommand } from "citty";
@@ -1,9 +1,9 @@
1
1
  import { n as vmsanPaths } from "./paths.mjs";
2
- import { b as vmNotFoundError, t as handleCommandError } from "./errors.mjs";
2
+ import { t as handleCommandError } from "./errors.mjs";
3
3
  import { t as createCommandLogger } from "./logger.mjs";
4
- import { t as FileVmStateStore } from "./vm-state.mjs";
4
+ import "./vm-state.mjs";
5
5
  import { t as AgentClient } from "./agent.mjs";
6
- import { t as waitForAgent } from "./connect.mjs";
6
+ import { n as waitForAgent, t as resolveVmState } from "./vm-context.mjs";
7
7
  import { basename, join, resolve } from "node:path";
8
8
  import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
9
9
  import { consola } from "consola";
@@ -29,23 +29,8 @@ const downloadCommand = defineCommand({
29
29
  const cmdLog = createCommandLogger("download");
30
30
  const paths = vmsanPaths();
31
31
  try {
32
- const state = new FileVmStateStore(paths.vmsDir).load(args.vmId);
33
- if (!state) throw vmNotFoundError(args.vmId);
34
- if (state.status !== "running") {
35
- consola.error(`VM ${args.vmId} is not running (status: ${state.status})`);
36
- cmdLog.emit();
37
- process.exitCode = 1;
38
- return;
39
- }
40
- if (!state.agentToken) {
41
- consola.error("VM has no agent token. Cannot download files without the agent.");
42
- cmdLog.emit();
43
- process.exitCode = 1;
44
- return;
45
- }
32
+ const { state, guestIp, port } = resolveVmState(args.vmId, paths);
46
33
  const log = consola.withTag(args.vmId);
47
- const guestIp = state.network.guestIp;
48
- const port = state.agentPort || paths.agentPort;
49
34
  consola.debug(`Agent endpoint: ${guestIp}:${port}`);
50
35
  log.start("Waiting for agent...");
51
36
  await waitForAgent(guestIp, port);
@@ -122,10 +122,16 @@ const chrootNotFoundError = (vmId) => new VmError("ERR_VM_CHROOT_NOT_FOUND", {
122
122
  fix: "The VM must be recreated with 'vmsan create'."
123
123
  });
124
124
  const networkSlotsExhaustedError = () => new VmError("ERR_VM_NETWORK_SLOTS_EXHAUSTED", { message: "No available network slots (max 255 VMs)" });
125
- const vmNotRunningError = (vmId) => new VmError("ERR_VM_NOT_RUNNING", {
125
+ const vmNotRunningError = (vmId, currentStatus) => new VmError("ERR_VM_NOT_RUNNING", {
126
126
  vmId,
127
- message: `VM ${vmId} is not running`,
128
- fix: "The VM must be running to update its network policy. Start it with 'vmsan start <vm-id>'."
127
+ message: currentStatus ? `VM ${vmId} is not running (current status: ${currentStatus})` : `VM ${vmId} is not running`,
128
+ fix: "The VM must be running. Start it with 'vmsan start <vm-id>'."
129
+ });
130
+ const vmNoAgentTokenError = (vmId) => new VmError("ERR_VM_NO_AGENT_TOKEN", {
131
+ vmId,
132
+ message: `VM ${vmId} has no agent token`,
133
+ why: "The vmsan-agent binary was not found at ~/.vmsan/bin/vmsan-agent when this VM was created.",
134
+ fix: "Install the agent binary into ~/.vmsan/bin/vmsan-agent and recreate the VM with 'vmsan create'."
129
135
  });
130
136
  const snapshotNotFoundError = (snapshotId) => new VmError("ERR_VM_SNAPSHOT_NOT_FOUND", { message: `Snapshot not found: ${snapshotId}` });
131
137
  var FirecrackerApiError = class extends VmsanError {
@@ -229,4 +235,4 @@ function handleCommandError(error, cmdLog) {
229
235
  if (error.link) consola.log(` More: ${error.link}`);
230
236
  } else consola.error(error instanceof Error ? error.message : String(error));
231
237
  }
232
- export { invalidDomainError as A, policyConflictError as B, vmStateNotFoundError as C, invalidCidrPrefixError as D, invalidCidrOctetError as E, invalidIntegerFlagError as F, VmsanError as H, invalidNetworkPolicyError as I, invalidPortError as L, invalidDurationError as M, invalidImageRefEmptyError as N, invalidDiskSizeFormatError as O, invalidImageRefTagError as P, invalidRuntimeError as R, vmNotStoppedError as S, invalidCidrFormatError as T, portConflictError as V, chrootNotFoundError as _, noKernelDirError as a, vmNotFoundError as b, TimeoutError as c, socketTimeoutError as d, NetworkError as f, VmError as g, firecrackerApiError as h, noExt4RootfsError as i, invalidDomainPatternError as j, invalidDiskSizeRangeError as k, agentTimeoutError as l, FirecrackerApiError as m, SetupError as n, noKernelError as o, defaultInterfaceNotFoundError as p, missingBinaryError as r, noRootfsDirError as s, handleCommandError as t, lockTimeoutError as u, networkSlotsExhaustedError as v, ValidationError as w, vmNotRunningError as x, snapshotNotFoundError as y, mutuallyExclusiveFlagsError as z };
238
+ export { invalidDiskSizeRangeError as A, mutuallyExclusiveFlagsError as B, vmNotStoppedError as C, invalidCidrOctetError as D, invalidCidrFormatError as E, invalidImageRefTagError as F, portConflictError as H, invalidIntegerFlagError as I, invalidNetworkPolicyError as L, invalidDomainPatternError as M, invalidDurationError as N, invalidCidrPrefixError as O, invalidImageRefEmptyError as P, invalidPortError as R, vmNotRunningError as S, ValidationError as T, VmsanError as U, policyConflictError as V, chrootNotFoundError as _, noKernelDirError as a, vmNoAgentTokenError as b, TimeoutError as c, socketTimeoutError as d, NetworkError as f, VmError as g, firecrackerApiError as h, noExt4RootfsError as i, invalidDomainError as j, invalidDiskSizeFormatError as k, agentTimeoutError as l, FirecrackerApiError as m, SetupError as n, noKernelError as o, defaultInterfaceNotFoundError as p, missingBinaryError as r, noRootfsDirError as s, handleCommandError as t, lockTimeoutError as u, networkSlotsExhaustedError as v, vmStateNotFoundError as w, vmNotFoundError as x, snapshotNotFoundError as y, invalidRuntimeError as z };
@@ -0,0 +1,193 @@
1
+ import { n as vmsanPaths } from "./paths.mjs";
2
+ import { t as handleCommandError } from "./errors.mjs";
3
+ import { t as createCommandLogger } from "./logger.mjs";
4
+ import "./vm-state.mjs";
5
+ import "./timeout-killer.mjs";
6
+ import { t as AgentClient } from "./agent.mjs";
7
+ import { t as TimeoutExtender } from "./timeout-extender.mjs";
8
+ import { n as waitForAgent, t as resolveVmState } from "./vm-context.mjs";
9
+ import { t as ShellSession } from "./shell.mjs";
10
+ import { consola } from "consola";
11
+ import { defineCommand } from "citty";
12
+ import { isatty } from "node:tty";
13
+ function shellEscape(s) {
14
+ if (/^[a-zA-Z0-9._\-/=:@]+$/.test(s)) return s;
15
+ return "'" + s.replace(/'/g, "'\\''") + "'";
16
+ }
17
+ function parseEnvFlags(targetCommand) {
18
+ const env = {};
19
+ const argv = process.argv;
20
+ let positionalCount = 0;
21
+ for (let i = 2; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ if (arg === "--") break;
24
+ if (!arg.startsWith("-")) {
25
+ positionalCount++;
26
+ if (positionalCount >= 3) break;
27
+ continue;
28
+ }
29
+ let value;
30
+ if (arg === "--env" || arg === "-e") {
31
+ value = argv[i + 1];
32
+ if (value !== void 0) i++;
33
+ } else if (arg.startsWith("--env=")) value = arg.slice(6);
34
+ else if (arg.startsWith("-e=")) value = arg.slice(3);
35
+ if (value !== void 0) {
36
+ const eqIdx = value.indexOf("=");
37
+ if (eqIdx > 0) env[value.slice(0, eqIdx)] = value.slice(eqIdx + 1);
38
+ }
39
+ }
40
+ return env;
41
+ }
42
+ function buildVmsanPrompt(vmId) {
43
+ return `\\[\\033[1;32m\\]vmsan:${vmId.slice(0, 8)}\\[\\033[0m\\]:\\[\\033[1;34m\\]\\w\\[\\033[0m\\]\\$ `;
44
+ }
45
+ const execCommand = defineCommand({
46
+ meta: {
47
+ name: "exec",
48
+ description: "Execute a command inside a running VM"
49
+ },
50
+ args: {
51
+ vmId: {
52
+ type: "positional",
53
+ description: "VM ID, followed by command and arguments",
54
+ required: true
55
+ },
56
+ sudo: {
57
+ type: "boolean",
58
+ default: false,
59
+ description: "Run with extended privileges (sudo)"
60
+ },
61
+ interactive: {
62
+ type: "boolean",
63
+ alias: "i",
64
+ default: false,
65
+ description: "Interactive shell mode (PTY)"
66
+ },
67
+ "no-extend-timeout": {
68
+ type: "boolean",
69
+ default: false,
70
+ description: "Skip timeout extension (interactive only)"
71
+ },
72
+ tty: {
73
+ type: "boolean",
74
+ alias: "t",
75
+ default: false,
76
+ description: "Allocate a pseudo-TTY (accepted for compatibility)"
77
+ },
78
+ workdir: {
79
+ type: "string",
80
+ alias: "w",
81
+ description: "Working directory inside the VM"
82
+ },
83
+ env: {
84
+ type: "string",
85
+ alias: "e",
86
+ description: "Environment variable (KEY=VAL), repeatable"
87
+ }
88
+ },
89
+ async run({ args }) {
90
+ const cmdLog = createCommandLogger("exec");
91
+ const paths = vmsanPaths();
92
+ try {
93
+ const command = args._[1];
94
+ const commandArgs = args._.slice(2);
95
+ if (!command) {
96
+ consola.error("No command provided. Usage: vmsan exec <vm_id> <command> [...args]");
97
+ cmdLog.emit();
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+ if (args.interactive && !isatty(1)) {
102
+ consola.error("--interactive requires a terminal (TTY).");
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ const envVars = parseEnvFlags(command);
107
+ const { state, guestIp, port, store } = resolveVmState(args.vmId, paths);
108
+ consola.debug(`Agent endpoint: ${guestIp}:${port}`);
109
+ const log = consola.withTag(args.vmId);
110
+ log.start("Waiting for agent...");
111
+ await waitForAgent(guestIp, port);
112
+ if (args.interactive) {
113
+ const parts = [];
114
+ parts.push(`export PS1=${shellEscape(buildVmsanPrompt(args.vmId))} TERM=xterm-256color &&`);
115
+ if (args.workdir) parts.push(`cd ${shellEscape(args.workdir)} &&`);
116
+ for (const [key, val] of Object.entries(envVars)) parts.push(`${key}=${shellEscape(val)}`);
117
+ if (args.sudo) parts.push("sudo");
118
+ parts.push(shellEscape(command));
119
+ for (const a of commandArgs) parts.push(shellEscape(a));
120
+ const injectedCmd = parts.join(" ") + "; exit $?\n";
121
+ log.success("Agent is ready. Connecting interactive shell...");
122
+ let extender = null;
123
+ if (!args["no-extend-timeout"] && state.timeoutMs) {
124
+ extender = new TimeoutExtender({
125
+ vmId: args.vmId,
126
+ store,
127
+ paths
128
+ });
129
+ extender.start();
130
+ }
131
+ try {
132
+ await new ShellSession({
133
+ host: guestIp,
134
+ port,
135
+ token: state.agentToken,
136
+ initialCommand: injectedCmd
137
+ }).connect();
138
+ } finally {
139
+ extender?.stop();
140
+ }
141
+ cmdLog.set({
142
+ vmId: args.vmId,
143
+ mode: "interactive",
144
+ command,
145
+ args: commandArgs,
146
+ ...args["no-extend-timeout"] && { noExtendTimeout: true }
147
+ });
148
+ cmdLog.emit();
149
+ } else {
150
+ consola.debug(`exec: ${command} ${commandArgs.join(" ")}`);
151
+ const agent = new AgentClient(`http://${guestIp}:${port}`, state.agentToken);
152
+ const cmd = args.sudo ? "sudo" : command;
153
+ const runArgs = args.sudo ? [command, ...commandArgs] : commandArgs;
154
+ const params = {
155
+ cmd,
156
+ args: runArgs.length > 0 ? runArgs : void 0,
157
+ cwd: args.workdir || void 0,
158
+ env: Object.keys(envVars).length > 0 ? envVars : void 0
159
+ };
160
+ const ac = new AbortController();
161
+ const onSignal = () => ac.abort();
162
+ process.on("SIGINT", onSignal);
163
+ process.on("SIGTERM", onSignal);
164
+ try {
165
+ const result = await (await agent.exec(params, {
166
+ signal: ac.signal,
167
+ onStdout: (line) => process.stdout.write(line + "\n"),
168
+ onStderr: (line) => process.stderr.write(line + "\n")
169
+ })).wait();
170
+ process.exitCode = result.exitCode;
171
+ if (result.timedOut) consola.error("Command timed out.");
172
+ } catch (err) {
173
+ if (!ac.signal.aborted) throw err;
174
+ process.exitCode = 130;
175
+ } finally {
176
+ process.removeListener("SIGINT", onSignal);
177
+ process.removeListener("SIGTERM", onSignal);
178
+ }
179
+ cmdLog.set({
180
+ vmId: args.vmId,
181
+ mode: "non-interactive",
182
+ command,
183
+ args: commandArgs
184
+ });
185
+ cmdLog.emit();
186
+ }
187
+ } catch (error) {
188
+ handleCommandError(error, cmdLog);
189
+ process.exitCode = 1;
190
+ }
191
+ }
192
+ });
193
+ export { execCommand as default };
@@ -4,6 +4,7 @@ import { r as getOutputMode, t as createCommandLogger } from "./logger.mjs";
4
4
  import { d as timeRemaining, l as table, u as timeAgo } from "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { consola } from "consola";
8
9
  import { defineCommand } from "citty";
9
10
  const STATUS_COLORS = {
@@ -1,9 +1,10 @@
1
1
  import "./paths.mjs";
2
- import { B as policyConflictError, t as handleCommandError } from "./errors.mjs";
2
+ import { V as policyConflictError, t as handleCommandError } from "./errors.mjs";
3
3
  import { r as getOutputMode, t as createCommandLogger } from "./logger.mjs";
4
4
  import "./vm-state.mjs";
5
5
  import { t as createVmsan } from "./context.mjs";
6
6
  import "./firecracker.mjs";
7
+ import "./timeout-killer.mjs";
7
8
  import { d as validateCidr, i as parseDomains, n as parseCidrList, s as parseNetworkPolicy } from "./validation.mjs";
8
9
  import { consola } from "consola";
9
10
  import { defineCommand } from "citty";