├── .gitignore ├── .env.sample ├── src ├── globals.ts ├── steps │ ├── actions │ │ ├── lcwd.ts │ │ ├── cl.ts │ │ ├── get.ts │ │ ├── push.ts │ │ ├── cmd.ts │ │ ├── rcwd.ts │ │ ├── pull.ts │ │ ├── env.ts │ │ ├── ghasset.ts │ │ └── connect.ts │ ├── step.ts │ ├── index.ts │ └── executor.ts ├── connection │ ├── typings.ts │ └── IBMi.ts └── index.ts ├── .npmignore ├── tsconfig.json ├── .github └── workflows │ └── release-package.yaml ├── examples └── exampleA.yml ├── package.json ├── .vscode └── launch.json ├── webpack.config.js └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .DS_Store 4 | .env 5 | downloaded -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | IBMI_HOST= 2 | IBMI_USER= 3 | IBMI_PASSWORD= 4 | IBMI_SSH_PORT= -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { IBMi } from "./connection/IBMi"; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | index.ts 2 | tsconfig.json 3 | webpack.config.js 4 | test 5 | src 6 | !dist/src 7 | node_modules 8 | .vscode 9 | .env.sample -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2019", 5 | "noImplicitAny": false, 6 | "noUnusedParameters": false, 7 | "strict": false, 8 | "allowJs": true, 9 | "outDir": "./dist", 10 | "esModuleInterop": true, 11 | "sourceMap": true, 12 | "declaration": true 13 | }, 14 | "exclude": ["dist", "downloaded"] 15 | } -------------------------------------------------------------------------------- /.github/workflows/release-package.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-gpr: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 18 15 | registry-url: 'https://registry.npmjs.org' 16 | 17 | - run: | 18 | npm ci 19 | npm run webpack 20 | npm publish --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /examples/exampleA.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - type: lcwd 3 | parameters: [src] 4 | - type: cmd 5 | parameters: [mkdir -p './builds/icisrc'] 6 | - type: cmd 7 | parameters: [dsdfksldfj] 8 | ignore: true 9 | - type: rcwd 10 | parameters: [./builds/icisrc] 11 | - type: push 12 | parameters: [.] 13 | - type: cmd 14 | parameters: [ls] 15 | - type: cl 16 | parameters: [WRKACTJOB] 17 | - type: cl 18 | parameters: [DLTLIB LIB(TEMP)] 19 | ignore: true 20 | - type: lcwd 21 | parameters: [../downloaded] 22 | - type: pull 23 | parameters: [.] -------------------------------------------------------------------------------- /src/steps/actions/lcwd.ts: -------------------------------------------------------------------------------- 1 | import { StepI } from "../step"; 2 | 3 | import * as path from "path"; 4 | 5 | export class LocalCwdStep extends StepI { 6 | public readonly id = `lcwd`; 7 | public readonly description = `Sets the current working directory on the local system`; 8 | public readonly requiredParams = ['localDirectory']; 9 | 10 | public async execute(): Promise { 11 | this.getState().lcwd = this.getValidLocalPath(this.parameters[0]); 12 | 13 | this.log(`Set local working directory to '${this.getState().lcwd}'`); 14 | 15 | return true; 16 | } 17 | 18 | validate(): boolean { 19 | return this.parameters.length >= this.requiredParams.length; 20 | } 21 | } -------------------------------------------------------------------------------- /src/connection/typings.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface StandardIO { 3 | onStdout?: (data: Buffer) => void; 4 | onStderr?: (data: Buffer) => void; 5 | stdin?: string; 6 | } 7 | 8 | /** 9 | * External interface for extensions to call `code-for-ibmi.runCommand` 10 | */ 11 | export interface RemoteCommand { 12 | title?: string; 13 | command: string; 14 | environment?: "ile" | "qsh" | "pase"; 15 | cwd?: string; 16 | env?: Record; 17 | } 18 | 19 | export interface CommandData extends StandardIO { 20 | command: string; 21 | directory?: string; 22 | env?: Record; 23 | } 24 | 25 | export interface CommandResult { 26 | code: number | null; 27 | stdout: string; 28 | stderr: string; 29 | command?: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/steps/actions/cl.ts: -------------------------------------------------------------------------------- 1 | import { StepI } from "../step"; 2 | 3 | export class ClStep extends StepI { 4 | public readonly id = `cl`; 5 | public readonly description = `Execute a CL command on the remote system`; 6 | public readonly requiredParams: string[] = [`clCommand`]; 7 | 8 | public async execute(): Promise { 9 | const command = this.parameters[0]; 10 | const fromDirectory = this.getState().rcwd; 11 | 12 | this.log(`> ${fromDirectory}`); 13 | this.log(`> ${command}`); 14 | 15 | const withSystem = `system "${command}"`; 16 | 17 | const cmdResult = await this.getConnection().sendCommand({ 18 | command: withSystem, 19 | directory: fromDirectory, 20 | }); 21 | 22 | this.log(cmdResult.stderr); 23 | this.log(``) 24 | this.log(cmdResult.stdout); 25 | 26 | return cmdResult.code === 0; 27 | } 28 | } -------------------------------------------------------------------------------- /src/steps/actions/get.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import { StepI } from "../step"; 5 | 6 | export class GetStep extends StepI { 7 | public readonly id = `get`; 8 | public readonly description = `Gets a specific file from IBM i`; 9 | public readonly requiredParams: string[] = [`remoteRelativeDirectory`, `localRelativePath`]; 10 | 11 | public async execute(): Promise { 12 | const remoteFile = this.getValidRemotePath(this.parameters[0]); 13 | const localFile = this.getValidLocalPath(this.parameters[1]); 14 | 15 | this.log(`Downloading file '${remoteFile}' to '${localFile}'`); 16 | 17 | const toDirectory = path.dirname(localFile); 18 | 19 | try { 20 | fs.mkdirSync(toDirectory, {recursive: true}); 21 | } catch (e) {}; 22 | 23 | await this.getConnection().downloadFile(localFile, remoteFile); 24 | 25 | return true; 26 | } 27 | } -------------------------------------------------------------------------------- /src/steps/actions/push.ts: -------------------------------------------------------------------------------- 1 | 2 | import { StepI } from "../step"; 3 | 4 | import * as path from "path"; 5 | 6 | export class PushStep extends StepI { 7 | public readonly id = `push`; 8 | public readonly description = `Pushes the current working directory to a chosen directory on the IBM i`; 9 | public readonly requiredParams: string[] = [`remoteRelativeDirectory`]; 10 | 11 | public async execute(): Promise { 12 | const toDirectory = this.getValidRemotePath(this.parameters[0]); 13 | const fromDirectory = this.getState().lcwd; 14 | 15 | this.log(`Uploading files to ${toDirectory}`); 16 | 17 | await this.getConnection().sendCommand({command: `mkdir -p "${toDirectory}"`}); 18 | await this.getConnection().uploadDirectory(fromDirectory, toDirectory, {tick: (localFile, remoteFile, error) => { 19 | this.log(`\t${localFile} -> ${remoteFile}`) 20 | }, concurrency: 10}); 21 | 22 | return true; 23 | } 24 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ibm/ibmi-ci", 3 | "version": "0.2.7", 4 | "description": "IBM i CLI tool", 5 | "bin": { 6 | "ici": "./dist/index.js" 7 | }, 8 | "main": "./dist/index.js", 9 | "types": "./dist/src/index.d.ts", 10 | "scripts": { 11 | "test": "vitest", 12 | "webpack:dev": "webpack --mode none --config ./webpack.config.js", 13 | "webpack": "webpack --mode production --config ./webpack.config.js", 14 | "local": "npm run webpack:dev && npm i -g", 15 | "deploy": "npm run webpack && npm i && npm publish --access public" 16 | }, 17 | "keywords": [ 18 | "ibmi" 19 | ], 20 | "author": "IBM", 21 | "license": "Apache 2", 22 | "devDependencies": { 23 | "ts-loader": "^9.4.4", 24 | "typescript": "^4.8.4", 25 | "webpack": "^5.24.3", 26 | "webpack-cli": "^4.5.0" 27 | }, 28 | "dependencies": { 29 | "node-ssh": "^13.1.0", 30 | "octokit": "^3.1.2", 31 | "yaml": "^2.3.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/steps/actions/cmd.ts: -------------------------------------------------------------------------------- 1 | import { StepI } from "../step"; 2 | 3 | export class CommandStep extends StepI { 4 | public readonly id = `cmd`; 5 | public readonly description = `Execute a command on the remote system`; 6 | public readonly requiredParams: string[] = [`shellCommand`]; 7 | 8 | public async execute(): Promise { 9 | const command = this.parameters[0]; 10 | const fromDirectory = this.getState().rcwd; 11 | 12 | this.log(`${fromDirectory} $ ${command}`); 13 | 14 | const cmdResult = await this.getConnection().sendCommand({ 15 | command, 16 | directory: fromDirectory, 17 | onStdout: (chunk) => { 18 | this.log(chunk.toString(), true); 19 | }, 20 | onStderr: (chunk) => { 21 | this.log(chunk.toString(), true); 22 | } 23 | }); 24 | 25 | this.log(``); 26 | 27 | return cmdResult.code === 0; 28 | } 29 | 30 | public validateParameters(): boolean { 31 | return this.parameters.length === this.requiredParams.length; 32 | } 33 | } -------------------------------------------------------------------------------- /src/steps/actions/rcwd.ts: -------------------------------------------------------------------------------- 1 | import { StepI } from "../step"; 2 | 3 | export class RemoteCwdStep extends StepI { 4 | public readonly id = `rcwd`; 5 | public readonly description = `Sets the current working directory on the remote system. It will be created if it does not exist.`; 6 | public readonly requiredParams = ['remoteDirectory']; 7 | 8 | public async execute(): Promise { 9 | const toDirectory = this.getValidRemotePath(this.parameters[0]); 10 | 11 | await this.getConnection().sendCommand({command: `mkdir -p "${toDirectory}"`}); 12 | 13 | const cmdResult = await this.getConnection().sendCommand({command: `cd "${toDirectory}"`}); 14 | 15 | if (cmdResult.code !== 0) { 16 | throw new Error(`Could not change directory to '${toDirectory}'. ${cmdResult.stderr}`); 17 | } 18 | 19 | this.getState().rcwd = toDirectory; 20 | this.log(`Set remote working directory to '${toDirectory}'`); 21 | 22 | return true; 23 | } 24 | 25 | validate(): boolean { 26 | return this.parameters.length >= this.requiredParams.length; 27 | } 28 | } -------------------------------------------------------------------------------- /src/steps/actions/pull.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import { StepI } from "../step"; 5 | 6 | export class PullStep extends StepI { 7 | public readonly id = `pull`; 8 | public readonly description = `Pulls a directory from IBM i to the local current working directory`; 9 | public readonly requiredParams: string[] = [`remoteRelativeDirectory`]; 10 | 11 | public async execute(): Promise { 12 | const fromDirectory = this.getValidRemotePath(this.parameters[0]); 13 | const toDirectory = this.getState().lcwd; 14 | 15 | this.log(`Downloading files from '${fromDirectory}' to '${toDirectory}'`); 16 | 17 | try { 18 | fs.mkdirSync(toDirectory, {recursive: true}); 19 | } catch (e) {}; 20 | 21 | await this.getConnection().downloadDirectory(toDirectory, fromDirectory, {tick: (localFile, remoteFile, error) => { 22 | this.log(`\t${remoteFile} -> ${localFile}`) 23 | }, concurrency: 10}); 24 | 25 | this.log(``); 26 | this.log(`Downloaded files from '${fromDirectory}' to '${toDirectory}'`); 27 | 28 | return true; 29 | } 30 | } -------------------------------------------------------------------------------- /src/steps/actions/env.ts: -------------------------------------------------------------------------------- 1 | import { StepI } from "../step"; 2 | 3 | export class EnvironmentStep extends StepI { 4 | public readonly id = `env`; 5 | public readonly description = `Sets the environment variables for the connected IBM i based on the host`; 6 | public readonly requiredParams: string[] = []; 7 | 8 | public async execute(): Promise { 9 | const ignoredEnvironmentVariables = [ 10 | `IBMI_HOST`, `IBMI_SSH_PORT`, `IBMI_USER`, `IBMI_PASSWORD`, `IBMI_PRIVATE_KEY`, 11 | `PWD`, `USER`, `HOME`, `SHELL`, `TERM`, `EDITOR`, `LANG`, `LC_ALL`, `LC_CTYPE`, `_`, `LOGNAME`, `PATH`, 12 | `CommonProgramFiles(x86)`, `ProgramFiles(x86)` 13 | ]; 14 | 15 | const environmentVariables = Object.keys(process.env).filter(key => !ignoredEnvironmentVariables.includes(key) && !key.includes('.') && !key.includes(' ')); 16 | const commandString = environmentVariables.map(key => `${key}='${process.env[key]}'`).join(` `); 17 | 18 | this.log(`Setting environment variables: ${environmentVariables.join(`, `)}`); 19 | 20 | const result = await this.getConnection().sendCommand({ 21 | command: commandString 22 | }); 23 | 24 | return result.code === 0; 25 | } 26 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug ibmi-ci CLI", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceFolder:ibmi-ci}/dist/index.js", 12 | "sourceMaps": true, 13 | "args": [ 14 | "--lcwd", "${workspaceFolder:ibmi-ci}/src", 15 | "--cmd", "mkdir -p './builds/icisrc'", 16 | "--ignore", "--cmd", "woejhraljdsfn", 17 | "--rcwd", "./builds/icisrc", 18 | "--ghasset", "ThePrez/CodeForIBMiServer", "v1.4.5", ".jar", 19 | "--push", ".", 20 | "--cmd", "ls", 21 | "--cl", "WRKACTJOB", 22 | "--ignore", "--cl", "DLTLIB LIB(TEMP)", 23 | "--lcwd", "../downloaded", 24 | "--pull", "." 25 | ], 26 | "preLaunchTask": { 27 | "type": "npm", 28 | "script": "webpack:dev" 29 | }, 30 | "envFile": "${workspaceFolder:ibmi-ci}/.env" 31 | }, 32 | { 33 | "name": "Debug ibmi-ci yaml", 34 | "type": "node", 35 | "request": "launch", 36 | "program": "${workspaceFolder:ibmi-ci}/dist/index.js", 37 | "sourceMaps": true, 38 | "args": [ 39 | "--file", "examples/exampleA.yml", 40 | ], 41 | "preLaunchTask": { 42 | "type": "npm", 43 | "script": "webpack:dev" 44 | }, 45 | "envFile": "${workspaceFolder:ibmi-ci}/.env" 46 | }, 47 | ] 48 | } -------------------------------------------------------------------------------- /src/steps/step.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | import { IBMi } from "../connection/IBMi"; 4 | import { ExecutorState, LoggerFunction } from "./executor"; 5 | 6 | 7 | export class StepI { 8 | public id = `base`; 9 | public description = `Base Step Description`; 10 | public requiredParams: string[] = []; 11 | 12 | public canError = false; 13 | public parameters: string[] = []; 14 | public state: ExecutorState|undefined; 15 | public logger?: LoggerFunction; 16 | 17 | constructor() {} 18 | 19 | log(value: string, append: boolean = false) { 20 | if (this.logger) { 21 | this.logger(value, append); 22 | } 23 | } 24 | 25 | setLogger(newLogger: LoggerFunction) { 26 | this.logger = newLogger; 27 | } 28 | 29 | addParameter(value: string) { 30 | this.parameters.push(value); 31 | } 32 | 33 | async execute(): Promise { return false }; 34 | 35 | validateParameters(): boolean { 36 | return this.parameters.length >= this.requiredParams.length; 37 | } 38 | 39 | ignoreErrors(value: boolean) { 40 | this.canError = value; 41 | } 42 | 43 | ignoreStepError(): boolean { 44 | return this.canError; 45 | } 46 | 47 | getState(): ExecutorState { 48 | return this.state!; 49 | } 50 | 51 | setState(newState: ExecutorState) { 52 | this.state = newState; 53 | } 54 | 55 | getConnection(): IBMi { 56 | return this.state!.connection!; 57 | } 58 | 59 | getValidRemotePath(inString: string) { 60 | return inString.startsWith(`.`) ? path.posix.join(this.state!.rcwd, inString) : inString; 61 | } 62 | 63 | getValidLocalPath(inString: string) { 64 | return inString.startsWith(`.`) ? path.join(this.state!.lcwd, inString) : inString; 65 | } 66 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | // @ts-nocheck 7 | 8 | 'use strict'; 9 | 10 | const path = require(`path`); 11 | const webpack = require(`webpack`); 12 | 13 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 14 | 15 | /**@type WebpackConfig*/ 16 | module.exports = { 17 | mode: `none`, // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 18 | target: `node`, // extensions run in a node context 19 | node: { 20 | __dirname: false // leave the __dirname-behaviour intact 21 | }, 22 | context: path.join(__dirname), 23 | resolve: { 24 | // Add `.ts` as a resolvable extension. 25 | extensions: [".ts", ".js"], 26 | // Add support for TypeScripts fully qualified ESM imports. 27 | extensionAlias: { 28 | ".js": [".js", ".ts"], 29 | ".cjs": [".cjs", ".cts"], 30 | ".mjs": [".mjs", ".mts"] 31 | } 32 | }, 33 | module: { 34 | rules: [ 35 | // all files with a `.ts`, `.cts`, `.mts` or `.tsx` extension will be handled by `ts-loader` 36 | { test: /\.([cm]?ts|tsx)$/, loader: "ts-loader", options: {allowTsInNodeModules: true} } 37 | ] 38 | }, 39 | entry: { 40 | extension: `./src/index.ts`, 41 | }, 42 | output: { 43 | filename: path.join(`index.js`), 44 | path: path.join(__dirname, `dist`), 45 | library: { 46 | "type": "commonjs" 47 | } 48 | }, 49 | // yes, really source maps 50 | devtool: `source-map`, 51 | plugins: [ 52 | new webpack.BannerPlugin({banner: `#! /usr/bin/env node`, raw: true}), 53 | 54 | // We do this so we don't ship the optional binaries provided by ssh2 55 | new webpack.IgnorePlugin({ resourceRegExp: /(cpu-features|sshcrypto\.node)/u }) 56 | ] 57 | }; -------------------------------------------------------------------------------- /src/steps/actions/ghasset.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { writeFile } from "fs/promises"; 3 | import * as path from "path"; 4 | 5 | import { StepI } from "../step"; 6 | import { Octokit } from "octokit"; 7 | 8 | const octokit = new Octokit(); 9 | 10 | export class GitHubAssetStep extends StepI { 11 | public readonly id = `ghasset`; 12 | public readonly description = `Pulls assets from a GitHub release to the local current working directory`; 13 | public readonly requiredParams: string[] = [`owner/repo`, `tag`, `assetName`]; 14 | 15 | public async execute(): Promise { 16 | const [owner, repo] = this.parameters[0].split('/'); 17 | const tag = this.parameters[1]; 18 | const findName = this.parameters[2]; 19 | 20 | const toDirectory = this.getState().lcwd; 21 | 22 | this.log(`Downloading files from ${owner}/${repo}@${tag} to '${toDirectory}'`); 23 | 24 | const result = await octokit.request(`GET /repos/{owner}/{repo}/releases/tags/${tag}`, { 25 | owner, 26 | repo, 27 | headers: { 28 | 'X-GitHub-Api-Version': '2022-11-28' 29 | } 30 | }); 31 | 32 | const newAsset = result.data.assets.find((asset: any) => asset.name.endsWith(findName)); 33 | 34 | if (newAsset) { 35 | const url = newAsset.browser_download_url; 36 | const localFile = path.join(toDirectory, newAsset.name); 37 | 38 | try { 39 | fs.mkdirSync(toDirectory, {recursive: true}); 40 | } catch (e) {}; 41 | 42 | await downloadFile(url, localFile); 43 | this.log(`Downloaded asset: ${owner}/${repo}@${tag}:${newAsset.name} -> ${localFile}`) 44 | 45 | return true; 46 | 47 | } else { 48 | this.log(`No asset found with name '${findName}'. Available assets:`); 49 | for (const asset of result.data.assets) { 50 | this.log(`\t${asset.name}`); 51 | } 52 | 53 | return false; 54 | } 55 | } 56 | 57 | validateParameters(): boolean { 58 | return this.parameters.length === this.requiredParams.length && this.parameters[0].includes('/'); 59 | } 60 | } 61 | 62 | 63 | function downloadFile(url: string, outputPath: string) { 64 | return fetch(url) 65 | .then(x => x.arrayBuffer()) 66 | .then(x => writeFile(outputPath, Buffer.from(x))); 67 | } 68 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ibmi-ci 2 | 3 | ibmi-ci is a command line tool to make it easier to work with IBM i from pipelines, like GitHub Actions, GitLab CICD, etc. 4 | 5 | ## Installation 6 | 7 | [Install via npm](https://www.npmjs.com/package/@ibm/ibmi-ci): 8 | 9 | ```sh 10 | npm i @ibm/ibmi-ci 11 | ``` 12 | 13 |
14 | Use in a GitHub Action 15 | 16 | ```yaml 17 | - run: npm i -g @ibm/ibmi-ci 18 | - name: Deploy to IBM i 19 | run: | 20 | ici \ 21 | --cmd "mkdir -p './builds/ics_${GITHUB_HEAD_REF}'" \ 22 | --rcwd "./builds/ics_${GITHUB_HEAD_REF}" \ 23 | --push "." \ 24 | --cmd "/QOpenSys/pkgs/bin/gmake BIN_LIB=CMPSYS" 25 | env: 26 | IBMI_HOST: ${{ secrets.IBMI_HOST }} 27 | IBMI_USER: ${{ secrets.IBMI_USER }} 28 | IBMI_PASSWORD: ${{ secrets.IBMI_PASSWORD }} 29 | IBMI_SSH_PORT: ${{ secrets.IBMI_SSH_PORT }} 30 | ``` 31 | 32 |
33 | 34 | 35 | 36 | ## How to use 37 | 38 | After installation, **run `ici` to see the help text and available parameters**. 39 | 40 | ibmi-ci is made up of steps and steps are built up from parameters, with the default step of connecting to the remote system, which always takes a place. 41 | 42 | The steps `ici` will take is based on the parameters used on the CLI. For example: 43 | 44 | ```sh 45 | ici \ 46 | --rcwd "./builds/myproject" \ 47 | --push "." \ 48 | --cmd "/QOpenSys/pkgs/bin/gmake BIN_LIB=MYLIB" 49 | ``` 50 | 51 | This command will run 3 steps: 52 | 53 | 1. Set the remote working directory to `./builds/myproject` 54 | 2. Upload the local working directory to the remote working directory (`.`) 55 | 3. Run a shell command 56 | 57 | ### Default steps 58 | 59 | By default, ibmi-ci will always: 60 | 61 | 1. Connect to the remote IBM i via SSH. Connection configuration is based on environment variables. Use `ici` to see more info. 62 | 2. Set the environment variables on the remote IBM i to those of the host runner (with some exceptions like `SHELL`, `HOME`, etc) 63 | 64 | ### Ignoring errors 65 | 66 | You can use a special ignore flag to suppress errors on certain steps: `--ignore`. This means if the following step errors, execution will continue nonetheless. 67 | 68 | ```sh 69 | ici \ 70 | --rcwd "./builds/myproject" \ 71 | --push "." \ 72 | --ignore --cl "CRTLIB $LIB" 73 | --cmd "/QOpenSys/pkgs/bin/gmake BIN_LIB=MYLIB" 74 | ``` 75 | 76 | ## Development 77 | 78 | After cloning the repo, there are two options: 79 | 80 | 1. `npm run local` to install `ici` 81 | 2. Open in VS Code and debug 82 | 83 | ## Todo ✅ 84 | 85 | * [ ] **Step for creating chroot** automatically as the first step, or to specify which chroot to use 86 | * [x] **Ignore errors** for certain steps. Sometimes we don't care if `mkdir` or `CRTLIB` failed. 87 | * [ ] **Daemon mode** so `ici` can be run multiple times but use the same connection -------------------------------------------------------------------------------- /src/steps/actions/connect.ts: -------------------------------------------------------------------------------- 1 | import { IBMi } from "../../connection/IBMi"; 2 | import { StepI } from "../step"; 3 | 4 | import * as node_ssh from "node-ssh"; 5 | 6 | export class ConnectStep extends StepI { 7 | public readonly id = `connect`; 8 | public readonly description = `Connect to the IBM i`; 9 | public readonly requiredParams: string[] = []; 10 | 11 | public async execute(): Promise { 12 | let connectResult: {success: boolean, error?: string} = { success: true, error: `` }; 13 | 14 | if (!this.getState().connection) { 15 | const requiredEnvironmentVariables = [ 16 | `IBMI_HOST`, `IBMI_SSH_PORT`, `IBMI_USER`, 17 | ]; 18 | 19 | for (const variable of requiredEnvironmentVariables) { 20 | if (!process.env[variable]) { 21 | throw new Error(`${variable} is required.`); 22 | } 23 | } 24 | 25 | const connectionDetail: node_ssh.Config = { 26 | host: process.env.IBMI_HOST, 27 | port: Number(process.env.IBMI_SSH_PORT), 28 | username: process.env.IBMI_USER, 29 | }; 30 | 31 | if (!process.env.IBMI_PASSWORD && !process.env.IBMI_PRIVATE_KEY) { 32 | throw new Error(`IBMI_PASSWORD or IBMI_PRIVATE_KEY is required`); 33 | } 34 | 35 | let authType: `password` | `privateKey` = `password`; 36 | let authToken: string = process.env.IBMI_PASSWORD!; 37 | 38 | if (process.env.IBMI_PRIVATE_KEY) { 39 | authType = `privateKey`; 40 | authToken = process.env.IBMI_PRIVATE_KEY; 41 | } 42 | 43 | connectionDetail[authType] = authToken; 44 | 45 | this.getState().connection = new IBMi(); 46 | 47 | connectResult = await this.getConnection().connect(connectionDetail); 48 | 49 | if (!connectResult.success) { 50 | throw new Error(`Failed to connect to IBMi: ${connectResult.error}`); 51 | } 52 | 53 | this.log(`Connected to system.`); 54 | } else { 55 | const connection = this.getConnection(); 56 | connectResult = {success: true}; 57 | 58 | this.log(`Connected to ${connection.currentUser}@${connection.currentHost}`); 59 | } 60 | 61 | // Let's also grab the users initial working directory 62 | const pwdResult = await this.getConnection().sendCommand({ command: `pwd`, directory: `.` }); 63 | if (pwdResult.code !== 0) { 64 | throw new Error(`Failed to get current working directory: ${pwdResult.stderr}`); 65 | } 66 | 67 | this.getState().rcwd = pwdResult.stdout.trim(); 68 | this.log(`Remote working directory is '${this.getState().rcwd}'`); 69 | 70 | // To make debugging easier. Let's also display their `PATH` environment variable 71 | const pathResult = await this.getConnection().sendCommand({ command: `echo $PATH`, directory: `.` }); 72 | if (pathResult.code === 0 && pathResult.stdout) { 73 | const paths = pathResult.stdout.trim().split(`:`).map(p => `${p}:`); 74 | this.log(`Remote PATH environment variable is:`); 75 | for (const path of paths) { 76 | this.log(`\t${path}`); 77 | } 78 | } else { 79 | this.log(`Failed to get remote PATH environment variable. ${pathResult.stderr}`); 80 | } 81 | 82 | return connectResult.success; 83 | } 84 | } -------------------------------------------------------------------------------- /src/steps/index.ts: -------------------------------------------------------------------------------- 1 | import { ConnectStep } from "./actions/connect"; 2 | import { LocalCwdStep } from "./actions/lcwd"; 3 | import { EnvironmentStep } from "./actions/env"; 4 | import { PushStep } from "./actions/push"; 5 | import { StepI } from "./step"; 6 | import { CommandStep } from "./actions/cmd"; 7 | import { RemoteCwdStep } from "./actions/rcwd"; 8 | import { PullStep } from "./actions/pull"; 9 | import { GetStep } from "./actions/get"; 10 | import { ClStep } from "./actions/cl"; 11 | import { Executor } from "./executor"; 12 | 13 | import { parse } from 'yaml' 14 | import { GitHubAssetStep } from "./actions/ghasset"; 15 | 16 | export const StepTypes: {[id: string]: typeof StepI} = { 17 | 'connect': ConnectStep, 18 | 'env': EnvironmentStep, 19 | 'lcwd': LocalCwdStep, 20 | 'rcwd': RemoteCwdStep, 21 | 'push': PushStep, 22 | 'pull': PullStep, 23 | 'get': GetStep, 24 | 'cmd': CommandStep, 25 | 'cl': ClStep, 26 | 'ghasset': GitHubAssetStep 27 | } 28 | 29 | export function buildStepsFromArray(executor: Executor, parameters: string[]): boolean { 30 | let ignoreNext = false; 31 | 32 | for (let i = 0; i < parameters.length; i++) { 33 | if (parameters[i].startsWith(`--`)) { 34 | switch (parameters[i]) { 35 | case `--ignore`: 36 | ignoreNext = true; 37 | break; 38 | 39 | default: 40 | const stepName = parameters[i].substring(2); 41 | const stepType = StepTypes[stepName]; 42 | 43 | if (!stepType) { 44 | console.log(`Unknown step: ${stepName}`); 45 | return false; 46 | } 47 | 48 | const newStep = new stepType(); 49 | 50 | if (ignoreNext) { 51 | newStep.ignoreErrors(true); 52 | ignoreNext = false; 53 | } 54 | 55 | executor.addSteps(newStep); 56 | break; 57 | } 58 | 59 | } else { 60 | const step = executor.getLastStep(); 61 | if (step) { 62 | step.addParameter(parameters[i]); 63 | } else { 64 | console.log(`Unknown parameter: ${parameters[i]}`); 65 | return false; 66 | } 67 | } 68 | } 69 | 70 | return true; 71 | } 72 | 73 | export interface ExecutorDocument { 74 | steps: { 75 | type: string; 76 | parameters?: string[]; 77 | ignore?: boolean; 78 | }[] 79 | } 80 | 81 | export function buildStepsFromYaml(executor: Executor, yaml: string) { 82 | const parsed: ExecutorDocument = parse(yaml) 83 | 84 | return buildStepsFromJson(executor, parsed); 85 | } 86 | 87 | export function buildStepsFromJson(executor: Executor, document: ExecutorDocument): boolean { 88 | if (!document.steps) { 89 | console.log(`No steps property found.`); 90 | return false; 91 | } 92 | 93 | for (const step of document.steps) { 94 | const stepType = StepTypes[step.type]; 95 | 96 | if (!stepType) { 97 | console.log(`Unknown step: ${step.type}`); 98 | return false; 99 | } 100 | 101 | const newStep = new stepType(); 102 | 103 | if (step.ignore) { 104 | newStep.ignoreErrors(true); 105 | } 106 | 107 | if (step.parameters) { 108 | for (const param of step.parameters) { 109 | newStep.addParameter(param); 110 | } 111 | } 112 | 113 | executor.addSteps(newStep); 114 | } 115 | 116 | return true; 117 | } -------------------------------------------------------------------------------- /src/steps/executor.ts: -------------------------------------------------------------------------------- 1 | import { IBMi } from "../connection/IBMi"; 2 | import { StepI } from "./step"; 3 | 4 | export type LoggerFunction = (value: string, append?: boolean) => void; 5 | 6 | interface ExecutorResult { 7 | code: number; 8 | output: string; 9 | } 10 | 11 | export interface ExecutorState { 12 | connection: IBMi|undefined; 13 | lcwd: string; 14 | rcwd: string; 15 | } 16 | 17 | export class Executor { 18 | private state: ExecutorState = { 19 | connection: undefined, 20 | lcwd: process.cwd(), 21 | rcwd: `.` 22 | } 23 | 24 | private steps: StepI[] = []; 25 | 26 | constructor() {} 27 | 28 | setConnection(connection: IBMi) { 29 | this.state.connection = connection; 30 | } 31 | 32 | getState(): ExecutorState { 33 | return this.state; 34 | } 35 | 36 | addSteps(...steps: StepI[]) { 37 | this.steps.push(...steps); 38 | } 39 | 40 | getLastStep(): StepI|undefined { 41 | return this.steps[this.steps.length - 1]; 42 | } 43 | 44 | getSteps(): StepI[] { 45 | return this.steps; 46 | } 47 | 48 | getInvalidSteps(): StepI[] { 49 | let invalidSteps: StepI[] = []; 50 | 51 | for (let i = 0; i < this.steps.length; i++) { 52 | const step = this.steps[i]; 53 | 54 | if (!step.validateParameters()) { 55 | invalidSteps.push(step); 56 | } 57 | } 58 | 59 | return invalidSteps; 60 | } 61 | 62 | dispose() { 63 | this.state.connection?.end(); 64 | } 65 | 66 | async executeSteps(events: {log?: LoggerFunction} = {}): Promise { 67 | let allOutput = ``; 68 | 69 | // Custom log function so we can collect all the output too 70 | const log: LoggerFunction = (value: string, append?: boolean) => { 71 | if (events.log) events.log(value, append); 72 | allOutput += (value + (append ? `` : `\n`)); 73 | } 74 | 75 | for (let i = 0; i < this.steps.length; i++) { 76 | const step = this.steps[i]; 77 | let shouldExit = false; 78 | 79 | log(``); 80 | log(`==========================================`); 81 | log(`Executing step ${i + 1}: ${step.id}`); 82 | log(`==========================================`); 83 | log(``); 84 | 85 | if (step.validateParameters()) { 86 | try { 87 | step.setState(this.state); 88 | step.setLogger(log); 89 | 90 | const result = await step.execute(); 91 | 92 | if (!result) { 93 | log(`Failed to execute step: ${step.id}`); 94 | shouldExit = true; 95 | } 96 | } catch (e: any) { 97 | log(`Failed to execute step: ${step.id}`); 98 | log(e.message); 99 | shouldExit = true; 100 | } 101 | 102 | if (shouldExit) { 103 | // Step errors can be ignored with the `--ignore` flag 104 | if (step.ignoreStepError()) { 105 | log(`Ignoring error for this step.`); 106 | } else { 107 | return { 108 | code: 1, 109 | output: allOutput 110 | }; 111 | } 112 | } 113 | 114 | } else { 115 | log(`Runtime error, which is odd because the validation should have caught it!`); 116 | log(`Invalid parameters for step: ${step.id}`); 117 | return { 118 | code: 0, 119 | output: allOutput 120 | }; 121 | } 122 | } 123 | 124 | return { 125 | code: 0, 126 | output: allOutput 127 | }; 128 | } 129 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ExecutorDocument, StepTypes, buildStepsFromArray, buildStepsFromJson, buildStepsFromYaml } from "./steps"; 2 | import { Executor, LoggerFunction } from "./steps/executor"; 3 | import * as path from "path"; 4 | import * as fs from "fs"; 5 | 6 | main(); 7 | 8 | async function main() { 9 | const parms = process.argv.slice(2); 10 | 11 | let validSteps = true; 12 | let executor = new Executor(); 13 | 14 | executor.addSteps(new StepTypes.connect(), new StepTypes.env()); 15 | 16 | if (parms.length === 0) { 17 | printHelpAndQuit(); 18 | 19 | } else if (parms[0] === `--file`) { 20 | const relativePath = parms[1]; 21 | const detail = path.parse(relativePath); 22 | 23 | const content = fs.readFileSync(relativePath, {encoding: `utf8`}); 24 | 25 | switch (detail.ext) { 26 | case `.json`: 27 | validSteps = buildStepsFromJson(executor, JSON.parse(content)); 28 | break; 29 | 30 | case `.yaml`: 31 | case `.yml`: 32 | validSteps = buildStepsFromYaml(executor, content); 33 | break; 34 | 35 | default: 36 | console.log(`Unknown file type: ${detail.ext}`); 37 | process.exit(1); 38 | } 39 | } else { 40 | validSteps = buildStepsFromArray(executor, parms); 41 | } 42 | 43 | if (!validSteps) { 44 | process.exit(1); 45 | } 46 | 47 | if (executor.getSteps().length > 2) { 48 | const invalidSteps = executor.getInvalidSteps(); 49 | if (invalidSteps.length > 0) { 50 | console.log(`Invalid steps found: ${invalidSteps.length}`); 51 | console.log(``); 52 | 53 | invalidSteps.forEach(step => { 54 | console.log(`\t> ${step.id} step`); 55 | console.log(`\t\tRequired parameters: ${step.requiredParams.join(`, `)}`); 56 | console.log(`\t\tPassed: ${step.parameters.map(p => `\`${p}\``).join(`, `)}`); 57 | console.log(``); 58 | }); 59 | process.exit(1); 60 | 61 | } else { 62 | console.log(`All steps are valid.`); 63 | } 64 | 65 | const logger: LoggerFunction = (value, append) => { 66 | if (append) { 67 | process.stdout.write(value); 68 | } else { 69 | console.log(value); 70 | } 71 | } 72 | 73 | const result = await executor.executeSteps({log: logger}); 74 | executor.dispose(); 75 | console.log(`ibmi-ci completed with exit code ${result.code}.`); 76 | process.exit(result.code); 77 | } 78 | } 79 | 80 | function printHelpAndQuit() { 81 | console.log(`IBM i CLI`); 82 | console.log(`The CLI is used to interact with the IBM i from the command line.`); 83 | console.log(); 84 | console.log(`It assumes these environment variables are set:`); 85 | console.log(`\tIBMI_HOST, IBMI_SSH_PORT, IBMI_USER`); 86 | console.log(`At least one of these environment variables is required:`); 87 | console.log(`\tIBMI_PASSWORD, IBMI_PRIVATE_KEY`); 88 | console.log(); 89 | 90 | // `connect` and `env` are special steps that are always run first. 91 | const uniqueSteps = Object.keys(StepTypes) 92 | .filter(key => ![`connect`, `env`].includes(key)) 93 | .map(key => new StepTypes[key]()); 94 | 95 | console.log(`Available parameters:`); 96 | console.log(`\t--file `); 97 | console.log(`\t\tLoads steps from a JSON or YAML file. This cannot`); 98 | console.log(`\t\tuse this in conjunction with other paramaters.`); 99 | console.log(); 100 | 101 | for (let i = 0; i < uniqueSteps.length; i++) { 102 | const step = uniqueSteps[i]; 103 | 104 | console.log(`\t--${step.id} ${step.requiredParams.map(p => `<${p}>`).join(` `)}`); 105 | console.log(`\t\t${step.description}`); 106 | } 107 | 108 | console.log(); 109 | 110 | process.exit(); 111 | } -------------------------------------------------------------------------------- /src/connection/IBMi.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as node_ssh from "node-ssh"; 3 | 4 | import { CommandData, CommandResult } from "./typings"; 5 | 6 | export class IBMi { 7 | client: node_ssh.NodeSSH; 8 | currentHost: string; 9 | currentPort: number; 10 | currentUser: string; 11 | 12 | constructor() { 13 | this.client = new node_ssh.NodeSSH; 14 | this.currentHost = ``; 15 | this.currentPort = 22; 16 | this.currentUser = ``; 17 | } 18 | 19 | /** 20 | * @returns {Promise<{success: boolean, error?: any}>} Was succesful at connecting or not. 21 | */ 22 | async connect(connectionObject: node_ssh.Config): Promise<{ success: boolean, error?: any }> { 23 | try { 24 | connectionObject.keepaliveInterval = 35000; 25 | 26 | await this.client.connect(connectionObject); 27 | 28 | this.currentHost = connectionObject.host; 29 | this.currentPort = connectionObject.port; 30 | this.currentUser = connectionObject.username; 31 | 32 | // const checkShellText = `This should be the only text!`; 33 | // const checkShellResult = await this.sendCommand({ 34 | // command: `echo "${checkShellText}"`, 35 | // directory: `.` 36 | // }); 37 | // if (checkShellResult.stdout.split(`\n`)[0] !== checkShellText) { 38 | // const chosen = await vscode.window.showErrorMessage(`Error in shell configuration!`, { 39 | // detail: [ 40 | // `This extension can not work with the shell configured on ${this.currentConnectionName},`, 41 | // `since the output from shell commands have additional content.`, 42 | // `This can be caused by running commands like "echo" or other`, 43 | // `commands creating output in your shell start script.`, ``, 44 | // `The connection to ${this.currentConnectionName} will be aborted.` 45 | // ].join(`\n`), 46 | // modal: true 47 | // }, `Read more`); 48 | 49 | // if (chosen === `Read more`) { 50 | // vscode.commands.executeCommand(`vscode.open`, `https://codefori.github.io/docs/#/pages/tips/setup`); 51 | // } 52 | 53 | // throw (`Shell config error, connection aborted.`); 54 | // } 55 | 56 | // let defaultHomeDir; 57 | /* 58 | const echoHomeResult = await this.sendCommand({ 59 | command: `echo $HOME && cd && test -w $HOME`, 60 | directory: `.` 61 | }); 62 | // Note: if the home directory does not exist, the behavior of the echo/cd/test command combo is as follows: 63 | // - stderr contains 'Could not chdir to home directory /home/________: No such file or directory' 64 | // (The output contains 'chdir' regardless of locale and shell, so maybe we could use that 65 | // if we iterate on this code again in the future) 66 | // - stdout contains the name of the home directory (even if it does not exist) 67 | // - The 'cd' command causes an error if the home directory does not exist or otherwise can't be cd'ed into 68 | // - The 'test' command causes an error if the home directory is not writable (one can cd into a non-writable directory) 69 | let isHomeUsable = (0 == echoHomeResult.code); 70 | if (isHomeUsable) { 71 | defaultHomeDir = echoHomeResult.stdout.trim(); 72 | } else { 73 | // Let's try to provide more valuable information to the user about why their home directory 74 | // is bad and maybe even provide the opportunity to create the home directory 75 | 76 | let actualHomeDir = echoHomeResult.stdout.trim(); 77 | 78 | // we _could_ just assume the home directory doesn't exist but maybe there's something more going on, namely mucked-up permissions 79 | let doesHomeExist = (0 === (await this.sendCommand({ command: `test -e ${actualHomeDir}` })).code); 80 | if (doesHomeExist) { 81 | // Note: this logic might look backward because we fall into this (failure) leg on what looks like success (home dir exists). 82 | // But, remember, but we only got here if 'cd $HOME' failed. 83 | // Let's try to figure out why.... 84 | if (0 !== (await this.sendCommand({ command: `test -d ${actualHomeDir}` })).code) { 85 | await vscode.window.showWarningMessage(`Your home directory (${actualHomeDir}) is not a directory! Code for IBM i may not function correctly. Please contact your system administrator`, { modal: !reconnecting }); 86 | } 87 | else if (0 !== (await this.sendCommand({ command: `test -w ${actualHomeDir}` })).code) { 88 | await vscode.window.showWarningMessage(`Your home directory (${actualHomeDir}) is not writable! Code for IBM i may not function correctly. Please contact your system administrator`, { modal: !reconnecting }); 89 | } 90 | else if (0 !== (await this.sendCommand({ command: `test -x ${actualHomeDir}` })).code) { 91 | await vscode.window.showWarningMessage(`Your home directory (${actualHomeDir}) is not usable due to permissions! Code for IBM i may not function correctly. Please contact your system administrator`, { modal: !reconnecting }); 92 | } 93 | else { 94 | // not sure, but get your sys admin involved 95 | await vscode.window.showWarningMessage(`Your home directory (${actualHomeDir}) exists but is unusable. Code for IBM i may not function correctly. Please contact your system administrator`, { modal: !reconnecting }); 96 | } 97 | } 98 | else if (reconnecting) { 99 | vscode.window.showWarningMessage(`Your home directory (${actualHomeDir}) does not exist. Code for IBM i may not function correctly.`, { modal: false }); 100 | } 101 | else if (await vscode.window.showWarningMessage(`Home directory does not exist`, { 102 | modal: true, 103 | detail: `Your home directory (${actualHomeDir}) does not exist, so Code for IBM i may not function correctly. Would you like to create this directory now?`, 104 | }, `Yes`)) { 105 | console.log(`creating home directory ${actuajlHomeDir}`); 106 | let mkHomeCmd = `mkdir -p ${actualHomeDir} && chown ${connectionObject.username.toLowerCase()} ${actualHomeDir} && chmod 0755 ${actualHomeDir}`; 107 | let mkHomeResult = await this.sendCommand({ command: mkHomeCmd, directory: `.` }); 108 | if (0 === mkHomeResult.code) { 109 | defaultHomeDir = actualHomeDir; 110 | } else { 111 | let mkHomeErrs = mkHomeResult.stderr; 112 | // We still get 'Could not chdir to home directory' in stderr so we need to hackily gut that out, as well as the bashisms that are a side effect of our API 113 | mkHomeErrs = mkHomeErrs.substring(1 + mkHomeErrs.indexOf(`\n`)).replace(`bash: line 1: `, ``); 114 | await vscode.window.showWarningMessage(`Error creating home directory (${actualHomeDir}):\n${mkHomeErrs}.\n\n Code for IBM i may not function correctly. Please contact your system administrator`, { modal: true }); 115 | } 116 | } 117 | } 118 | */ 119 | 120 | //Since the compiles are stateless, then we have to set the library list each time we use the `SYSTEM` command 121 | //We setup the defaultUserLibraries here so we can remove them later on so the user can setup their own library list 122 | // let currentLibrary = `QGPL`; 123 | // this.defaultUserLibraries = []; 124 | 125 | // const liblResult = await this.sendQsh({ 126 | // command: `liblist` 127 | // }); 128 | // if (liblResult.code === 0) { 129 | // const libraryListString = liblResult.stdout; 130 | // if (libraryListString !== ``) { 131 | // const libraryList = libraryListString.split(`\n`); 132 | 133 | // let lib, type; 134 | // for (const line of libraryList) { 135 | // lib = line.substring(0, 10).trim(); 136 | // type = line.substring(12); 137 | 138 | // switch (type) { 139 | // case `USR`: 140 | // this.defaultUserLibraries.push(lib); 141 | // break; 142 | 143 | // case `CUR`: 144 | // currentLibrary = lib; 145 | // break; 146 | // } 147 | // } 148 | 149 | // //If this is the first time the config is made, then these arrays will be empty 150 | // if (this.config.currentLibrary.length === 0) { 151 | // this.config.currentLibrary = currentLibrary; 152 | // } 153 | // if (this.config.libraryList.length === 0) { 154 | // this.config.libraryList = this.defaultUserLibraries; 155 | // } 156 | // } 157 | // } 158 | 159 | 160 | return { 161 | success: true 162 | }; 163 | 164 | } catch (e) { 165 | 166 | if (this.client.isConnected()) { 167 | this.client.dispose(); 168 | } 169 | 170 | return { 171 | success: false, 172 | error: e 173 | }; 174 | } 175 | } 176 | 177 | isConnected() { 178 | return this.client && this.client.isConnected(); 179 | } 180 | 181 | async sendQsh(options: CommandData) { 182 | options.stdin = options.command; 183 | 184 | return this.sendCommand({ 185 | ...options, 186 | command: `/QOpenSys/usr/bin/qsh` 187 | }); 188 | } 189 | 190 | /** 191 | * Send commands to pase through the SSH connection. 192 | * Commands sent here end up in the 'Code for IBM i' output channel. 193 | */ 194 | async sendCommand(options: CommandData): Promise { 195 | let commands: string[] = []; 196 | if (options.env) { 197 | commands.push(...Object.entries(options.env).map(([key, value]) => `export ${key}="${value?.replace(/\$/g, `\\$`).replace(/"/g, `\\"`) || `` 198 | }"`)) 199 | } 200 | 201 | commands.push(options.command); 202 | 203 | const command = commands.join(` && `); 204 | const directory = options.directory || `.`; 205 | 206 | const result = await this.client.execCommand(command, { 207 | cwd: directory, 208 | stdin: options.stdin, 209 | onStdout: options.onStdout, 210 | onStderr: options.onStderr, 211 | }); 212 | 213 | // Some simplification 214 | if (result.code === null) result.code = 0; 215 | 216 | return result; 217 | } 218 | 219 | async end() { 220 | this.client.connection?.removeAllListeners(); 221 | this.client.dispose(); 222 | } 223 | 224 | async uploadFiles(files: { local: string, remote: string }[], options?: node_ssh.SSHPutFilesOptions) { 225 | await this.client.putFiles(files.map(f => { return { local: f.local, remote: f.remote } }), options); 226 | } 227 | 228 | async downloadFile(localFile: string, remoteFile: string) { 229 | await this.client.getFile(localFile, remoteFile); 230 | } 231 | 232 | async uploadDirectory(localDirectory: string, remoteDirectory: string, options?: node_ssh.SSHGetPutDirectoryOptions) { 233 | await this.client.putDirectory(localDirectory, remoteDirectory, options); 234 | } 235 | 236 | async downloadDirectory(localDirectory: string, remoteDirectory: string, options?: node_ssh.SSHGetPutDirectoryOptions) { 237 | await this.client.getDirectory(localDirectory, remoteDirectory, options); 238 | } 239 | } 240 | --------------------------------------------------------------------------------