typescript-virtual-container 0.1.0 → 1.0.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.
@@ -3,11 +3,10 @@ name: Auto Create Pull Request
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - 'dev'
7
6
 
8
7
  jobs:
9
8
  create-pull-request:
10
- if: github.ref == 'refs/heads/dev'
9
+ if: github.ref != 'refs/heads/main'
11
10
  runs-on: ubuntu-latest
12
11
  steps:
13
12
  - name: Validate pull request token
@@ -26,18 +25,21 @@ jobs:
26
25
  script: |
27
26
  const owner = context.repo.owner;
28
27
  const repo = context.repo.repo;
29
- const head = `${owner}:dev`;
28
+ const sourceBranch = context.ref.startsWith('refs/heads/')
29
+ ? context.ref.slice('refs/heads/'.length)
30
+ : context.ref;
31
+ const head = `${owner}:${sourceBranch}`;
30
32
  const base = 'main';
31
33
 
32
- // Check if there are commits between main and dev
34
+ // Check if there are commits between main and the current branch
33
35
  const { data: comparison } = await github.rest.repos.compareCommits({
34
36
  owner,
35
37
  repo,
36
38
  base: 'main',
37
- head: 'dev',
39
+ head: sourceBranch,
38
40
  }).catch(err => {
39
41
  if (err.status === 404 && err.message.includes('No commits')) {
40
- core.info('No commits between main and dev - skipping PR creation');
42
+ core.info('No commits between main and ' + sourceBranch + ' - skipping PR creation');
41
43
  return { data: { ahead_by: 0 } };
42
44
  }
43
45
  throw err;
@@ -64,13 +66,13 @@ jobs:
64
66
  const { data: pullRequest } = await github.rest.pulls.create({
65
67
  owner,
66
68
  repo,
67
- head: 'dev',
69
+ head: sourceBranch,
68
70
  base,
69
- title: 'chore: auto PR from dev to main',
71
+ title: `chore: auto PR from ${sourceBranch} to main`,
70
72
  body: [
71
73
  'This pull request was created automatically by GitHub Actions.',
72
74
  '',
73
- `- source branch: \`dev\``,
75
+ `- source branch: \`${sourceBranch}\``,
74
76
  `- target branch: \`main\``,
75
77
  `- triggered by commit \`${context.sha}\``,
76
78
  ].join('\n'),
@@ -3,8 +3,6 @@ name: Test battery
3
3
  on:
4
4
  pull_request:
5
5
  branches:
6
- - main
7
- - dev
8
6
 
9
7
  permissions:
10
8
  contents: read
package/CHANGELOG.md CHANGED
@@ -15,6 +15,32 @@ The format is based on Keep a Changelog.
15
15
  - CODE_OF_CONDUCT.md
16
16
  - GitHub issue and PR templates
17
17
 
18
+ ## [1.0.1] - 2026-04-14
19
+
20
+ ### Added
21
+
22
+ - `ls -l` / `ls --long` support with long listing format (permissions, size, updated time).
23
+ - Host-command mirroring for network tools:
24
+ - `curl` now runs through host `curl` via `child_process`.
25
+ - `wget` now runs through host `wget` via `child_process`.
26
+ - Temporary host download flow for `wget` using `/tmp` before import into VFS.
27
+ - Terminal line normalization utility for command help and diagnostics rendering.
28
+
29
+ ### Changed
30
+
31
+ - `curl` behavior is now aligned with the host binary output and exit codes.
32
+ - `curl -o` writes host command output to the virtual filesystem target path.
33
+ - `wget` writes downloaded payloads to VFS after host-side transfer, preserving command semantics.
34
+ - URL fetch helper now accepts host-only inputs by normalizing missing protocol to `http://`.
35
+ - Auto pull-request GitHub workflow now targets any non-`main` branch instead of only `dev`.
36
+ - Auto PR metadata now uses the dynamic source branch name in PR head/title/body.
37
+ - Test workflow trigger scope was generalized by removing hardcoded branch filters.
38
+
39
+ ### Fixed
40
+
41
+ - Resolved large horizontal spacing artifacts in SSH terminal output by normalizing TTY line endings (`\r\n`) in both interactive shell and exec paths.
42
+ - Reduced excessive whitespace in help output rendering (`curl --help`, `wget --help`) by normalizing tabs and over-padded spacing.
43
+
18
44
  ## [1.0.0] - 2026-04-14
19
45
 
20
46
  ### Added
package/biome.json CHANGED
@@ -1,4 +1,11 @@
1
1
  {
2
+ "assist": {
3
+ "actions": {
4
+ "source": {
5
+ "organizeImports": "off"
6
+ }
7
+ }
8
+ },
2
9
  "linter": {
3
10
  "rules": {
4
11
  "suspicious": {
@@ -10,11 +17,5 @@
10
17
  "noNonNullAssertion": "off"
11
18
  }
12
19
  }
13
- },
14
- "assist": {
15
- "source": {
16
- "autoImport": "on",
17
- "importModuleSpecifierPreference": "relative"
18
- }
19
20
  }
20
21
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "In-memory SSH server with virtual filesystem and typed programmatic API",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
- "version": "0.1.0",
6
+ "version": "1.0.1",
7
7
  "license": "MIT",
8
8
  "keywords": [
9
9
  "ssh",
@@ -1,27 +1,152 @@
1
+ import { spawn } from "node:child_process";
1
2
  import type { ShellModule } from "../../types/commands";
2
- import { fetchResource, parseOutputPath, resolvePath } from "./helpers";
3
+ import { normalizeTerminalOutput, resolvePath } from "./helpers";
4
+
5
+ function parseCurlOutputPath(args: string[]): {
6
+ outputPath: string | null;
7
+ inputArgs: string[];
8
+ } {
9
+ const filtered: string[] = [];
10
+ let outputPath: string | null = null;
11
+
12
+ for (let index = 0; index < args.length; index += 1) {
13
+ const arg = args[index]!;
14
+
15
+ if (arg === "-o" || arg === "--output") {
16
+ outputPath = args[index + 1] ?? null;
17
+ index += 1;
18
+ continue;
19
+ }
20
+
21
+ if (arg.startsWith("-o=")) {
22
+ outputPath = arg.slice(3);
23
+ continue;
24
+ }
25
+
26
+ if (arg.startsWith("--output=")) {
27
+ outputPath = arg.slice("--output=".length);
28
+ continue;
29
+ }
30
+
31
+ filtered.push(arg);
32
+ }
33
+
34
+ return { outputPath, inputArgs: filtered };
35
+ }
36
+
37
+ function runHostCurl(args: string[]): Promise<{
38
+ stdout: string;
39
+ stderr: string;
40
+ exitCode: number;
41
+ }> {
42
+ return new Promise((resolve) => {
43
+ let childProcess: ReturnType<typeof spawn>;
44
+
45
+ try {
46
+ childProcess = spawn("curl", args, {
47
+ stdio: ["ignore", "pipe", "pipe"],
48
+ });
49
+ } catch (error) {
50
+ resolve({
51
+ stdout: "",
52
+ stderr: `curl: ${error instanceof Error ? error.message : String(error)}`,
53
+ exitCode: 1,
54
+ });
55
+ return;
56
+ }
57
+
58
+ let stdout = "";
59
+ let stderr = "";
60
+ const stdoutStream = childProcess.stdout;
61
+ const stderrStream = childProcess.stderr;
62
+
63
+ if (!stdoutStream || !stderrStream) {
64
+ resolve({
65
+ stdout: "",
66
+ stderr: "curl: failed to capture process output",
67
+ exitCode: 1,
68
+ });
69
+ return;
70
+ }
71
+
72
+ stdoutStream.setEncoding("utf8");
73
+ stderrStream.setEncoding("utf8");
74
+
75
+ stdoutStream.on("data", (chunk: string) => {
76
+ stdout += chunk;
77
+ });
78
+
79
+ stderrStream.on("data", (chunk: string) => {
80
+ stderr += chunk;
81
+ });
82
+
83
+ childProcess.on("error", (error) => {
84
+ const errorCode =
85
+ error instanceof Error && "code" in error
86
+ ? String((error as NodeJS.ErrnoException).code ?? "")
87
+ : "";
88
+ resolve({
89
+ stdout: "",
90
+ stderr: `curl: ${error.message}`,
91
+ exitCode: errorCode === "ENOENT" ? 127 : 1,
92
+ });
93
+ });
94
+
95
+ childProcess.on("close", (code) => {
96
+ resolve({
97
+ stdout,
98
+ stderr,
99
+ exitCode: code ?? 1,
100
+ });
101
+ });
102
+ });
103
+ }
3
104
 
4
105
  export const curlCommand: ShellModule = {
5
106
  name: "curl",
6
107
  params: ["[-o file] <url>"],
7
108
  run: async ({ vfs, cwd, args }) => {
8
- const { outputPath, inputArgs } = parseOutputPath(args);
109
+ const { outputPath, inputArgs } = parseCurlOutputPath(args);
9
110
  const url = inputArgs[0];
111
+ const isHelpLike = inputArgs.some(
112
+ (arg) =>
113
+ arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
114
+ );
10
115
 
11
116
  if (!url) {
12
117
  return { stderr: "curl: missing URL", exitCode: 1 };
13
118
  }
14
119
 
15
- const result = await fetchResource(url);
16
- if (result.status >= 400) {
17
- return { stderr: `curl: HTTP ${result.status}`, exitCode: 22 };
120
+ const passthroughArgs = outputPath ? [...inputArgs, "-o", "-"] : inputArgs;
121
+ const result = await runHostCurl(passthroughArgs);
122
+
123
+ if (result.exitCode !== 0) {
124
+ return {
125
+ stderr: normalizeTerminalOutput(
126
+ result.stderr || `curl: exited with code ${result.exitCode}`,
127
+ ),
128
+ exitCode: result.exitCode,
129
+ };
18
130
  }
19
131
 
20
132
  if (outputPath) {
21
- vfs.writeFile(resolvePath(cwd, outputPath), result.text);
22
- return { exitCode: 0 };
133
+ vfs.writeFile(resolvePath(cwd, outputPath), result.stdout);
134
+ return {
135
+ stderr: result.stderr
136
+ ? normalizeTerminalOutput(result.stderr)
137
+ : undefined,
138
+ exitCode: 0,
139
+ };
23
140
  }
24
141
 
25
- return { stdout: result.text, exitCode: 0 };
142
+ return {
143
+ stdout: isHelpLike
144
+ ? normalizeTerminalOutput(result.stdout)
145
+ : result.stdout,
146
+ stderr: result.stderr
147
+ ? normalizeTerminalOutput(result.stderr)
148
+ : undefined,
149
+ exitCode: 0,
150
+ };
26
151
  },
27
152
  };
@@ -1,6 +1,28 @@
1
1
  import * as path from "node:path";
2
2
  import type VirtualFileSystem from "../../VirtualFileSystem";
3
3
 
4
+ function normalizeFetchUrl(input: string): string {
5
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input)) {
6
+ return input;
7
+ }
8
+
9
+ return `http://${input}`;
10
+ }
11
+
12
+ export function normalizeTerminalOutput(text: string): string {
13
+ return text
14
+ .replace(/\r\n/g, "\n")
15
+ .replace(/\r/g, "\n")
16
+ .replace(/\t/g, " ")
17
+ .split("\n")
18
+ .map((line) =>
19
+ line.replace(/^[ \u00A0]{8,}/, " ").replace(/[ \u00A0]{3,}/g, " "),
20
+ )
21
+ .join("\n")
22
+ .replace(/\n{3,}/g, "\n\n")
23
+ .trimEnd();
24
+ }
25
+
4
26
  export function resolvePath(cwd: string, inputPath: string): string {
5
27
  if (!inputPath || inputPath.trim() === "") {
6
28
  return cwd;
@@ -56,7 +78,7 @@ export function stripUrlFilename(url: string): string {
56
78
  export async function fetchResource(
57
79
  url: string,
58
80
  ): Promise<{ text: string; status: number; contentType: string | null }> {
59
- const response = await fetch(url);
81
+ const response = await fetch(normalizeFetchUrl(url));
60
82
  const contentType = response.headers.get("content-type");
61
83
  return {
62
84
  text: await response.text(),
@@ -1,14 +1,48 @@
1
1
  import type { ShellModule } from "../../types/commands";
2
2
  import { joinListWithType, resolvePath } from "./helpers";
3
3
 
4
+ function formatPermissions(mode: number, isDirectory: boolean): string {
5
+ const fileType = isDirectory ? "d" : "-";
6
+ const permissionBits = [
7
+ [0o400, "r"],
8
+ [0o200, "w"],
9
+ [0o100, "x"],
10
+ [0o040, "r"],
11
+ [0o020, "w"],
12
+ [0o010, "x"],
13
+ [0o004, "r"],
14
+ [0o002, "w"],
15
+ [0o001, "x"],
16
+ ] as const;
17
+ const permissions = permissionBits
18
+ .map(([bit, symbol]) => (mode & bit ? symbol : "-"))
19
+ .join("");
20
+
21
+ return `${fileType}${permissions}`;
22
+ }
23
+
24
+ function formatDate(date: Date): string {
25
+ return date.toISOString().replace("T", " ").slice(0, 16);
26
+ }
27
+
4
28
  export const lsCommand: ShellModule = {
5
29
  name: "ls",
6
30
  params: ["[path]"],
7
31
  run: ({ vfs, cwd, args }) => {
32
+ const longFormat = args.includes("-l") || args.includes("--long");
8
33
  const targetArg = args.find((arg) => !arg.startsWith("-"));
9
34
  const target = resolvePath(cwd, targetArg ?? cwd);
10
35
  const items = vfs.list(target).filter((name) => !name.startsWith("."));
11
- const rendered = joinListWithType(target, items, (p) => vfs.stat(p));
36
+ const rendered = longFormat
37
+ ? items
38
+ .map((name) => {
39
+ const childPath = resolvePath(target, name);
40
+ const stat = vfs.stat(childPath);
41
+ const size = stat.type === "file" ? stat.size : stat.childrenCount;
42
+ return `${formatPermissions(stat.mode, stat.type === "directory")} 1 ${size} ${formatDate(stat.updatedAt)} ${name}${stat.type === "directory" ? "/" : ""}`;
43
+ })
44
+ .join("\n")
45
+ : joinListWithType(target, items, (p) => vfs.stat(p));
12
46
  return { stdout: rendered, exitCode: 0 };
13
47
  },
14
48
  };
@@ -1,33 +1,135 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
1
5
  import type { ShellModule } from "../../types/commands";
2
6
  import {
3
- fetchResource,
7
+ normalizeTerminalOutput,
4
8
  parseOutputPath,
5
9
  resolvePath,
6
10
  stripUrlFilename,
7
11
  } from "./helpers";
8
12
 
13
+ function runHostWget(args: string[]): Promise<{
14
+ stdout: string;
15
+ stderr: string;
16
+ exitCode: number;
17
+ }> {
18
+ return new Promise((resolve) => {
19
+ let childProcess: ReturnType<typeof spawn>;
20
+
21
+ try {
22
+ childProcess = spawn("wget", args, {
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ });
25
+ } catch (error) {
26
+ resolve({
27
+ stdout: "",
28
+ stderr: `wget: ${error instanceof Error ? error.message : String(error)}`,
29
+ exitCode: 1,
30
+ });
31
+ return;
32
+ }
33
+
34
+ let stdout = "";
35
+ let stderr = "";
36
+ const stdoutStream = childProcess.stdout;
37
+ const stderrStream = childProcess.stderr;
38
+
39
+ if (!stdoutStream || !stderrStream) {
40
+ resolve({
41
+ stdout: "",
42
+ stderr: "wget: failed to capture process output",
43
+ exitCode: 1,
44
+ });
45
+ return;
46
+ }
47
+
48
+ stdoutStream.setEncoding("utf8");
49
+ stderrStream.setEncoding("utf8");
50
+
51
+ stdoutStream.on("data", (chunk: string) => {
52
+ stdout += chunk;
53
+ });
54
+
55
+ stderrStream.on("data", (chunk: string) => {
56
+ stderr += chunk;
57
+ });
58
+
59
+ childProcess.on("error", (error) => {
60
+ const errorCode =
61
+ error instanceof Error && "code" in error
62
+ ? String((error as NodeJS.ErrnoException).code ?? "")
63
+ : "";
64
+ resolve({
65
+ stdout: "",
66
+ stderr: `wget: ${error.message}`,
67
+ exitCode: errorCode === "ENOENT" ? 127 : 1,
68
+ });
69
+ });
70
+
71
+ childProcess.on("close", (code) => {
72
+ resolve({
73
+ stdout,
74
+ stderr,
75
+ exitCode: code ?? 1,
76
+ });
77
+ });
78
+ });
79
+ }
80
+
9
81
  export const wgetCommand: ShellModule = {
10
82
  name: "wget",
11
83
  params: ["[url]"],
12
84
  run: async ({ vfs, cwd, args }) => {
13
85
  const { outputPath, inputArgs } = parseOutputPath(args);
14
86
  const url = inputArgs[0];
87
+ const isHelpLike = inputArgs.some(
88
+ (arg) =>
89
+ arg === "-h" || arg === "--help" || arg === "-V" || arg === "--version",
90
+ );
15
91
 
16
92
  if (!url) {
17
93
  return { stderr: "wget: missing URL", exitCode: 1 };
18
94
  }
19
95
 
20
- const result = await fetchResource(url);
21
- if (result.status >= 400) {
22
- return { stderr: `wget: HTTP ${result.status}`, exitCode: 8 };
96
+ if (isHelpLike) {
97
+ const result = await runHostWget(inputArgs);
98
+ return {
99
+ stdout: normalizeTerminalOutput(result.stdout),
100
+ stderr: result.stderr
101
+ ? normalizeTerminalOutput(result.stderr)
102
+ : undefined,
103
+ exitCode: result.exitCode,
104
+ };
23
105
  }
24
106
 
25
- const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
26
- vfs.writeFile(target, result.text);
107
+ const tempDir = await mkdtemp(join(tmpdir(), "virtual-env-js-wget-"));
108
+ const tempFile = join(tempDir, "download");
109
+
110
+ try {
111
+ const hostArgs = [...inputArgs, "-O", tempFile];
112
+ const result = await runHostWget(hostArgs);
27
113
 
28
- return {
29
- stdout: `saved ${target}`,
30
- exitCode: 0,
31
- };
114
+ if (result.exitCode !== 0) {
115
+ return {
116
+ stderr: normalizeTerminalOutput(
117
+ result.stderr || `wget: exited with code ${result.exitCode}`,
118
+ ),
119
+ exitCode: result.exitCode,
120
+ };
121
+ }
122
+
123
+ const content = await readFile(tempFile, "utf8");
124
+ const target = resolvePath(cwd, outputPath ?? stripUrlFilename(url));
125
+ vfs.writeFile(target, content);
126
+
127
+ return {
128
+ stdout: `saved ${target}`,
129
+ exitCode: 0,
130
+ };
131
+ } finally {
132
+ await rm(tempDir, { recursive: true, force: true });
133
+ }
32
134
  },
33
135
  };
@@ -3,6 +3,13 @@ import type VirtualFileSystem from "../VirtualFileSystem";
3
3
  import { runCommand } from "./commands";
4
4
  import type { VirtualUserManager } from "./users";
5
5
 
6
+ function toTtyLines(text: string): string {
7
+ return text
8
+ .replace(/\r\n/g, "\n")
9
+ .replace(/\r/g, "\n")
10
+ .replace(/\n/g, "\r\n");
11
+ }
12
+
6
13
  export function runExec(
7
14
  stream: ExecStream,
8
15
  cmd: string,
@@ -23,11 +30,11 @@ export function runExec(
23
30
  ),
24
31
  ).then((result) => {
25
32
  if (result.stdout) {
26
- stream.write(`${result.stdout}\n`);
33
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
27
34
  }
28
35
 
29
36
  if (result.stderr) {
30
- stream.stderr.write(`${result.stderr}\n`);
37
+ stream.stderr.write(`${toTtyLines(result.stderr)}\r\n`);
31
38
  }
32
39
 
33
40
  stream.exit(result.exitCode ?? 0);
@@ -33,6 +33,13 @@ interface TerminalSize {
33
33
  rows: number;
34
34
  }
35
35
 
36
+ function toTtyLines(text: string): string {
37
+ return text
38
+ .replace(/\r\n/g, "\n")
39
+ .replace(/\r/g, "\n")
40
+ .replace(/\n/g, "\r\n");
41
+ }
42
+
36
43
  export function startShell(
37
44
  stream: ShellStream,
38
45
  authUser: string,
@@ -198,11 +205,11 @@ export function startShell(
198
205
  }
199
206
 
200
207
  if (result.stdout) {
201
- stream.write(`${result.stdout}\r\n`);
208
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
202
209
  }
203
210
 
204
211
  if (result.stderr) {
205
- stream.write(`${result.stderr}\r\n`);
212
+ stream.write(`${toTtyLines(result.stderr)}\r\n`);
206
213
  }
207
214
 
208
215
  if (result.switchUser) {
@@ -671,11 +678,11 @@ export function startShell(
671
678
  }
672
679
 
673
680
  if (result.stdout) {
674
- stream.write(`${result.stdout}\r\n`);
681
+ stream.write(`${toTtyLines(result.stdout)}\r\n`);
675
682
  }
676
683
 
677
684
  if (result.stderr) {
678
- stream.write(`${result.stderr}\r\n`);
685
+ stream.write(`${toTtyLines(result.stderr)}\r\n`);
679
686
  }
680
687
 
681
688
  if (result.closeSession) {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export type {
10
10
  CommandResult,
11
11
  NanoEditorSession,
12
12
  ShellModule,
13
- SudoChallenge
13
+ SudoChallenge,
14
14
  } from "./types/commands";
15
15
  export type { ExecStream, ShellStream } from "./types/streams";
16
16
  export type {
@@ -25,10 +25,12 @@ export type {
25
25
  VfsSnapshotDirectoryNode,
26
26
  VfsSnapshotFileNode,
27
27
  VfsSnapshotNode,
28
- WriteFileOptions
28
+ WriteFileOptions,
29
29
  } from "./types/vfs";
30
30
 
31
31
  export {
32
- SshClient, VirtualFileSystem, SshMimic as VirtualMachine, VirtualUserManager
32
+ SshClient,
33
+ VirtualFileSystem,
34
+ SshMimic as VirtualMachine,
35
+ VirtualUserManager,
33
36
  };
34
-