From c75f82df4720735dcc30ba7ea6ed52ee815b0dcc Mon Sep 17 00:00:00 2001 From: Chen Yangjian <252317+cyjake@users.noreply.github.com> Date: Mon, 17 Nov 2025 21:11:49 +0800 Subject: [PATCH] feat: support deno --- .github/workflows/deno.yml | 22 ++ .github/workflows/nodejs-legacy.yml | 36 +++ .github/workflows/nodejs.yml | 2 +- .gitignore | 2 + .mocharc.json | 7 +- deno.json | 21 ++ package.json | 26 +- src/glob.ts | 2 +- src/index.ts | 3 - src/ssh-config.ts | 27 +- test/deno_test.ts | 7 + test/helpers.cjs | 17 + test/helpers.ts | 9 +- test/legacy/compute.test.ts | 303 ++++++++++++++++++ test/legacy/glob.test.ts | 49 +++ test/legacy/parse.test.ts | 358 +++++++++++++++++++++ test/legacy/ssh-config.test.ts | 461 ++++++++++++++++++++++++++++ test/legacy/stringify.test.ts | 202 ++++++++++++ test/unit/compute.test.ts | 68 ++-- test/unit/glob.test.ts | 4 +- test/unit/parse.test.ts | 39 ++- test/unit/ssh-config.test.ts | 13 +- test/unit/stringify.test.ts | 16 +- tsconfig.cjs.json | 10 + tsconfig.json | 20 +- 25 files changed, 1616 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/deno.yml create mode 100644 .github/workflows/nodejs-legacy.yml create mode 100644 deno.json delete mode 100644 src/index.ts create mode 100644 test/deno_test.ts create mode 100644 test/helpers.cjs create mode 100644 test/legacy/compute.test.ts create mode 100644 test/legacy/glob.test.ts create mode 100644 test/legacy/parse.test.ts create mode 100644 test/legacy/ssh-config.test.ts create mode 100644 test/legacy/stringify.test.ts create mode 100644 tsconfig.cjs.json diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml new file mode 100644 index 0000000..686c348 --- /dev/null +++ b/.github/workflows/deno.yml @@ -0,0 +1,22 @@ +name: Deno Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run Deno smoke tests + run: deno task test diff --git a/.github/workflows/nodejs-legacy.yml b/.github/workflows/nodejs-legacy.yml new file mode 100644 index 0000000..fd680d8 --- /dev/null +++ b/.github/workflows/nodejs-legacy.yml @@ -0,0 +1,36 @@ +name: Node CI + +on: + pull_request: + push: + branches: + - main + - master + + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [14.x, 16.x, 18.x, 20.x] + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: npm install, build, and test + run: | + npm install + npm run build --if-present + npm run test:legacy:coverage + + - name: Codecov + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b68b767..eb60432 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - node-version: [14.x, 16.x, 18.x, 20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index f3de1a2..9c87a60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ coverage +dist +lib node_modules package-lock.json .nyc_output diff --git a/.mocharc.json b/.mocharc.json index 7cfd796..6c9d746 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,7 +1,8 @@ { - "require": ["ts-node/register"], + "require": "ts-node/register", "extensions": ["ts"], - "spec": ["test/unit/**/*.test.ts"], "exit": true, - "recursive": true + "recursive": true, + "enableSourceMaps": true, + "timeout": 5000 } diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..12000cd --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "name": "@cyjake/ssh-config", + "version": "5.0.4", + "exports": "./src/ssh-config.ts", + "imports": { + "sinon": "npm:sinon@^21.0.0", + "mocha": "npm:mocha@^10.0.0" + }, + "publish": { + "include": [ + "src", + "LICENSE", + "Readme.md" + ] + }, + "tasks": { + "test": "tsc && deno test test/deno_test.ts --allow-env --allow-read --allow-run --no-check --sloppy-imports" + }, + "nodeModulesDir": "auto", + "lock": false +} diff --git a/package.json b/package.json index 35694ad..2dd5301 100644 --- a/package.json +++ b/package.json @@ -8,35 +8,41 @@ "url": "git@github.com:cyjake/ssh-config.git" }, "files": [ - "dist/**" + "dist/**", + "lib/**" ], "devDependencies": { "@types/mocha": "^9.1.0", "@types/node": "^17.0.45", - "@types/sinon": "^17.0.3", + "@types/sinon": "^21.0.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint": "^8.31.0", "heredoc": "^1.3.1", - "mocha": "^8.2.1", + "mocha": "^11.7.5", "nyc": "^15.1.0", - "sinon": "^17.0.1", + "sinon": "^21.0.0", "ts-node": "^10.9.2", "typescript": "^5.4.4" }, "scripts": { "lint": "eslint --ext ts .", "lint:fix": "eslint --ext ts --fix .", - "build": "tsc", + "build": "tsc && tsc -p tsconfig.cjs.json", "prepack": "npm run build", "pretest": "npm run build", - "test": "mocha", - "test:coverage": "nyc mocha && nyc report --reporter=lcov" + "test": "mocha --spec test/unit/**/*.test.ts --node-option=experimental-transform-types", + "test:coverage": "nyc mocha --spec test/unit/**/*.test.ts --node-option=experimental-transform-types && nyc report --reporter=lcov", + "test:legacy": "TS_NODE_PROJECT=./tsconfig.cjs.json mocha --spec test/legacy/**/*.test.ts", + "test:legacy:coverage": "TS_NODE_PROJECT=./tsconfig.cjs.json nyc mocha --spec test/legacy/**/*.test.ts && nyc report --reporter=lcov" + }, + "main": "dist/ssh-config.js", + "exports": { + "import": "./lib/ssh-config.js", + "require": "./dist/ssh-config.js" }, - "main": "dist/index.js", - "types": "dist/index.d.ts", "engine": { - "node": ">= 14.0.0" + "node": ">= 14.13.1" }, "license": "MIT" } diff --git a/src/glob.ts b/src/glob.ts index 0268589..eb7a8c8 100644 --- a/src/glob.ts +++ b/src/glob.ts @@ -23,7 +23,7 @@ function match(pattern: string, text: string) { * @param {string|string[]} patternList * @param {string} str */ -function glob(patternList: string | string[], text: string) { +function glob(patternList: string | string[], text: string): boolean { const patterns = Array.isArray(patternList) ? patternList : patternList.split(/,/) // > If a negated entry is matched, then the Host entry is ignored, regardless of whether any other patterns on the line match. diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 7245725..0000000 --- a/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import SSHConfig from './ssh-config' -export * from './ssh-config' -export default SSHConfig diff --git a/src/ssh-config.ts b/src/ssh-config.ts index 1f7bce9..b790559 100644 --- a/src/ssh-config.ts +++ b/src/ssh-config.ts @@ -1,7 +1,7 @@ -import glob from './glob' -import { spawnSync } from 'child_process' -import os from 'os' +import glob from './glob.ts' +import { spawnSync } from 'node:child_process' +import os from 'node:os' const RE_SPACE = /\s/ const RE_LINE_BREAK = /\r|\n/ @@ -84,7 +84,8 @@ const REPEATABLE_DIRECTIVES = [ 'CertificateFile', ] -function compare(line, opts) { +function compare(line: Directive, opts: FindOptions) { + // @ts-ignore return opts.hasOwnProperty(line.param) && opts[line.param] === line.value } @@ -283,23 +284,23 @@ export default class SSHConfig extends Array { /** * Find by Host or Match. */ - public find(opts: FindOptions): Line | undefined; + public override find(opts: FindOptions): Line | undefined; /** * Find by search function. * @param predicate Function to check against each line; should return a truthy value when a * matching line is given. */ - public find(predicate: (line: Line, index: number, config: Line[]) => unknown): Line | undefined; + public override find(predicate: (line: Line, index: number, config: Line[]) => unknown): Line | undefined; - public find(opts: ((line: Line, index: number, config: Line[]) => unknown) | FindOptions) { + public override find(opts: ((line: Line, index: number, config: Line[]) => unknown) | FindOptions) { if (typeof opts === 'function') return super.find(opts) if (!(opts && ('Host' in opts || 'Match' in opts))) { throw new Error('Can only find by Host or Match') } - return super.find(line => compare(line, opts)) + return super.find((line: Line) => 'param' in line && compare(line, opts)) } @@ -323,13 +324,13 @@ export default class SSHConfig extends Array { } else if (!(opts && ('Host' in opts || 'Match' in opts))) { throw new Error('Can only remove by Host or Match') } else { - index = super.findIndex(line => compare(line, opts)) + index = super.findIndex((line: Line) => 'param' in line && compare(line, opts)) } if (index >= 0) return this.splice(index, 1) } - public toString(): string { + public override toString(): string { return stringify(this) } @@ -704,9 +705,9 @@ export function stringify(config: SSHConfig): string { return quoted ? `"${value}"` : value } - function formatDirective(line) { + function formatDirective(line: Directive) { const quoted = line.quoted - || (RE_QUOTE_DIRECTIVE.test(line.param) && RE_SPACE.test(line.value)) + || (RE_QUOTE_DIRECTIVE.test(line.param) && typeof line.value === 'string' && RE_SPACE.test(line.value)) const value = formatValue(line.value, quoted) return `${line.param}${line.separator}${value}` } @@ -739,4 +740,4 @@ export function stringify(config: SSHConfig): string { return str } -export { glob } \ No newline at end of file +export { glob, SSHConfig } diff --git a/test/deno_test.ts b/test/deno_test.ts new file mode 100644 index 0000000..3b87868 --- /dev/null +++ b/test/deno_test.ts @@ -0,0 +1,7 @@ +import 'https://deno.land/x/deno_mocha/global.ts' + +import './unit/stringify.test.ts' +import './unit/parse.test.ts' +import './unit/compute.test.ts' +import './unit/ssh-config.test.ts' +import './unit/glob.test.ts' diff --git a/test/helpers.cjs b/test/helpers.cjs new file mode 100644 index 0000000..c0b2ade --- /dev/null +++ b/test/helpers.cjs @@ -0,0 +1,17 @@ +const path = require('node:path') +const fs = require('node:fs/promises') + +const stripPattern = /^[ \t]*(?=[^\s]+)/mg + +exports.heredoc = function heredoc(text) { + const indentLen = text.match(stripPattern).reduce((min, line) => Math.min(min, line.length), Infinity) + const indent = new RegExp('^[ \\t]{' + indentLen + '}', 'mg') + return indentLen > 0 + ? text.replace(indent, '').trimStart().replace(/ +?$/, '') + : text +} + +exports.readFixture = async function readFixture(fname) { + const fpath = path.join(__dirname, 'fixture', fname) + return (await fs.readFile(fpath, 'utf-8')).replace(/\r\n/g, '\n') +} diff --git a/test/helpers.ts b/test/helpers.ts index b57dfcc..63624d6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,8 @@ -import fs from 'fs/promises' -import path from 'path' +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) const stripPattern = /^[ \t]*(?=[^\s]+)/mg @@ -12,6 +15,6 @@ export function heredoc(text: string) { } export async function readFixture(fname: string) { - const fpath = path.join(__dirname, 'fixture', fname) + const fpath = path.join(currentDir, 'fixture', fname) return (await fs.readFile(fpath, 'utf-8')).replace(/\r\n/g, '\n') } diff --git a/test/legacy/compute.test.ts b/test/legacy/compute.test.ts new file mode 100644 index 0000000..973fc59 --- /dev/null +++ b/test/legacy/compute.test.ts @@ -0,0 +1,303 @@ +import { strict as assert } from 'node:assert' +import SSHConfig from '../../dist/ssh-config.js' +import os from 'node:os' +import sinon from 'sinon' +import { readFixture } from '../helpers.cjs' + +describe('compute', function() { + afterEach(function() { + sinon.restore() + }) + + it('.compute by Host', async function() { + const config = SSHConfig.parse(await readFixture('config')) + const opts = config.compute('tahoe2') + + assert(opts.User === 'nil') + assert.deepEqual(opts.IdentityFile, ['~/.ssh/id_rsa']) + + // the first obtained parameter value will be used. So there's no way to + // override parameter values. + assert.equal(opts.ServerAliveInterval, '80') + + // the computed result is flat on purpose. + assert.deepEqual(config.compute('tahoe1'), { + Compression: 'yes', + ControlMaster: 'auto', + ControlPath: '~/.ssh/master-%r@%h:%p', + Host: 'tahoe1', + HostName: 'tahoe1.com', + IdentityFile: [ + '~/.ssh/id_rsa' + ], + ProxyCommand: 'ssh -q gateway -W %h:%p', + ServerAliveInterval: '80', + User: 'nil', + ForwardAgent: 'true', + }) + }) + + it('.compute by Host with globbing', async function() { + const config = SSHConfig.parse(` + Host example* + HostName example.com + User simon + `) + + assert.deepEqual(config.compute('example1'), { + Host: 'example*', + HostName: 'example.com', + User: 'simon' + }) + }) + + it('.compute by Host with multiple patterns', async function() { + const config = SSHConfig.parse(` + Host foo "*.bar" "baz ham" + HostName example.com + User robb + `) + + for (const host of ['foo', 'foo.bar', 'baz ham']) { + assert.deepEqual(config.compute(host), { + Host: [ + 'foo', + '*.bar', + 'baz ham', + ], + HostName: 'example.com', + User: 'robb' + }) + } + }) + + /** + * - https://github.com/cyjake/ssh-config/issues/19 + * - https://github.com/microsoft/vscode-remote-release/issues/612 + * - https://en.wikibooks.org/wiki/OpenSSH/Cookbook/Proxies_and_Jump_Hosts#Recursively_Chaining_an_Arbitrary_Number_of_Hosts + */ + it('.compute by Host with chaining hosts', async function() { + const config = SSHConfig.parse(` + Host *+* + ProxyCommand ssh -W $(echo %h | sed 's/^.*+//;s/^\\([^:]*$\\)/\\1:22/') $(echo %h | sed 's/+[^+]*$//;s/\\([^+%%]*\\)%%\\([^+]*\\)$/\\2 -l \\1/;s/:\\([^:+]*\\)$/ -p \\1/') + `) + + for (const host of ['host1+host2', 'host1:2022+host2:2224']) { + const result = config.compute(host) + assert(result) + assert(result.hasOwnProperty('ProxyCommand')) + } + }) + + it('.compute By Host with canonical domains', async function() { + const config = SSHConfig.parse(` + Host www.example.com + ServerAliveInterval 60 + ServerAliveCountMax 2 + + Host www + User matthew + CanonicalizeHostName yes + CanonicalDomains example.notfound example.com + `) + + const result = config.compute('www') + assert.ok(result) + assert.equal(result.ServerAliveCountMax, '2') + }) + + it('.compute By Host with canonical domains', async () => { + const config = SSHConfig.parse(` + Host www.example.net + ServerAliveInterval 60 + ServerAliveCountMax 2 + + Host * + User matthew + CanonicalizeHostName yes + CanonicalDomains example.net example.com + `) + + const result = config.compute('www') + assert.ok(result) + assert.equal(result.ServerAliveCountMax, '2') + }) + + it('.compute By Host with multiple values', async () => { + const config = SSHConfig.parse(` + Host example.com example.me + HostName example-inc.com + `) + + const result = config.compute('example.me') + assert.ok(result) + assert.equal(result.HostName, 'example-inc.com') + }) + + it('.compute by Match host', async function() { + const config = SSHConfig.parse(` + Match host tahoe1 + HostName tahoe.com + + Match host tahoe2 + HostName tahoe.org + `) + const result = config.compute('tahoe1') + assert.ok(result) + assert.equal(result.HostName, 'tahoe.com') + assert.equal(result.Match, undefined) + }) + + it('.compute by Match exec', async function() { + const config = SSHConfig.parse(` + Match exec "return 1" host tahoe1 + HostName tahoe.com + + Match exec "return 0" host tahoe1 + HostName tahoe.local + `) + const result = config.compute('tahoe1') + assert.ok(result) + assert.equal(result.HostName, 'tahoe.local') + }) + + it('.compute by Match host and user', async function() { + const config = SSHConfig.parse(` + Match host tahoe1 user foo + HostName tahoe.com + + Match host tahoe1 user bar + # comment + HostName tahoe.org + + IdentityFIle /path/to/key + `) + const result = config.compute({ Host: 'tahoe1', User: 'bar' }) + assert.ok(result) + assert.equal(result.HostName, 'tahoe.org') + assert.equal(result.Match, undefined) + }) + + it('.compute by explicit user', async function() { + const config = SSHConfig.parse(` + User wrong + `) + const result = config.compute({ Host: 'tahoe1', User: 'bar' }) + assert.ok(result) + assert.equal(result.User, 'bar') + }) + + it('.compute by Match host uses HostName', async function() { + const config = SSHConfig.parse(` + Host tahoe + HostName tahoe.com + + # Host does not use HostName until the second pass + Host *.com + ProxyJump wrong.proxy.com + + Match host *.com + ProxyJump proxy.com + `) + const result: Record = config.compute({ Host: 'tahoe' }) + assert.ok(result) + assert.equal(result.HostName, 'tahoe.com') + assert.equal(result.ProxyJump, 'proxy.com') + }) + + it('.compute with Match final does second pass with HostName', async function() { + const config = SSHConfig.parse(` + Match final + + Host tahoe + HostName tahoe.com + + Host *.com + ProxyJump proxy.com + `) + const result: Record = config.compute({ Host: 'tahoe' }) + assert.ok(result) + assert.equal(result.HostName, 'tahoe.com') + assert.equal(result.ProxyJump, 'proxy.com') + }) + + it('.compute with os.userInfo() throwing SystemError should have fallback', async () => { + const mock = sinon.mock(os) + mock.expects('userInfo').throws(new Error('user has no username or homedir')) + const config = SSHConfig.parse('') + const result = config.compute({ Host: 'tahoe' }) + assert.ok(result) + }) + + it('.compute should fallback to process.env.USERNAME on Windows', async () => { + const mock = sinon.mock(os) + mock.expects('userInfo').throws(new Error('user has no username or homedir')) + const originEnv: Record = {} + for (const [key, value] of Object.entries({ USER: undefined, USERNAME: 'test' })) { + if (process.env[key] != null) { + originEnv[key] = process.env[key] + process.env[key] = value + } + } + try { + const config = SSHConfig.parse('') + const result = config.compute({ Host: 'tahoe' }) + assert.ok(result) + } finally { + for (const [key, value] of Object.entries(originEnv)) { + process.env[key] = value + } + } + }) + + it('.compute should default username to empty string if failed to get from env', async () => { + const mock = sinon.mock(os) + mock.expects('userInfo').throws(new Error('user has no username or homedir')) + const originEnv: Record = {} + for (const key of ['USER', 'USERNAME']) { + if (process.env[key] != null) { + originEnv[key] = process.env[key] + process.env[key] = undefined + } + } + try { + const config = SSHConfig.parse('') + const result = config.compute({ Host: 'tahoe' }) + assert.ok(result) + } finally { + for (const [key, value] of Object.entries(originEnv)) { + process.env[key] = value + } + } + }) + + it('.compute should preserve separators in multi-value directives', async () => { + const config = SSHConfig.parse(` + Host YYYY + HostName YYYY + IdentityFile ~/.ssh/id_rsa + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ProxyCommand ssh -i ~/.ssh/id_rsa -W %h:%p -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null XXX@ZZZ + User XXX + `) + + const result: Record = config.compute({ Host: 'YYYY' }) + assert.equal(result.ProxyCommand, 'ssh -i ~/.ssh/id_rsa -W %h:%p -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null XXX@ZZZ') + }) + + it('.compute should preserve quotes in multi-value directives', async () => { + const config = SSHConfig.parse(` + Host YYYY + HostName YYYY + IdentityFile ~/.ssh/id_rsa + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ProxyCommand "/foo/bar - baz/proxylauncher.sh" "/some/param with space" + User XXX + `) + const result: Record = config.compute({ Host: 'YYYY' }) + assert.equal(result.ProxyCommand, '"/foo/bar - baz/proxylauncher.sh" "/some/param with space"') + }) + +}) diff --git a/test/legacy/glob.test.ts b/test/legacy/glob.test.ts new file mode 100644 index 0000000..f05cdca --- /dev/null +++ b/test/legacy/glob.test.ts @@ -0,0 +1,49 @@ +import { strict as assert } from 'node:assert' +import { glob } from '../../dist/ssh-config.js' + +describe('glob', function() { + it('glob asterisk mark', function() { + assert(glob('*', 'laputa')) + assert(glob('lap*', 'laputa')) + assert(glob('lap*ta', 'laputa')) + assert(glob('laputa*', 'laputa')) + + assert(!glob('lap*', 'castle')) + }) + + it('glob question mark', function() { + assert(glob('lap?ta', 'laputa')) + assert(glob('laputa?', 'laputa')) + + assert(!glob('lap?ta', 'castle')) + }) + + it('glob pattern list', function() { + assert(glob('laputa,castle', 'laputa')) + assert(!glob('castle,in,the,sky', 'laputa')) + }) + + it('glob negated pattern list', function() { + assert(glob('!*.dialup.example.com,*.example.com', 'www.example.com')) + + assert(!glob('!*.dialup.example.com,*.example.com', 'www.dialup.example.com')) + assert(!glob('*.example.com,!*.dialup.example.com', 'www.dialup.example.com')) + }) + + it('glob the whole string', function() { + assert(!glob('example', 'example1')) + }) + + it('glob chaining hosts', function() { + assert(glob('*/*', 'host1/host2')) + assert(glob('*+*', 'host1+host2')) + }) + + it('glob special chars', function() { + assert(glob('(foo', '(foo')) + assert(!glob('(foo)', 'foo')) + assert(glob('[foo]', '[foo]')) + assert(glob('{foo', '{foo')) + assert(glob('^foo|ba\\r$', '^foo|ba\\r$')) + }) +}) diff --git a/test/legacy/parse.test.ts b/test/legacy/parse.test.ts new file mode 100644 index 0000000..3562559 --- /dev/null +++ b/test/legacy/parse.test.ts @@ -0,0 +1,358 @@ +import { strict as assert } from 'node:assert' +import { SSHConfig, parse, LineType, type Line } from '../../dist/ssh-config.js' +import { readFixture } from '../helpers.cjs' + +const { COMMENT, DIRECTIVE } = SSHConfig + +describe('parse', function() { + it('.parse empty config', async function() { + assert.equal(parse('').length, 0) + assert.equal(parse('\n').length, 1) + assert.equal(parse(' \n ').length, 1) + }) + + it('.parse simple config', async function() { + const config = parse(await readFixture('config')) + + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'ControlMaster') + assert.equal(config[0].value, 'auto') + assert.equal(config.length, 7) + + assert.deepEqual(config.find({ Host: 'tahoe1' }), { + type: DIRECTIVE, + before: '', + after: '\n', + param: 'Host', + separator: ' ', + value: 'tahoe1', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'HostName', + separator: ' ', + value: 'tahoe1.com', + }, { + type: DIRECTIVE, + before: ' ', + after: '\n\n', + param: 'Compression', + separator: ' ', + value: 'yes' + }) + }) + }) + + it('.parse config with parameters and values separated by =', function() { + const config = parse(` + Host=tahoe4 + HostName=tahoe4.com + User=keanu + `) + + assert.deepEqual(config[0], { + type: DIRECTIVE, + before: '\n ', + after: '\n', + param: 'Host', + separator: '=', + value: 'tahoe4', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'HostName', + separator: '=', + value: 'tahoe4.com' + },{ + type: DIRECTIVE, + before: ' ', + after: '\n ', + param: 'User', + separator: '=', + value: 'keanu' + }) + }) + }) + + it('.parse comments', function() { + const config = parse(` + # I'd like to travel to lake tahoe. + Host tahoe1 + HostName tahoe1.com + + # or whatever place it is. + # I just need another vocation. + Host * + IdentityFile ~/.ssh/ids/whosyourdaddy + `) + + assert.equal(config[0].type, COMMENT) + assert.equal(config[0].content, "# I'd like to travel to lake tahoe.") + + // The comments goes with sections. So the structure is not the way it seems. + assert.equal(config[1].type, DIRECTIVE) + assert.ok('config' in config[1]) + assert.equal(config[1].config[1].type, COMMENT) + assert.equal(config[1].config[1].content, '# or whatever place it is.') + }) + + it('.parse multiple IdentityFile', function() { + const config = parse(` + # Fallback Identify Files + IdentityFile ~/.ssh/ids/%h/%r/id_rsa + IdentityFile ~/.ssh/ids/%h/id_rsa + IdentityFile ~/.ssh/id_rsa + `) + + assert.equal(config[1].type, DIRECTIVE) + assert.equal(config[1].param, 'IdentityFile') + assert.equal(config[1].value, '~/.ssh/ids/%h/%r/id_rsa') + + assert.equal(config[2].type, DIRECTIVE) + assert.equal(config[2].param, 'IdentityFile') + assert.equal(config[2].value, '~/.ssh/ids/%h/id_rsa') + + assert.equal(config[3].type, DIRECTIVE) + assert.equal(config[3].param, 'IdentityFile') + assert.equal(config[3].value, '~/.ssh/id_rsa') + }) + + it('.parse IdentityFile with spaces', function() { + const config = parse(` + IdentityFile C:\\Users\\John Doe\\.ssh\\id_rsa + IdentityFile "C:\\Users\\John Doe\\.ssh\\id_rsa" + `) + + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'IdentityFile') + assert.equal(config[0].value, 'C:\\Users\\John Doe\\.ssh\\id_rsa') + + assert.equal(config[1].type, DIRECTIVE) + assert.equal(config[1].param, 'IdentityFile') + assert.equal(config[1].value, 'C:\\Users\\John Doe\\.ssh\\id_rsa') + }) + + it('.parse quoted values with escaped double quotes', function() { + const config = parse('IdentityFile "C:\\Users\\John\\" Doe\\.ssh\\id_rsa"') + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'IdentityFile') + assert.equal(config[0].value, 'C:\\Users\\John" Doe\\.ssh\\id_rsa') + }) + + it('.parse unquoted values that contain double quotes', function() { + const config = parse('ProxyCommand ssh -W "%h:%p" firewall.example.org') + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'ProxyCommand') + assert.ok(Array.isArray(config[0].value)) + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ + 'ssh', + '-W', + '%h:%p', + 'firewall.example.org', + ]) + }) + + // https://github.com/microsoft/vscode-remote-release/issues/5562 + it('.parse ProxyCommand with multiple args, some quoted', function() { + const config = parse(` + Host foo + ProxyCommand "C:\\foo bar\\baz.exe" "arg" "arg" "arg" + `) + + assert.equal(config[0].type, DIRECTIVE) + assert.ok('config' in config[0]) + assert.equal(config[0].config[0].type, DIRECTIVE) + assert.equal(config[0].config[0].param, 'ProxyCommand') + assert.ok(Array.isArray(config[0].config[0].value)) + assert.deepEqual(config[0].config[0].value.map(({ val }: { val: string }) => val), ['C:\\foo bar\\baz.exe', 'arg', 'arg', 'arg']) + }) + + it('.parse open ended values', function() { + assert.throws(() => parse('IdentityFile "C:\\'), /Unexpected line break/) + assert.throws(() => parse('Host "foo bar'), /Unexpected line break/) + assert.throws(() => parse('Host "foo bar\\"'), /Unexpected line break/) + }) + + it('.parse Host with quoted hosts that contain spaces', function() { + const config = parse('Host "foo bar"') + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'Host') + assert.equal(config[0].value, 'foo bar') + }) + + it('.parse Host with multiple patterns', function() { + const config = parse('Host foo "!*.bar" "baz ham" "foo\\"bar"') + + assert.equal(config[0].type, DIRECTIVE) + assert.equal(config[0].param, 'Host') + assert.ok(Array.isArray(config[0].value)) + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ + 'foo', + '!*.bar', + 'baz ham', + 'foo"bar' + ]) + }) + + it('.parse Host with multiple random patterns', function() { + const config = parse('Host me local wi*ldcard? thisVM "two words"') + + assert.equal(config[0].type, DIRECTIVE) + assert.ok(Array.isArray(config[0].value)) + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ + 'me', + 'local', + 'wi*ldcard?', + 'thisVM', + 'two words' + ]) + }) + + // https://github.com/cyjake/ssh-config/issues/32 + it('.parse Host with trailing spaces', function() { + const config = parse(` + Host penlv + HostName penlv-devbox + User penlv + `.replace('penlv\n', 'penlv \n')) + + assert.equal(config[0].type, DIRECTIVE) + assert.deepEqual(config[0].value, 'penlv') + }) + + it('.parse parameter and value separated with tab', function() { + /** + * Host foo + * HostName example.com + */ + const config = parse('Host\tfoo\n\tHostName\texample.com') + + assert.deepEqual(config[0], { + type: 1, + param: 'Host', + separator: '\t', + value: 'foo', + before: '', + after: '\n', + config: new SSHConfig({ + type: 1, + param: 'HostName', + separator: '\t', + value: 'example.com', + before: '\t', + after: '' + }) + }) + }) + + it('.parse config with extra blank lines', function() { + const config = parse(` + IdentityFile ~/.ssh/id_rsa + + Host ness + HostName lochness.com + `) + assert.deepEqual(config.find({ Host: 'ness' }), { + type: 1, + param: 'Host', + separator: ' ', + value: 'ness', + before: ' ', + after: '\n', + config: new SSHConfig({ + type: 1, + param: 'HostName', + separator: ' ', + value: 'lochness.com', + before: ' ', + after: '\n ' + }) + }) + }) + + it('.parse standalone match criteria', function() { + const config = parse(` + Match all canonical final + `) + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') + assert.ok(match) + assert.ok('criteria' in match) + assert.deepEqual(match.criteria, { + all: [], + canonical: [], + final: [], + }) + }) + + // https://github.com/cyjake/ssh-config/issues/58 + it('.parse match criteria', function() { + const config = parse(` + Match exec "/Users/me/onsubnet --not 192.168.1." final host docker + ProxyJump exthost + Hostname 192.168.1.10 + User user1 + Port 22 + + Host docker + Hostname docker + `) + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') + assert.ok(match) + assert.ok('criteria' in match) + assert.deepEqual(match.criteria, { + exec: '/Users/me/onsubnet --not 192.168.1.', + host: 'docker', + final: [], + }) + }) + + // https://github.com/cyjake/ssh-config/issues/73 + it('.parse match with comments', function() { + const config = parse(` + # CLOUDFLARE SETUP https://URL_REDACTED + Match all # CLOUDFLARE SETUP + Include /Users/mtovino/.ssh/cloudflare/config # CLOUDFLARE SETUP + `) + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') + assert.ok(match) + assert.ok('criteria' in match) + assert.deepEqual(match.criteria, { + all: [], + }) + assert.deepEqual(match.config.find((entry: Line) => 'param' in entry && entry.param === 'Include'), { + after: '', + before: ' ', + param: 'Include', + separator: ' ', + type: 1, + value: '/Users/mtovino/.ssh/cloudflare/config', + }) + }) + + // https://github.com/cyjake/ssh-config/issues/74 + it('.parse match host=', function() { + const config = parse(` + Match host=*.ligo-*.caltech.edu + User albert.einstein + `) + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') + assert.ok(match) + assert.ok('criteria' in match) + assert.deepEqual(match.criteria, { + host: '*.ligo-*.caltech.edu', + }) + }) + + // https://github.com/cyjake/ssh-config/issues/79 + it('.parse with # within value', () => { + const config = parse(` + Host name#with#hash + HostName localhost + `) + assert.equal(config.length, 1) + assert.equal(config[0].type, LineType.DIRECTIVE) + assert.equal(config[0].value, 'name#with#hash') + }) +}) diff --git a/test/legacy/ssh-config.test.ts b/test/legacy/ssh-config.test.ts new file mode 100644 index 0000000..b44a409 --- /dev/null +++ b/test/legacy/ssh-config.test.ts @@ -0,0 +1,461 @@ +import { strict as assert } from 'node:assert' +import { SSHConfig, type Line } from '../../dist/ssh-config.js' +import { heredoc, readFixture } from '../helpers.cjs' + +const { DIRECTIVE } = SSHConfig + +describe('SSHConfig', function() { + it('.find with nothing shall yield error', async function() { + const config = SSHConfig.parse(await readFixture('config')) + assert.throws(function() { config.find({}) }) + }) + + it('.find shall return null if nothing were found', async function() { + const config = SSHConfig.parse(await readFixture('config')) + assert(config.find({ Host: 'not.exist' }) == null) + }) + + it('.find by Host', async function() { + const config = SSHConfig.parse(await readFixture('config')) + + assert.deepEqual(config.find({ Host: 'tahoe1' }), { + type: DIRECTIVE, + before: '', + after: '\n', + param: 'Host', + separator: ' ', + value: 'tahoe1', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'HostName', + separator: ' ', + value: 'tahoe1.com' + }, { + type: DIRECTIVE, + before: ' ', + after: '\n\n', + param: 'Compression', + separator: ' ', + value: 'yes' + }) + }) + + assert.deepEqual(config.find({ Host: '*' }), { + type: DIRECTIVE, + before: '', + after: '\n', + param: 'Host', + separator: ' ', + value: '*', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n\n', + param: 'IdentityFile', + separator: ' ', + value: '~/.ssh/id_rsa' + }) + }) + }) + + it('.remove by Host', async function() { + const config = SSHConfig.parse(await readFixture('config')) + const length = config.length + + config.remove({ Host: 'no.such.host' }) + assert(config.length === length) + + config.remove({ Host: 'tahoe2' }) + assert(config.find({ Host: 'tahoe2' }) == null) + assert(config.length === length - 1) + + assert.throws(function() { config.remove({}) }) + }) + + it('.remove by function', async function() { + const config = SSHConfig.parse(await readFixture('config')) + const length = config.length + + config.remove((line: Line) => line.type === DIRECTIVE && /Host/i.test(line.param) && line.value === 'tahoe2') + assert(config.find({ Host: 'tahoe2' }) == null) + assert(config.length === length - 1) + + assert.throws(function() { config.remove({}) }) + }) + + it('.append lines', async function() { + const config = SSHConfig.parse(` + Host example + HostName example.com + User root + Port 22 + IdentityFile /path/to/key + `) + + config.append({ + Host: 'example2.com', + User: 'pegg', + IdentityFile: '~/.ssh/id_rsa' + }) + + const opts = config.compute('example2.com') + assert(opts.User === 'pegg') + assert.deepEqual(opts.IdentityFile, ['~/.ssh/id_rsa']) + assert.deepEqual(config.find({ Host: 'example2.com' }), { + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'Host', + separator: ' ', + value: 'example2.com', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'User', + separator: ' ', + value: 'pegg' + },{ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'IdentityFile', + separator: ' ', + value: '~/.ssh/id_rsa' + }) + }) + }) + + it('.append with original identation recognized', function() { + const config = SSHConfig.parse(` + Host example1 + HostName example1.com + User simon + Port 1000 + IdentityFile /path/to/key + `.replace(/ /g, '\t')) + + config.append({ + Host: 'example3.com', + User: 'paul' + }) + + assert.deepEqual(config.find({ Host: 'example3.com' }), { + type: DIRECTIVE, + before: '\t\t\t', + after: '\n', + param: 'Host', + separator: ' ', + value: 'example3.com', + config: new SSHConfig({ + type: DIRECTIVE, + before: '\t\t\t\t', + after: '\n', + param: 'User', + separator: ' ', + value: 'paul' + }) + }) + }) + + it('.append with newline insersion', function() { + const config = SSHConfig.parse(` + Host test + HostName google.com`) + + config.append({ + Host: 'test2', + HostName: 'microsoft.com' + }) + + assert.equal(config.toString(), ` + Host test + HostName google.com + + Host test2 + HostName microsoft.com +`) + }) + + it('.append to empty config', function() { + const config = new SSHConfig() + config.append({ + IdentityFile: '~/.ssh/id_rsa', + Host: 'test2', + HostName: 'example.com' + }) + + assert.equal(config.toString(), heredoc(` + IdentityFile ~/.ssh/id_rsa + + Host test2 + HostName example.com + `)) + }) + + it('.append to empty config with new section', function() { + const config = new SSHConfig() + config.append({ + Host: 'test', + HostName: 'example.com', + }) + + assert.equal(config.toString(), heredoc(` + Host test + HostName example.com + `)) + }) + + it('.append to empty section config', function() { + const config = SSHConfig.parse('Host test') + config.append({ + HostName: 'example.com' + }) + + assert.equal(config.toString(), heredoc(` + Host test + HostName example.com + `)) + }) + + it('.compute with properties with multiple values', async function() { + const config = SSHConfig.parse(` + Host myHost + HostName example.com + LocalForward 1234 localhost:1234 + CertificateFile /foo/bar + LocalForward 9876 localhost:9876 + CertificateFile /foo/bar2 + RemoteForward 8888 localhost:8888 + + Host * + CertificateFile /foo/bar3 + `) + + assert.deepEqual(config.compute('myHost'), { + Host: 'myHost', + HostName: 'example.com', + LocalForward: ['1234 localhost:1234', '9876 localhost:9876'], + RemoteForward: ['8888 localhost:8888'], + CertificateFile: ['/foo/bar', '/foo/bar2', '/foo/bar3'] + }) + }) + + it('.prepend lines', async function() { + const config = SSHConfig.parse(` + Host example + HostName example.com + User root + Port 22 + IdentityFile /path/to/key + `) + + config.prepend({ + Host: 'examplePrepend2.com', + User: 'pegg2', + IdentityFile: '~/.ssh/id_rsa' + }) + + const opts = config.compute('examplePrepend2.com') + assert(opts.User === 'pegg2') + assert.deepEqual(opts.IdentityFile, ['~/.ssh/id_rsa']) + assert.deepEqual(config.find({ Host: 'examplePrepend2.com' }), { + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'Host', + separator: ' ', + value: 'examplePrepend2.com', + config: new SSHConfig({ + type: DIRECTIVE, + before: ' ', + after: '\n', + param: 'User', + separator: ' ', + value: 'pegg2' + },{ + type: DIRECTIVE, + before: ' ', + after: '\n\n', + param: 'IdentityFile', + separator: ' ', + value: '~/.ssh/id_rsa' + }) + }) + }) + + it('.prepend with original identation recognized', function() { + const config = SSHConfig.parse(` + Host example1 + HostName example1.com + User simon + Port 1000 + IdentityFile /path/to/key + `.replace(/ /g, '\t')) + + config.prepend({ + Host: 'examplePrepend3.com', + User: 'paul2' + }) + + assert.deepEqual(config.find({ Host: 'examplePrepend3.com' }), { + type: DIRECTIVE, + before: '\t\t\t', + after: '\n', + param: 'Host', + separator: ' ', + value: 'examplePrepend3.com', + config: new SSHConfig({ + type: DIRECTIVE, + before: '\t\t\t\t', + after: '\n\n', + param: 'User', + separator: ' ', + value: 'paul2' + }) + }) + }) + + it('.prepend with newline insertion', function() { + const config = SSHConfig.parse(` + Host test + HostName google.com`) + + config.prepend({ + Host: 'testPrepend2', + HostName: 'microsoft.com' + }) + + assert.equal(config.toString(), ` Host testPrepend2 + HostName microsoft.com + + + Host test + HostName google.com`) + }) + + it('.prepend to empty config', function() { + const config = new SSHConfig() + config.prepend({ + IdentityFile: '~/.ssh/id_rsa', + Host: 'prependTest', + HostName: 'example.com' + }) + + assert.equal(config.toString().trim(), heredoc(` + IdentityFile ~/.ssh/id_rsa + + Host prependTest + HostName example.com + + `).trim()) + }) + + it('.prepend to empty config with new section', function() { + const config = new SSHConfig() + config.prepend({ + Host: 'prependTest', + HostName: 'example.com', + }) + + assert.equal(config.toString(), heredoc(` + Host prependTest + HostName example.com + + `)) + }) + + it('.prepend to empty section config', function() { + const config = SSHConfig.parse('Host test\n') + config.prepend({ + HostName: 'example.com', + User: 'brian' + }) + + assert.equal(config.toString(), heredoc(` + HostName example.com + User brian + + Host test + `)) + }) + + it('.prepend to empty section and existing section config', function() { + const config = SSHConfig.parse(heredoc(` + Host test + + Host test2 + HostName google.com + `)) + + config.prepend({ + HostName: 'example.com', + User: 'brian' + }) + + assert.equal(config.toString(), heredoc(` + HostName example.com + User brian + + Host test + + Host test2 + HostName google.com + `)) + }) + + it('.prepend to with Include', function() { + const config = SSHConfig.parse(` + Include ~/.ssh/configs/* + + Host test2 + HostName google.com`) + + config.prepend({ + Host: 'example', + HostName: 'microsoft.com', + }, true) + + assert.equal(config.toString(), ` + Include ~/.ssh/configs/* + + Host example + HostName microsoft.com + + Host test2 + HostName google.com`) + }) + + it('.prepend to with empty Include', function() { + const config = SSHConfig.parse('Include ~/.ssh/configs/* ') + + config.prepend({ + Host: 'example', + HostName: 'microsoft.com', + }, true) + + assert.equal(config.toString(), heredoc(` + Include ~/.ssh/configs/* + + Host example + HostName microsoft.com + `)) + }) + + it('.prepend directive with multiple values', function() { + const config = new SSHConfig() + + config.prepend({ + Host: ['example.com', 'example.me'], + HostName: 'example-inc.dev', + }) + + assert.equal(config.toString(), heredoc(` + Host example.com example.me + HostName example-inc.dev + + `)) + }) +}) diff --git a/test/legacy/stringify.test.ts b/test/legacy/stringify.test.ts new file mode 100644 index 0000000..7ab73a0 --- /dev/null +++ b/test/legacy/stringify.test.ts @@ -0,0 +1,202 @@ + +import { strict as assert } from 'node:assert' +import { SSHConfig, parse, stringify } from '../../dist/ssh-config.js' +import { readFixture } from '../helpers.cjs' + +describe('stringify', function() { + it('.stringify the parsed object back to string', async function() { + const fixture = await readFixture('config') + const config = parse(fixture) + assert.equal(fixture, stringify(config)) + }) + + it('.stringify empty config retained', function() { + assert.equal(stringify(parse('')), '') + assert.equal(stringify(parse('\n')), '\n') + assert.equal(stringify(parse(' \n ')), ' \n ') + }) + + it('.stringify config with white spaces and comments retained', function() { + const config = parse(` + # Lake tahoe + Host tahoe4 + + HostName tahoe4.com + # Breeze from the hills + User keanu + `) + + assert.equal(stringify(config), ` + # Lake tahoe + Host tahoe4 + + HostName tahoe4.com + # Breeze from the hills + User keanu + `) + }) + + it('.stringify IdentityFile entries with double quotes', function() { + const config = parse(` + Host example + HostName example.com + User dan + IdentityFile "/path to my/.ssh/id_rsa" + `) + + assert.equal(stringify(config), ` + Host example + HostName example.com + User dan + IdentityFile "/path to my/.ssh/id_rsa" + `) + }) + + it('.stringify IndentityAgent entries with double quotes', function() { + const config = parse(` + Host example + HostName example.com + IdentityAgent "~/Library/Group Containers" + `) + + assert.equal(stringify(config), ` + Host example + HostName example.com + IdentityAgent "~/Library/Group Containers" + `) + }) + + it('.stringify IndentityAgent entries with double quotes', function() { + const config = new SSHConfig() + config.append({ + Host: 'example', + IdentityAgent: '~/Library/Group Containers', + }) + + assert.equal(stringify(config), `Host example + IdentityAgent "~/Library/Group Containers" +`) + }) + + it('.stringify Host entries with multiple patterns', function() { + const config = parse(` + Host foo bar "baz" "egg ham" + HostName example.com + `) + + assert.equal(stringify(config), ` + Host foo bar "baz" "egg ham" + HostName example.com + `) + }) + + // #36 + it('.stringify User names with spaces', function() { + const config = parse(` + Host example + User "dan abramov" + `) + + assert.equal(stringify(config), ` + Host example + User "dan abramov" + `) + }) + + // #38 + it('.stringify LocalForward without quotes', function() { + const config = parse(` + Host example + LocalForward 1234 localhost:1234 + `) + + assert.equal(stringify(config), ` + Host example + LocalForward 1234 localhost:1234 + `) + }) + + it('.stringify multiple LocalForward', function() { + const config = parse(` + Host foo + LocalForward 3128 127.0.0.1:3128 + LocalForward 3000 127.0.0.1:3000 +`) + + config.append({ + Host: 'bar', + LocalForward: [ + '3128 127.0.0.1:3128', + '3000 127.0.0.1:3000' + ], + }) + + assert.equal(stringify(config), ` + Host foo + LocalForward 3128 127.0.0.1:3128 + LocalForward 3000 127.0.0.1:3000 + + Host bar + LocalForward 3128 127.0.0.1:3128 + LocalForward 3000 127.0.0.1:3000 +`) + }) + + // #43 + it('.stringify IdentityFile with spaces', function() { + const config = new SSHConfig().append({ + Host: 'foo', + IdentityFile: 'C:\\Users\\John Doe\\.ssh\\id_rsa' + }) + + assert.equal(stringify(config), `Host foo + IdentityFile "C:\\Users\\John Doe\\.ssh\\id_rsa" +`) + }) + + // https://github.com/microsoft/vscode-remote-release/issues/5562 + it('.stringify ProxyCommand with spaces', function() { + const config = parse(` + Host foo + ProxyCommand "C:\\foo bar\\baz.exe" "arg" "arg" "arg" + `) + + assert.equal(stringify(config), ` + Host foo + ProxyCommand "C:\\foo bar\\baz.exe" "arg" "arg" "arg" + `) + }) + + it('.stringify Match with criteria', function() { + const config = parse(` + Match host foo final exec "return 0" + HostName localhost + `) + assert.equal(stringify(config), ` + Match host foo final exec "return 0" + HostName localhost + `) + }) + + // https://github.com/cyjake/ssh-config/issues/84 + it('.stringify ProxyCommand with =', function() { + const config = parse(` + Host YYYY + HostName YYYY + IdentityFile ~/.ssh/id_rsa + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ProxyCommand ssh -i ~/.ssh/id_rsa -W %h:%p -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null XXX@ZZZ + User XXX + `) + assert.equal(stringify(config), ` + Host YYYY + HostName YYYY + IdentityFile ~/.ssh/id_rsa + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + ProxyCommand ssh -i ~/.ssh/id_rsa -W %h:%p -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null XXX@ZZZ + User XXX + `) + }) +}) diff --git a/test/unit/compute.test.ts b/test/unit/compute.test.ts index 4318ff1..eccf8f4 100644 --- a/test/unit/compute.test.ts +++ b/test/unit/compute.test.ts @@ -1,14 +1,13 @@ -import { strict as assert } from 'assert' -import SSHConfig from '../..' -import os from 'os' +import { strict as assert } from 'node:assert' +import SSHConfig from '../../src/ssh-config.ts' +import os from 'node:os' import sinon from 'sinon' -import { readFixture } from '../helpers' - -afterEach(() => { - sinon.restore() -}) +import { readFixture } from '../helpers.ts' describe('compute', function() { + afterEach(function() { + sinon.restore() + }) it('.compute by Host', async function() { const config = SSHConfig.parse(await readFixture('config')) @@ -90,16 +89,16 @@ describe('compute', function() { } }) - it('.compute By Host with canonical domains', async () => { + it('.compute By Host with canonical domains', async function() { const config = SSHConfig.parse(` - Host www.cyj.me + Host www.example.com ServerAliveInterval 60 ServerAliveCountMax 2 Host www User matthew CanonicalizeHostName yes - CanonicalDomains cyj.notfound cyj.me + CanonicalDomains example.notfound example.com `) const result = config.compute('www') @@ -200,7 +199,7 @@ describe('compute', function() { Match host *.com ProxyJump proxy.com `) - const result = config.compute({ Host: 'tahoe' }) + const result: Record = config.compute({ Host: 'tahoe' }) assert.ok(result) assert.equal(result.HostName, 'tahoe.com') assert.equal(result.ProxyJump, 'proxy.com') @@ -216,7 +215,7 @@ describe('compute', function() { Host *.com ProxyJump proxy.com `) - const result = config.compute({ Host: 'tahoe' }) + const result: Record = config.compute({ Host: 'tahoe' }) assert.ok(result) assert.equal(result.HostName, 'tahoe.com') assert.equal(result.ProxyJump, 'proxy.com') @@ -233,24 +232,43 @@ describe('compute', function() { it('.compute should fallback to process.env.USERNAME on Windows', async () => { const mock = sinon.mock(os) mock.expects('userInfo').throws(new Error('user has no username or homedir')) - for (const key of ['USER', 'USERNAME']) { - if (process.env[key] != null) sinon.stub(process.env, key).value(undefined) + const originEnv: Record = {} + for (const [key, value] of Object.entries({ USER: undefined, USERNAME: 'test' })) { + if (process.env[key] != null) { + originEnv[key] = process.env[key] + process.env[key] = value + } + } + try { + const config = SSHConfig.parse('') + const result = config.compute({ Host: 'tahoe' }) + assert.ok(result) + } finally { + for (const [key, value] of Object.entries(originEnv)) { + process.env[key] = value + } } - sinon.define(process.env, 'USERNAME', 'test') - const config = SSHConfig.parse('') - const result = config.compute({ Host: 'tahoe' }) - assert.ok(result) }) it('.compute should default username to empty string if failed to get from env', async () => { const mock = sinon.mock(os) mock.expects('userInfo').throws(new Error('user has no username or homedir')) + const originEnv: Record = {} for (const key of ['USER', 'USERNAME']) { - if (process.env[key] != null) sinon.stub(process.env, key).value(undefined) + if (process.env[key] != null) { + originEnv[key] = process.env[key] + process.env[key] = undefined + } + } + try { + const config = SSHConfig.parse('') + const result = config.compute({ Host: 'tahoe' }) + assert.ok(result) + } finally { + for (const [key, value] of Object.entries(originEnv)) { + process.env[key] = value + } } - const config = SSHConfig.parse('') - const result = config.compute({ Host: 'tahoe' }) - assert.ok(result) }) it('.compute should preserve separators in multi-value directives', async () => { @@ -264,7 +282,7 @@ describe('compute', function() { User XXX `) - const result = config.compute({ Host: 'YYYY' }) + const result: Record = config.compute({ Host: 'YYYY' }) assert.equal(result.ProxyCommand, 'ssh -i ~/.ssh/id_rsa -W %h:%p -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null XXX@ZZZ') }) @@ -278,7 +296,7 @@ describe('compute', function() { ProxyCommand "/foo/bar - baz/proxylauncher.sh" "/some/param with space" User XXX `) - const result = config.compute({ Host: 'YYYY' }) + const result: Record = config.compute({ Host: 'YYYY' }) assert.equal(result.ProxyCommand, '"/foo/bar - baz/proxylauncher.sh" "/some/param with space"') }) diff --git a/test/unit/glob.test.ts b/test/unit/glob.test.ts index 6e7fd16..e1784fa 100644 --- a/test/unit/glob.test.ts +++ b/test/unit/glob.test.ts @@ -1,5 +1,5 @@ -import { strict as assert } from 'assert' -import { glob } from '../..' +import { strict as assert } from 'node:assert' +import { glob } from '../../src/ssh-config.ts' describe('glob', function() { it('glob asterisk mark', function() { diff --git a/test/unit/parse.test.ts b/test/unit/parse.test.ts index 15f6273..146557c 100644 --- a/test/unit/parse.test.ts +++ b/test/unit/parse.test.ts @@ -1,14 +1,8 @@ -import { strict as assert } from 'assert' -import fs from 'fs' -import path from 'path' -import SSHConfig, { LineType } from '../..' +import { strict as assert } from 'node:assert' +import { SSHConfig, parse, LineType, type Line } from '../../src/ssh-config.ts' +import { readFixture } from '../helpers.ts' -const { parse, COMMENT, DIRECTIVE } = SSHConfig - -function readFile(fname) { - const fpath = path.join(__dirname, '..', fname) - return fs.readFileSync(fpath, 'utf-8').replace(/\r\n/g, '\n') -} +const { COMMENT, DIRECTIVE } = SSHConfig describe('parse', function() { it('.parse empty config', async function() { @@ -18,7 +12,7 @@ describe('parse', function() { }) it('.parse simple config', async function() { - const config = parse(readFile('fixture/config')) + const config = parse(await readFixture('config')) assert.equal(config[0].type, DIRECTIVE) assert.equal(config[0].param, 'ControlMaster') @@ -152,7 +146,12 @@ describe('parse', function() { assert.equal(config[0].type, DIRECTIVE) assert.equal(config[0].param, 'ProxyCommand') assert.ok(Array.isArray(config[0].value)) - assert.deepEqual(config[0].value.map(({ val }) => val), ['ssh', '-W', '%h:%p', 'firewall.example.org']) + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ + 'ssh', + '-W', + '%h:%p', + 'firewall.example.org', + ]) }) // https://github.com/microsoft/vscode-remote-release/issues/5562 @@ -167,7 +166,7 @@ describe('parse', function() { assert.equal(config[0].config[0].type, DIRECTIVE) assert.equal(config[0].config[0].param, 'ProxyCommand') assert.ok(Array.isArray(config[0].config[0].value)) - assert.deepEqual(config[0].config[0].value.map(({ val }) => val), ['C:\\foo bar\\baz.exe', 'arg', 'arg', 'arg']) + assert.deepEqual(config[0].config[0].value.map(({ val }: { val: string }) => val), ['C:\\foo bar\\baz.exe', 'arg', 'arg', 'arg']) }) it('.parse open ended values', function() { @@ -189,7 +188,7 @@ describe('parse', function() { assert.equal(config[0].type, DIRECTIVE) assert.equal(config[0].param, 'Host') assert.ok(Array.isArray(config[0].value)) - assert.deepEqual(config[0].value.map(({ val }) => val), [ + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ 'foo', '!*.bar', 'baz ham', @@ -202,7 +201,7 @@ describe('parse', function() { assert.equal(config[0].type, DIRECTIVE) assert.ok(Array.isArray(config[0].value)) - assert.deepEqual(config[0].value.map(({ val }) => val), [ + assert.deepEqual(config[0].value.map(({ val }: { val: string }) => val), [ 'me', 'local', 'wi*ldcard?', @@ -277,7 +276,7 @@ describe('parse', function() { const config = parse(` Match all canonical final `) - const match = config.find(line => line.type === DIRECTIVE && line.param === 'Match') + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') assert.ok(match) assert.ok('criteria' in match) assert.deepEqual(match.criteria, { @@ -299,7 +298,7 @@ describe('parse', function() { Host docker Hostname docker `) - const match = config.find(line => line.type === DIRECTIVE && line.param === 'Match') + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') assert.ok(match) assert.ok('criteria' in match) assert.deepEqual(match.criteria, { @@ -316,13 +315,13 @@ describe('parse', function() { Match all # CLOUDFLARE SETUP Include /Users/mtovino/.ssh/cloudflare/config # CLOUDFLARE SETUP `) - const match = config.find(line => line.type === DIRECTIVE && line.param === 'Match') + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') assert.ok(match) assert.ok('criteria' in match) assert.deepEqual(match.criteria, { all: [], }) - assert.deepEqual(match.config.find(entry => 'param' in entry && entry.param === 'Include'), { + assert.deepEqual(match.config.find((entry: Line) => 'param' in entry && entry.param === 'Include'), { after: '', before: ' ', param: 'Include', @@ -338,7 +337,7 @@ describe('parse', function() { Match host=*.ligo-*.caltech.edu User albert.einstein `) - const match = config.find(line => line.type === DIRECTIVE && line.param === 'Match') + const match = config.find((line: Line) => line.type === DIRECTIVE && line.param === 'Match') assert.ok(match) assert.ok('criteria' in match) assert.deepEqual(match.criteria, { diff --git a/test/unit/ssh-config.test.ts b/test/unit/ssh-config.test.ts index af88f60..f5a2a73 100644 --- a/test/unit/ssh-config.test.ts +++ b/test/unit/ssh-config.test.ts @@ -1,14 +1,9 @@ -import { strict as assert } from 'assert' -import SSHConfig from '../..' -import sinon from 'sinon' -import { heredoc, readFixture } from '../helpers' +import { strict as assert } from 'node:assert' +import { SSHConfig, type Line } from '../../src/ssh-config.ts' +import { heredoc, readFixture } from '../helpers.ts' const { DIRECTIVE } = SSHConfig -afterEach(() => { - sinon.restore() -}) - describe('SSHConfig', function() { it('.find with nothing shall yield error', async function() { const config = SSHConfig.parse(await readFixture('config')) @@ -83,7 +78,7 @@ describe('SSHConfig', function() { const config = SSHConfig.parse(await readFixture('config')) const length = config.length - config.remove((line) => line.type === DIRECTIVE && /Host/i.test(line.param) && line.value === 'tahoe2') + config.remove((line: Line) => line.type === DIRECTIVE && /Host/i.test(line.param) && line.value === 'tahoe2') assert(config.find({ Host: 'tahoe2' }) == null) assert(config.length === length - 1) diff --git a/test/unit/stringify.test.ts b/test/unit/stringify.test.ts index 0ca5d65..3a9e062 100644 --- a/test/unit/stringify.test.ts +++ b/test/unit/stringify.test.ts @@ -1,19 +1,11 @@ -import { strict as assert } from 'assert' -import fs from 'fs' -import path from 'path' -import SSHConfig from '../..' - -const { parse, stringify } = SSHConfig - -function readFile(fname: string) { - const fpath = path.join(__dirname, '..', fname) - return fs.readFileSync(fpath, 'utf-8').replace(/\r\n/g, '\n') -} +import { strict as assert } from 'node:assert' +import { SSHConfig, parse, stringify } from '../../src/ssh-config.ts' +import { readFixture } from '../helpers.ts' describe('stringify', function() { it('.stringify the parsed object back to string', async function() { - const fixture = readFile('fixture/config') + const fixture = await readFixture('config') const config = parse(fixture) assert.equal(fixture, stringify(config)) }) diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..1cd02d5 --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist" + } +} diff --git a/tsconfig.json b/tsconfig.json index 3656bd8..1d06f5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,18 @@ { "compilerOptions": { - "target": "es2018", + "module": "ESNext", "moduleResolution": "Node", - "module": "CommonJS", + "target": "ESNext", "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true, "sourceMap": true, - "declaration": true, - "declarationDir": "./dist", "skipLibCheck": true, "strictNullChecks": true, - "outDir": "./dist", + "declaration": true, + "rewriteRelativeImportExtensions": true, + "declarationDir": "./lib", + "outDir": "./lib", "rootDir": "src" }, "include": [ @@ -20,5 +21,12 @@ "exclude": [ "dist", "node_modules" - ] + ], + "ts-node": { + "esm": true, + "compilerOptions": { + "allowJs": true, + "allowImportingTsExtensions": true + } + } }