├── .gitignore ├── .prettierignore ├── TODO ├── images ├── icon.png ├── icon.psd ├── init.png ├── list.png ├── init-after.png ├── password.png ├── privatekey.png ├── autocomplete.png ├── autofeature.png ├── downloadall.png └── multiserver.png ├── .prettierrc ├── .gitmodules ├── .vscode ├── closurecompiler.json ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── src ├── util │ ├── fileinfo.ts │ ├── config.ts │ ├── pglob.ts │ ├── reflect.ts │ ├── ftp_path.ts │ ├── sm.ts │ ├── serverinfo.ts │ ├── event.ts │ ├── util.ts │ └── ftpkr_config.ts ├── cmd │ └── config.ts ├── vsutil │ ├── error.ts │ ├── tmpfile.ts │ ├── cmd.ts │ ├── lazywatcher.ts │ ├── ftptreeitem.ts │ ├── ws.ts │ ├── vsutil.ts │ ├── work.ts │ ├── log.ts │ ├── sftp.ts │ ├── fileinterface.ts │ └── ftp.ts ├── index.ts ├── sshmgr.ts ├── tool │ ├── schema_to_md.ts │ └── ssh.ts ├── ftpdown.ts ├── ftpsync.ts ├── ftptree.ts ├── watcher.ts ├── config.ts └── ftpmgr.ts ├── .vscodeignore ├── .eslintrc.json ├── tsconfig.json ├── LICENSE.txt ├── schema ├── ftp.schema.json ├── ftp-kr.schema.json ├── ftp-kr.md ├── sftp.schema.json ├── server.schema.json └── tls.schema.json ├── README.md ├── package.json └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /out/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /ssh2 2 | /out 3 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | check upload progress for blockDetectingDuration,connectionTimeout 2 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/icon.psd -------------------------------------------------------------------------------- /images/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/init.png -------------------------------------------------------------------------------- /images/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/list.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "semi": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /images/init-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/init-after.png -------------------------------------------------------------------------------- /images/password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/password.png -------------------------------------------------------------------------------- /images/privatekey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/privatekey.png -------------------------------------------------------------------------------- /images/autocomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/autocomplete.png -------------------------------------------------------------------------------- /images/autofeature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/autofeature.png -------------------------------------------------------------------------------- /images/downloadall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/downloadall.png -------------------------------------------------------------------------------- /images/multiserver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karikera/ftp-kr/HEAD/images/multiserver.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ssh2"] 2 | path = ssh2 3 | url = https://github.com/karikera/ssh2.git 4 | -------------------------------------------------------------------------------- /.vscode/closurecompiler.json: -------------------------------------------------------------------------------- 1 | { 2 | "create_source_map": "%js_output_file%.map", 3 | "output_wrapper": "%output%\n//# sourceMappingURL=%js_output_file_filename%.map" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /src/util/fileinfo.ts: -------------------------------------------------------------------------------- 1 | export type FileType = '' | '-' | 'd' | 'l'; 2 | 3 | export class FileInfo { 4 | type: FileType = ''; 5 | name = ''; 6 | size = 0; 7 | date = 0; 8 | link: string | undefined; 9 | } 10 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # it uses the absolute path always 2 | 3 | .vscode 4 | .gitignore 5 | .prettierignore 6 | tsconfig.json 7 | TODO 8 | 9 | ../if-tsb 10 | node_modules 11 | ssh2 12 | src 13 | out/schema_to_md.js 14 | 15 | images/*.psd 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "[typescript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "isBackground": false, 6 | "tasks": [ 7 | { 8 | "label": "build", 9 | "type": "npm", 10 | "group": { 11 | "isDefault": true, 12 | "kind": "build" 13 | }, 14 | "script": "watch", 15 | "isBackground": true, 16 | "problemMatcher": "$tsc-watch" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "prettier" 6 | ], 7 | "parser": "@typescript-eslint/parser", 8 | "plugins": ["@typescript-eslint"], 9 | "root": true, 10 | "rules": { 11 | "@typescript-eslint/no-namespace": "off", 12 | "@typescript-eslint/no-explicit-any": "off", 13 | "@typescript-eslint/no-empty-function": "off", 14 | "@typescript-eslint/no-non-null-assertion": "off", 15 | "@typescript-eslint/no-this-alias": "off", 16 | "no-case-declarations": "off" 17 | }, 18 | "ignorePatterns": ["/ssh2", "/out"] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "entry": { 3 | "./src/index.ts": "./out/index.bundle.js", 4 | "./src/tool/schema_to_md.ts": "./out/schema_to_md.js" 5 | }, 6 | "compileOnSave": true, 7 | "bundlerOptions": { 8 | "bundleExternals": true, 9 | "externals": ["vscode"] 10 | }, 11 | "compilerOptions": { 12 | "lib": ["ES2018"], 13 | "module": "commonjs", 14 | "target": "ES2018", 15 | "outDir": "out", 16 | "sourceMap": true, 17 | "strictNullChecks": true, 18 | "noImplicitThis": true, 19 | "strict": true, 20 | "useUnknownInCatchVariables": false, 21 | "experimentalDecorators": true 22 | }, 23 | "include": ["src/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /src/cmd/config.ts: -------------------------------------------------------------------------------- 1 | import { vsutil } from '../vsutil/vsutil'; 2 | import { Command, CommandArgs } from '../vsutil/cmd'; 3 | import { Config } from '../config'; 4 | import { Workspace } from '../vsutil/ws'; 5 | import { Scheduler } from '../vsutil/work'; 6 | 7 | export const commands: Command = { 8 | async 'ftpkr.init'(args: CommandArgs) { 9 | args.workspace = await vsutil.createWorkspace(); 10 | if (!args.workspace) return; 11 | const config = args.workspace.query(Config); 12 | config.init(); 13 | }, 14 | 15 | async 'ftpkr.cancel'() { 16 | for (const workspace of Workspace.all()) { 17 | workspace.query(Scheduler).cancel(); 18 | } 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/vsutil/error.ts: -------------------------------------------------------------------------------- 1 | import { vsutil } from './vsutil'; 2 | import { Logger } from './log'; 3 | 4 | declare global { 5 | interface Error { 6 | suppress?: boolean; 7 | task?: string; 8 | } 9 | } 10 | 11 | export function processError(logger: Logger, err: any): void { 12 | if (err instanceof Error) { 13 | if (!err.suppress) { 14 | logger.error(err); 15 | } else { 16 | logger.show(); 17 | logger.message(err.message); 18 | } 19 | if (err.file) { 20 | if (err.line) { 21 | vsutil.open(err.file, err.line, err.column); 22 | } else { 23 | vsutil.open(err.file); 24 | } 25 | } 26 | } else { 27 | logger.error(err); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/util/config.ts: -------------------------------------------------------------------------------- 1 | export class ConfigContainer { 2 | protected readonly properties: Set; 3 | constructor(properties: (keyof T)[]) { 4 | this.properties = new Set(properties); 5 | Object.freeze(this.properties); 6 | } 7 | 8 | protected isProperty(name: number | string | symbol): name is keyof T { 9 | return this.properties.has(name); 10 | } 11 | 12 | protected clearConfig() { 13 | for (const name of this.properties) { 14 | delete (this)[name]; 15 | } 16 | } 17 | 18 | protected appendConfig(config: T): void { 19 | for (const p in config) { 20 | if (!this.isProperty(p)) continue; 21 | (this)[p] = config[p]; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/util/pglob.ts: -------------------------------------------------------------------------------- 1 | import glob_inner = require('glob'); 2 | 3 | function glob(pattern: string): Promise { 4 | pattern = pattern.replace(/\\/g, '/'); 5 | return new Promise((resolve, reject) => { 6 | glob_inner(pattern, (err, files) => { 7 | if (err) reject(err); 8 | else resolve(files); 9 | }); 10 | }); 11 | } 12 | 13 | async function globAll(files: string[]): Promise { 14 | const res: string[] = []; 15 | for (const file of files) { 16 | res.push(...(await glob(file))); 17 | } 18 | return res; 19 | } 20 | 21 | export default function (pattern: string | string[]): Promise { 22 | if (pattern instanceof Array) return globAll(pattern); 23 | return glob(pattern); 24 | } 25 | -------------------------------------------------------------------------------- /src/util/reflect.ts: -------------------------------------------------------------------------------- 1 | import type * as ts from 'typescript'; 2 | 3 | export function keys( 4 | ctx: ts.TransformationContext, 5 | typeChecker: ts.TypeChecker, 6 | type: ts.Type 7 | ): ts.ArrayLiteralExpression { 8 | const factory = ctx.factory; 9 | const properties: string[] = []; 10 | function readProperties(type: ts.Type): void { 11 | if (type.isClassOrInterface()) { 12 | const baseTypes = typeChecker.getBaseTypes(type); 13 | for (const base of baseTypes) { 14 | readProperties(base); 15 | } 16 | } 17 | for (const prop of typeChecker.getPropertiesOfType(type)) { 18 | properties.push(prop.name); 19 | } 20 | } 21 | 22 | readProperties(type); 23 | return factory.createArrayLiteralExpression( 24 | properties.map((property) => factory.createStringLiteral(property)) 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that launches the extension inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "name": "Launch Extension", 7 | "type": "extensionHost", 8 | "request": "launch", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 11 | "preLaunchTask": "${defaultBuildTask}" 12 | }, 13 | { 14 | "name": "Launch Tests", 15 | "type": "extensionHost", 16 | "request": "launch", 17 | "runtimeExecutable": "${execPath}", 18 | "args": [ 19 | "--extensionDevelopmentPath=${workspaceRoot}", 20 | "--extensionTestsPath=${workspaceRoot}/test" 21 | ] 22 | },{ 23 | "name": "if-tsb", 24 | "type": "node", 25 | "program": "./node_modules/if-tsb/cli.bundle.js", 26 | "args": ["-w"], 27 | "request": "launch" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 karikera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/util/ftp_path.ts: -------------------------------------------------------------------------------- 1 | export const ftp_path = { 2 | normalize(ftppath: string): string { 3 | const pathes = ftppath.split('/'); 4 | const npathes: string[] = []; 5 | for (const name of pathes) { 6 | switch (name) { 7 | case '': 8 | break; 9 | case '.': 10 | break; 11 | case '..': 12 | if (npathes.length === 0 || npathes[npathes.length - 1] === '..') { 13 | npathes.push('..'); 14 | } else npathes.pop(); 15 | break; 16 | default: 17 | npathes.push(name); 18 | break; 19 | } 20 | } 21 | if (npathes.length === 0) { 22 | if (ftppath.startsWith('/')) return '/'; 23 | return '.'; 24 | } 25 | 26 | if (ftppath.startsWith('/')) return '/' + npathes.join('/'); 27 | return npathes.join('/'); 28 | }, 29 | dirname(ftppath: string): string { 30 | const idx = ftppath.lastIndexOf('/'); 31 | if (idx === 0) { 32 | if (ftppath.length === 1) throw Error('No more parent'); 33 | return '/'; 34 | } 35 | if (idx !== -1) return ftppath.substr(0, idx); 36 | return '.'; 37 | }, 38 | basename(ftppath: string): string { 39 | const idx = ftppath.lastIndexOf('/'); 40 | return ftppath.substr(idx + 1); 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | try { 2 | require('source-map-support/register'); 3 | } catch (err) { 4 | // empty 5 | } 6 | 7 | import { ExtensionContext, window, workspace } from 'vscode'; 8 | import { commands as cfgcmd } from './cmd/config'; 9 | import { commands as ftpcmd } from './cmd/ftpsync'; 10 | import { Config } from './config'; 11 | import { FtpDownloader } from './ftpdown'; 12 | import { ftpTree } from './ftptree'; 13 | import { Command } from './vsutil/cmd'; 14 | import { Workspace } from './vsutil/ws'; 15 | import { WorkspaceWatcher } from './watcher'; 16 | 17 | Workspace.onNew((workspace) => { 18 | workspace.query(WorkspaceWatcher); 19 | workspace.query(Config); 20 | workspace.query(FtpDownloader); 21 | }); 22 | 23 | export function activate(context: ExtensionContext) { 24 | console.log('[extension: ftp-kr] activate'); 25 | 26 | Command.register(context, cfgcmd, ftpcmd); 27 | 28 | Workspace.loadAll(); 29 | 30 | context.subscriptions.push( 31 | workspace.registerFileSystemProvider('ftpkr', ftpTree) 32 | ); 33 | context.subscriptions.push( 34 | window.registerTreeDataProvider('ftpkr.explorer', ftpTree) 35 | ); 36 | // vsutil.makeFolder(Uri.parse('ftpkr:/'), '[ftp-kr-remote]'); 37 | } 38 | export function deactivate() { 39 | try { 40 | Workspace.unloadAll(); 41 | console.log('[extension: ftp-kr] deactivate'); 42 | } catch (err) { 43 | console.error(err); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/util/sm.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { SourceMapConsumer } from 'source-map'; 3 | import { replaceErrorUrlAsync } from './util'; 4 | 5 | export async function getTsPosition( 6 | js: File, 7 | line: number, 8 | column: number 9 | ): Promise<{ source: string; line: number; column: number }> { 10 | try { 11 | const sm = await js.reext('js.map').json(); 12 | const res = await SourceMapConsumer.with(sm, null, (consumer) => 13 | consumer.originalPositionFor({ line, column }) 14 | ); 15 | 16 | const source = res.source ? js.child(res.source).fsPath : js.fsPath; 17 | return { source, line: res.line || line, column: res.column || column }; 18 | } catch (err) { 19 | return { source: js.fsPath, line, column }; 20 | } 21 | } 22 | 23 | export async function getMappedStack(err: any): Promise { 24 | if (!err) return null; 25 | const stack = err.stack; 26 | if (typeof stack !== 'string') return null; 27 | 28 | return replaceErrorUrlAsync(stack, async (path, line, column) => { 29 | const pos = await getTsPosition(new File(path), line, column); 30 | let res = ''; 31 | res += pos.source; 32 | res += ':'; 33 | res += pos.line; 34 | res += ':'; 35 | res += pos.column; 36 | return res; 37 | }); 38 | } 39 | 40 | export async function printMappedError(err: any): Promise { 41 | const stack = await getMappedStack(err); 42 | console.error(stack || err); 43 | } 44 | -------------------------------------------------------------------------------- /schema/ftp.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "host": { 5 | "type": "string", 6 | "description": "The hostname or IP address of the FTP server. Default: 'localhost'" 7 | }, 8 | "port": { 9 | "type": "number", 10 | "description": "The port of the FTP server. Default: 21" 11 | }, 12 | "secure": { 13 | "type": ["string", "boolean"], 14 | "description": "Set to true for both control and data connection encryption, 'control' for control connection encryption only, or 'implicit' for\nimplicitly encrypted control connection (this mode is deprecated in modern times, but usually uses port 990) Default: false" 15 | }, 16 | "secureOptions": { "$ref": "tls.schema.json" }, 17 | "user": { 18 | "type": "string", 19 | "description": "Username for authentication. Default: 'anonymous'" 20 | }, 21 | "password": { 22 | "type": "string", 23 | "description": "Password for authentication. Default: 'anonymous@'" 24 | }, 25 | "connTimeout": { 26 | "type": "number", 27 | "description": "How long (in milliseconds) to wait for the control connection to be established. Default: 10000", 28 | "default": 10000 29 | }, 30 | "pasvTimeout": { 31 | "type": "number", 32 | "description": "How long (in milliseconds) to wait for a PASV data connection to be established. Default: 10000", 33 | "default": 10000 34 | }, 35 | "keepalive": { 36 | "type": "number", 37 | "description": "How often (in milliseconds) to send a 'dummy' (NOOP) command to keep the connection alive. Default: 10000", 38 | "default": 10000 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/sshmgr.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import * as os from 'os'; 3 | import { window, workspace } from 'vscode'; 4 | 5 | import { Config } from './config'; 6 | import { FtpCacher } from './ftpcacher'; 7 | import { vsutil } from './vsutil/vsutil'; 8 | 9 | const ssh_js = new File(__dirname + '/tool/ssh.js').fsPath; 10 | 11 | function getShellType(): string | undefined { 12 | if (os.platform() !== 'win32') return; 13 | const terminalSettings = workspace.getConfiguration('terminal'); 14 | let shellPath: string | undefined = terminalSettings.integrated.shell.windows; 15 | if (!shellPath) return undefined; 16 | shellPath = shellPath.toLowerCase(); 17 | if (shellPath.endsWith('bash.exe')) return 'wslbash'; 18 | if (shellPath.endsWith('cmd.exe')) return 'cmd'; 19 | } 20 | 21 | export function openSshTerminal(server: FtpCacher): void { 22 | const terminal = window.createTerminal(server.getName()); 23 | 24 | let dir = server.workspace.fsPath; 25 | switch (getShellType()) { 26 | case 'wslbash': 27 | // c:\workspace\foo to /mnt/c/workspace/foo 28 | dir = dir.replace(/(\w):/, '/mnt/$1').replace(/\\/g, '/'); 29 | break; 30 | case 'cmd': 31 | // send 1st two characters (drive letter and colon) to the terminal 32 | // so that drive letter is updated before running cd 33 | terminal.sendText(dir.slice(0, 2)); 34 | break; 35 | } 36 | if (server.config.protocol !== 'sftp') { 37 | server.logger 38 | .errorConfirm( 39 | 'Cannot open SSH. Need to set protocol to sftp in ftp-kr.json', 40 | 'Open config' 41 | ) 42 | .then((res) => { 43 | switch (res) { 44 | case 'Open config': 45 | vsutil.open(server.workspace.query(Config).path); 46 | break; 47 | } 48 | }); 49 | return; 50 | } 51 | 52 | terminal.sendText(`node "${ssh_js}" "${dir}" ${server.config.index}`); 53 | terminal.show(); 54 | } 55 | -------------------------------------------------------------------------------- /src/util/serverinfo.ts: -------------------------------------------------------------------------------- 1 | import { Options as FtpOptions } from 'ftp'; 2 | import { reflect } from 'if-tsb/reflect'; 3 | import { ConnectConfig as SftpOptions } from 'ssh2'; 4 | 5 | export interface ServerConfig { 6 | remotePath: string; 7 | protocol: string; 8 | 9 | fileNameEncoding: string; 10 | 11 | host: string; 12 | username: string; 13 | secure: boolean; 14 | 15 | port?: number; 16 | ignoreWrongFileEncoding: boolean; 17 | 18 | name?: string; 19 | 20 | password?: string; 21 | keepPasswordInMemory: boolean; 22 | 23 | passphrase?: string; 24 | connectionTimeout: number; 25 | autoDownloadRefreshTime: number; 26 | refreshTime: number; 27 | blockDetectingDuration: number; 28 | privateKey?: string; 29 | showGreeting: boolean; 30 | 31 | ftpOverride?: FtpOptions; 32 | sftpOverride?: SftpOptions; 33 | 34 | // generateds 35 | index: number; // 0 is the main server, 1 or more are alt servers 36 | url: string; // For visibility 37 | hostUrl: string; // It uses like the id 38 | passwordInMemory?: string; // temp password 39 | } 40 | 41 | export type LogLevel = 'VERBOSE' | 'NORMAL' | 'ERROR'; 42 | 43 | export interface FtpKrConfigProperties extends ServerConfig { 44 | ignore: string[]; 45 | autoUpload: boolean; 46 | autoDelete: boolean; 47 | autoDownload: boolean; 48 | 49 | altServer: ServerConfig[]; 50 | localBasePath?: string; 51 | followLink: boolean; 52 | autoDownloadAlways: number; 53 | createSyncCache: boolean; 54 | logLevel: LogLevel; 55 | dontOpenOutput: boolean; 56 | viewSizeLimit: number; 57 | downloadTimeExtraThreshold: number; 58 | ignoreRemoteModification: boolean; 59 | ignoreJsonUploadCaution: boolean; 60 | noticeFileCount: number; 61 | includeAllAlwaysForAllCommand: boolean; 62 | showReportMessage: number | false; 63 | } 64 | 65 | export namespace FtpKrConfigProperties { 66 | export const keys = reflect<'./reflect', 'keys', FtpKrConfigProperties>(); 67 | } 68 | -------------------------------------------------------------------------------- /src/vsutil/tmpfile.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { commands, Disposable, TextDocument, Uri, workspace } from 'vscode'; 3 | 4 | let disposer: Disposable | null = null; 5 | const listenMap = new Map void>(); 6 | 7 | function onCloseDoc(e: TextDocument) { 8 | fireListen(e.uri.fsPath); 9 | } 10 | 11 | function fireListen(fileName: string): void { 12 | const cb = listenMap.get(fileName); 13 | if (cb !== undefined) { 14 | listenMap.delete(fileName); 15 | if (listenMap.size === 0) { 16 | if (disposer === null) throw Error('Invalid state'); 17 | disposer.dispose(); 18 | disposer = null; 19 | } 20 | cb(); 21 | } 22 | } 23 | 24 | function listen(fileName: string, cb: () => void): void { 25 | if (listenMap.has(fileName)) throw Error(`listener overlapped: ${fileName}`); 26 | listenMap.set(fileName, cb); 27 | if (disposer === null) { 28 | disposer = workspace.onDidCloseTextDocument(onCloseDoc); 29 | } 30 | } 31 | 32 | export class TemporalDocument { 33 | public readonly onClose: Promise; 34 | private readonly editorFileUri: Uri; 35 | private closed = false; 36 | 37 | constructor( 38 | public readonly editorFile: File, 39 | public readonly targetFile: File 40 | ) { 41 | this.editorFileUri = Uri.file(this.editorFile.fsPath); 42 | this.onClose = new Promise((resolve) => 43 | listen(this.editorFileUri.fsPath, () => { 44 | if (this.closed) return; 45 | this.closed = true; 46 | this.targetFile.quietUnlink(); 47 | resolve(); 48 | }) 49 | ); 50 | } 51 | 52 | close(): void { 53 | if (this.closed) return; 54 | this.closed = true; 55 | // commands.executeCommand('vscode.open', this.editorFileUri); 56 | commands.executeCommand('workbench.action.focusActiveEditorGroup'); 57 | commands.executeCommand('workbench.action.closeActiveEditor'); 58 | this.targetFile.quietUnlink(); 59 | fireListen(this.editorFileUri.fsPath); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/util/event.ts: -------------------------------------------------------------------------------- 1 | import { Deferred } from './util'; 2 | 3 | export interface Event { 4 | (onfunc: (value: T) => void | Promise): void; 5 | 6 | fire(value?: T): Promise; 7 | remove(onfunc: (value: T) => void | Promise): boolean; 8 | clear(): void; 9 | } 10 | 11 | class FiredEvent extends Deferred { 12 | constructor(public value: T, public reverse: boolean) { 13 | super(); 14 | } 15 | } 16 | 17 | export namespace Event { 18 | export function make(reverse: boolean): Event { 19 | let list: (((value: T) => void | Promise) | undefined)[] = []; 20 | let firing = false; 21 | const fireQueue: FiredEvent[] = []; 22 | 23 | const event = >( 24 | function event(onfunc: (value: T) => void | Promise): void { 25 | list.push(onfunc); 26 | } 27 | ); 28 | 29 | async function processFire() { 30 | firing = true; 31 | await Promise.resolve(); 32 | 33 | for (;;) { 34 | const fired = fireQueue.shift(); 35 | if (!fired) break; 36 | 37 | list = list.filter((v) => v); 38 | try { 39 | if (reverse) { 40 | for (let i = list.length - 1; i >= 0; i--) { 41 | const func = list[i]; 42 | if (!func) continue; 43 | const prom = func(fired.value); 44 | if (prom) await prom; 45 | } 46 | } else { 47 | for (const func of list) { 48 | if (!func) continue; 49 | const prom = func(fired.value); 50 | if (prom) await prom; 51 | } 52 | } 53 | fired.resolve(); 54 | } catch (err) { 55 | fired.reject(err); 56 | } 57 | } 58 | firing = false; 59 | } 60 | 61 | event.fire = (value: T) => { 62 | const fired = new FiredEvent(value, false); 63 | fireQueue.push(fired); 64 | if (!firing) processFire(); 65 | return fired; 66 | }; 67 | event.remove = (onfunc: (value: T) => void | Promise) => { 68 | const idx = list.indexOf(onfunc); 69 | if (idx !== -1) { 70 | list[idx] = undefined; 71 | return true; 72 | } 73 | return false; 74 | }; 75 | event.clear = () => { 76 | list.length = 0; 77 | }; 78 | return event; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VSCode FTP Extension 2 | 3 | Start with `ftp-kr Init` command on the project directory. 4 | 5 | ![init](images/init.png) 6 | 7 | ![init after](images/init-after.png) 8 | It will try to connect when you save it. 9 | 10 | ![download all](images/downloadall.png) 11 | 12 | ## Details 13 | 14 | - Disable Auto Upload 15 | By default, the auto-sync feature is enabled 16 | If you want to disable auto-sync, please set autoUpload/autoDelete to false. 17 | ![auto](images/autofeature.png) 18 | 19 | - Use Password Input 20 | You can use password input instead of password field. 21 | ![password input](images/password.png) 22 | 23 | - Browse FTP Server 24 | You can browse remote directory with `ftp-kr List` command. 25 | ![list](images/list.png) 26 | 27 | - You can find extra options in ftp-kr.json with auto complete(`Ctrl+Space`). 28 | [See schema of ftp-kr.json](./schema/ftp-kr.md) 29 | ![autocom](images/autocomplete.png) 30 | 31 | - Use Multiple Servers 32 | ![multiserver](images/multiserver.png) 33 | if you write altServer field, It will show server selection in some commands. 34 | 35 | - Use Private Key 36 | You can use SFTP with private key. 37 | ![privatekey](images/privatekey.png) 38 | 39 | - Use more ftp/sftp options 40 | You can override ftp/sftp options by `ftpOverride`/`sftpOverride` field, It will pass to connect function of `ftp`/`ssh2` package. 41 | 42 | ## Available functions & commands 43 | 44 | - Real-Time FTP/SFTP synchronization. 45 | - `ftp-kr: Init` - Starts up extension and generates `ftp-kr.json`. 46 | - `ftp-kr: Upload All` - Upload all without same size files. 47 | - `ftp-kr: Download All` - Download all without same size files. 48 | - `ftp-kr: Upload This` - Upload this file. 49 | - `ftp-kr: Download This` - Download this file. 50 | - `ftp-kr: Delete This` - Delete file in remote server. 51 | 52 | ## Advanced commands 53 | 54 | - `ftp-kr: Diff This` - Diff this file. 55 | - `ftp-kr: Refresh` - Rescan remote files. 56 | - `ftp-kr: Clean All` - Cleaning remote files that not in workspace. 57 | - `ftp-kr: Run task.json` - Run a batch task. It is auto generated and run by `* All` commands. You can use it with same syntax. 58 | - `ftp-kr: Reconnect` - Reconnect the server. 59 | - `ftp-kr: Cancel` - Cancel current tasks 60 | - `ftp-kr: Target` - Swaps the main server. For using with alternate servers. 61 | - `ftp-kr: List` - Browse remote directories. 62 | - `ftp-kr: View` - View a remote file. used internally. 63 | 64 | ## And... 65 | 66 | Bug Report & Feature Request: https://github.com/karikera/ftp-kr/issues 67 | Wiki: https://github.com/karikera/ftp-kr/wiki 68 | -------------------------------------------------------------------------------- /src/tool/schema_to_md.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { printMappedError } from '../util/sm'; 3 | 4 | function mergeType(obj: any, other: any): void { 5 | if (obj.properties) { 6 | if (other.properties) { 7 | for (const p in other.properties) { 8 | const ori = obj.properties[p]; 9 | if (ori) mergeType(ori, other.properties[p]); 10 | else obj.properties[p] = other.properties[p]; 11 | } 12 | } 13 | } 14 | } 15 | 16 | async function readType(file: File, obj: any): Promise { 17 | if (obj.$ref) { 18 | return await readSchema(file.sibling(obj.$ref)); 19 | } 20 | if (obj.allOf) { 21 | for (let i = 0; i < obj.allOf.length; i++) { 22 | const c = (obj.allOf[i] = await readType(file, obj.allOf[i])); 23 | mergeType(obj, c); 24 | } 25 | } 26 | return obj; 27 | } 28 | 29 | async function readSchema(file: File): Promise { 30 | const obj = await file.json(); 31 | await readType(file, obj); 32 | return obj; 33 | } 34 | 35 | class MdWriter { 36 | private md = ''; 37 | private objects: { [key: string]: string } = {}; 38 | private address = ''; 39 | private itemName = ''; 40 | 41 | constructor() {} 42 | 43 | finalize(): string { 44 | let md = ''; 45 | for (const name in this.objects) { 46 | md += '## ' + (name || 'ftp-kr.json') + '\n\n'; 47 | md += this.objects[name]; 48 | } 49 | return md; 50 | } 51 | 52 | object(obj: any): void { 53 | const olditemname = this.itemName; 54 | const oldaddress = this.address; 55 | const oldmd = this.md; 56 | const prefix = oldaddress ? oldaddress + '.' : ''; 57 | for (const p in obj.properties) { 58 | this.itemName = p; 59 | this.address = prefix + p; 60 | this.type(obj.properties[p]); 61 | } 62 | this.itemName = olditemname; 63 | this.address = oldaddress; 64 | this.objects[this.address] = this.md; 65 | this.md = oldmd; 66 | } 67 | 68 | type(obj: any): void { 69 | this.md += `- **${this.address}** `; 70 | const enumlist = obj.enum; 71 | if (enumlist && enumlist.length <= 5) { 72 | this.md += `(enum: ${enumlist.join(', ')}) `; 73 | } else if (obj.items) { 74 | this.md += `(${obj.items.type}[])`; 75 | } else if (obj.type) this.md += `(${obj.type}) `; 76 | if (obj.deprecationMessage) { 77 | this.md += '(**DEPRECATED: ' + obj.deprecationMessage + '**) '; 78 | } 79 | 80 | let desc = obj.description || ''; 81 | if (obj.properties) { 82 | this.object(obj); 83 | desc += ` [see properties](${this.address.replace(/\./g, '')})`; 84 | } 85 | if (desc) this.md += '- ' + desc; 86 | this.md += '\n'; 87 | } 88 | } 89 | 90 | async function main(): Promise { 91 | const arg = process.argv[2]; 92 | if (!arg) return; 93 | const file = new File(arg); 94 | const obj = await readSchema(file); 95 | const writer = new MdWriter(); 96 | writer.object(obj); 97 | await file.reext('md').create(writer.finalize()); 98 | } 99 | 100 | main().catch((err) => printMappedError(err)); 101 | -------------------------------------------------------------------------------- /src/vsutil/cmd.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { commands, ExtensionContext, Uri, window } from 'vscode'; 3 | import { ftpTree } from '../ftptree'; 4 | 5 | import { processError } from './error'; 6 | import { FtpTreeItem } from './ftptreeitem'; 7 | import { defaultLogger, Logger } from './log'; 8 | import { Workspace } from './ws'; 9 | 10 | export class CommandArgs { 11 | file?: File; 12 | files?: File[]; 13 | uri?: Uri; 14 | treeItem?: FtpTreeItem; 15 | workspace?: Workspace; 16 | openedFile?: boolean; 17 | 18 | private logger: Logger | null = null; 19 | 20 | getLogger(): Logger { 21 | if (this.logger === null) { 22 | this.logger = this.workspace 23 | ? this.workspace.query(Logger) 24 | : defaultLogger; 25 | } 26 | return this.logger; 27 | } 28 | } 29 | 30 | export type Command = { [key: string]: (args: CommandArgs) => unknown }; 31 | 32 | async function runCommand( 33 | commands: Command, 34 | name: string, 35 | ...args: unknown[] 36 | ): Promise { 37 | const cmdargs = new CommandArgs(); 38 | 39 | try { 40 | const arg = args[0]; 41 | if (arg instanceof FtpTreeItem) { 42 | cmdargs.treeItem = arg; 43 | cmdargs.workspace = arg.server.workspace; 44 | } else { 45 | if (arg instanceof Uri) { 46 | if (arg.scheme === 'file') { 47 | cmdargs.file = new File(arg.fsPath); 48 | const files = args[1]; 49 | if (files && files instanceof Array && files[0] instanceof Uri) { 50 | cmdargs.files = []; 51 | for (const uri of files) { 52 | if (uri.scheme === 'file') { 53 | cmdargs.files.push(new File(uri)); 54 | } 55 | } 56 | if (cmdargs.files.indexOf(cmdargs.file) === -1) 57 | cmdargs.files.push(cmdargs.file); 58 | } else { 59 | cmdargs.files = [cmdargs.file]; 60 | } 61 | } else { 62 | cmdargs.uri = arg; 63 | ftpTree.getServerFromUri(arg); 64 | } 65 | } else { 66 | const editor = window.activeTextEditor; 67 | if (editor) { 68 | const doc = editor.document; 69 | if (doc.uri.scheme === 'file') { 70 | cmdargs.file = new File(doc.uri.fsPath); 71 | cmdargs.files = [cmdargs.file]; 72 | cmdargs.openedFile = true; 73 | } 74 | await doc.save(); 75 | } 76 | } 77 | if (cmdargs.file) { 78 | cmdargs.workspace = Workspace.fromFile(cmdargs.file); 79 | } 80 | } 81 | 82 | cmdargs.getLogger().verbose(`[Command] ${name}`); 83 | await commands[name](cmdargs); 84 | } catch (err) { 85 | processError(cmdargs.getLogger(), err); 86 | } 87 | } 88 | 89 | export namespace Command { 90 | export function register(context: ExtensionContext, ...cmdlist: Command[]) { 91 | for (const cmds of cmdlist) { 92 | for (const name in cmds) { 93 | const disposable = commands.registerCommand(name, (...args) => 94 | runCommand(cmds, name, ...args) 95 | ); 96 | context.subscriptions.push(disposable); 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ftpdown.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | 3 | import { Logger, StringError } from './vsutil/log'; 4 | import { PRIORITY_IDLE, Scheduler } from './vsutil/work'; 5 | import { Workspace, WorkspaceItem } from './vsutil/ws'; 6 | 7 | import { Config } from './config'; 8 | import { FtpSyncManager } from './ftpsync'; 9 | import { printMappedError } from './util/sm'; 10 | 11 | export class FtpDownloader implements WorkspaceItem { 12 | private readonly config: Config; 13 | private readonly logger: Logger; 14 | private readonly ftpmgr: FtpSyncManager; 15 | private readonly scheduler: Scheduler; 16 | 17 | private timer: NodeJS.Timer | null = null; 18 | private enabled = false; 19 | 20 | constructor(workspace: Workspace) { 21 | this.config = workspace.query(Config); 22 | this.logger = workspace.query(Logger); 23 | this.ftpmgr = workspace.query(FtpSyncManager); 24 | this.scheduler = workspace.query(Scheduler); 25 | this.config.onLoad(() => this._resetTimer()); 26 | } 27 | 28 | public dispose(): void { 29 | if (this.timer) { 30 | clearTimeout(this.timer); 31 | this.timer = null; 32 | } 33 | this.enabled = false; 34 | } 35 | 36 | private _resetTimer(): void { 37 | if (this.timer) { 38 | clearTimeout(this.timer); 39 | this.timer = null; 40 | } 41 | if (this.config.autoDownloadAlways) { 42 | this.enabled = true; 43 | this.timer = setTimeout( 44 | () => this.requestDownloadAll(), 45 | this.config.autoDownloadAlways 46 | ); 47 | } else { 48 | this.enabled = false; 49 | } 50 | } 51 | 52 | private async requestDownloadAll(): Promise { 53 | try { 54 | await this._downloadDir(this.config.getBasePath()); 55 | if (this.enabled) { 56 | if (!this.config.autoDownloadAlways) throw Error('Assert'); 57 | this.timer = setTimeout( 58 | () => this.requestDownloadAll(), 59 | this.config.autoDownloadAlways 60 | ); 61 | } 62 | } catch (err) { 63 | this.logger.error(err); 64 | } 65 | } 66 | 67 | private async _downloadDir(dir: File): Promise { 68 | const ftppath = this.ftpmgr.targetServer.toFtpPath(dir); 69 | const list = await this.scheduler.taskMust( 70 | 'downloadAlways.list', 71 | (task) => this.ftpmgr.targetServer.ftpList(ftppath, task), 72 | null, 73 | PRIORITY_IDLE 74 | ); 75 | if (!this.enabled) throw StringError.IGNORE; 76 | for (let child of list.children()) { 77 | const childFile = dir.child(child.name); 78 | if (this.config.checkIgnorePath(dir)) continue; 79 | 80 | try { 81 | if (child.type === 'l') { 82 | if (!this.config.followLink) continue; 83 | const stats = await this.scheduler.taskMust( 84 | 'downloadAlways.readLink', 85 | (task) => this.ftpmgr.targetServer.ftpTargetStat(child, task), 86 | null, 87 | PRIORITY_IDLE 88 | ); 89 | if (!stats) continue; 90 | child = stats; 91 | } 92 | if (child.type === 'd') { 93 | await this._downloadDir(childFile); 94 | } else { 95 | await this.scheduler.taskMust( 96 | 'downloadAlways', 97 | (task) => 98 | this.ftpmgr.targetServer.ftpDownloadWithCheck(childFile, task), 99 | null, 100 | PRIORITY_IDLE 101 | ); 102 | if (!this.enabled) throw StringError.IGNORE; 103 | } 104 | } catch (err) { 105 | printMappedError(err); 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /schema/ftp-kr.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [{ "$ref": "server.schema.json" }], 3 | "type": "object", 4 | "properties": { 5 | "closure": { 6 | "deprecationMessage": "Closure Compiler feature is splitted. If you want to use, Please download Closure Compiler extension." 7 | }, 8 | "disableFtp": { 9 | "deprecationMessage": "This option is deleted" 10 | }, 11 | "altServer": { 12 | "type": "array", 13 | "description": "alternate servers. It has similar properties with root manifest", 14 | "items": { 15 | "type": "object", 16 | "allOf": [{ "$ref": "server.schema.json" }], 17 | "properties": { 18 | "name": { 19 | "type": "string", 20 | "description": "Display name for alternate server" 21 | } 22 | } 23 | } 24 | }, 25 | "createSyncCache": { 26 | "type": "boolean", 27 | "description": "Create ftp-kr.cache.json file to save remote modification (default: true)", 28 | "default": true 29 | }, 30 | "followLink": { 31 | "type": "boolean", 32 | "description": "If it is true, extension will access symlink target or ignore symlink (default: false)", 33 | "default": false 34 | }, 35 | "localBasePath": { 36 | "type": "string", 37 | "desciption": "Set local directory to sync (default: workspace root)" 38 | }, 39 | "autoUpload": { 40 | "type": "boolean", 41 | "description": "Upload file by watcher (default: false)", 42 | "default": false 43 | }, 44 | "autoDelete": { 45 | "type": "boolean", 46 | "description": "Delete FTP file on delete in workspace (default: false)", 47 | "default": false 48 | }, 49 | "autoDownload": { 50 | "type": "boolean", 51 | "description": "It will check modification of every opening and download if it modified (default: false)", 52 | "default": false 53 | }, 54 | "autoDownloadAlways": { 55 | "type": "number", 56 | "description": "Check server modification at set time intervals and download it. If it is zero, feature is disabled (default: 0)", 57 | "default": 0 58 | }, 59 | "viewSizeLimit": { 60 | "type": "number", 61 | "description": "Bytes. File download size limit in ftp tree view (default: 4MiB)", 62 | "default": 4194304 63 | }, 64 | "downloadTimeExtraThreshold": { 65 | "type": "number", 66 | "description": "Milliseconds. To avoid upload just downloaded file (default: 1000)", 67 | "default": 1000 68 | }, 69 | "ignoreRemoteModification": { 70 | "type": "boolean", 71 | "description": "Disable remote modification checker", 72 | "default": false 73 | }, 74 | "ignoreJsonUploadCaution": { 75 | "type": "boolean", 76 | "description": "ignore caution when ftp-kr.json is uploaded", 77 | "default:": false 78 | }, 79 | "noticeFileCount": { 80 | "type": "number", 81 | "description": "Notice with task.json if upload/download file is too many", 82 | "default": 10 83 | }, 84 | "logLevel": { 85 | "enum": ["VERBOSE", "NORMAL"], 86 | "description": "Log level setting for debug (default: NORMAL)", 87 | "default": "NORMAL" 88 | }, 89 | "dontOpenOutput": { 90 | "type": "boolean", 91 | "description": "Open the output window with some commands (default: false)", 92 | "default": false 93 | }, 94 | "ignore": { 95 | "type": "array", 96 | "description": "Ignore file or directory list. Is NOT the glob pattern", 97 | "items": { 98 | "type": "string", 99 | "description": "Ignore file or directory" 100 | } 101 | }, 102 | "includeAllAlwaysForAllCommand": { 103 | "type": "boolean", 104 | "description": "Always put all files for Download All & Upload All. It skips the modification check", 105 | "default:": false 106 | }, 107 | "showReportMessage": { 108 | "type": ["number", "boolean"], 109 | "description": "Report the completion message with the modal if the task time is longer than 1000 ms. if it's a number it changes the timeout duration.", 110 | "default:": true 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/tool/ssh.ts: -------------------------------------------------------------------------------- 1 | import { Client, ConnectConfig, ClientChannel } from 'ssh2'; 2 | import read = require('read'); 3 | import { File } from 'krfile'; 4 | 5 | import { FtpKrConfig } from '../util/ftpkr_config'; 6 | import { ServerConfig } from '../util/serverinfo'; 7 | import { merge } from '../util/util'; 8 | import { printMappedError } from '../util/sm'; 9 | 10 | if (process.stdin.setRawMode) process.stdin.setRawMode(true); 11 | 12 | process.stdin.resume(); 13 | process.argv[0]; // node 14 | process.argv[1]; // js 15 | const workspaceDir = new File(process.argv[2] + ''); // workspaceDir 16 | const serverIdx = +process.argv[3] | 0; // serverIndex 17 | 18 | let stream: ClientChannel | null = null; 19 | 20 | function setStream(s: ClientChannel | null): void { 21 | if (stream) { 22 | stream.stdout.unpipe(); 23 | stream.stderr.unpipe(); 24 | process.stdin.unpipe(); 25 | stream.end(); 26 | } 27 | stream = s; 28 | if (s) { 29 | s.stdout.pipe(process.stdout); 30 | s.stderr.pipe(process.stderr); 31 | process.stdin.pipe(s); 32 | } 33 | } 34 | 35 | process.stdout.on('resize', () => { 36 | const rows = process.stdout.rows || 0; 37 | const columns = process.stdout.columns || 0; 38 | // VSCode terminal character size: 7x17 (calculated with my screenshot!) 39 | if (stream) stream.setWindow(rows, columns, rows * 17, columns * 7); 40 | }); 41 | 42 | async function main(): Promise { 43 | try { 44 | const ftpKrConfig = new FtpKrConfig(workspaceDir); 45 | await ftpKrConfig.readJson(); 46 | 47 | const config: ServerConfig = 48 | serverIdx === 0 ? ftpKrConfig : ftpKrConfig.altServer[serverIdx - 1]; 49 | if (!config) { 50 | console.error('Server index overflow: ' + serverIdx); 51 | return; 52 | } 53 | 54 | if (config.protocol !== 'sftp') { 55 | console.error('Need sftp protocol'); 56 | return; 57 | } 58 | 59 | let options: ConnectConfig = {}; 60 | if (config.privateKey) { 61 | const keyPath = config.privateKey; 62 | const keybuf = await workspaceDir.child('.vscode', keyPath).open(); 63 | options.privateKey = keybuf; 64 | options.passphrase = config.passphrase; 65 | } else { 66 | if (config.password) options.password = config.password; 67 | } 68 | options.host = config.host; 69 | (options.port = config.port ? config.port : 22), 70 | (options.username = config.username); 71 | // options.hostVerifier = (keyHash:string) => false; 72 | 73 | options = merge(options, config.sftpOverride); 74 | 75 | for (;;) { 76 | if (!config.privateKey && !options.password) { 77 | const password = await new Promise((resolve, reject) => 78 | read({ prompt: 'Password: ', silent: true }, (err, result) => { 79 | if (err) reject(err); 80 | else resolve(result); 81 | }) 82 | ); 83 | options.password = password; 84 | } 85 | const client = new Client(); 86 | try { 87 | await new Promise((resolve, reject) => { 88 | client.on('ready', resolve).on('error', reject).connect(options); 89 | }); 90 | } catch (err) { 91 | if (err.message === 'All configured authentication methods failed') { 92 | console.error('Invalid password'); 93 | options.password = ''; 94 | client.destroy(); 95 | continue; 96 | } else { 97 | throw err; 98 | } 99 | } 100 | client.shell( 101 | { 102 | cols: process.stdout.columns, 103 | rows: process.stdout.rows, 104 | term: 'xterm-256color', 105 | }, 106 | (err, stream) => { 107 | stream.allowHalfOpen = true; 108 | stream.write(`cd ${config.remotePath}\n`); 109 | setStream(stream); 110 | } 111 | ); 112 | 113 | await new Promise((resolve) => client.once('close', resolve)); 114 | setStream(null); 115 | client.destroy(); 116 | } 117 | } catch (err) { 118 | switch (err) { 119 | case 'NOTFOUND': 120 | console.error('/.vscode/ftp-kr.json not found in ' + workspaceDir); 121 | process.exit(-1); 122 | break; 123 | default: 124 | await printMappedError(err); 125 | process.exit(-1); 126 | break; 127 | } 128 | } 129 | } 130 | 131 | main(); 132 | -------------------------------------------------------------------------------- /schema/ftp-kr.md: -------------------------------------------------------------------------------- 1 | ## ftp-kr.json 2 | 3 | - **closure** (**DEPRECATED: Closure Compiler feature is splitted. If you want to use, Please download Closure Compiler extension.**) 4 | - **disableFtp** (**DEPRECATED: This option is deleted**) 5 | - **altServer** (object[])- alternate servers. It has similar properties with root manifest 6 | - **createSyncCache** (boolean) - Create ftp-kr.cache.json file to save remote modification (default: true) 7 | - **followLink** (boolean) - If it is true, extension will access symlink target or ignore symlink (default: false) 8 | - **localBasePath** (string) 9 | - **autoUpload** (boolean) - Upload file by watcher (default: false) 10 | - **autoDelete** (boolean) - Delete FTP file on delete in workspace (default: false) 11 | - **autoDownload** (boolean) - It will check modification of every opening and download if it modified (default: false) 12 | - **autoDownloadAlways** (number) - Check server modification at set time intervals and download it. If it is zero, feature is disabled (default: 0) 13 | - **viewSizeLimit** (number) - Bytes. File download size limit in ftp tree view (default: 4MiB) 14 | - **downloadTimeExtraThreshold** (number) - Milliseconds. To avoid upload just downloaded file (default: 1000) 15 | - **ignoreRemoteModification** (boolean) - Disable remote modification checker 16 | - **ignoreJsonUploadCaution** (boolean) - ignore caution when ftp-kr.json is uploaded 17 | - **noticeFileCount** (number) - Notice with task.json if upload/download file is too many 18 | - **logLevel** (enum: VERBOSE, NORMAL) - Log level setting for debug (default: NORMAL) 19 | - **dontOpenOutput** (boolean) - Open the output window with some commands (default: false) 20 | - **ignore** (string[])- Ignore file or directory list. Is NOT the glob pattern 21 | - **includeAllAlwaysForAllCommand** (boolean) - Always put all files for Download All & Upload All. It skips the modification check 22 | - **showReportMessage** (number,boolean) - Report the completion message with the modal if the task time is longer than 1000 ms. if it's a number it changes the timeout duration. 23 | - **protocol** (enum: ftp, sftp, ftps) - Connection protocol 24 | - **sslProtocol** - Optional SSL method to use, default is "SSLv23_method". The possible values are listed as https://www.openssl.org/docs/man1.0.2/ssl/ssl.html#DEALING-WITH-PROTOCOL-METHODS , use the function names as strings. For example, "SSLv3_method" to force SSL version 3. 25 | - **host** (string) - Address of the FTP/SFTP server 26 | - **username** (string) - FTP/SFTP user name 27 | - **password** (string) - FTP/SFTP password 28 | - **secure** (boolean) - Set to true for both control and data connection encryption. (default: false) 29 | - **keepPasswordInMemory** (boolean) - Keep password into internal variable for reconnection (default: false) 30 | - **remotePath** (string) - FTP/SFTP side directory 31 | - **port** (integer) - Port number of FTP/SFTP server. If it is zero, use the default port 32 | - **privateKey** (string) - Private key file for SFTP connection. Use OpenSSH format. If it is non empty, password will be ignored 33 | - **passphrase** (string) - Password for an encrypted private key. 34 | - **connectionTimeout** (integer) - Disconnect from FTP/SFTP server after timeout(ms) (default: 60000) 35 | - **fileNameEncoding** - Filename encoding for FTP. Encoding with iconv-lite. 36 | See all supported encodings: https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings 37 | - **ignoreWrongFileEncoding** (boolean) - Suppress of the file encoding error assumption 38 | - **autoDownloadRefreshTime** (integer) (**DEPRECATED: Use refreshTime instead**) - Milliseconds. Auto refresh after this value when set 'autoDownload' as true. default is 1000 39 | - **blockDetectingDuration** (integer) - Milliseconds. If FTP connection is blocked for this value reconnect and retry action. default is 8000 40 | - **refreshTime** (integer) - Milliseconds. re-list remote files After this duration. 'autoDownload' is affected by this property. default is 1000 41 | - **showGreeting** (boolean) - Show greeting message of Remote Server 42 | - **ftpOverride** - It will pass to connect function of nodejs ftp package 43 | - **sftpOverride** - It will pass to connect function of nodejs ssh2 package 44 | -------------------------------------------------------------------------------- /src/vsutil/lazywatcher.ts: -------------------------------------------------------------------------------- 1 | import { FileSystemWatcher, Uri, workspace } from 'vscode'; 2 | import { Event } from '../util/event'; 3 | 4 | enum UpdateState { 5 | Change, 6 | Create, 7 | Delete, 8 | NoChange, 9 | Disposed, 10 | } 11 | 12 | class DelayableTimeout { 13 | private timeout: NodeJS.Timeout | null; 14 | private resolve: () => void; 15 | private readonly promise: Promise; 16 | constructor(timeout: number) { 17 | this.resolve = null as any; 18 | this.promise = new Promise((resolve) => { 19 | this.resolve = () => { 20 | this.timeout = null; 21 | resolve(); 22 | }; 23 | }); 24 | this.timeout = setTimeout(this.resolve, timeout); 25 | } 26 | 27 | wait(): Promise { 28 | return this.promise; 29 | } 30 | 31 | delay(timeout: number): void { 32 | if (this.timeout !== null) clearTimeout(this.timeout); 33 | this.timeout = setTimeout(this.resolve, timeout); 34 | } 35 | 36 | isDone(): boolean { 37 | return this.timeout === null; 38 | } 39 | 40 | done(): void { 41 | if (this.timeout === null) return; 42 | clearTimeout(this.timeout); 43 | this.timeout = null; 44 | this.resolve(); 45 | } 46 | } 47 | 48 | class WatchItem { 49 | public readonly timeout: DelayableTimeout; 50 | 51 | constructor( 52 | public readonly watcher: LazyWatcher, 53 | public readonly uri: Uri, 54 | public readonly uristr: string, 55 | public state: UpdateState 56 | ) { 57 | this.timeout = new DelayableTimeout(watcher.waitingDuration); 58 | this._fire(); 59 | } 60 | 61 | private async _fire(): Promise { 62 | do { 63 | await this.timeout.wait(); 64 | if (this.state === UpdateState.Disposed) return; 65 | if (this.state === UpdateState.NoChange) break; 66 | const ev = this.watcher.events[this.state]; 67 | this.state = UpdateState.NoChange; 68 | await ev.fire(this.uri); 69 | } while (this.state !== UpdateState.NoChange); 70 | this.watcher.items.delete(this.uristr); 71 | } 72 | 73 | update(state: UpdateState): void { 74 | switch (this.state) { 75 | case UpdateState.Change: 76 | switch (state) { 77 | case UpdateState.Delete: 78 | this.state = UpdateState.Delete; 79 | break; 80 | } 81 | break; 82 | case UpdateState.Create: 83 | switch (state) { 84 | case UpdateState.Delete: 85 | this.state = UpdateState.NoChange; 86 | this.timeout.done(); 87 | return; 88 | } 89 | break; 90 | case UpdateState.Delete: 91 | switch (state) { 92 | case UpdateState.Create: 93 | this.state = UpdateState.Change; 94 | break; 95 | case UpdateState.Change: 96 | this.state = UpdateState.Create; 97 | break; 98 | } 99 | break; 100 | case UpdateState.NoChange: 101 | this.state = state; 102 | return; 103 | } 104 | this.timeout.delay(this.watcher.waitingDuration); 105 | } 106 | 107 | dispose(): void { 108 | if (this.state === UpdateState.Disposed) return; 109 | this.state = UpdateState.Disposed; 110 | this.timeout.done(); 111 | } 112 | } 113 | 114 | export class LazyWatcher { 115 | private readonly watcher: FileSystemWatcher; 116 | 117 | public readonly items = new Map(); 118 | 119 | public readonly onDidChange = Event.make(false); 120 | public readonly onDidCreate = Event.make(false); 121 | public readonly onDidDelete = Event.make(false); 122 | 123 | public readonly events = [ 124 | this.onDidChange, 125 | this.onDidCreate, 126 | this.onDidDelete, 127 | ]; 128 | 129 | private disposed = false; 130 | 131 | private onEvent(state: UpdateState, uri: Uri): void { 132 | const uristr = uri.toString(); 133 | const item = this.items.get(uristr); 134 | if (item == null) { 135 | const nitem = new WatchItem(this, uri, uristr, state); 136 | this.items.set(uristr, nitem); 137 | } else { 138 | item.update(state); 139 | } 140 | } 141 | 142 | constructor(watcherPath: string, public waitingDuration: number = 500) { 143 | this.watcher = workspace.createFileSystemWatcher(watcherPath); 144 | 145 | this.watcher.onDidChange((uri) => { 146 | this.onEvent(UpdateState.Change, uri); 147 | }); 148 | this.watcher.onDidCreate((uri) => { 149 | this.onEvent(UpdateState.Create, uri); 150 | }); 151 | this.watcher.onDidDelete((uri) => { 152 | this.onEvent(UpdateState.Delete, uri); 153 | }); 154 | } 155 | 156 | dispose(): void { 157 | if (this.disposed) return; 158 | this.disposed = true; 159 | this.watcher.dispose(); 160 | for (const item of this.items.values()) { 161 | item.dispose(); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/vsutil/ftptreeitem.ts: -------------------------------------------------------------------------------- 1 | import { TreeItem, TreeItemCollapsibleState, TreeItemLabel } from 'vscode'; 2 | 3 | import { VFSState, VFSSymLink } from '../util/filesystem'; 4 | import { ServerConfig } from '../util/serverinfo'; 5 | 6 | import { FtpCacher } from '../ftpcacher'; 7 | import { Logger } from './log'; 8 | import { Scheduler } from './work'; 9 | import { Workspace } from './ws'; 10 | 11 | const ftpTreeItemFromFile = new Map(); 12 | 13 | function getLabelName(name: string | TreeItemLabel | undefined): string { 14 | if (name === undefined) return ''; 15 | if (typeof name === 'string') return name; 16 | return name.label; 17 | } 18 | 19 | export class FtpTreeItem extends TreeItem { 20 | public server: FtpTreeServer; 21 | public children?: FtpTreeItem[]; 22 | 23 | static clear() { 24 | for (const items of ftpTreeItemFromFile.values()) { 25 | for (const item of items) { 26 | item.children = undefined; 27 | } 28 | } 29 | ftpTreeItemFromFile.clear(); 30 | } 31 | 32 | static get(ftpFile: VFSState): FtpTreeItem[] { 33 | const array = ftpTreeItemFromFile.get(ftpFile); 34 | if (array) return array; 35 | else return []; 36 | } 37 | 38 | static add(ftpFile: VFSState, item: FtpTreeItem): void { 39 | let array = ftpTreeItemFromFile.get(ftpFile); 40 | if (!array) ftpTreeItemFromFile.set(ftpFile, (array = [])); 41 | array.push(item); 42 | } 43 | 44 | static delete(item: FtpTreeItem): void { 45 | if (!item.ftpFile) return; 46 | const array = ftpTreeItemFromFile.get(item.ftpFile); 47 | if (!array) return; 48 | 49 | for (let i = 0; i < array.length; i++) { 50 | if (array[i] !== item) continue; 51 | array.splice(i, 1); 52 | 53 | if (array.length === 0) { 54 | ftpTreeItemFromFile.delete(item.ftpFile); 55 | } 56 | if (item.children) { 57 | for (const child of item.children) { 58 | FtpTreeItem.delete(child); 59 | } 60 | item.children = undefined; 61 | } 62 | break; 63 | } 64 | } 65 | 66 | static create(ftpFile: VFSState, server: FtpTreeServer): FtpTreeItem { 67 | for (const item of FtpTreeItem.get(ftpFile)) { 68 | if (item.server === server) { 69 | return item; 70 | } 71 | } 72 | return new FtpTreeItem(ftpFile.name, ftpFile, server); 73 | } 74 | 75 | constructor( 76 | label: string, 77 | public ftpFile: VFSState | undefined, 78 | server?: FtpTreeServer 79 | ) { 80 | super( 81 | label, 82 | !ftpFile || ftpFile.type === 'd' 83 | ? TreeItemCollapsibleState.Collapsed 84 | : TreeItemCollapsibleState.None 85 | ); 86 | this.server = server || this; 87 | 88 | if (ftpFile) { 89 | FtpTreeItem.add(ftpFile, this); 90 | if (ftpFile.type === '-') { 91 | this.command = { 92 | command: 'ftpkr.view', 93 | title: 'View This', 94 | arguments: [ftpFile.getUri()], 95 | }; 96 | } 97 | } 98 | } 99 | 100 | compare(other: FtpTreeItem): number { 101 | return ( 102 | (other.collapsibleState || 0) - (this.collapsibleState || 0) || 103 | +(this.label != null) - +(other.label != null) || 104 | getLabelName(this.label).localeCompare(getLabelName(other.label)) 105 | ); 106 | } 107 | 108 | async getChildren(): Promise { 109 | if (this.children) return this.children; 110 | const items = await this.server.getChildrenFrom(this); 111 | if (this.ftpFile) this.ftpFile.treeCached = true; 112 | this.children = items; 113 | return items; 114 | } 115 | 116 | static *all(): IterableIterator { 117 | for (const items of ftpTreeItemFromFile.values()) { 118 | yield* items; 119 | } 120 | } 121 | } 122 | 123 | export class FtpTreeServer extends FtpTreeItem { 124 | public readonly logger: Logger; 125 | public readonly config: ServerConfig; 126 | private readonly scheduler: Scheduler; 127 | 128 | constructor( 129 | public readonly workspace: Workspace, 130 | public readonly ftp: FtpCacher 131 | ) { 132 | super(ftp.getName(), undefined); 133 | 134 | this.logger = this.workspace.query(Logger); 135 | this.scheduler = this.workspace.query(Scheduler); 136 | this.config = this.ftp.config; 137 | } 138 | 139 | public dispose(): void { 140 | FtpTreeItem.delete(this); 141 | } 142 | 143 | public async getChildrenFrom(file: FtpTreeItem): Promise { 144 | if (!file.ftpFile) { 145 | await this.ftp.init(); 146 | file.ftpFile = this.ftp.home; 147 | FtpTreeItem.add(file.ftpFile, file); 148 | } 149 | const path: string = file.ftpFile.getPath(); 150 | 151 | const files: FtpTreeItem[] = []; 152 | const dir = await this.ftp.ftpList(path); 153 | 154 | for (let childfile of dir.children()) { 155 | while (childfile instanceof VFSSymLink) { 156 | const putfile: VFSSymLink = childfile; 157 | const nchildfile = await this.ftp.ftpTargetStat(putfile); 158 | if (!nchildfile) return []; 159 | childfile = nchildfile; 160 | } 161 | 162 | files.push(FtpTreeItem.create(childfile, this)); 163 | } 164 | files.sort((a, b) => a.compare(b)); 165 | return files; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/vsutil/ws.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { Disposable, Uri, workspace, WorkspaceFolder } from 'vscode'; 3 | import { Event } from '../util/event'; 4 | 5 | export interface WorkspaceItem { 6 | dispose(): void; 7 | } 8 | 9 | interface WorkspaceItemConstructor { 10 | new (workspace: Workspace): T; 11 | } 12 | 13 | interface ItemMap { 14 | values(): Iterable; 15 | get(ctr: WorkspaceItemConstructor): T | undefined; 16 | set(ctr: WorkspaceItemConstructor, item: T): void; 17 | clear(): void; 18 | } 19 | 20 | export enum WorkspaceOpenState { 21 | CREATED, 22 | OPENED, 23 | } 24 | 25 | export class Workspace extends File { 26 | private static wsmap = new Map(); 27 | private static wsloading = new Map(); 28 | private readonly items: ItemMap = new Map(); 29 | public readonly name: string; 30 | 31 | constructor( 32 | public readonly workspaceFolder: WorkspaceFolder, 33 | public readonly openState: WorkspaceOpenState 34 | ) { 35 | super(workspaceFolder.uri.fsPath); 36 | this.name = workspaceFolder.name; 37 | } 38 | 39 | public query(type: WorkspaceItemConstructor): T { 40 | let item = this.items.get(type); 41 | if (item === undefined) { 42 | item = new type(this); 43 | this.items.set(type, item); 44 | } 45 | return item; 46 | } 47 | 48 | private dispose(): void { 49 | for (const item of this.items.values()) { 50 | item.dispose(); 51 | } 52 | this.items.clear(); 53 | } 54 | 55 | static getInstance(workspace: WorkspaceFolder): Workspace | undefined { 56 | return Workspace.wsmap.get(workspace.uri.fsPath); 57 | } 58 | 59 | static createInstance( 60 | workspaceFolder: WorkspaceFolder 61 | ): Workspace | undefined { 62 | const workspacePath = workspaceFolder.uri.fsPath; 63 | let fsws = Workspace.wsmap.get(workspacePath); 64 | if (fsws) return fsws; 65 | Workspace.wsloading.delete(workspacePath); 66 | fsws = new Workspace(workspaceFolder, WorkspaceOpenState.CREATED); 67 | Workspace.wsmap.set(workspacePath, fsws); 68 | Workspace.onNew.fire(fsws); 69 | return fsws; 70 | } 71 | 72 | static async load(workspaceFolder: WorkspaceFolder): Promise { 73 | try { 74 | const fsws = new Workspace(workspaceFolder, WorkspaceOpenState.OPENED); 75 | const workspacePath = workspaceFolder.uri.fsPath; 76 | if (Workspace.wsloading.has(workspacePath)) return; 77 | 78 | Workspace.wsloading.set(workspacePath, fsws); 79 | const existed = await fsws.child('.vscode/ftp-kr.json').exists(); 80 | 81 | if (!Workspace.wsloading.has(workspacePath)) return; 82 | Workspace.wsloading.delete(workspacePath); 83 | 84 | if (existed) { 85 | Workspace.wsmap.set(workspacePath, fsws); 86 | await Workspace.onNew.fire(fsws); 87 | } 88 | } catch (err) { 89 | console.error(err); 90 | } 91 | } 92 | 93 | static unload(workspaceFolder: WorkspaceFolder): void { 94 | const workspacePath = workspaceFolder.uri.fsPath; 95 | Workspace.wsloading.delete(workspacePath); 96 | 97 | const ws = Workspace.wsmap.get(workspacePath); 98 | if (ws) { 99 | ws.dispose(); 100 | Workspace.wsmap.delete(workspacePath); 101 | } 102 | } 103 | 104 | static loadAll(): void { 105 | workspaceWatcher = workspace.onDidChangeWorkspaceFolders((e) => { 106 | for (const ws of e.added) { 107 | Workspace.load(ws); 108 | } 109 | for (const ws of e.removed) { 110 | Workspace.unload(ws); 111 | } 112 | }); 113 | if (workspace.workspaceFolders) { 114 | for (const ws of workspace.workspaceFolders) { 115 | Workspace.load(ws); 116 | } 117 | } 118 | } 119 | 120 | static unloadAll(): void { 121 | if (workspaceWatcher) { 122 | workspaceWatcher.dispose(); 123 | workspaceWatcher = undefined; 124 | } 125 | for (const ws of Workspace.wsmap.values()) { 126 | ws.dispose(); 127 | } 128 | Workspace.wsmap.clear(); 129 | Workspace.wsloading.clear(); 130 | } 131 | 132 | static first(): Workspace { 133 | if (workspace.workspaceFolders) { 134 | for (const ws of workspace.workspaceFolders) { 135 | const fsws = Workspace.wsmap.get(ws.uri.fsPath); 136 | if (!fsws) continue; 137 | return fsws; 138 | } 139 | } 140 | throw Error('Need workspace'); 141 | } 142 | 143 | static *all(): Iterable { 144 | if (workspace.workspaceFolders) { 145 | for (const ws of workspace.workspaceFolders) { 146 | const fsws = Workspace.wsmap.get(ws.uri.fsPath); 147 | if (fsws) yield fsws; 148 | } 149 | } 150 | } 151 | 152 | static one(): Workspace | undefined { 153 | if (Workspace.wsmap.size === 1) 154 | return Workspace.wsmap.values().next().value; 155 | return undefined; 156 | } 157 | 158 | static fromFile(file: File): Workspace { 159 | const workspaceFolder = workspace.getWorkspaceFolder(Uri.file(file.fsPath)); 160 | if (!workspaceFolder) throw Error(file.fsPath + ' is not in workspace'); 161 | const fsworkspace = Workspace.getInstance(workspaceFolder); 162 | if (!fsworkspace) throw Error(file.fsPath + ' ftp-kr is not inited'); 163 | return fsworkspace; 164 | } 165 | 166 | static readonly onNew = Event.make(false); 167 | } 168 | 169 | let workspaceWatcher: Disposable | undefined; 170 | -------------------------------------------------------------------------------- /schema/sftp.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "host": { 5 | "type": "string", 6 | "description": "Hostname or IP address of the server." 7 | }, 8 | "port": { 9 | "type": "number", 10 | "description": "Port number of the server." 11 | }, 12 | "forceIPv4": { 13 | "type": "boolean", 14 | "description": "Only connect via resolved IPv4 address for `host`." 15 | }, 16 | "forceIPv6": { 17 | "type": "boolean", 18 | "description": "Only connect via resolved IPv6 address for `host`." 19 | }, 20 | "hostHash": { 21 | "enum": ["md5", "sha1"], 22 | "description": "The host's key is hashed using this method and passed to `hostVerifier`." 23 | }, 24 | "username": { 25 | "type": "string", 26 | "description": "Username for authentication." 27 | }, 28 | "password": { 29 | "type": "string", 30 | "description": "Password for password-based user authentication." 31 | }, 32 | "agent": { 33 | "type": "string", 34 | "description": "Path to ssh-agent's UNIX socket for ssh-agent-based user authentication (or 'pageant' when using Pagent on Windows)." 35 | }, 36 | "privateKey": { 37 | "type": "string", 38 | "description": "Buffer or string that contains a private key for either key-based or hostbased user authentication (OpenSSH format)." 39 | }, 40 | "passphrase": { 41 | "type": "string", 42 | "description": "For an encrypted private key, this is the passphrase used to decrypt it." 43 | }, 44 | "localHostname": { 45 | "type": "string", 46 | "description": "Along with `localUsername` and `privateKey`, set this to a non-empty string for hostbased user authentication." 47 | }, 48 | "localUsername": { 49 | "type": "string", 50 | "description": "Along with `localHostname` and `privateKey`, set this to a non-empty string for hostbased user authentication." 51 | }, 52 | "tryKeyboard": { 53 | "type": "boolean", 54 | "description": "Try keyboard-interactive user authentication if primary user authentication method fails." 55 | }, 56 | "keepaliveInterval": { 57 | "type": "number", 58 | "description": "How often (in milliseconds) to send SSH-level keepalive packets to the server. Set to 0 to disable." 59 | }, 60 | "keepaliveCountMax": { 61 | "type": "number", 62 | "description": "How many consecutive, unanswered SSH-level keepalive packets that can be sent to the server before disconnection." 63 | }, 64 | "readyTimeout": { 65 | "type": "number", 66 | "description": "* How long (in milliseconds) to wait for the SSH handshake to complete." 67 | }, 68 | "strictVendor": { 69 | "type": "boolean", 70 | "description": "Performs a strict server vendor check before sending vendor-specific requests." 71 | }, 72 | "agentForward": { 73 | "type": "boolean", 74 | "description": "Set to `true` to use OpenSSH agent forwarding (`auth-agent@openssh.com`) for the life of the connection." 75 | }, 76 | "algorithms": { 77 | "type": "object", 78 | "description": "Explicit overrides for the default transport layer algorithms used for the connection.", 79 | "properties": { 80 | "kex": { 81 | "type": "array", 82 | "items": { 83 | "enum": [ 84 | "ecdh-sha2-nistp256", 85 | "ecdh-sha2-nistp384", 86 | "ecdh-sha2-nistp521", 87 | "diffie-hellman-group-exchange-sha256", 88 | "diffie-hellman-group14-sha1", 89 | "diffie-hellman-group-exchange-sha1", 90 | "diffie-hellman-group1-sha1" 91 | ] 92 | }, 93 | "description": "Key exchange algorithms." 94 | }, 95 | "cipher": { 96 | "type": "array", 97 | "items": { 98 | "enum": [ 99 | "aes128-ctr", 100 | "aes192-ctr", 101 | "aes256-ctr", 102 | "aes128-gcm (node v0.11.12 or newer)", 103 | "aes128-gcm@openssh.com (node v0.11.12 or newer)", 104 | "aes256-gcm (node v0.11.12 or newer)", 105 | "aes256-gcm@openssh.com (node v0.11.12 or newer)", 106 | "aes256-cbc", 107 | "aes192-cbc", 108 | "aes128-cbc", 109 | "blowfish-cbc", 110 | "3des-cbc", 111 | "arcfour256", 112 | "arcfour128", 113 | "cast128-cbc", 114 | "arcfour" 115 | ] 116 | }, 117 | "description": "Ciphers." 118 | }, 119 | "serverHostKey": { 120 | "type": "array", 121 | "items": { 122 | "enum": [ 123 | "ssh-rsa", 124 | "ecdsa-sha2-nistp256", 125 | "ecdsa-sha2-nistp384", 126 | "ecdsa-sha2-nistp521", 127 | "ssh-dss" 128 | ] 129 | }, 130 | "description": "Server host key formats. In server mode, this list must agree with the host private keys set in the hostKeys config setting." 131 | }, 132 | "hmac": { 133 | "type": "array", 134 | "items": { 135 | "enum": [ 136 | "hmac-sha2-256", 137 | "hmac-sha2-512", 138 | "hmac-sha1", 139 | "hmac-md5", 140 | "hmac-sha2-256-96", 141 | "hmac-sha2-512-96", 142 | "hmac-ripemd160", 143 | "hmac-sha1-96", 144 | "hmac-md5-96" 145 | ] 146 | }, 147 | "description": "(H)MAC algorithms." 148 | }, 149 | "compress": { 150 | "type": "array", 151 | "items": { 152 | "enum": ["none", "zlib@openssh.com", "zlib"] 153 | }, 154 | "description": "Compression algorithms." 155 | } 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/vsutil/vsutil.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { 3 | commands, 4 | Position, 5 | Range, 6 | Selection, 7 | StatusBarItem, 8 | TextDocument, 9 | TextEditor, 10 | Uri, 11 | window, 12 | workspace, 13 | } from 'vscode'; 14 | 15 | import { TemporalDocument } from './tmpfile'; 16 | import { Workspace, WorkspaceItem } from './ws'; 17 | 18 | export class StateBar implements WorkspaceItem { 19 | private statebar: StatusBarItem | undefined; 20 | private disposed = false; 21 | 22 | constructor() { 23 | // empty 24 | } 25 | 26 | public dispose() { 27 | if (this.disposed) return; 28 | this.close(); 29 | this.disposed = true; 30 | } 31 | 32 | public close() { 33 | if (this.statebar) { 34 | this.statebar.dispose(); 35 | this.statebar = undefined; 36 | } 37 | } 38 | 39 | public set(state: string): void { 40 | if (this.disposed) return; 41 | if (!this.statebar) this.statebar = window.createStatusBarItem(); 42 | this.statebar.text = state; 43 | this.statebar.show(); 44 | } 45 | } 46 | 47 | export class QuickPickItem implements QuickPickItem { 48 | public label = ''; 49 | public description = ''; 50 | public detail?: string; 51 | public onselect: () => unknown = () => { 52 | // empty 53 | }; 54 | } 55 | 56 | export class QuickPick { 57 | public items: QuickPickItem[] = []; 58 | public oncancel: () => unknown = () => { 59 | // empty 60 | }; 61 | 62 | constructor() { 63 | // empty 64 | } 65 | 66 | public clear() { 67 | this.items.length = 0; 68 | } 69 | 70 | public item( 71 | label: string, 72 | onselect: () => Promise | void 73 | ): QuickPickItem { 74 | const item = new QuickPickItem(); 75 | item.label = label; 76 | item.onselect = onselect; 77 | this.items.push(item); 78 | return item; 79 | } 80 | 81 | async open(placeHolder?: string): Promise { 82 | const selected = await window.showQuickPick(this.items, { 83 | placeHolder, 84 | }); 85 | if (selected === undefined) { 86 | await this.oncancel(); 87 | } else { 88 | await selected.onselect(); 89 | } 90 | } 91 | } 92 | 93 | export const vsutil = { 94 | createWorkspace(): Promise { 95 | return new Promise((resolve, reject) => { 96 | const pick = new QuickPick(); 97 | if (!workspace.workspaceFolders) { 98 | reject(Error('Need workspace')); 99 | return; 100 | } 101 | if (workspace.workspaceFolders.length === 1) { 102 | resolve(Workspace.createInstance(workspace.workspaceFolders[0])); 103 | return; 104 | } 105 | for (const workspaceFolder of workspace.workspaceFolders) { 106 | const fsws = Workspace.getInstance(workspaceFolder); 107 | let name = workspaceFolder.name; 108 | if (fsws) name += ' [inited]'; 109 | pick.item(name, () => 110 | resolve(Workspace.createInstance(workspaceFolder)) 111 | ); 112 | } 113 | pick.oncancel = () => resolve(undefined); 114 | pick.open('Select Workspace'); 115 | }); 116 | }, 117 | 118 | selectWorkspace(): Promise { 119 | return new Promise((resolve, reject) => { 120 | const pick = new QuickPick(); 121 | for (const workspaceFolder of Workspace.all()) { 122 | pick.item(workspaceFolder.name, () => resolve(workspaceFolder)); 123 | } 124 | if (pick.items.length === 0) { 125 | reject(Error('Need workspace')); 126 | return; 127 | } 128 | if (pick.items.length === 1) { 129 | pick.items[0].onselect(); 130 | return; 131 | } 132 | pick.oncancel = () => resolve(undefined); 133 | pick.open('Select Workspace'); 134 | }); 135 | }, 136 | 137 | makeFolder(uri: Uri, name: string): void { 138 | const folders = workspace.workspaceFolders; 139 | if (folders === undefined) throw Error('workspaceFolders not found'); 140 | workspace.updateWorkspaceFolders(folders.length, 0, { uri, name }); 141 | }, 142 | 143 | async info(info: string, ...items: string[]): Promise { 144 | const res = await window.showInformationMessage( 145 | info, 146 | { 147 | modal: true, 148 | }, 149 | ...items 150 | ); 151 | return res; 152 | }, 153 | 154 | async openUri(uri: Uri | string): Promise { 155 | if (typeof uri === 'string') uri = Uri.parse(uri); 156 | const doc = await workspace.openTextDocument(uri); 157 | await window.showTextDocument(doc); 158 | }, 159 | 160 | async open(path: File, line?: number, column?: number): Promise { 161 | const doc = await workspace.openTextDocument(path.fsPath); 162 | const editor = await window.showTextDocument(doc); 163 | if (line !== undefined) { 164 | line--; 165 | if (column === undefined) column = 0; 166 | 167 | const pos = new Position(line, column); 168 | editor.selection = new Selection(pos, pos); 169 | editor.revealRange(new Range(pos, pos)); 170 | } 171 | return editor; 172 | }, 173 | 174 | async openNew(content: string): Promise { 175 | const doc = await workspace.openTextDocument({ content }); 176 | window.showTextDocument(doc); 177 | return doc; 178 | }, 179 | 180 | diff(left: File, right: File, title?: string): Thenable { 181 | return new Promise((resolve) => { 182 | const leftUri = Uri.file(left.fsPath); 183 | const rightUri = Uri.file(right.fsPath); 184 | commands 185 | .executeCommand('vscode.diff', leftUri, rightUri, title) 186 | .then(() => { 187 | resolve(new TemporalDocument(right, left)); 188 | }); 189 | }); 190 | }, 191 | }; 192 | -------------------------------------------------------------------------------- /src/ftpsync.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | 3 | import { VFSServerList } from './util/filesystem'; 4 | import { ServerConfig } from './util/serverinfo'; 5 | 6 | import { Logger } from './vsutil/log'; 7 | import { QuickPick } from './vsutil/vsutil'; 8 | import { Scheduler, Task } from './vsutil/work'; 9 | import { Workspace, WorkspaceItem } from './vsutil/ws'; 10 | 11 | import { Config } from './config'; 12 | import { BatchOptions, FtpCacher } from './ftpcacher'; 13 | import { ftpTree } from './ftptree'; 14 | 15 | export class FtpSyncManager implements WorkspaceItem { 16 | private readonly logger: Logger; 17 | private readonly config: Config; 18 | private readonly scheduler: Scheduler; 19 | private readonly cacheFile: File; 20 | public readonly servers: Map = new Map(); 21 | private readonly fs: VFSServerList = new VFSServerList(); 22 | public targetServer: FtpCacher = null; 23 | public mainServer: FtpCacher = null; 24 | 25 | constructor(public readonly workspace: Workspace) { 26 | this.logger = workspace.query(Logger); 27 | this.config = workspace.query(Config); 28 | this.scheduler = workspace.query(Scheduler); 29 | this.cacheFile = this.workspace.child('.vscode/ftp-kr.sync.cache.json'); 30 | 31 | this.fs.onRefreshTree((file) => ftpTree.refreshTree(file)); 32 | } 33 | 34 | private _getServerFromIndex(index: number): FtpCacher { 35 | if (index > 0 && index <= this.config.altServer.length) { 36 | const server = this.servers.get(this.config.altServer[index - 1]); 37 | if (server) return server; 38 | } 39 | const server = this.servers.get(this.config); 40 | if (!server) throw Error('Main server not found'); 41 | return server; 42 | } 43 | 44 | public clear(): void { 45 | for (const server of this.servers.values()) { 46 | ftpTree.removeServer(server); 47 | server.terminate(); 48 | } 49 | this.servers.clear(); 50 | this.mainServer = null; 51 | this.targetServer = null; 52 | } 53 | 54 | public async onLoadConfig(): Promise { 55 | let targetServerIndex = this.targetServer 56 | ? this.targetServer.config.index 57 | : 0; 58 | try { 59 | if (this.config.createSyncCache) { 60 | const extra = await this.fs.load(this.cacheFile, ''); 61 | if ('$targetServer' in extra) 62 | targetServerIndex = Number(extra.$targetServer || 0); 63 | } 64 | } catch (err) { 65 | // empty 66 | } 67 | 68 | this.clear(); 69 | 70 | const mainServer = new FtpCacher(this.workspace, this.config, this.fs); 71 | this.servers.set(this.config, mainServer); 72 | this.mainServer = mainServer; 73 | 74 | ftpTree.addServer(mainServer); 75 | 76 | for (const config of this.config.altServer) { 77 | const server = new FtpCacher(this.workspace, config, this.fs); 78 | this.servers.set(config, server); 79 | ftpTree.addServer(server); 80 | } 81 | 82 | ftpTree.refreshTree(); 83 | this.targetServer = 84 | this._getServerFromIndex(targetServerIndex) || mainServer; 85 | } 86 | 87 | public onNotFoundConfig(): void { 88 | this.clear(); 89 | ftpTree.refreshTree(); 90 | } 91 | 92 | public dispose(): void { 93 | try { 94 | if (this.config.createSyncCache) { 95 | const using = new Set(); 96 | for (const config of this.servers.keys()) { 97 | using.add(config.hostUrl); 98 | } 99 | for (const server of this.fs.children()) { 100 | if (using.has(server.name)) continue; 101 | this.fs.deleteItem(server.name); 102 | } 103 | 104 | const extra: any = {}; 105 | if (this.targetServer.config !== this.config) { 106 | const targetServerUrl = this.targetServer.config.index; 107 | if (targetServerUrl) extra.$targetServer = targetServerUrl; 108 | } 109 | this.fs.save(this.cacheFile, extra); 110 | } 111 | for (const server of this.servers.values()) { 112 | ftpTree.removeServer(server); 113 | server.terminate(); 114 | } 115 | this.servers.clear(); 116 | } catch (err) { 117 | console.error(err); 118 | } 119 | } 120 | 121 | public async selectServer( 122 | openAlways?: boolean 123 | ): Promise { 124 | let selected: FtpCacher | undefined = undefined; 125 | const pick = new QuickPick(); 126 | for (const server of this.servers.values()) { 127 | const config = server.config; 128 | let name: string; 129 | if (server.config === this.config) name = 'Main Server'; 130 | else name = config.name || config.host; 131 | if (server === this.targetServer) name += ' *'; 132 | pick.item(name, () => { 133 | selected = this.servers.get(config); 134 | }); 135 | } 136 | if (!openAlways && pick.items.length === 1) { 137 | pick.items[0].onselect(); 138 | } else { 139 | if (pick.items.length === 0) throw Error('Server not found'); 140 | await pick.open(); 141 | } 142 | return selected; 143 | } 144 | 145 | public reconnect(task?: Task | null): Promise { 146 | return this.scheduler.taskMust( 147 | 'Reconnect', 148 | (task) => { 149 | this.targetServer.terminate(); 150 | return this.targetServer.init(task); 151 | }, 152 | task 153 | ); 154 | } 155 | 156 | public async runTaskJson( 157 | taskName: string, 158 | taskjson: File, 159 | options: BatchOptions 160 | ): Promise { 161 | const selected = await this.selectServer(); 162 | if (selected === undefined) return; 163 | const tasks = await taskjson.json(); 164 | await selected.runTaskJsonWithConfirm( 165 | taskName, 166 | tasks, 167 | taskjson.basename(), 168 | options 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /schema/server.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "protocol": { 5 | "enum": ["ftp", "sftp", "ftps"], 6 | "description": "Connection protocol" 7 | }, 8 | "sslProtocol": { 9 | "enum": [ 10 | "SSLv23_method", 11 | "TLSv1_2_method", 12 | "TLSv1_1_method", 13 | "TLSv1_method", 14 | "SSLv3_method", 15 | "SSLv2_method" 16 | ], 17 | "default": "SSLv23_method", 18 | "description": "Optional SSL method to use, default is \"SSLv23_method\". The possible values are listed as https://www.openssl.org/docs/man1.0.2/ssl/ssl.html#DEALING-WITH-PROTOCOL-METHODS , use the function names as strings. For example, \"SSLv3_method\" to force SSL version 3." 19 | }, 20 | "host": { 21 | "type": "string", 22 | "minLength": 1, 23 | "description": "Address of the FTP/SFTP server", 24 | "format": "hostname" 25 | }, 26 | "username": { 27 | "type": "string", 28 | "minLength": 1, 29 | "description": "FTP/SFTP user name" 30 | }, 31 | "password": { 32 | "type": "string", 33 | "description": "FTP/SFTP password" 34 | }, 35 | "secure": { 36 | "type": "boolean", 37 | "default": false, 38 | "description": "Set to true for both control and data connection encryption. (default: false)" 39 | }, 40 | "keepPasswordInMemory": { 41 | "type": "boolean", 42 | "default": false, 43 | "description": "Keep password into internal variable for reconnection (default: false)" 44 | }, 45 | "remotePath": { 46 | "type": "string", 47 | "description": "FTP/SFTP side directory" 48 | }, 49 | "port": { 50 | "type": "integer", 51 | "default": 0, 52 | "description": "Port number of FTP/SFTP server. If it is zero, use the default port" 53 | }, 54 | "privateKey": { 55 | "type": "string", 56 | "description": "Private key file for SFTP connection. Use OpenSSH format. If it is non empty, password will be ignored" 57 | }, 58 | "passphrase": { 59 | "type": "string", 60 | "description": "Password for an encrypted private key." 61 | }, 62 | "connectionTimeout": { 63 | "type": "integer", 64 | "default": 6000, 65 | "description": "Disconnect from FTP/SFTP server after timeout(ms) (default: 60000)" 66 | }, 67 | "fileNameEncoding": { 68 | "enum": [ 69 | "utf8", 70 | "usc2", 71 | "utf16le", 72 | "ascii", 73 | "binary", 74 | "base64", 75 | "hex", 76 | "UTF-16BE", 77 | "UTF-16", 78 | "cp874", 79 | "cp1250", 80 | "cp1251", 81 | "cp1252", 82 | "cp1253", 83 | "cp1254", 84 | "cp1255", 85 | "cp1256", 86 | "cp1257", 87 | "cp1258", 88 | "win874", 89 | "win1250", 90 | "win1251", 91 | "win1252", 92 | "win1253", 93 | "win1254", 94 | "win1255", 95 | "win1256", 96 | "win1257", 97 | "win1258", 98 | "windows874", 99 | "windows1250", 100 | "windows1251", 101 | "windows1252", 102 | "windows1253", 103 | "windows1254", 104 | "windows1255", 105 | "windows1256", 106 | "windows1257", 107 | "windows1258", 108 | "ISO-8859-1", 109 | "ISO-8859-2", 110 | "ISO-8859-3", 111 | "ISO-8859-4", 112 | "ISO-8859-5", 113 | "ISO-8859-6", 114 | "ISO-8859-7", 115 | "ISO-8859-8", 116 | "ISO-8859-9", 117 | "ISO-8859-10", 118 | "ISO-8859-11", 119 | "ISO-8859-12", 120 | "ISO-8859-13", 121 | "ISO-8859-14", 122 | "ISO-8859-15", 123 | "ISO-8859-16", 124 | "cp437", 125 | "cp737", 126 | "cp775", 127 | "cp808", 128 | "cp850", 129 | "cp852", 130 | "cp855", 131 | "cp856", 132 | "cp857", 133 | "cp858", 134 | "cp860", 135 | "cp861", 136 | "cp862", 137 | "cp863", 138 | "cp864", 139 | "cp865", 140 | "cp866", 141 | "cp869", 142 | "cp922", 143 | "cp1046", 144 | "cp1124", 145 | "cp1125", 146 | "cp1129", 147 | "cp1133", 148 | "cp1161", 149 | "cp1162", 150 | "cp1163", 151 | "ibm437", 152 | "ibm737", 153 | "ibm775", 154 | "ibm808", 155 | "ibm850", 156 | "ibm852", 157 | "ibm855", 158 | "ibm856", 159 | "ibm857", 160 | "ibm858", 161 | "ibm860", 162 | "ibm861", 163 | "ibm862", 164 | "ibm863", 165 | "ibm864", 166 | "ibm865", 167 | "ibm866", 168 | "ibm869", 169 | "ibm922", 170 | "ibm1046", 171 | "ibm1124", 172 | "ibm1125", 173 | "ibm1129", 174 | "ibm1133", 175 | "ibm1161", 176 | "ibm1162", 177 | "ibm1163", 178 | "maccroatian", 179 | "maccyrillic", 180 | "macgreek", 181 | "maciceland", 182 | "macroman", 183 | "macromania", 184 | "macthai", 185 | "macturkish", 186 | "macukraine", 187 | "maccenteuro", 188 | "macintosh", 189 | "koi8-r", 190 | "koi8-u", 191 | "koi8-ru", 192 | "koi8-t", 193 | "armscii8", 194 | "rk1048", 195 | "tcvn", 196 | "georgianacademy", 197 | "georgianps", 198 | "pt154", 199 | "viscii", 200 | "iso646cn", 201 | "iso646jp", 202 | "hproman8", 203 | "tis620", 204 | "Shift_JIS", 205 | "Windows-31j", 206 | "Windows932", 207 | "EUC-JP", 208 | "GB2312", 209 | "GBK", 210 | "GB18030", 211 | "Windows936", 212 | "EUC-CN", 213 | "KS_C_5601", 214 | "Windows949", 215 | "EUC-KR", 216 | "Big5", 217 | "Big5-HKSCS", 218 | "Windows950" 219 | ], 220 | "description": "Filename encoding for FTP. Encoding with iconv-lite.\n See all supported encodings: https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings" 221 | }, 222 | "ignoreWrongFileEncoding": { 223 | "type": "boolean", 224 | "description": "Suppress of the file encoding error assumption" 225 | }, 226 | "autoDownloadRefreshTime": { 227 | "type": "integer", 228 | "default": 1000, 229 | "description": "Milliseconds. Auto refresh after this value when set 'autoDownload' as true. default is 1000", 230 | "deprecationMessage": "Use refreshTime instead" 231 | }, 232 | "blockDetectingDuration": { 233 | "type": "integer", 234 | "default": 8000, 235 | "description": "Milliseconds. If FTP connection is blocked for this value reconnect and retry action. default is 8000" 236 | }, 237 | "refreshTime": { 238 | "type": "integer", 239 | "default": 1000, 240 | "description": "Milliseconds. re-list remote files After this duration. 'autoDownload' is affected by this property. default is 1000" 241 | }, 242 | "showGreeting": { 243 | "type": "boolean", 244 | "description": "Show greeting message of Remote Server" 245 | }, 246 | "ftpOverride": { 247 | "$ref": "ftp.schema.json", 248 | "description": "It will pass to connect function of nodejs ftp package" 249 | }, 250 | "sftpOverride": { 251 | "$ref": "sftp.schema.json", 252 | "description": "It will pass to connect function of nodejs ssh2 package" 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/ftptree.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | Event, 4 | EventEmitter, 5 | FileChangeEvent, 6 | FileStat, 7 | FileSystemError, 8 | FileSystemProvider, 9 | FileType as VSCodeFileType, 10 | TreeDataProvider, 11 | TreeItem, 12 | Uri, 13 | } from 'vscode'; 14 | 15 | import { VFSState } from './util/filesystem'; 16 | 17 | import { FtpCacher } from './ftpcacher'; 18 | import { FileType } from './util/fileinfo'; 19 | import { processError } from './vsutil/error'; 20 | import { FtpTreeItem, FtpTreeServer } from './vsutil/ftptreeitem'; 21 | import { defaultLogger } from './vsutil/log'; 22 | 23 | const toVSCodeFileType: Record = Object.create(null); 24 | toVSCodeFileType['-'] = VSCodeFileType.File; 25 | toVSCodeFileType['d'] = VSCodeFileType.Directory; 26 | toVSCodeFileType['l'] = VSCodeFileType.SymbolicLink; 27 | 28 | export class FtpTree 29 | implements TreeDataProvider, FileSystemProvider 30 | { 31 | private readonly _onDidChangeTreeData: EventEmitter< 32 | FtpTreeItem | FtpTreeItem[] | undefined | null | void 33 | > = new EventEmitter(); 34 | readonly onDidChangeTreeData: Event< 35 | FtpTreeItem | FtpTreeItem[] | undefined | null | void 36 | > = this._onDidChangeTreeData.event; 37 | 38 | private readonly _onDidChangeFile = new EventEmitter(); 39 | readonly onDidChangeFile = this._onDidChangeFile.event; 40 | 41 | private readonly map = new Map(); 42 | 43 | watch( 44 | uri: Uri, 45 | options: { recursive: boolean; excludes: string[] } 46 | ): Disposable { 47 | try { 48 | const server = ftpTree.getServerFromUri(uri); 49 | const ftppath = uri.path; 50 | const stat = server.ftp.toFtpFileFromFtpPath(ftppath); 51 | if (stat === undefined) { 52 | return new Disposable(() => {}); 53 | } 54 | const watcher = stat.watch((to, from, type) => { 55 | const data: FileChangeEvent[] = [ 56 | { 57 | type, 58 | uri: to.getUri(), 59 | }, 60 | ]; 61 | this._onDidChangeFile.fire(data); 62 | }, options); 63 | return watcher; 64 | } catch (err) { 65 | return new Disposable(() => {}); 66 | } 67 | } 68 | 69 | async readFile(uri: Uri): Promise { 70 | const server = ftpTree.getServerFromUri(uri); 71 | const ftppath = uri.path; 72 | const viewed = await server.ftp.downloadAsBuffer(ftppath); 73 | return viewed.content; 74 | } 75 | 76 | async writeFile( 77 | uri: Uri, 78 | content: Uint8Array, 79 | options: { create: boolean; overwrite: boolean } 80 | ): Promise { 81 | const server = ftpTree.getServerFromUri(uri); 82 | const ftppath = uri.path; 83 | options.create; // TODO: check 84 | options.overwrite; // TODO: check 85 | await server.ftp.uploadBuffer(ftppath, Buffer.from(content)); 86 | } 87 | 88 | async delete(uri: Uri, options: { recursive: boolean }): Promise { 89 | const server = ftpTree.getServerFromUri(uri); 90 | const ftppath = uri.path; 91 | options.recursive; // TODO: check 92 | await server.ftp.ftpDelete(ftppath); 93 | } 94 | 95 | async createDirectory(uri: Uri): Promise { 96 | const server = ftpTree.getServerFromUri(uri); 97 | const ftppath = uri.path; 98 | await server.ftp.ftpMkdir(server.ftp.fromFtpPath(ftppath)); 99 | } 100 | 101 | async readDirectory(uri: Uri): Promise<[string, VSCodeFileType][]> { 102 | const server = ftpTree.getServerFromUri(uri); 103 | const ftppath = uri.path; 104 | const dir = await server.ftp.ftpList(ftppath); 105 | const out: [string, VSCodeFileType][] = []; 106 | for (const child of dir.children()) { 107 | out.push([child.name, toVSCodeFileType[child.type]]); 108 | } 109 | return out; 110 | } 111 | 112 | async stat(uri: Uri): Promise { 113 | try { 114 | FileSystemError.FileNotFound(uri); 115 | } catch (err) { 116 | // empty 117 | } 118 | const server = ftpTree.getServerFromUri(uri); 119 | const ftppath = uri.path; 120 | const stat = await server.ftp.ftpStat(ftppath); 121 | if (stat === undefined) throw Error('File not found'); 122 | 123 | return { 124 | type: toVSCodeFileType[stat.type], 125 | ctime: stat.date, 126 | mtime: stat.date, 127 | size: stat.size, 128 | }; 129 | } 130 | 131 | async rename( 132 | oldUri: Uri, 133 | newUri: Uri, 134 | options: { overwrite: boolean } 135 | ): Promise { 136 | const server = ftpTree.getServerFromUri(oldUri); 137 | const newServer = ftpTree.getServerFromUri(newUri); 138 | options.overwrite; // TODO: check 139 | if (server !== newServer) { 140 | throw Error('Cross-server moving is not supported yet'); 141 | } else { 142 | await server.ftp.ftpRename( 143 | server.ftp.fromFtpPath(oldUri.path), 144 | server.ftp.fromFtpPath(newUri.path) 145 | ); 146 | } 147 | } 148 | 149 | public refreshTree(target?: VFSState): void { 150 | defaultLogger.verbose('refreshTree ' + (target ? target.getUri() : 'all')); 151 | if (target === undefined) { 152 | FtpTreeItem.clear(); 153 | this._onDidChangeTreeData.fire(); 154 | for (const server of this.map.values()) { 155 | server.children = undefined; 156 | server.ftpFile = undefined; 157 | } 158 | } else { 159 | for (const item of FtpTreeItem.get(target)) { 160 | if (item.children) { 161 | for (const child of item.children) { 162 | FtpTreeItem.delete(child); 163 | } 164 | item.children = undefined; 165 | } 166 | if (item.server === item) { 167 | item.ftpFile = undefined; 168 | } 169 | this._onDidChangeTreeData.fire(item); 170 | } 171 | } 172 | } 173 | 174 | public getServerFromUri(uri: Uri): FtpTreeServer { 175 | const hostUri = `${uri.scheme}://${uri.authority}`; 176 | for (const server of this.map.values()) { 177 | if (hostUri === server.ftp.fs.hostUri) { 178 | return server; 179 | } 180 | } 181 | throw Error('Server not found: ' + uri); 182 | } 183 | 184 | public addServer(server: FtpCacher): void { 185 | const folder = new FtpTreeServer(server.workspace, server); 186 | this.map.set(server, folder); 187 | } 188 | 189 | public removeServer(server: FtpCacher): void { 190 | const folder = this.map.get(server); 191 | if (folder) { 192 | this.map.delete(server); 193 | folder.dispose(); 194 | } 195 | } 196 | 197 | public getTreeItem(element: FtpTreeItem): TreeItem { 198 | return element; 199 | } 200 | 201 | public async getChildren(element?: FtpTreeItem): Promise { 202 | let logger = defaultLogger; 203 | try { 204 | if (!element) { 205 | return [...this.map.values()]; 206 | } else { 207 | logger = element.server.logger; 208 | return await element.getChildren(); 209 | } 210 | } catch (err) { 211 | processError(logger, err); 212 | return []; 213 | } 214 | } 215 | } 216 | 217 | export const ftpTree = new FtpTree(); 218 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ftp-kr", 3 | "displayName": "ftp-kr", 4 | "description": "FTP/SFTP Sync Extension", 5 | "license": "MIT", 6 | "version": "1.4.5", 7 | "icon": "images/icon.png", 8 | "publisher": "ruakr", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/karikera/ftp-kr" 12 | }, 13 | "bugs": "https://github.com/karikera/ftp-kr/issues", 14 | "engines": { 15 | "vscode": "^1.76.0" 16 | }, 17 | "categories": [ 18 | "Other" 19 | ], 20 | "activationEvents": [ 21 | "workspaceContains:.vscode/ftp-kr.json", 22 | "onCommand:ftpkr.init" 23 | ], 24 | "main": "./out/index.bundle", 25 | "contributes": { 26 | "commands": [ 27 | { 28 | "command": "ftpkr.init", 29 | "title": "Init", 30 | "category": "ftp-kr" 31 | }, 32 | { 33 | "command": "ftpkr.cancel", 34 | "title": "Cancel All", 35 | "category": "ftp-kr" 36 | }, 37 | { 38 | "command": "ftpkr.uploadAll", 39 | "title": "Upload All", 40 | "category": "ftp-kr" 41 | }, 42 | { 43 | "command": "ftpkr.downloadAll", 44 | "title": "Download All", 45 | "category": "ftp-kr" 46 | }, 47 | { 48 | "command": "ftpkr.new", 49 | "title": "New File...", 50 | "category": "ftp-kr" 51 | }, 52 | { 53 | "command": "ftpkr.mkdir", 54 | "title": "New Folder...", 55 | "category": "ftp-kr" 56 | }, 57 | { 58 | "command": "ftpkr.upload", 59 | "title": "Upload This", 60 | "category": "ftp-kr" 61 | }, 62 | { 63 | "command": "ftpkr.download", 64 | "title": "Download This", 65 | "category": "ftp-kr" 66 | }, 67 | { 68 | "command": "ftpkr.delete", 69 | "title": "WARNING: Delete This", 70 | "category": "ftp-kr" 71 | }, 72 | { 73 | "command": "ftpkr.diff", 74 | "title": "Diff This", 75 | "category": "ftp-kr" 76 | }, 77 | { 78 | "command": "ftpkr.refresh", 79 | "title": "Refresh", 80 | "category": "ftp-kr" 81 | }, 82 | { 83 | "command": "ftpkr.cleanAll", 84 | "title": "Clean All", 85 | "category": "ftp-kr" 86 | }, 87 | { 88 | "command": "ftpkr.list", 89 | "title": "List", 90 | "category": "ftp-kr" 91 | }, 92 | { 93 | "command": "ftpkr.view", 94 | "title": "View", 95 | "category": "ftp-kr" 96 | }, 97 | { 98 | "command": "ftpkr.reconnect", 99 | "title": "Reconnect", 100 | "category": "ftp-kr" 101 | }, 102 | { 103 | "command": "ftpkr.runtask", 104 | "title": "Run task.json", 105 | "category": "ftp-kr" 106 | }, 107 | { 108 | "command": "ftpkr.target", 109 | "title": "Target", 110 | "category": "ftp-kr" 111 | }, 112 | { 113 | "command": "ftpkr.ssh", 114 | "title": "SSH", 115 | "category": "ftp-kr" 116 | } 117 | ], 118 | "jsonValidation": [ 119 | { 120 | "fileMatch": "ftp-kr.json", 121 | "url": "./schema/ftp-kr.schema.json" 122 | } 123 | ], 124 | "menus": { 125 | "explorer/context": [ 126 | { 127 | "command": "ftpkr.upload", 128 | "group": "ftp-kr.action" 129 | }, 130 | { 131 | "command": "ftpkr.download", 132 | "group": "ftp-kr.action" 133 | }, 134 | { 135 | "command": "ftpkr.delete", 136 | "group": "ftp-kr.action" 137 | }, 138 | { 139 | "command": "ftpkr.diff", 140 | "group": "ftp-kr.navigation" 141 | } 142 | ], 143 | "view/title": [ 144 | { 145 | "command": "ftpkr.refresh", 146 | "when": "view == ftpkr.explorer", 147 | "group": "navigation" 148 | } 149 | ], 150 | "editor/context": [ 151 | { 152 | "command": "ftpkr.upload", 153 | "group": "ftp-kr.action", 154 | "when": "!inOutput" 155 | }, 156 | { 157 | "command": "ftpkr.download", 158 | "group": "ftp-kr.action", 159 | "when": "!inOutput" 160 | }, 161 | { 162 | "command": "ftpkr.delete", 163 | "group": "ftp-kr.action", 164 | "when": "!inOutput" 165 | }, 166 | { 167 | "command": "ftpkr.diff", 168 | "group": "ftp-kr.navigation", 169 | "when": "!inOutput" 170 | }, 171 | { 172 | "command": "ftpkr.refresh", 173 | "when": "resourceScheme == sftp", 174 | "group": "ftp-kr.navigation" 175 | }, 176 | { 177 | "command": "ftpkr.refresh", 178 | "when": "resourceScheme == ftp", 179 | "group": "ftp-kr.navigation" 180 | }, 181 | { 182 | "command": "ftpkr.refresh", 183 | "when": "resourceScheme == ftps", 184 | "group": "ftp-kr.navigation" 185 | } 186 | ], 187 | "view/item/context": [ 188 | { 189 | "command": "ftpkr.new", 190 | "when": "view == ftpkr.explorer", 191 | "group": "ftp-kr.act" 192 | }, 193 | { 194 | "command": "ftpkr.mkdir", 195 | "when": "view == ftpkr.explorer", 196 | "group": "ftp-kr.act" 197 | }, 198 | { 199 | "command": "ftpkr.upload", 200 | "when": "view == ftpkr.explorer", 201 | "group": "ftp-kr.action" 202 | }, 203 | { 204 | "command": "ftpkr.download", 205 | "when": "view == ftpkr.explorer", 206 | "group": "ftp-kr.action" 207 | }, 208 | { 209 | "command": "ftpkr.delete", 210 | "when": "view == ftpkr.explorer", 211 | "group": "ftp-kr.action" 212 | }, 213 | { 214 | "command": "ftpkr.diff", 215 | "when": "view == ftpkr.explorer", 216 | "group": "ftp-kr.navigation" 217 | }, 218 | { 219 | "command": "ftpkr.refresh", 220 | "when": "view == ftpkr.explorer", 221 | "group": "ftp-kr.navigation" 222 | } 223 | ] 224 | }, 225 | "views": { 226 | "explorer": [ 227 | { 228 | "id": "ftpkr.explorer", 229 | "name": "ftp-kr: Explorer" 230 | } 231 | ] 232 | } 233 | }, 234 | "scripts": { 235 | "mdgen": "node ./out/schema_to_md.js ./schema/ftp-kr.schema.json", 236 | "watch": "if-tsb -w", 237 | "build": "if-tsb" 238 | }, 239 | "dependencies": { 240 | "@types/glob": "^7.2.0", 241 | "ftp": "^0.3.10", 242 | "glob": "^7.2.0", 243 | "iconv-lite": "^0.6.3", 244 | "if-tsb": "^0.4.16", 245 | "krfile": "^1.0.3", 246 | "krjson": "^1.0.3", 247 | "minimatch": "^5.1.1", 248 | "node-ipc": "^10.1.0", 249 | "read": "^1.0.7", 250 | "source-map": "^0.7.2", 251 | "ssh2": "file:ssh2", 252 | "strip-json-comments": "^4.0.0" 253 | }, 254 | "devDependencies": { 255 | "@types/ftp": "^0.3.29", 256 | "@types/iconv-lite": "0.0.1", 257 | "@types/mocha": "^9.0.0", 258 | "@types/node": "^17.0.2", 259 | "@types/node-ipc": "^9.1.0", 260 | "@types/read": "^0.0.29", 261 | "@types/source-map": "^0.5.7", 262 | "@types/ssh2": "^0.5.35", 263 | "@types/strip-json-comments": "^3.0.0", 264 | "@typescript-eslint/eslint-plugin": "^5.46.0", 265 | "@typescript-eslint/parser": "^5.46.0", 266 | "eslint": "^8.29.0", 267 | "eslint-config-prettier": "^8.5.0", 268 | "mocha": "^9.1.3", 269 | "prettier": "^2.8.1", 270 | "source-map-support": "^0.5.21", 271 | "typescript": "^5.0.2", 272 | "@types/vscode": "^1.76.0" 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | export class Deferred implements Promise { 2 | public resolve: (v: T | PromiseLike) => void; 3 | public reject: (v?: any) => void; 4 | public readonly [Symbol.toStringTag] = 'Promise'; 5 | private promise: Promise; 6 | 7 | constructor() { 8 | this.resolve = undefined; 9 | this.reject = undefined; 10 | this.promise = new Promise((res, rej) => { 11 | this.resolve = res; 12 | this.reject = rej; 13 | }); 14 | } 15 | 16 | public then( 17 | onfulfilled: (v: T) => R1 | Promise, 18 | onreject?: (v: any) => R2 | Promise 19 | ): Promise { 20 | return this.promise.then(onfulfilled, onreject); 21 | } 22 | 23 | public catch(func: (v: any) => R2 | Promise): Promise { 24 | return this.promise.catch(func); 25 | } 26 | 27 | public finally(func?: () => void): Promise { 28 | return this.promise.finally(func); 29 | } 30 | } 31 | 32 | export function isEmptyObject(obj: any): boolean { 33 | for (const p in obj) return false; 34 | return true; 35 | } 36 | 37 | export function addOptions( 38 | args: string[], 39 | options: { [key: string]: any } 40 | ): void { 41 | for (const key in options) { 42 | const value = options[key]; 43 | if (Array.isArray(value)) { 44 | for (const val of value) { 45 | args.push('--' + key); 46 | args.push(val); 47 | } 48 | continue; 49 | } 50 | if (typeof value === 'boolean' && value === false) { 51 | continue; 52 | } 53 | args.push('--' + key); 54 | if (value !== true) { 55 | args.push(value); 56 | } 57 | } 58 | } 59 | 60 | export function merge>( 61 | original: T, 62 | overrider?: T, 63 | access?: T 64 | ): T { 65 | if (overrider === undefined) return original; 66 | 67 | const conststr: string[] = []; 68 | const arrlist: string[][] = []; 69 | let nex: T; 70 | 71 | if (access === undefined) { 72 | nex = original; 73 | } else { 74 | nex = access; 75 | for (const p in original) access[p] = original[p]; 76 | } 77 | 78 | function convert(value: any): any { 79 | if (typeof value !== 'string') return value; 80 | 81 | let nvalue = ''; 82 | let i = 0; 83 | for (;;) { 84 | let j = value.indexOf('%', i); 85 | if (j === -1) break; 86 | const tx = value.substring(i, j); 87 | j++; 88 | const k = value.indexOf('%', j); 89 | if (k === -1) break; 90 | nvalue += tx; 91 | const varname = value.substring(j, k); 92 | if (varname in nex) { 93 | const val = nex[varname]; 94 | if (val instanceof Array) { 95 | if (val.length === 1) { 96 | nvalue += val[0]; 97 | } else { 98 | conststr.push(nvalue); 99 | nvalue = ''; 100 | arrlist.push(val); 101 | } 102 | } else nvalue += val; 103 | } else nvalue += '%' + varname + '%'; 104 | i = k + 1; 105 | } 106 | 107 | nvalue += value.substr(i); 108 | if (arrlist.length !== 0) { 109 | conststr.push(nvalue); 110 | let from: string[][] = [conststr]; 111 | let to: string[][] = []; 112 | for (let j = 0; j < arrlist.length; j++) { 113 | const list = arrlist[j]; 114 | for (let i = 0; i < list.length; i++) { 115 | for (let k = 0; k < from.length; k++) { 116 | const cs = from[k]; 117 | const ncs = cs.slice(1, cs.length); 118 | ncs[0] = cs[0] + list[i] + cs[1]; 119 | to.push(ncs); 120 | } 121 | } 122 | const t = to; 123 | to = from; 124 | from = t; 125 | to.length = 0; 126 | } 127 | return from.map((v) => v[0]); 128 | } 129 | return nvalue; 130 | } 131 | 132 | const out: T = {}; 133 | 134 | for (const p in overrider) { 135 | const value = overrider[p] as any; 136 | if (value instanceof Array) { 137 | const nvalue: any[] = []; 138 | for (let val of value) { 139 | val = convert(val); 140 | if (val instanceof Array) nvalue.push(nvalue, ...val); 141 | else nvalue.push(val); 142 | } 143 | out[p] = nvalue; 144 | } else if (value instanceof Object) { 145 | const ori = original[p] as any; 146 | if (ori instanceof Object) { 147 | out[p] = merge(ori, value, nex[p]); 148 | } else { 149 | out[p] = value; 150 | } 151 | } else { 152 | out[p] = convert(value); 153 | } 154 | } 155 | for (const p in original) { 156 | if (p in out) continue; 157 | out[p] = original[p]; 158 | } 159 | return out; 160 | } 161 | 162 | export function getFilePosition( 163 | content: string, 164 | index: number 165 | ): { line: number; column: number } { 166 | const front = content.substring(0, index); 167 | let line = 1; 168 | let lastidx = 0; 169 | for (;;) { 170 | const idx = front.indexOf('\n', lastidx); 171 | if (idx === -1) break; 172 | line++; 173 | lastidx = idx + 1; 174 | } 175 | return { 176 | line, 177 | column: index - lastidx, 178 | }; 179 | } 180 | 181 | export function clone(value: T): T { 182 | if (!(value instanceof Object)) return value; 183 | if (value instanceof Array) { 184 | const arr = new Array(value.length); 185 | for (let i = 0; i < arr.length; i++) { 186 | arr[i] = clone(value[i]); 187 | } 188 | return arr; 189 | } 190 | if (value instanceof Map) { 191 | const map = new Map(value.entries()); 192 | return map; 193 | } 194 | if (value instanceof Set) { 195 | const set = new Set(value.values()); 196 | return set; 197 | } 198 | if (value instanceof RegExp) { 199 | return value; 200 | } 201 | const nobj: { [key: string]: any } = new Object(); 202 | nobj.__proto__ = (value).__proto__; 203 | 204 | for (const p in value) { 205 | nobj[p] = (value as any)[p]; 206 | } 207 | return nobj; 208 | } 209 | 210 | export function promiseErrorWrap(prom: Promise): Promise { 211 | const stack = Error().stack || ''; 212 | return prom.catch((err) => { 213 | if (err && err.stack) { 214 | if (!err.__messageCodeAttached && err.code) { 215 | err.message = err.message + '[' + err.code + ']'; 216 | err.__messageCodeAttached = true; 217 | } 218 | err.stack = err.stack + stack.substr(stack.indexOf('\n')); 219 | } 220 | throw err; 221 | }); 222 | } 223 | 224 | export function replaceErrorUrl( 225 | stack: string, 226 | foreach: (path: string, line: number, column: number) => string 227 | ): string { 228 | const regexp = /^\tat ([^(\n]+) \(([^)\n]+):([0-9]+):([0-9]+)\)$/gm; 229 | let arr: RegExpExecArray | null; 230 | let lastIndex = 0; 231 | let out = ''; 232 | while ((arr = regexp.exec(stack))) { 233 | out += stack.substring(lastIndex, arr.index); 234 | out += '\tat '; 235 | out += arr[1]; 236 | out += ' ('; 237 | out += foreach(arr[2], +arr[3], +arr[4]); 238 | out += ')'; 239 | lastIndex = regexp.lastIndex; 240 | } 241 | out += stack.substr(lastIndex); 242 | return out; 243 | } 244 | 245 | export async function replaceErrorUrlAsync( 246 | stack: string, 247 | foreach: (path: string, line: number, column: number) => Promise 248 | ): Promise { 249 | const regexp = /^\tat ([^(\n]+) \(([^)\n]+):([0-9]+):([0-9]+)\)$/gm; 250 | let arr: RegExpExecArray | null; 251 | let lastIndex = 0; 252 | let out = ''; 253 | while ((arr = regexp.exec(stack))) { 254 | out += stack.substring(lastIndex, arr.index); 255 | out += '\tat '; 256 | out += arr[1]; 257 | out += ' ('; 258 | out += await foreach(arr[2], +arr[3], +arr[4]); 259 | out += ')'; 260 | lastIndex = regexp.lastIndex; 261 | } 262 | out += stack.substr(lastIndex); 263 | return out; 264 | } 265 | 266 | export function timeout(ms?: number): Promise { 267 | return new Promise((resolve) => setTimeout(resolve, ms)); 268 | } 269 | -------------------------------------------------------------------------------- /schema/tls.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Additional options to be passed to tls.connect(). Default: (none)", 4 | "properties": { 5 | "host": { 6 | "type": "string", 7 | "description": "Host the client should connect to, defaults to 'localhost'." 8 | }, 9 | "port": { 10 | "type": "number", 11 | "description": "Port the client should connect to." 12 | }, 13 | "path": { 14 | "type": "string", 15 | "description": "Creates unix socket connection to path. If this option is specified, host and port are ignored." 16 | }, 17 | "rejectUnauthorized": { 18 | "type": "boolean", 19 | "description": "If true, the server certificate is verified against the list of supplied CAs. An 'error' event is emitted if verification fails; err.code contains the OpenSSL error code. Defaults to true." 20 | }, 21 | "NPNProtocols": { 22 | "type": "array", 23 | "description": "An array of strings or Buffers containing supported NPN protocols. Buffers should have the format [len][name][len][name]... e.g. 0x05hello0x05world, where the first byte is the length of the next protocol name. Passing an array is usually much simpler, e.g. ['hello', 'world'].", 24 | "items": { 25 | "type": "string" 26 | } 27 | }, 28 | "ALPNProtocols": { 29 | "type": "array", 30 | "description": "An array of strings or Buffers containing the supported ALPN protocols. Buffers should have the format [len][name][len][name]... e.g. 0x05hello0x05world, where the first byte is the length of the next protocol name. Passing an array is usually much simpler: ['hello', 'world'].)", 31 | "items": { 32 | "type": "string" 33 | } 34 | }, 35 | "servername": { 36 | "type": "string", 37 | "description": "Server name for the SNI (Server Name Indication) TLS extension." 38 | }, 39 | /* 40 | "session": { 41 | "type": "string", 42 | "description": "A Buffer instance, containing TLS session." 43 | }, 44 | */ 45 | "minDHSize": { 46 | "type": "number", 47 | "description": "Minimum size of the DH parameter in bits to accept a TLS connection. When a server offers a DH parameter with a size less than minDHSize, the TLS connection is destroyed and an error is thrown. Defaults to 1024." 48 | }, 49 | "pfx": { 50 | "type": "string", 51 | "description": "Optional PFX or PKCS12 encoded private key and certificate chain. pfx is an alternative to providing key and cert individually. PFX is usually encrypted, if it is, passphrase will be used to decrypt it." 52 | }, 53 | "key": { 54 | "type": ["string", "array"], 55 | "description": "Optional private keys in PEM format. PEM allows the option of private keys being encrypted. Encrypted keys will be decrypted with options.passphrase. Multiple keys using different algorithms can be provided either as an array of unencrypted key strings or buffers, or an array of objects in the form {pem: [, passphrase: ]}. The object form can only occur in an array. object.passphrase is optional. Encrypted keys will be decrypted with object.passphrase if provided, or options.passphrase if it is not.", 56 | "items": { 57 | "type": "string" 58 | } 59 | }, 60 | "passphrase": { 61 | "type": "string", 62 | "description": "Optional shared passphrase used for a single private key and/or a PFX." 63 | }, 64 | "cert": { 65 | "type": ["string", "array"], 66 | "description": "Optional cert chains in PEM format. One cert chain should be provided per private key. Each cert chain should consist of the PEM formatted certificate for a provided private key, followed by the PEM formatted intermediate certificates (if any), in order, and not including the root CA (the root CA must be pre-known to the peer, see ca). When providing multiple cert chains, they do not have to be in the same order as their private keys in key. If the intermediate certificates are not provided, the peer will not be able to validate the certificate, and the handshake will fail.", 67 | "items": { 68 | "type": "string" 69 | } 70 | }, 71 | "ca": { 72 | "type": ["string", "array"], 73 | "description": "Optionally override the trusted CA certificates. Default is to trust the well-known CAs curated by Mozilla. Mozilla's CAs are completely replaced when CAs are explicitly specified using this option. The value can be a string or Buffer, or an Array of strings and/or Buffers. Any string or Buffer can contain multiple PEM CAs concatenated together. The peer's certificate must be chainable to a CA trusted by the server for the connection to be authenticated. When using certificates that are not chainable to a well-known CA, the certificate's CA must be explicitly specified as a trusted or the connection will fail to authenticate. If the peer uses a certificate that doesn't match or chain to one of the default CAs, use the ca option to provide a CA certificate that the peer's certificate can match or chain to. For self-signed certificates, the certificate is its own CA, and must be provided.", 74 | "items": { 75 | "type": "string" 76 | } 77 | }, 78 | "crl": { 79 | "type": ["string", "array"], 80 | "description": "Optional PEM formatted CRLs (Certificate Revocation Lists).", 81 | "items": { 82 | "type": "string" 83 | } 84 | }, 85 | "ciphers": { 86 | "type": "string", 87 | "description": "Optional cipher suite specification, replacing the default. For more information, see modifying the default cipher suite." 88 | }, 89 | "honorCipherOrder": { 90 | "type": "boolean", 91 | "description": "Attempt to use the server's cipher suite preferences instead of the client's. When true, causes SSL_OP_CIPHER_SERVER_PREFERENCE to be set in secureOptions, see OpenSSL Options for more information. Note: tls.createServer() sets the default value to true, other APIs that create secure contexts leave it unset." 92 | }, 93 | "ecdhCurve": { 94 | "type": "string", 95 | "description": "A string describing a named curve to use for ECDH key agreement or false to disable ECDH. Defaults to tls.DEFAULT_ECDH_CURVE. Use crypto.getCurves() to obtain a list of available curve names. On recent releases, openssl ecparam -list_curves will also display the name and description of each available elliptic curve." 96 | }, 97 | "dhparam": { 98 | "type": "string", 99 | "description": "Diffie Hellman parameters, required for Perfect Forward Secrecy. Use openssl dhparam to create the parameters. The key length must be greater than or equal to 1024 bits, otherwise an error will be thrown. It is strongly recommended to use 2048 bits or larger for stronger security. If omitted or invalid, the parameters are silently discarded and DHE ciphers will not be available." 100 | }, 101 | "secureProtocol": { 102 | "type": "string", 103 | "description": "Optional SSL method to use, default is \"SSLv23_method\". The possible values are listed as SSL_METHODS, use the function names as strings. For example, \"SSLv3_method\" to force SSL version 3." 104 | }, 105 | "secureOptions": { 106 | "type": "number", 107 | "description": "Optionally affect the OpenSSL protocol behaviour, which is not usually necessary. This should be used carefully if at all! Value is a numeric bitmask of the SSL_OP_* options from OpenSSL Options." 108 | }, 109 | "sessionIdContext": { 110 | "type": "string", 111 | "description": "Optional opaque identifier used by servers to ensure session state is not shared between applications. Unused by clients. Note: tls.createServer() uses a 128 bit truncated SHA1 hash value generated from process.argv, other APIs that create secure contexts have no default value." 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/vsutil/work.ts: -------------------------------------------------------------------------------- 1 | import { Logger, StringError } from './log'; 2 | import { Workspace, WorkspaceItem } from './ws'; 3 | 4 | enum TaskState { 5 | WAIT, 6 | STARTED, 7 | DONE, 8 | } 9 | 10 | export class OnCancel { 11 | constructor(private task: TaskImpl, private target?: () => unknown) {} 12 | 13 | public dispose(): void { 14 | if (this.target === undefined) return; 15 | this.task.removeCancelListener(this.target); 16 | this.target = undefined; 17 | } 18 | } 19 | 20 | export interface Task { 21 | readonly cancelled: boolean; 22 | readonly logger: Logger; 23 | readonly name: string; 24 | 25 | oncancel(oncancel: () => unknown): OnCancel; 26 | checkCanceled(): void; 27 | with(waitWith: Promise): Promise; 28 | } 29 | 30 | class TaskImpl implements Task { 31 | public next: TaskImpl | null = null; 32 | public previous: TaskImpl | null = null; 33 | public cancelled = false; 34 | 35 | private state: TaskState = TaskState.WAIT; 36 | private cancelListeners: Array<() => unknown> = []; 37 | private timeout: NodeJS.Timer | undefined; 38 | public readonly promise: Promise; 39 | public readonly logger: Logger; 40 | 41 | private resolve: (value: T) => void; 42 | private reject: (err: unknown) => void; 43 | 44 | constructor( 45 | public readonly scheduler: Scheduler, 46 | public readonly name: string, 47 | public readonly priority: number, 48 | public readonly task: (task: Task) => Promise 49 | ) { 50 | this.logger = scheduler.logger; 51 | this.resolve = undefined as any; 52 | this.reject = undefined as any; 53 | this.promise = new Promise((resolve, reject) => { 54 | this.resolve = resolve; 55 | this.reject = reject; 56 | }); 57 | } 58 | 59 | public setTimeLimit(timeout: number): void { 60 | if (this.timeout == null) return; 61 | if (this.state >= TaskState.STARTED) return; 62 | 63 | let stack = Error('').stack; 64 | if (stack != null) stack = stack.substr(stack.indexOf('\n')); 65 | 66 | this.timeout = setTimeout(() => { 67 | const task = this.scheduler.currentTask; 68 | const message = `ftp-kr is busy: [${ 69 | task === null ? 'null...?' : task.name 70 | }] is being proceesed. Cannot run [${this.name}]`; 71 | const err = Error(message); 72 | err.stack = message + stack; 73 | this.logger.error(err); 74 | this.cancel(); 75 | }, timeout); 76 | } 77 | 78 | public async play(): Promise { 79 | if (this.state >= TaskState.STARTED) { 80 | throw Error('play must call once'); 81 | } 82 | this.state = TaskState.STARTED; 83 | if (this.timeout) { 84 | clearTimeout(this.timeout); 85 | } 86 | 87 | if (this.cancelled) throw StringError.TASK_CANCEL; 88 | 89 | this.logger.verbose(`[TASK:${this.name}] started`); 90 | const prom = this.task(this); 91 | prom.then( 92 | (v) => { 93 | this.logger.verbose(`[TASK:${this.name}] done`); 94 | this.resolve(v); 95 | }, 96 | (err) => { 97 | if (err === StringError.TASK_CANCEL) { 98 | this.logger.verbose(`[TASK:${this.name}] cancelled`); 99 | this.reject(StringError.IGNORE); 100 | } else { 101 | if (err instanceof Error) { 102 | err.task = this.name; 103 | } 104 | 105 | this.logger.verbose(`[TASK:${this.name}] errored`); 106 | this.reject(err); 107 | } 108 | } 109 | ); 110 | 111 | return await this.promise; 112 | } 113 | 114 | public cancel(): void { 115 | if (this.cancelled) return; 116 | this.cancelled = true; 117 | if (this.state === TaskState.WAIT) { 118 | this.reject(StringError.IGNORE); 119 | } 120 | this.fireCancel(); 121 | } 122 | 123 | public with(waitWith: Promise): Promise { 124 | if (this.state !== TaskState.STARTED) { 125 | return Promise.reject(Error('Task.with must call in task')); 126 | } 127 | 128 | if (this.cancelled) return Promise.reject(StringError.TASK_CANCEL); 129 | return new Promise((resolve, reject) => { 130 | this.oncancel(() => reject(StringError.TASK_CANCEL)); 131 | waitWith.then( 132 | (v) => { 133 | if (this.cancelled) return; 134 | this.removeCancelListener(reject); 135 | resolve(v); 136 | }, 137 | (err) => { 138 | if (this.cancelled) return; 139 | this.removeCancelListener(reject); 140 | reject(err); 141 | } 142 | ); 143 | }); 144 | } 145 | 146 | public oncancel(oncancel: () => unknown): OnCancel { 147 | if (this.cancelled) { 148 | oncancel(); 149 | return new OnCancel(this); 150 | } 151 | this.cancelListeners.push(oncancel); 152 | return new OnCancel(this, oncancel); 153 | } 154 | 155 | public removeCancelListener(oncancel: () => unknown): boolean { 156 | const idx = this.cancelListeners.lastIndexOf(oncancel); 157 | if (idx === -1) return false; 158 | this.cancelListeners.splice(idx, 1); 159 | return true; 160 | } 161 | 162 | public checkCanceled(): void { 163 | if (this.cancelled) throw StringError.TASK_CANCEL; 164 | } 165 | 166 | private fireCancel(): void { 167 | for (const listener of this.cancelListeners) { 168 | listener(); 169 | } 170 | this.cancelListeners.length = 0; 171 | } 172 | } 173 | 174 | export class Scheduler implements WorkspaceItem { 175 | public currentTask: TaskImpl | null = null; 176 | private firstTask: TaskImpl | null = null; 177 | private lastTask: TaskImpl | null = null; 178 | public readonly logger: Logger; 179 | 180 | constructor(arg: Logger | Workspace) { 181 | if (arg instanceof Workspace) { 182 | this.logger = arg.query(Logger); 183 | } else { 184 | this.logger = arg; 185 | } 186 | } 187 | 188 | private _addTask(task: TaskImpl): void { 189 | let node = this.lastTask; 190 | if (node !== null) { 191 | if (task.priority <= node.priority) { 192 | node.next = task; 193 | task.previous = node; 194 | this.lastTask = task; 195 | } else { 196 | for (;;) { 197 | const nodenext = node; 198 | node = node.previous; 199 | if (node === null) { 200 | const first = this.firstTask; 201 | if (first === null) throw Error('Impossible'); 202 | task.next = first; 203 | first.previous = task; 204 | this.firstTask = task; 205 | break; 206 | } 207 | if (task.priority <= node.priority) { 208 | nodenext.previous = task; 209 | task.next = nodenext; 210 | task.previous = node; 211 | node.next = task; 212 | break; 213 | } 214 | } 215 | } 216 | } else { 217 | this.firstTask = this.lastTask = task; 218 | } 219 | } 220 | 221 | public dispose(): void { 222 | this.cancel(); 223 | } 224 | 225 | public cancel(): Thenable { 226 | const task = this.currentTask; 227 | if (!task) return Promise.resolve(); 228 | 229 | task.cancel(); 230 | 231 | this.logger.message(`[${task.name}] task is cancelled`); 232 | 233 | let next = task.next; 234 | while (next) { 235 | this.logger.message(`[${next.name}] task is cancelled`); 236 | next = next.next; 237 | } 238 | 239 | task.next = null; 240 | this.firstTask = null; 241 | this.lastTask = null; 242 | return task.promise.catch(() => {}); 243 | } 244 | 245 | public taskMust( 246 | name: string, 247 | taskfunc: (task: Task) => Promise, 248 | taskFrom?: Task | null, 249 | priority?: number 250 | ): Promise { 251 | if (taskFrom) { 252 | return taskfunc(taskFrom); 253 | } 254 | if (priority === undefined) priority = PRIORITY_NORMAL; 255 | const task = new TaskImpl(this, name, priority, taskfunc); 256 | this._addTask(task); 257 | if (!this.currentTask) { 258 | this.logger.verbose('[SCHEDULAR] start'); 259 | this.progress(); 260 | } 261 | return task.promise; 262 | } 263 | 264 | public task( 265 | name: string, 266 | taskfunc: (task: Task) => Promise, 267 | taskFrom?: Task | null, 268 | priority?: number, 269 | timeout?: number 270 | ): Promise { 271 | if (taskFrom) { 272 | return taskfunc(taskFrom); 273 | } 274 | if (priority === undefined) priority = PRIORITY_NORMAL; 275 | if (timeout === undefined) timeout = 2000; 276 | const task = new TaskImpl(this, name, priority, taskfunc); 277 | task.setTimeLimit(timeout); 278 | this._addTask(task); 279 | if (this.currentTask == null) { 280 | this.logger.verbose('[SCHEDULAR] start'); 281 | this.progress(); 282 | } 283 | return task.promise; 284 | } 285 | 286 | private progress(): void { 287 | const task = this.firstTask; 288 | if (task === null) { 289 | this.logger.verbose('[SCHEDULAR] end'); 290 | this.currentTask = null; 291 | return; 292 | } 293 | this.currentTask = task; 294 | 295 | const next = task.next; 296 | if (next === null) { 297 | this.firstTask = this.lastTask = null; 298 | } else { 299 | this.firstTask = next; 300 | } 301 | const prom = task.play(); 302 | prom.then( 303 | () => this.progress(), 304 | () => this.progress() 305 | ); 306 | } 307 | } 308 | 309 | export const PRIORITY_HIGH = 2000; 310 | export const PRIORITY_NORMAL = 1000; 311 | export const PRIORITY_IDLE = 0; 312 | -------------------------------------------------------------------------------- /src/vsutil/log.ts: -------------------------------------------------------------------------------- 1 | import { OutputChannel, window } from 'vscode'; 2 | import { WorkspaceItem, Workspace } from './ws'; 3 | import { vsutil } from './vsutil'; 4 | import { LogLevel } from '../util/serverinfo'; 5 | import { getMappedStack } from '../util/sm'; 6 | import * as os from 'os'; 7 | import { File } from 'krfile'; 8 | import { replaceErrorUrl } from '../util/util'; 9 | import { parseJson } from 'krjson'; 10 | 11 | enum LogLevelEnum { 12 | VERBOSE, 13 | NORMAL, 14 | ERROR, 15 | } 16 | 17 | interface ErrorObject extends Error { 18 | code?: string; 19 | errno?: number; 20 | } 21 | 22 | export enum StringError { 23 | TASK_CANCEL = 'TASK_CANCEL', 24 | IGNORE = 'IGNORE', 25 | } 26 | 27 | export class Logger implements WorkspaceItem { 28 | public logLevel: LogLevelEnum = LogLevelEnum.NORMAL; 29 | public dontOpen = false; 30 | private output: OutputChannel | null = null; 31 | private workspace: Workspace | null = null; 32 | public static all: Set = new Set(); 33 | private task: Promise = Promise.resolve(); 34 | private readonly _onError = async (err: unknown) => { 35 | const stack = await getMappedStack(err); 36 | if (stack !== null) { 37 | console.error(stack); 38 | this.logRaw(LogLevelEnum.ERROR, stack); 39 | } else { 40 | console.error(err); 41 | this.logRaw(LogLevelEnum.ERROR, err + ''); 42 | } 43 | }; 44 | 45 | constructor(name: string | Workspace) { 46 | if (name instanceof Workspace) { 47 | this.workspace = name; 48 | name = 'ftp-kr/' + name.name; 49 | } 50 | this.output = window.createOutputChannel(name); 51 | Logger.all.add(this); 52 | } 53 | 54 | private logRaw(level: LogLevelEnum, ...message: string[]): void { 55 | if (level < this.logLevel) return; 56 | if (!this.output) return; 57 | switch (this.logLevel) { 58 | case LogLevelEnum.VERBOSE: 59 | this.output.appendLine( 60 | LogLevelEnum[level] + 61 | ': ' + 62 | message.join(' ').replace(/\n/g, '\nVERBOSE: ') 63 | ); 64 | break; 65 | default: 66 | this.output.appendLine(message.join(' ')); 67 | break; 68 | } 69 | } 70 | private log(level: LogLevelEnum, ...message: string[]): Promise { 71 | return (this.task = this.task 72 | .then(() => this.logRaw(level, ...message)) 73 | .catch(this._onError)); 74 | } 75 | 76 | public setLogLevel(level: LogLevel): void { 77 | const oldlevel = this.logLevel; 78 | this.logLevel = LogLevelEnum[level]; 79 | this.verbose(`logLevel = ${level}`); 80 | 81 | if (oldlevel === defaultLogger.logLevel) { 82 | let minLevel = LogLevelEnum.ERROR; 83 | for (const logger of Logger.all) { 84 | if (logger.logLevel < minLevel) { 85 | minLevel = logger.logLevel; 86 | } 87 | } 88 | defaultLogger.logLevel = minLevel; 89 | } 90 | } 91 | 92 | public message(...message: string[]): void { 93 | this.log(LogLevelEnum.NORMAL, ...message); 94 | } 95 | 96 | public verbose(...message: string[]): void { 97 | this.log(LogLevelEnum.VERBOSE, ...message); 98 | } 99 | 100 | public error(err: unknown): Promise { 101 | return (this.task = this.task 102 | .then(async () => { 103 | if (err === StringError.IGNORE) return; 104 | let stack = await getMappedStack(err); 105 | if (stack !== null) { 106 | const error = err as ErrorObject; 107 | console.error(stack); 108 | let extensionPathUsed = false; 109 | const extensionPath = new File(__dirname).parent().parent().fsPath; 110 | this.logRaw(LogLevelEnum.ERROR, stack); 111 | const res = await window.showErrorMessage( 112 | error.message, 113 | { modal: true }, 114 | 'Detail' 115 | ); 116 | if (res !== 'Detail') return; 117 | let output = '[ftp-kr Reporting]\n'; 118 | if (error.task) { 119 | output += `Task: ${error.task}\n`; 120 | } 121 | const pathRemap: string[] = []; 122 | pathRemap.push(extensionPath, '[ftp-kr]'); 123 | if (this.workspace) { 124 | pathRemap.push(this.workspace.fsPath, '[workspace]'); 125 | } 126 | output += `platform: ${os.platform()}\n`; 127 | output += `arch: ${os.arch()}\n\n`; 128 | output += `[${error.constructor.name}]\nmessage: ${error.message}`; 129 | if (error.code) { 130 | output += `\ncode: ${error.code}`; 131 | } 132 | if (error.errno) { 133 | output += `\nerrno: ${error.errno}`; 134 | } 135 | 136 | const repath = (path: string): string => { 137 | for (let i = 0; i < pathRemap.length; i += 2) { 138 | const prevPath = pathRemap[i]; 139 | if (path.startsWith(prevPath)) { 140 | if (i === 2) { 141 | extensionPathUsed = true; 142 | } 143 | return pathRemap[i + 1] + path.substr(prevPath.length); 144 | } 145 | } 146 | return path; 147 | }; 148 | 149 | const filterAllField = (value: unknown): unknown => { 150 | if (typeof value === 'string') { 151 | return repath(value); 152 | } 153 | if (typeof value === 'object') { 154 | if (value instanceof Array) { 155 | for (let i = 0; i < value.length; i++) { 156 | value[i] = filterAllField(value[i]); 157 | } 158 | } else { 159 | if (value === null) { 160 | return null; 161 | } 162 | const objmap = value as Record; 163 | for (const name in value) { 164 | objmap[name] = filterAllField(objmap[name]); 165 | } 166 | 167 | if ('host' in objmap) { 168 | if (typeof objmap.host === 'string') 169 | objmap.host = objmap.host.replace(/[a-zA-Z]/g, '*'); 170 | else objmap.host = '[' + typeof objmap.host + ']'; 171 | } 172 | if ('privateKey' in objmap) { 173 | if (typeof objmap.privateKey === 'string') 174 | objmap.privateKey = objmap.privateKey.replace( 175 | /[a-zA-Z]/g, 176 | '*' 177 | ); 178 | else objmap.privateKey = '[' + typeof objmap.privateKey + ']'; 179 | } 180 | if ('password' in objmap) { 181 | const type = typeof objmap.password; 182 | if (type === 'string') objmap.password = '********'; 183 | else objmap.password = '[' + type + ']'; 184 | } 185 | if ('passphrase' in objmap) { 186 | const type = typeof objmap.passphrase; 187 | if (type === 'string') objmap.passphrase = '********'; 188 | else objmap.passphrase = '[' + type + ']'; 189 | } 190 | } 191 | } 192 | return value; 193 | }; 194 | 195 | stack = replaceErrorUrl( 196 | stack, 197 | (path, line, column) => `${repath(path)}:${line}:${column}` 198 | ); 199 | output += `\n\n[Stack Trace]\n${stack}\n`; 200 | 201 | if (this.workspace) { 202 | output += '\n[ftp-kr.json]\n'; 203 | const ftpkrjson = this.workspace.child('.vscode/ftp-kr.json'); 204 | try { 205 | const readedjson = await ftpkrjson.open(); 206 | try { 207 | const obj = filterAllField(parseJson(readedjson)); 208 | output += JSON.stringify(obj, null, 4); 209 | } catch (err) { 210 | output += 'Cannot Parse: ' + err + '\n'; 211 | output += readedjson; 212 | } 213 | } catch (err) { 214 | output += 'Cannot Read: ' + err + '\n'; 215 | } 216 | } 217 | 218 | if (!extensionPathUsed) { 219 | output = extensionPath + '\n\n' + output; 220 | } 221 | vsutil.openNew(output); 222 | } else { 223 | console.error(err); 224 | const errString = err + ''; 225 | this.logRaw(LogLevelEnum.ERROR, errString); 226 | await window.showErrorMessage(errString, { modal: true }); 227 | } 228 | }) 229 | .catch(this._onError)); 230 | } 231 | 232 | public errorConfirm( 233 | err: Error | string, 234 | ...items: string[] 235 | ): Thenable { 236 | let msg: string; 237 | let error: Error; 238 | if (err instanceof Error) { 239 | msg = err.message; 240 | error = err; 241 | } else { 242 | msg = err; 243 | error = Error(err); 244 | } 245 | 246 | this.task = this.task.then(async () => { 247 | const stack = await getMappedStack(error); 248 | this.logRaw(LogLevelEnum.ERROR, stack || error + ''); 249 | }); 250 | 251 | return window.showErrorMessage( 252 | msg, 253 | { 254 | modal: true, 255 | }, 256 | ...items 257 | ); 258 | } 259 | 260 | public wrap(func: () => void): void { 261 | try { 262 | func(); 263 | } catch (err) { 264 | this.error(err); 265 | } 266 | } 267 | 268 | public show(): void { 269 | if (!this.output) return; 270 | if (this.dontOpen) return; 271 | this.output.show(); 272 | } 273 | 274 | public clear(): void { 275 | const out = this.output; 276 | if (!out) return; 277 | out.clear(); 278 | } 279 | 280 | public dispose(): void { 281 | const out = this.output; 282 | if (!out) return; 283 | out.dispose(); 284 | this.output = null; 285 | Logger.all.delete(this); 286 | } 287 | } 288 | 289 | export const defaultLogger: Logger = new Logger('ftp-kr'); 290 | -------------------------------------------------------------------------------- /src/vsutil/sftp.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { Client, ConnectConfig, SFTPWrapper } from 'ssh2'; 3 | import { FileInfo } from '../util/fileinfo'; 4 | import { ServerConfig } from '../util/serverinfo'; 5 | import { merge, promiseErrorWrap } from '../util/util'; 6 | import { FileInterface, FtpErrorCode, NOT_CREATED } from './fileinterface'; 7 | import { Workspace } from './ws'; 8 | 9 | export class SftpConnection extends FileInterface { 10 | private client: Client | null = null; 11 | private sftp: SFTPWrapper | null = null; 12 | 13 | constructor(workspace: Workspace, config: ServerConfig) { 14 | super(workspace, config); 15 | } 16 | 17 | connected(): boolean { 18 | return this.client !== null; 19 | } 20 | 21 | async _connect(password?: string): Promise { 22 | try { 23 | if (this.client) throw Error('Already created'); 24 | const client = (this.client = new Client()); 25 | if (this.config.showGreeting) { 26 | client.on('banner', (msg: string) => this.log(msg)); 27 | } 28 | 29 | let options: ConnectConfig = {}; 30 | const config = this.config; 31 | if (config.privateKey) { 32 | const keyPath = config.privateKey; 33 | const keybuf = await this.workspace.child('.vscode', keyPath).open(); 34 | options.privateKey = keybuf; 35 | options.passphrase = config.passphrase; 36 | } else { 37 | options.password = password; 38 | } 39 | options.host = config.host; 40 | (options.port = config.port ? config.port : 22), 41 | (options.username = config.username); 42 | // options.hostVerifier = (keyHash:string) => false; 43 | 44 | options = merge(options, config.sftpOverride); 45 | 46 | return await new Promise((resolve, reject) => { 47 | client.on('ready', resolve).on('error', reject).connect(options); 48 | }); 49 | } catch (err) { 50 | this._endSftp(); 51 | if (this.client) { 52 | this.client.destroy(); 53 | this.client = null; 54 | } 55 | switch (err.code) { 56 | case 'ECONNREFUSED': 57 | err.ftpCode = FtpErrorCode.CONNECTION_REFUSED; 58 | break; 59 | default: 60 | switch (err.message) { 61 | case 'Login incorrect.': 62 | case 'All configured authentication methods failed': 63 | err.ftpCode = FtpErrorCode.AUTH_FAILED; 64 | break; 65 | } 66 | } 67 | throw err; 68 | } 69 | } 70 | 71 | disconnect(): void { 72 | this._endSftp(); 73 | if (this.client) { 74 | this.client.end(); 75 | this.client = null; 76 | } 77 | } 78 | 79 | terminate(): void { 80 | this._endSftp(); 81 | if (this.client) { 82 | this.client.destroy(); 83 | this.client = null; 84 | } 85 | } 86 | 87 | exec(command: string): Promise { 88 | return promiseErrorWrap( 89 | new Promise((resolve, reject) => { 90 | if (!this.client) return reject(Error(NOT_CREATED)); 91 | this._endSftp(); 92 | this.client.exec(command, (err, stream) => { 93 | if (err) return reject(err); 94 | let data = ''; 95 | let errs = ''; 96 | stream 97 | .on( 98 | 'data', 99 | (stdout: string | undefined, stderr: string | undefined) => { 100 | if (stdout) data += stdout; 101 | if (stderr) errs += stderr; 102 | } 103 | ) 104 | .on('error', (err: any) => reject(err)) 105 | .on('exit', () => { 106 | if (errs) reject(Error(errs)); 107 | else resolve(data.trim()); 108 | stream.end(); 109 | }); 110 | }); 111 | }) 112 | ); 113 | } 114 | 115 | pwd(): Promise { 116 | return this.exec('pwd'); 117 | } 118 | 119 | private _endSftp(): void { 120 | if (this.sftp) { 121 | this.sftp.end(); 122 | this.sftp = null; 123 | } 124 | } 125 | private _getSftp(): Promise { 126 | return new Promise((resolve, reject) => { 127 | if (this.sftp) return resolve(this.sftp); 128 | if (!this.client) return reject(Error(NOT_CREATED)); 129 | this.client.sftp((err, sftp) => { 130 | this.sftp = sftp; 131 | if (err) reject(err); 132 | else resolve(sftp); 133 | }); 134 | }); 135 | } 136 | 137 | _rmdir(ftppath: string): Promise { 138 | return this._getSftp().then( 139 | (sftp) => 140 | new Promise((resolve, reject) => { 141 | return sftp.rmdir(ftppath, (err) => { 142 | if (err) { 143 | if (err.code === 2) err.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 144 | reject(err); 145 | } else { 146 | resolve(); 147 | } 148 | }); 149 | }) 150 | ); 151 | } 152 | 153 | _delete(ftppath: string): Promise { 154 | return this._getSftp().then( 155 | (sftp) => 156 | new Promise((resolve, reject) => { 157 | sftp.unlink(ftppath, (err) => { 158 | if (err) { 159 | if (err.code === 2) err.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 160 | reject(err); 161 | return false; 162 | } 163 | resolve(); 164 | }); 165 | }) 166 | ); 167 | } 168 | 169 | _mkdir(ftppath: string): Promise { 170 | return this._getSftp().then( 171 | (sftp) => 172 | new Promise((resolve, reject) => { 173 | sftp.mkdir(ftppath, (err) => { 174 | if (err) { 175 | if (err.code !== 3 && err.code !== 4 && err.code !== 5) { 176 | if (err.code === 2) { 177 | err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 178 | } 179 | return reject(err); 180 | } 181 | } 182 | resolve(); 183 | }); 184 | }) 185 | ); 186 | } 187 | 188 | _put(localpath: File, ftppath: string): Promise { 189 | return this._getSftp().then( 190 | (sftp) => 191 | new Promise((resolve, reject) => { 192 | sftp.fastPut(localpath.fsPath, ftppath, (err) => { 193 | if (err) { 194 | if (err.code === 2) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 195 | reject(err); 196 | return; 197 | } 198 | resolve(); 199 | }); 200 | }) 201 | ); 202 | } 203 | 204 | _write(buffer: Buffer, ftppath: string): Promise { 205 | return this._getSftp().then( 206 | (sftp) => 207 | new Promise((resolve, reject) => { 208 | sftp.writeFile(ftppath, buffer, (err) => { 209 | if (err) { 210 | if (err.code === 2) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 211 | reject(err); 212 | return; 213 | } 214 | resolve(); 215 | }); 216 | }) 217 | ); 218 | } 219 | 220 | _get(ftppath: string): Promise { 221 | return this._getSftp() 222 | .then( 223 | (sftp) => 224 | new Promise((resolve) => { 225 | const stream = sftp.createReadStream(ftppath, { 226 | encoding: null, 227 | }); 228 | resolve(stream); 229 | }) 230 | ) 231 | .catch((err) => { 232 | if (err.code === 2) err.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 233 | else if (err.code === 550) err.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 234 | throw err; 235 | }); 236 | } 237 | 238 | _list(ftppath: string): Promise { 239 | return this._getSftp().then( 240 | (sftp) => 241 | new Promise((resolve, reject) => { 242 | sftp.readdir(ftppath, (err, list) => { 243 | if (err) { 244 | if (err.code === 2) return resolve([]); 245 | else if (err.code === 550) return resolve([]); 246 | else reject(err); 247 | return; 248 | } 249 | 250 | if (!ftppath.endsWith('/')) ftppath += '/'; 251 | 252 | // reset file info 253 | const nlist: FileInfo[] = new Array(list.length); 254 | for (let i = 0; i < list.length; i++) { 255 | const item = list[i]; 256 | const to = new FileInfo(); 257 | to.type = item.longname.substr(0, 1); 258 | to.name = item.filename; 259 | to.date = item.attrs.mtime * 1000; 260 | to.size = +item.attrs.size; 261 | // const reg = /-/gi; 262 | // accessTime: item.attrs.atime * 1000, 263 | // rights: { 264 | // user: item.longname.substr(1, 3).replace(reg, ''), 265 | // group: item.longname.substr(4,3).replace(reg, ''), 266 | // other: item.longname.substr(7, 3).replace(reg, '') 267 | // }, 268 | // owner: item.attrs.uid, 269 | // group: item.attrs.gid 270 | nlist[i] = to; 271 | } 272 | resolve(nlist); 273 | }); 274 | }) 275 | ); 276 | } 277 | 278 | _readlink(fileinfo: FileInfo, ftppath: string): Promise { 279 | return this._getSftp().then( 280 | (sftp) => 281 | new Promise((resolve, reject) => { 282 | sftp.readlink(ftppath, (err, target) => { 283 | if (err) return reject(err); 284 | fileinfo.link = target; 285 | resolve(target); 286 | }); 287 | }) 288 | ); 289 | } 290 | 291 | _rename(ftppathFrom: string, ftppathTo: string): Promise { 292 | return this._getSftp().then( 293 | (sftp) => 294 | new Promise((resolve, reject) => { 295 | sftp.rename(ftppathFrom, ftppathTo, (err) => { 296 | if (err) reject(err); 297 | else resolve(); 298 | }); 299 | }) 300 | ); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/watcher.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { Disposable, workspace } from 'vscode'; 3 | import { Config } from './config'; 4 | import { FtpSyncManager } from './ftpsync'; 5 | import { FtpKrConfigProperties } from './util/serverinfo'; 6 | import { processError } from './vsutil/error'; 7 | import { LazyWatcher } from './vsutil/lazywatcher'; 8 | import { Logger } from './vsutil/log'; 9 | import { Scheduler, Task } from './vsutil/work'; 10 | import { Workspace, WorkspaceItem } from './vsutil/ws'; 11 | 12 | enum WatcherMode { 13 | NONE, 14 | CONFIG, 15 | FULL, 16 | } 17 | 18 | function ignoreVsCodeDir(config: FtpKrConfigProperties): void { 19 | for (let i = 0; i < config.ignore.length; ) { 20 | const ignore = config.ignore[i]; 21 | if (ignore === '/.vscode') { 22 | config.ignore.splice(i, 1); 23 | } else if (ignore.startsWith('/.vscode/')) { 24 | config.ignore.splice(i, 1); 25 | } else { 26 | i++; 27 | } 28 | } 29 | config.ignore.push('/.vscode'); 30 | } 31 | 32 | export class WorkspaceWatcher implements WorkspaceItem { 33 | private watcherQueue: Promise = Promise.resolve(); 34 | private watcher: LazyWatcher | null = null; 35 | private openWatcher: Disposable | null = null; 36 | 37 | private watcherMode: WatcherMode = WatcherMode.NONE; 38 | private openWatcherMode = false; 39 | 40 | private readonly logger: Logger; 41 | private readonly config: Config; 42 | private readonly scheduler: Scheduler; 43 | private readonly ftp: FtpSyncManager; 44 | 45 | constructor(public readonly workspace: Workspace) { 46 | this.logger = this.workspace.query(Logger); 47 | this.config = this.workspace.query(Config); 48 | this.scheduler = this.workspace.query(Scheduler); 49 | this.ftp = this.workspace.query(FtpSyncManager); 50 | this.attachWatcher(WatcherMode.CONFIG); 51 | 52 | this.config.onLoad(async (task) => { 53 | await this.ftp.onLoadConfig(); 54 | this.attachWatcher( 55 | this.config.autoUpload || this.config.autoDelete 56 | ? WatcherMode.FULL 57 | : WatcherMode.CONFIG 58 | ); 59 | this.attachOpenWatcher(this.config.autoDownload); 60 | 61 | if ( 62 | !this.config.ignoreJsonUploadCaution && 63 | !this.config.checkIgnorePath(this.config.path) 64 | ) { 65 | this.logger 66 | .errorConfirm( 67 | 'ftp-kr CAUTION: ftp-kr.json will be uploaded to remote. Are you sure?', 68 | 'Delete and Ignore /.vscode path', 69 | "It's OK" 70 | ) 71 | .then(async (selected) => { 72 | switch (selected) { 73 | case 'Delete and Ignore /.vscode path': 74 | this.config.updateIgnorePath(); 75 | for (const server of this.ftp.servers.values()) { 76 | await server.ftpDelete( 77 | server.toFtpPath( 78 | this.config.getBasePath().child('.vscode') 79 | ), 80 | task 81 | ); 82 | } 83 | await this.config.modifySave((cfg) => ignoreVsCodeDir(cfg)); 84 | break; 85 | case "It's OK": 86 | await this.config.modifySave( 87 | (cfg) => (cfg.ignoreJsonUploadCaution = true) 88 | ); 89 | break; 90 | } 91 | }); 92 | } 93 | if (this.ftp.mainServer === null) throw Error('MainServer not found'); 94 | return this.ftp.mainServer.init(task); 95 | }); 96 | this.config.onInvalid(() => { 97 | this.attachOpenWatcher(false); 98 | this.attachWatcher(WatcherMode.CONFIG); 99 | }); 100 | this.config.onNotFound(() => { 101 | this.ftp.onNotFoundConfig(); 102 | this.attachOpenWatcher(false); 103 | this.attachWatcher(WatcherMode.NONE); 104 | }); 105 | 106 | this.config.path.exists().then((exists) => { 107 | if (exists) { 108 | this.attachWatcher(WatcherMode.CONFIG); 109 | this.config.load(); 110 | } 111 | }); 112 | } 113 | 114 | dispose(): void { 115 | this.attachWatcher(WatcherMode.NONE); 116 | } 117 | 118 | async processWatcher( 119 | path: File, 120 | workFunc: (task: Task, path: File) => Promise, 121 | workName: string, 122 | autoSync: boolean 123 | ): Promise { 124 | try { 125 | if (path.fsPath == this.config.path.fsPath) { 126 | this.logger.show(); 127 | this.config.load(); 128 | if (this.watcherMode === WatcherMode.CONFIG) return; 129 | } 130 | if (!autoSync) return; 131 | if (this.config.checkIgnorePath(path)) return; 132 | if (!path.in(this.config.getBasePath())) return; 133 | await this.scheduler.taskMust( 134 | workName + ' ' + this.config.workpath(path), 135 | (task) => workFunc(task, path) 136 | ); 137 | } catch (err) { 138 | processError(this.logger, err); 139 | } 140 | } 141 | 142 | attachOpenWatcher(mode: boolean): void { 143 | if (this.openWatcherMode === mode) return; 144 | this.openWatcherMode = mode; 145 | if (mode) { 146 | this.openWatcher = workspace.onDidOpenTextDocument((e) => { 147 | try { 148 | const path = new File(e.uri.fsPath); 149 | let workspace: Workspace; 150 | try { 151 | workspace = Workspace.fromFile(path); 152 | } catch (err) { 153 | return; 154 | } 155 | const config = workspace.query(Config); 156 | const scheduler = workspace.query(Scheduler); 157 | // const logger = workspace.query(Logger); 158 | 159 | if (!config.autoDownload) return; 160 | if (config.checkIgnorePath(path)) return; 161 | if (!path.in(this.config.getBasePath())) return; 162 | scheduler 163 | .taskMust('download ' + config.workpath(path), (task) => 164 | this.ftp.targetServer.ftpDownloadWithCheck(path, task) 165 | ) 166 | .catch((err) => processError(this.logger, err)); 167 | } catch (err) { 168 | processError(this.logger, err); 169 | } 170 | }); 171 | } else { 172 | if (this.openWatcher) { 173 | this.openWatcher.dispose(); 174 | this.openWatcher = null; 175 | } 176 | } 177 | } 178 | 179 | async uploadCascade(path: File): Promise { 180 | const workspace = Workspace.fromFile(path); 181 | const config = workspace.query(Config); 182 | 183 | this.processWatcher( 184 | path, 185 | (task, path) => 186 | this.ftp.targetServer.ftpUpload(path, task, { 187 | ignoreNotExistFile: true, 188 | cancelWhenLatest: true, 189 | }), 190 | 'upload', 191 | !!config.autoUpload 192 | ); 193 | try { 194 | if (!(await path.isDirectory())) return; 195 | } catch (err) { 196 | if (err.code === 'ENOENT') { 197 | // already deleted 198 | return; 199 | } 200 | throw err; 201 | } 202 | 203 | for (const cs of await path.children()) { 204 | await this.uploadCascade(cs); 205 | } 206 | } 207 | 208 | attachWatcher(mode: WatcherMode): void { 209 | if (this.watcherMode === mode) return; 210 | if (this.watcher !== null) { 211 | this.watcher.dispose(); 212 | this.watcher = null; 213 | } 214 | this.watcherMode = mode; 215 | this.logger.verbose('watcherMode = ' + WatcherMode[mode]); 216 | 217 | let watcherPath: string; 218 | switch (this.watcherMode) { 219 | case WatcherMode.FULL: 220 | watcherPath = this.workspace.fsPath + '/**/*'; 221 | break; 222 | case WatcherMode.CONFIG: 223 | watcherPath = this.config.path.fsPath; 224 | break; 225 | case WatcherMode.NONE: 226 | this.watcher = null; 227 | return; 228 | default: 229 | return; 230 | } 231 | 232 | this.watcher = new LazyWatcher(watcherPath); 233 | 234 | this.watcher.onDidChange((uri) => { 235 | this.logger.verbose('watcher.onDidChange: ' + uri.fsPath); 236 | this.watcherQueue = this.watcherQueue 237 | .then(() => { 238 | const path = new File(uri.fsPath); 239 | return this.processWatcher( 240 | path, 241 | (task, path) => 242 | this.ftp.targetServer.ftpUpload(path, task, { 243 | ignoreNotExistFile: true, 244 | cancelWhenLatest: true, 245 | whenRemoteModed: this.config.ignoreRemoteModification 246 | ? 'upload' 247 | : 'diff', 248 | }), 249 | 'upload', 250 | !!this.config.autoUpload 251 | ); 252 | }) 253 | .catch((err) => this.logger.error(err)); 254 | }); 255 | this.watcher.onDidCreate((uri) => { 256 | const path = new File(uri.fsPath); 257 | const workspace = Workspace.fromFile(path); 258 | const logger = workspace.query(Logger); 259 | logger.verbose('watcher.onDidCreate: ' + uri.fsPath); 260 | this.watcherQueue = this.watcherQueue 261 | .then(() => { 262 | return this.uploadCascade(path); 263 | }) 264 | .catch((err) => logger.error(err)); 265 | }); 266 | this.watcher.onDidDelete((uri) => { 267 | const path = new File(uri.fsPath); 268 | const workspace = Workspace.fromFile(path); 269 | const logger = workspace.query(Logger); 270 | const config = workspace.query(Config); 271 | logger.verbose('watcher.onDidDelete: ' + uri.fsPath); 272 | this.watcherQueue = this.watcherQueue 273 | .then(() => { 274 | return this.processWatcher( 275 | path, 276 | (task, path) => { 277 | const server = this.ftp.targetServer; 278 | return server.ftpDelete(path, task); 279 | }, 280 | 'remove', 281 | !!config.autoDelete 282 | ); 283 | }) 284 | .catch((err) => logger.error(err)); 285 | }); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/vsutil/fileinterface.ts: -------------------------------------------------------------------------------- 1 | import * as iconv from 'iconv-lite'; 2 | import { File } from 'krfile'; 3 | 4 | import { FileInfo } from '../util/fileinfo'; 5 | import { ftp_path } from '../util/ftp_path'; 6 | import { Logger } from './log'; 7 | import { StateBar } from './vsutil'; 8 | import { Workspace } from './ws'; 9 | import { ServerConfig } from '../util/serverinfo'; 10 | import { promiseErrorWrap } from '../util/util'; 11 | 12 | export const NOT_CREATED = 'not created connection access'; 13 | export enum FtpErrorCode { 14 | REQUEST_RECURSIVE = 1, 15 | FILE_NOT_FOUND = 2, 16 | REUQEST_RECONNECT_AND_RETRY = 3, 17 | REUQEST_RECONNECT_AND_RETRY_ONCE = 4, 18 | CONNECTION_REFUSED = 5, 19 | AUTH_FAILED = 6, 20 | UNKNOWN = 7, 21 | } 22 | 23 | declare global { 24 | interface Error { 25 | ftpCode?: number; 26 | } 27 | } 28 | 29 | export abstract class FileInterface { 30 | protected readonly logger: Logger; 31 | protected readonly stateBar: StateBar; 32 | public oninvalidencoding: (errfiles: string[]) => void = () => {}; 33 | 34 | constructor( 35 | public readonly workspace: Workspace, 36 | protected readonly config: ServerConfig 37 | ) { 38 | this.logger = workspace.query(Logger); 39 | this.stateBar = workspace.query(StateBar); 40 | } 41 | 42 | public connect(password?: string): Promise { 43 | return promiseErrorWrap(this._connect(password)); 44 | } 45 | 46 | abstract _connect(password?: string): Promise; 47 | abstract disconnect(): void; 48 | abstract terminate(): void; 49 | abstract connected(): boolean; 50 | abstract pwd(): Promise; 51 | 52 | private bin2str(bin: string): string { 53 | const buf = iconv.encode(bin, 'binary'); 54 | return iconv.decode(buf, this.config.fileNameEncoding); 55 | } 56 | private str2bin(str: string): string { 57 | const buf = iconv.encode(str, this.config.fileNameEncoding); 58 | return iconv.decode(buf, 'binary'); 59 | } 60 | 61 | private logWithState(command: string): void { 62 | const message = this.config.name 63 | ? this.config.name + '> ' + command 64 | : command; 65 | this.stateBar.set(message); 66 | this.logger.message(message); 67 | } 68 | 69 | public log(command: string): void { 70 | const message = this.config.name 71 | ? this.config.name + '> ' + command 72 | : command; 73 | this.logger.message(message); 74 | } 75 | 76 | _callWithName( 77 | name: string, 78 | ftppath: string, 79 | ignorecode: number, 80 | defVal: T, 81 | callback: (name: string) => Promise 82 | ): Promise { 83 | this.logWithState(name + ' ' + ftppath); 84 | return promiseErrorWrap( 85 | callback(this.str2bin(ftppath)).then( 86 | (v) => { 87 | this.stateBar.close(); 88 | return v; 89 | }, 90 | (err): T => { 91 | this.stateBar.close(); 92 | if (err.ftpCode === ignorecode) return defVal; 93 | this.log(`${name} fail: ${ftppath}, ${err.message || err}`); 94 | throw err; 95 | } 96 | ) 97 | ); 98 | } 99 | 100 | upload(ftppath: string, localpath: File): Promise { 101 | this.logWithState('upload ' + ftppath); 102 | const binpath = this.str2bin(ftppath); 103 | 104 | let errorMessageOverride: string | undefined; 105 | 106 | return promiseErrorWrap( 107 | this._put(localpath, binpath).then( 108 | () => { 109 | this.stateBar.close(); 110 | }, 111 | (err) => { 112 | this.stateBar.close(); 113 | this.log( 114 | `upload fail: ${ftppath}, ${ 115 | errorMessageOverride || err.message || err 116 | }` 117 | ); 118 | throw err; 119 | } 120 | ) 121 | ); 122 | } 123 | 124 | download(localpath: File, ftppath: string): Promise { 125 | this.logWithState('download ' + ftppath); 126 | 127 | return promiseErrorWrap( 128 | this._get(this.str2bin(ftppath)).then( 129 | (stream) => { 130 | return new Promise((resolve, reject) => { 131 | stream.once('close', () => { 132 | this.stateBar.close(); 133 | resolve(); 134 | }); 135 | stream.once('error', (err: any) => { 136 | this.stateBar.close(); 137 | reject(err); 138 | }); 139 | stream.pipe(localpath.createWriteStream()); 140 | }); 141 | }, 142 | (err) => { 143 | this.stateBar.close(); 144 | this.log(`download fail: ${ftppath}, ${err.message || err}`); 145 | throw err; 146 | } 147 | ) 148 | ); 149 | } 150 | 151 | view(ftppath: string): Promise { 152 | this.logWithState('view ' + ftppath); 153 | 154 | return promiseErrorWrap( 155 | this._get(this.str2bin(ftppath)).then( 156 | (stream) => { 157 | return new Promise((resolve, reject) => { 158 | const buffers: Buffer[] = []; 159 | stream.once('close', () => { 160 | this.stateBar.close(); 161 | resolve(Buffer.concat(buffers)); 162 | }); 163 | stream.once('error', (err: any) => { 164 | this.stateBar.close(); 165 | reject(err); 166 | }); 167 | stream.on('data', (data: Buffer) => { 168 | buffers.push(data); 169 | }); 170 | }); 171 | }, 172 | (err) => { 173 | this.stateBar.close(); 174 | this.log(`view fail: ${ftppath}, ${err.message || err}`); 175 | throw err; 176 | } 177 | ) 178 | ); 179 | } 180 | 181 | write(ftppath: string, content: Buffer): Promise { 182 | this.logWithState('write ' + ftppath); 183 | 184 | return promiseErrorWrap( 185 | this._write(content, this.str2bin(ftppath)).then( 186 | () => { 187 | this.stateBar.close(); 188 | }, 189 | (err) => { 190 | this.stateBar.close(); 191 | this.log(`write fail: ${ftppath}, ${err.message || err}`); 192 | throw err; 193 | } 194 | ) 195 | ); 196 | } 197 | 198 | list(ftppath: string): Promise { 199 | if (!ftppath) ftppath = '.'; 200 | this.logWithState('list ' + ftppath); 201 | 202 | return promiseErrorWrap( 203 | this._list(this.str2bin(ftppath)).then( 204 | (list) => { 205 | this.stateBar.close(); 206 | 207 | const errfiles: string[] = []; 208 | for (let i = 0; i < list.length; i++) { 209 | const file = list[i]; 210 | const fn = (file.name = this.bin2str(file.name)); 211 | if (!this.config.ignoreWrongFileEncoding) { 212 | if (fn.indexOf('�') !== -1 || fn.indexOf('?') !== -1) 213 | errfiles.push(fn); 214 | } 215 | } 216 | if (errfiles.length) { 217 | setTimeout(() => this.oninvalidencoding(errfiles), 0); 218 | } 219 | return list; 220 | }, 221 | (err) => { 222 | this.stateBar.close(); 223 | this.log(`list fail: ${ftppath}, ${err.message || err}`); 224 | throw err; 225 | } 226 | ) 227 | ); 228 | } 229 | 230 | rmdir(ftppath: string): Promise { 231 | return this._callWithName( 232 | 'rmdir', 233 | ftppath, 234 | FtpErrorCode.FILE_NOT_FOUND, 235 | undefined, 236 | (binpath) => this._rmdir(binpath) 237 | ); 238 | } 239 | 240 | delete(ftppath: string): Promise { 241 | return this._callWithName( 242 | 'delete', 243 | ftppath, 244 | FtpErrorCode.FILE_NOT_FOUND, 245 | undefined, 246 | (binpath) => this._delete(binpath) 247 | ); 248 | } 249 | 250 | mkdir(ftppath: string): Promise { 251 | return this._callWithName('mkdir', ftppath, 0, undefined, (binpath) => 252 | this._mkdir(binpath) 253 | ); 254 | } 255 | 256 | readlink(fileinfo: FileInfo, ftppath: string): Promise { 257 | if (fileinfo.type !== 'l') throw Error(ftppath + ' is not symlink'); 258 | if (fileinfo.link !== undefined) { 259 | return Promise.resolve(fileinfo.link); 260 | } 261 | 262 | this.logWithState('readlink ' + fileinfo.name); 263 | 264 | return promiseErrorWrap( 265 | this._readlink(fileinfo, this.str2bin(ftppath)).then( 266 | (v) => { 267 | if (v.startsWith('/')) v = ftp_path.normalize(v); 268 | else v = ftp_path.normalize(ftppath + '/../' + v); 269 | fileinfo.link = v; 270 | this.stateBar.close(); 271 | return v; 272 | }, 273 | (err) => { 274 | this.stateBar.close(); 275 | this.log(`readlink fail: ${fileinfo.name}, ${err.message || err}`); 276 | throw err; 277 | } 278 | ) 279 | ); 280 | } 281 | 282 | rename(ftppathFrom: string, ftppathTo: string): Promise { 283 | this.logWithState(`rename ${ftppathFrom} ${ftppathTo}`); 284 | return promiseErrorWrap( 285 | this._rename(this.str2bin(ftppathFrom), this.str2bin(ftppathTo)).then( 286 | () => { 287 | this.stateBar.close(); 288 | }, 289 | (err) => { 290 | this.stateBar.close(); 291 | this.log( 292 | `rename fail: ${ftppathFrom} ${ftppathTo}, ${err.message || err}` 293 | ); 294 | throw err; 295 | } 296 | ) 297 | ); 298 | } 299 | 300 | abstract _mkdir(path: string): Promise; 301 | abstract _rmdir(path: string): Promise; 302 | abstract _delete(ftppath: string): Promise; 303 | abstract _put(localpath: File, ftppath: string): Promise; 304 | abstract _write(buffer: Buffer, ftppath: string): Promise; 305 | abstract _get(ftppath: string): Promise; 306 | abstract _list(ftppath: string): Promise>; 307 | abstract _readlink(fileinfo: FileInfo, ftppath: string): Promise; 308 | abstract _rename(ftppathFrom: string, ftppathTo: string): Promise; 309 | } 310 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.4.4 2 | 3 | - wait loading for command. 4 | - bugfix - mkdir/rmdir 5 | - bugix - not in remotePath 6 | 7 | # 1.4.3 8 | 9 | - ftp explorer - New File / New Directory 10 | - ftp explorer - bug fix 11 | 12 | # 1.4.2 13 | 14 | - new option - showReportMessage 15 | 16 | # 1.4.1 17 | 18 | - editable tree view 19 | 20 | # 1.3.30 21 | 22 | - new option - includeAllAlwaysForAllCommand 23 | - auto close tasks.json 24 | - reconnect on ECONNRESET 25 | - several bugfix 26 | 27 | # 1.3.22 28 | 29 | - update npm packages 30 | 31 | # 1.3.21 32 | 33 | - add a `dontOpenOutput` option 34 | 35 | # 1.3.20 36 | 37 | - remove make.json schema 38 | 39 | # 1.3.16 ~ 1.3.19 40 | 41 | - bugfix 42 | 43 | # 1.3.15 44 | 45 | - Multiple selected file upload/download support 46 | - `Download all` checks with file size now 47 | - Make `Detail` more detail 48 | - many bugfix 49 | 50 | # 1.3.14 51 | 52 | - bugfix - SFTP alt server error 53 | - bugfix - Tree view after config updated 54 | - SSH password re-ask 55 | - append js.map for debugging 56 | 57 | # 1.3.13 58 | 59 | - sftp exec bug fix (about stream count limit) 60 | 61 | # 1.3.12 62 | 63 | - Fix init error 64 | - Fix too many request view & list problem 65 | 66 | # 1.3.11 67 | 68 | - Fix diff 69 | 70 | # 1.3.10 71 | 72 | - Bugfix about cache 73 | 74 | # 1.3.9 75 | 76 | - Download/Clean All fix 77 | 78 | # 1.3.8 79 | 80 | - Crash bug fix 81 | 82 | # 1.3.7 83 | 84 | - minor bug fix 85 | 86 | # 1.3.6 87 | 88 | - minor bug fix 89 | 90 | # 1.3.5 91 | 92 | - Show warning if ftp-kr.json is uploaded 93 | 94 | # 1.3.4 95 | 96 | - "ignore" field bug fix 97 | - Reduce reconnection when modify ftp-kr.json 98 | 99 | # 1.3.3 100 | 101 | - SSH Terminal Support! 102 | - Suppress error when file not found in tree view 103 | 104 | # 1.3.2 105 | 106 | - `task.json` extra command (`upload from 'path'` & `download to 'path'`) 107 | 108 | # 1.3.1 109 | 110 | - Connection auto canceling when save config 111 | 112 | # 1.3.0 113 | 114 | - FTP TREE VIEW! 115 | - `ftp-kr: Target` - Change the main server 116 | - `task.json` extra command (`upload to 'path'` & `download from 'path'`) 117 | - Force disconnecting before reconnecting 118 | 119 | # 1.2.17 120 | 121 | - `autoDelete` bug fix 122 | 123 | # 1.2.16 124 | 125 | - add `ignoreRemoteModification` option 126 | - add `ftp-kr: Run task.json` command 127 | 128 | # 1.2.15 129 | 130 | - bug fix 131 | - remove FTP Explorer dummy view 132 | 133 | # 1.2.14 134 | 135 | - Fix linux error 136 | 137 | # 1.2.13 138 | 139 | - Conflict check and diff when auto upload 140 | - Reuse cache file 141 | 142 | # 1.2.12 143 | 144 | - Diff! 145 | 146 | # 1.2.11 147 | 148 | - Fix autoDownload bug 149 | 150 | # 1.2.10 151 | 152 | - Fix directory auto generation 153 | 154 | # 1.2.9 155 | 156 | - Update algorithms field of ssh2 schema json 157 | 158 | # 1.2.8 159 | 160 | - Fix FTP/SFTP connection exception 161 | 162 | # 1.2.7 163 | 164 | - Fix SFTP connection exception 165 | 166 | # 1.2.6 167 | 168 | - Fix path normalize error 169 | 170 | # 1.2.5 171 | 172 | - Windows share path error fix 173 | 174 | # 1.2.4 175 | 176 | - Bugfix about path 177 | 178 | # 1.2.3 179 | 180 | - Fix \*All commands 181 | - Fix list-download without local directory 182 | 183 | # 1.2.2 184 | 185 | - Fix that localBasePath is not activated if it is ends with slash 186 | - Fix that ftp-kr.task.json is generated at root 187 | - Add `followLink` option (Symlink support) 188 | 189 | # 1.2.1 190 | 191 | - Add reconnect command & block detecting 192 | - Add `localBasePath` option 193 | - Fix remote modification detection 194 | 195 | # 1.2.0 196 | 197 | - Remove closure compiler 198 | - Multi workspace support 199 | - Download/Upload Directory (in `ftp-kr.list` command) 200 | - Multi Server Support 201 | - Add Icon! 202 | 203 | ## header.header 204 | 205 | # 1.1.7 206 | 207 | - Fix `Init` command 208 | 209 | # 1.1.6 210 | 211 | - FTP fix: Fix `Cancel All` & `Download All` & `List` commands 212 | - FTP fix: Password fail reconnect 213 | 214 | # 1.1.5 215 | 216 | - Change ignore list to use previous feature 217 | - FTP rmdir bug fix when path has white space 218 | 219 | # 1.1.4 220 | 221 | - Change ignore list to use glob pattern 222 | - Ignore ENOENT error while upload 223 | 224 | # 1.1.3 225 | 226 | - Fix closure compiler error 227 | 228 | # 1.1.2 229 | 230 | - Fix closure compiler error 231 | 232 | # 1.1.1 233 | 234 | - Fix `Download All` command 235 | - Update Closure Compiler schema 236 | 237 | # 1.1.0 238 | 239 | - Cancel connection if config file is changed 240 | - Add `Cancel All` command 241 | - Prompt password when `password` field is not existed 242 | - Ignore directory not found error by `remotePath` 243 | - Closure Compiler update(closure-compiler-v20170806) 244 | - Add new bug what i don't know 245 | 246 | # 1.0.14 247 | 248 | - Add logLevel option to config 249 | 250 | # 1.0.13 251 | 252 | - Suppress duplicated input(Closure Compiler) 253 | - Save latest compile target(Closure Compiler) 254 | 255 | # 1.0.12 256 | 257 | - Whether or not ftp-kr is busy, reserve auto upload/download 258 | 259 | # 1.0.11 260 | 261 | - Show last error when use command with invalid configuration 262 | 263 | # 1.0.10 264 | 265 | - Change encoding of sftp file transfer from utf-8 to binary 266 | 267 | # 1.0.9 268 | 269 | - Fix about list 550 error 270 | - Fix upload & download with shortcut 271 | 272 | # 1.0.8 273 | 274 | - Fix tree upload by watcher 275 | - Auto save batch list when press OK 276 | - Bypass reupload issue 277 | 278 | # 1.0.7 279 | 280 | - Stop create test.txt 281 | 282 | # 1.0.6 283 | 284 | - Fix autoDownload bug... maybe? 285 | 286 | # 1.0.5 287 | 288 | - Changing infotext for reviewing sync remote to local operations. 289 | - Open log when upload/download manually 290 | 291 | # 1.0.4 292 | 293 | - Fix autoDownload bug... maybe not... 294 | 295 | # 1.0.3 296 | 297 | - Nothing 298 | 299 | # 1.0.2 300 | 301 | - Use error code instead of error number when exception check for OS compatibility 302 | - Remove unusing files 303 | 304 | # 1.0.1 305 | 306 | - Bug fix 307 | 308 | # 1.0.0 309 | 310 | - Set version to 1.0.0 without much meaning 311 | - Port all javascript to typescript 312 | - Use ftp-ssl when set protocol to `ftps` 313 | - Add `ftpOverride` and `sftpOverride` field, It can force override option of ftp/sftp connection 314 | 315 | # 0.0.26 316 | 317 | - Fix SFTP private key absolute path problem 318 | 319 | # 0.0.25 320 | 321 | - SFTP private key support 322 | ![privatekey](images/privatekey.png) 323 | 324 | # 0.0.24 325 | 326 | - Update Closure compiler to v20170124 327 | 328 | # 0.0.23 329 | 330 | - Add `autoDownload` option, It check modification and download every opening 331 | 332 | # 0.0.22 333 | 334 | - Add connectionTimeout option 335 | - Do not opens up output for every connection 336 | 337 | # 0.0.21 338 | 339 | - Fix ignore list did not apply to `Download/Clean All` command 340 | - Reverse ordering of CHANGELOG.md 341 | - Add `List` command 342 | ![list](images/list.png) 343 | 344 | # 0.0.20 345 | 346 | - Fix `Download/Clean All` commands 347 | - Add `Refresh All` command 348 | 349 | # 0.0.19 350 | 351 | - Add missing module 352 | 353 | # 0.0.18 354 | 355 | - Show notification when task takes longer then 1 second 356 | - Add SFTP support 357 | - Fix `Upload/Download this` in file menu 358 | - If use `Upload/Download this` at directory it will use `Upload/Download All` command 359 | 360 | # 0.0.17 361 | 362 | - Add generate button when not found make.json 363 | 364 | # 0.0.16 365 | 366 | - Update closure compiler to v20161201 367 | 368 | # 0.0.15 369 | 370 | - Fix disableFtp option 371 | 372 | # 0.0.14 373 | 374 | - Add disableFtp option 375 | 376 | # 0.0.13 377 | 378 | - Fix invalid error when multiple init command 379 | - Add detail button in error message 380 | - Add image to README.md 381 | 382 | # 0.0.12 383 | 384 | - Change output as ftp-kr when use Closure-Compiler 385 | - If make.json is not found use the latest one 386 | 387 | # 0.0.11 388 | 389 | - Add config.createSyncCache option! default is true 390 | - Implement array option inheritance for Closure-Compiler settings! 391 | - Add json schema 392 | - Make Json command will add new config field 393 | 394 | # 0.0.10 395 | 396 | - Fix Closure-Compiler variable option remapping 397 | 398 | # 0.0.9 399 | 400 | - Split config.autosync -> config.autoUpload & config.autoDelete 401 | - Set default value of config.autoDelete as false 402 | - Init command will add new config field 403 | 404 | # 0.0.8 405 | 406 | - Add config.fileNameEncoding option! 407 | - Fix being mute with wrong connection 408 | - Fix Upload All command 409 | - Fix Closure Compile All command 410 | - Do not stop batch work even occured error 411 | 412 | # 0.0.7 413 | 414 | - Fix download all command 415 | 416 | # 0.0.6 417 | 418 | - Fix init command 419 | 420 | # 0.0.5 421 | 422 | - Fix creating dot ended directory when open 423 | 424 | # 0.0.4 425 | 426 | - Fix init command not found error (npm package dependency error) 427 | - Fix init command error when not exists .vscode folder 428 | - Fix ignorePath error of init command when use twice 429 | - Fix download all command 430 | - Decide to use MIT license 431 | 432 | # 0.0.3 433 | 434 | - Add git repository address! 435 | 436 | # 0.0.2 437 | 438 | - Fix Closure-Compiler 439 | - Add Download This command 440 | - Add Upload This command 441 | 442 | # 0.0.1 443 | 444 | - I publish It! 445 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import 'krjson'; 3 | import { getLoadErrorMessage, LoadError } from './ftpmgr'; 4 | import { Event } from './util/event'; 5 | import { FtpKrConfig } from './util/ftpkr_config'; 6 | import { FtpKrConfigProperties } from './util/serverinfo'; 7 | import { Deferred } from './util/util'; 8 | import { processError } from './vsutil/error'; 9 | import { Logger, StringError } from './vsutil/log'; 10 | import { vsutil } from './vsutil/vsutil'; 11 | import { Scheduler, Task } from './vsutil/work'; 12 | import { Workspace, WorkspaceItem } from './vsutil/ws'; 13 | 14 | const REGEXP_MAP: { [key: string]: string } = { 15 | '.': '\\.', 16 | '+': '\\+', 17 | '?': '\\?', 18 | '[': '\\[', 19 | ']': '\\]', 20 | '^': '^]', 21 | $: '$]', 22 | '*': '[^/]*', 23 | '**': '.*', 24 | }; 25 | 26 | export enum ConfigState { 27 | LOADING, 28 | NOTFOUND, 29 | INVALID, 30 | LOADED, 31 | } 32 | 33 | function patternToRegExp(pattern: string): RegExp { 34 | let regexp = pattern.replace(/([.?+[\]^$]|\*\*?)/g, (chr) => REGEXP_MAP[chr]); 35 | if (regexp.startsWith('/')) regexp = '^' + regexp; 36 | else regexp = '.*/' + regexp; 37 | if (!regexp.endsWith('/')) regexp += '(/.*)?$'; 38 | return new RegExp(regexp); 39 | } 40 | 41 | export class Config extends FtpKrConfig implements WorkspaceItem { 42 | private state: ConfigState = ConfigState.LOADING; 43 | private lastError: unknown = null; 44 | private basePath: File | undefined = undefined; 45 | 46 | private loadingPromise: Deferred | null = new Deferred(); 47 | public readonly onLoad = Event.make(false); 48 | public readonly onLoadAfter = Event.make(false); 49 | public readonly onInvalid = Event.make(false); 50 | public readonly onNotFound = Event.make(true); 51 | 52 | private ignorePatterns: RegExp[] | null = null; 53 | private readonly logger: Logger; 54 | private readonly scheduler: Scheduler; 55 | 56 | constructor(private workspace: Workspace) { 57 | super(workspace); 58 | 59 | this.logger = workspace.query(Logger); 60 | this.scheduler = workspace.query(Scheduler); 61 | } 62 | 63 | dispose() { 64 | // do nothing 65 | } 66 | 67 | getBasePath(): File { 68 | if (this.basePath === undefined) throw Error('basePath is not defined'); 69 | return this.basePath; 70 | } 71 | 72 | public async modifySave( 73 | cb: (cfg: FtpKrConfigProperties) => void 74 | ): Promise { 75 | const json = await this.path.json(); 76 | cb(json); 77 | cb(this); 78 | await this.path.create(JSON.stringify(json, null, 4)); 79 | } 80 | 81 | public updateIgnorePath(): void { 82 | this.ignorePatterns = null; 83 | } 84 | 85 | /** 86 | * if true, path needs to ignore 87 | */ 88 | public checkIgnorePath(path: File): boolean { 89 | if (!this.ignorePatterns) { 90 | this.ignorePatterns = this.ignore.map(patternToRegExp); 91 | } 92 | 93 | const pathFromWorkspace = '/' + path.relativeFrom(this.workspace); 94 | for (const pattern of this.ignorePatterns) { 95 | if (pattern.test(pathFromWorkspace)) { 96 | return true; 97 | } 98 | } 99 | return false; 100 | } 101 | 102 | public init(): void { 103 | this.runTask('init', async () => { 104 | await this.initJson(); 105 | vsutil.open(this.path); 106 | }); 107 | } 108 | 109 | public setState(newState: ConfigState, newLastError: unknown): void { 110 | if (this.state === newState) return; 111 | this.state = newState; 112 | this.lastError = newLastError; 113 | this.logger.verbose( 114 | `${this.workspace.name}.state = ${ConfigState[newState]}` 115 | ); 116 | } 117 | 118 | public load(): void { 119 | this.loadAndRunTask('config loading', () => this.readJson()); 120 | } 121 | 122 | private fireNotFound(): Promise { 123 | if (this.state === ConfigState.NOTFOUND) return Promise.resolve(); 124 | 125 | this.setState(ConfigState.NOTFOUND, 'NOTFOUND'); 126 | return this.onNotFound.fire(); 127 | } 128 | 129 | private fireInvalid(err: unknown): Promise { 130 | if (this.state === ConfigState.INVALID) return Promise.resolve(); 131 | 132 | this.setState(ConfigState.INVALID, err); 133 | return this.onInvalid.fire(); 134 | } 135 | 136 | private async onLoadError(err: unknown): Promise { 137 | let throwValue: unknown; 138 | switch (err) { 139 | case LoadError.NOTFOUND: 140 | this.logger.message('/.vscode/ftp-kr.json: Not found'); 141 | await this.fireNotFound(); 142 | throwValue = StringError.IGNORE; 143 | break; 144 | case LoadError.CONNECTION_FAILED: 145 | vsutil.info('ftp-kr Connection Failed', 'Retry').then((confirm) => { 146 | if (confirm === 'Retry') { 147 | this.loadAndRunTask('login'); 148 | } 149 | }); 150 | await this.fireInvalid(err); 151 | throwValue = StringError.IGNORE; 152 | break; 153 | case LoadError.PASSWORD_CANCEL: 154 | vsutil.info('ftp-kr Login Request', 'Login').then((confirm) => { 155 | if (confirm === 'Login') { 156 | this.loadAndRunTask('login'); 157 | } 158 | }); 159 | await this.fireInvalid(err); 160 | throwValue = StringError.IGNORE; 161 | break; 162 | case LoadError.AUTH_FAILED: 163 | this.logger.message(getLoadErrorMessage(err)); 164 | await this.fireInvalid(err); 165 | throwValue = StringError.IGNORE; 166 | break; 167 | default: 168 | if (err instanceof Error) err.file = this.path; 169 | await this.fireInvalid(err); 170 | throwValue = err; 171 | break; 172 | } 173 | if (this.loadingPromise !== null) { 174 | this.loadingPromise.reject(throwValue); 175 | this.loadingPromise = null; 176 | } 177 | throw throwValue; 178 | } 179 | 180 | public loadTest(): Promise { 181 | if (this.state !== ConfigState.LOADED) { 182 | if (this.state === ConfigState.NOTFOUND) { 183 | return Promise.reject('ftp-kr.json is not defined'); 184 | } 185 | if (this.state === ConfigState.LOADING) { 186 | return this.loadingPromise!; 187 | } 188 | return this.onLoadError(this.lastError); 189 | } 190 | return Promise.resolve(); 191 | } 192 | 193 | /** 194 | * path from localBasePath 195 | */ 196 | public workpath(file: File): string { 197 | if (this.basePath === undefined) throw Error('basePath is not defined'); 198 | const workpath = file.relativeFrom(this.basePath); 199 | if (workpath === undefined) { 200 | if (this.basePath !== this.workspace) { 201 | throw Error(`${file.fsPath} is not in localBasePath`); 202 | } else { 203 | throw Error(`${file.fsPath} is not in workspace`); 204 | } 205 | } 206 | return '/' + workpath; 207 | } 208 | 209 | public fromWorkpath(workpath: string, parent?: File): File { 210 | if (this.basePath === undefined) throw Error('basePath is not defined'); 211 | if (workpath.startsWith('/')) { 212 | return this.basePath.child(workpath.substr(1)); 213 | } else { 214 | return (parent ?? this.basePath).child(workpath); 215 | } 216 | } 217 | 218 | private async runTask( 219 | name: string, 220 | onwork: (task: Task) => Promise 221 | ): Promise { 222 | await this.scheduler.cancel(); 223 | try { 224 | await this.scheduler.taskMust(name, async (task) => { 225 | await onwork(task); 226 | 227 | this.ignorePatterns = null; 228 | if (this.localBasePath) { 229 | this.basePath = this.workspace.child(this.localBasePath); 230 | } else { 231 | this.basePath = this.workspace; 232 | } 233 | }); 234 | } catch (err) { 235 | processError(this.logger, err); 236 | } 237 | } 238 | private async loadAndRunTask( 239 | name: string, 240 | taskBefore?: (task: Task) => Promise 241 | ): Promise { 242 | await this.scheduler.cancel(); 243 | try { 244 | await this.scheduler.taskMust(name, async (task) => { 245 | try { 246 | this.logger.message('/.vscode/ftp-kr.json: Loading'); 247 | this.loadingPromise = new Deferred(); 248 | this.setState(ConfigState.LOADING, null); 249 | if (taskBefore !== undefined) await taskBefore(task); 250 | 251 | this.ignorePatterns = null; 252 | if (this.localBasePath) { 253 | this.basePath = this.workspace.child(this.localBasePath); 254 | } else { 255 | this.basePath = this.workspace; 256 | } 257 | 258 | this.logger.dontOpen = this.dontOpenOutput; 259 | this.logger.setLogLevel(this.logLevel); 260 | 261 | await this.onLoad.fire(task); 262 | this.logger.message('/.vscode/ftp-kr.json: Loaded successfully'); 263 | this.setState(ConfigState.LOADED, null); 264 | if (this.loadingPromise !== null) { 265 | this.loadingPromise.resolve(); 266 | this.loadingPromise = null; 267 | } 268 | } catch (err) { 269 | await this.onLoadError(err); 270 | } 271 | }); 272 | await this.onLoadAfter.fire(); 273 | } catch (err) { 274 | processError(this.logger, err); 275 | } 276 | } 277 | 278 | public reportTaskCompletion(taskname: string, startTime: number): void { 279 | if (this.showReportMessage !== false) { 280 | const passedTime = Date.now() - startTime; 281 | if (passedTime >= this.showReportMessage) { 282 | vsutil.info(taskname + ' completed'); 283 | } 284 | } 285 | this.logger.show(); 286 | this.logger.message(taskname + ' completed'); 287 | } 288 | 289 | public async reportTaskCompletionPromise( 290 | taskname: string, 291 | taskpromise: Promise 292 | ): Promise { 293 | const startTime = Date.now(); 294 | const res = await taskpromise; 295 | if (this.showReportMessage !== false) { 296 | const passedTime = Date.now() - startTime; 297 | if (passedTime >= this.showReportMessage) { 298 | vsutil.info(taskname + ' completed'); 299 | } 300 | } 301 | return res; 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/vsutil/ftp.ts: -------------------------------------------------------------------------------- 1 | import FtpClientO = require('ftp'); 2 | import { File } from 'krfile'; 3 | import * as stream from 'stream'; 4 | 5 | import { FileInfo } from '../util/fileinfo'; 6 | import * as util from '../util/util'; 7 | 8 | import { printMappedError } from '../util/sm'; 9 | import { FileInterface, FtpErrorCode, NOT_CREATED } from './fileinterface'; 10 | 11 | class FtpClient extends FtpClientO { 12 | // list(path: string, useCompression: boolean, callback: (error: Error, listing: Client.ListingElement[]) => void): void; 13 | // list(path: string, callback: (error: Error, listing: Client.ListingElement[]) => void): void; 14 | // list(useCompression: boolean, callback: (error: Error, listing: Client.ListingElement[]) => void): void; 15 | // list(callback: (error: Error, listing: Client.ListingElement[]) => void): void; 16 | 17 | public list( 18 | path: 19 | | string 20 | | boolean 21 | | ((error: Error, listing: FtpClientO.ListingElement[]) => void), 22 | zcomp?: 23 | | boolean 24 | | ((error: Error, listing: FtpClientO.ListingElement[]) => void), 25 | cb?: (error: Error, listing: FtpClientO.ListingElement[]) => void 26 | ): void { 27 | let pathcmd: string; 28 | if (typeof path === 'string') { 29 | pathcmd = '-al ' + path; 30 | if (typeof zcomp === 'function') { 31 | cb = zcomp; 32 | zcomp = false; 33 | } else if (typeof zcomp === 'boolean') { 34 | if (!cb) throw Error('Invalid parameter'); 35 | } else { 36 | if (!cb) throw Error('Invalid parameter'); 37 | zcomp = false; 38 | } 39 | } else if (typeof path === 'boolean') { 40 | if (typeof zcomp !== 'function') { 41 | throw Error('Invalid parameter'); 42 | } 43 | cb = zcomp; 44 | zcomp = path; 45 | pathcmd = '-al'; 46 | path = ''; 47 | } else { 48 | cb = path; 49 | zcomp = false; 50 | pathcmd = '-al'; 51 | path = ''; 52 | } 53 | if (path.indexOf(' ') === -1) return super.list(pathcmd, zcomp, cb); 54 | 55 | const path_ = path; 56 | const callback = cb; 57 | 58 | // store current path 59 | this.pwd((err, origpath) => { 60 | if (err) return callback(err, []); 61 | // change to destination path 62 | this.cwd(path_, (err) => { 63 | if (err) return callback(err, []); 64 | // get dir listing 65 | super.list('-al', false, (err, list) => { 66 | // change back to original path 67 | if (err) return this.cwd(origpath, () => callback(err, [])); 68 | this.cwd(origpath, (err) => { 69 | if (err) return callback(err, []); 70 | callback(err, list); 71 | }); 72 | }); 73 | }); 74 | }); 75 | } 76 | 77 | public terminate() { 78 | const anythis = this; 79 | if (anythis._pasvSock) { 80 | if (anythis._pasvSock.writable) anythis._pasvSock.destroy(); 81 | anythis._pasvSock = undefined; 82 | } 83 | if (anythis._socket) { 84 | if (anythis._socket.writable) anythis._socket.destroy(); 85 | anythis._socket = undefined; 86 | } 87 | anythis._reset(); 88 | } 89 | } 90 | 91 | export class FtpConnection extends FileInterface { 92 | client: FtpClient | null = null; 93 | 94 | connected(): boolean { 95 | return this.client !== null; 96 | } 97 | 98 | async _connect(password?: string): Promise { 99 | try { 100 | if (this.client) throw Error('Already created'); 101 | const client = (this.client = new FtpClient()); 102 | if (this.config.showGreeting) { 103 | client.on('greeting', (msg: string) => this.log(msg)); 104 | } 105 | 106 | let options: FtpClientO.Options; 107 | const config = this.config; 108 | if (config.protocol === 'ftps' || config.secure) { 109 | options = { 110 | secure: true, 111 | secureOptions: { 112 | rejectUnauthorized: false, 113 | //checkServerIdentity: (servername, cert)=>{} 114 | }, 115 | }; 116 | } else { 117 | options = {}; 118 | } 119 | 120 | options.host = config.host; 121 | options.port = config.port ? config.port : 21; 122 | options.user = config.username; 123 | options.password = password; 124 | 125 | options = util.merge(options, config.ftpOverride); 126 | 127 | return await new Promise((resolve, reject) => { 128 | client 129 | .on('ready', () => { 130 | if (!client) return reject(Error(NOT_CREATED)); 131 | 132 | const socket: stream.Duplex = (client)._socket; 133 | const oldwrite = socket.write; 134 | socket.write = (str: string) => 135 | oldwrite.call(socket, str, 'binary' as any); // XXX: TS bug, overloading is not considered. 136 | socket.setEncoding('binary'); 137 | client.binary((err) => { 138 | if (err) printMappedError(err); 139 | resolve(); 140 | }); 141 | }) 142 | .on('error', reject) 143 | .connect(options); 144 | }); 145 | } catch (err) { 146 | if (this.client) { 147 | this.client.terminate(); 148 | this.client = null; 149 | } 150 | switch (err.code) { 151 | case 530: 152 | err.ftpCode = FtpErrorCode.AUTH_FAILED; 153 | break; 154 | case 'ECONNREFUSED': 155 | err.ftpCode = FtpErrorCode.CONNECTION_REFUSED; 156 | break; 157 | } 158 | throw err; 159 | } 160 | } 161 | 162 | disconnect(): void { 163 | if (this.client) { 164 | this.client.end(); 165 | this.client = null; 166 | } 167 | } 168 | 169 | terminate(): void { 170 | if (this.client) { 171 | this.client.terminate(); 172 | this.client = null; 173 | } 174 | } 175 | 176 | pwd(): Promise { 177 | const client = this.client; 178 | if (!client) return Promise.reject(Error(NOT_CREATED)); 179 | 180 | return new Promise((resolve, reject) => { 181 | client.pwd((err, path) => { 182 | if (err) reject(err); 183 | else resolve(path); 184 | }); 185 | }); 186 | } 187 | 188 | static wrapToPromise( 189 | callback: (cb: (err: Error, val: T) => void) => void 190 | ): Promise { 191 | return new Promise((resolve, reject) => 192 | callback((err, val) => { 193 | if (err) reject(err); 194 | else resolve(val); 195 | }) 196 | ); 197 | } 198 | 199 | _rmdir(ftppath: string): Promise { 200 | const client = this.client; 201 | if (!client) return Promise.reject(Error(NOT_CREATED)); 202 | 203 | return FtpConnection.wrapToPromise((callback) => 204 | client.rmdir(ftppath, callback) 205 | ).catch((err) => { 206 | if (err.code === 'ECONNRESET') 207 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 208 | else if (err.code === 550) err.ftpCode = FtpErrorCode.UNKNOWN; 209 | throw err; 210 | }); 211 | } 212 | 213 | _mkdir(ftppath: string): Promise { 214 | const client = this.client; 215 | if (!client) return Promise.reject(Error(NOT_CREATED)); 216 | 217 | return FtpConnection.wrapToPromise((callback) => 218 | client.mkdir(ftppath, callback) 219 | ).catch((err) => { 220 | if (err.code === 521 || err.code === 550) { 221 | err.ftpCode = FtpErrorCode.UNKNOWN; 222 | } 223 | throw err; 224 | }); 225 | } 226 | 227 | _delete(ftppath: string): Promise { 228 | const client = this.client; 229 | if (!client) return Promise.reject(Error(NOT_CREATED)); 230 | return FtpConnection.wrapToPromise((callback) => 231 | client.delete(ftppath, callback) 232 | ).catch((e) => { 233 | if (e.code === 550) e.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 234 | throw e; 235 | }); 236 | } 237 | 238 | _put(localpath: File, ftppath: string): Promise { 239 | const client = this.client; 240 | if (!client) return Promise.reject(Error(NOT_CREATED)); 241 | return FtpConnection.wrapToPromise((callback) => 242 | client.put(localpath.fsPath, ftppath, callback) 243 | ).catch((err) => { 244 | if (err.code === 'ECONNRESET') 245 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 246 | else if (err.code === 451) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 247 | else if (err.code === 553) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 248 | else if (err.code === 550) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 249 | throw err; 250 | }); 251 | } 252 | 253 | _write(buffer: Buffer, ftppath: string): Promise { 254 | const client = this.client; 255 | if (!client) return Promise.reject(Error(NOT_CREATED)); 256 | return FtpConnection.wrapToPromise((callback) => 257 | client.put(buffer, ftppath, callback) 258 | ).catch((err) => { 259 | if (err.code === 'ECONNRESET') 260 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 261 | else if (err.code === 451) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 262 | else if (err.code === 553) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 263 | else if (err.code === 550) err.ftpCode = FtpErrorCode.REQUEST_RECURSIVE; 264 | throw err; 265 | }); 266 | } 267 | 268 | _get(ftppath: string): Promise { 269 | const client = this.client; 270 | if (!client) return Promise.reject(Error(NOT_CREATED)); 271 | return FtpConnection.wrapToPromise((callback) => 272 | client.get(ftppath, callback) 273 | ).catch((err) => { 274 | if (err.code === 'ECONNRESET') 275 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 276 | else if (err.code === 550) err.ftpCode = FtpErrorCode.FILE_NOT_FOUND; 277 | throw err; 278 | }); 279 | } 280 | 281 | _list(ftppath: string): Promise { 282 | const client = this.client; 283 | if (!client) return Promise.reject(Error(NOT_CREATED)); 284 | return FtpConnection.wrapToPromise( 285 | (callback) => client.list(ftppath, false, callback) 286 | ).then( 287 | (list) => 288 | list.map((from) => { 289 | const to = new FileInfo(); 290 | to.type = from.type; 291 | to.name = from.name; 292 | to.date = +from.date; 293 | to.size = +from.size; 294 | to.link = from.target; 295 | return to; 296 | }), 297 | (err) => { 298 | if (err.code === 550) return []; 299 | if (err.code === 'ECONNRESET') 300 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 301 | throw err; 302 | } 303 | ); 304 | } 305 | 306 | _readlink(fileinfo: FileInfo, ftppath: string): Promise { 307 | if (fileinfo.link === undefined) 308 | return Promise.reject(ftppath + ' is not symlink'); 309 | return Promise.resolve(fileinfo.link); 310 | } 311 | 312 | _rename(ftppathFrom: string, ftppathTo: string): Promise { 313 | const client = this.client; 314 | if (!client) return Promise.reject(Error(NOT_CREATED)); 315 | return FtpConnection.wrapToPromise((callback) => 316 | client.rename(ftppathFrom, ftppathTo, callback) 317 | ).catch((err) => { 318 | if (err.code === 'ECONNRESET') 319 | err.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE; 320 | throw err; 321 | }); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/util/ftpkr_config.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { parseJson } from 'krjson'; 3 | import { ConfigContainer } from './config'; 4 | import { ftp_path } from './ftp_path'; 5 | import { FtpKrConfigProperties, ServerConfig } from './serverinfo'; 6 | import * as util from './util'; 7 | 8 | export const DEFAULT_IGNORE_LIST = ['.git', '/.vscode']; 9 | 10 | const CONFIG_INIT: FtpKrConfigProperties = { 11 | host: '', 12 | username: '', 13 | password: '', 14 | remotePath: '', 15 | protocol: 'ftp', 16 | port: 0, 17 | fileNameEncoding: 'utf8', 18 | autoUpload: true, 19 | autoDelete: false, 20 | autoDownload: false, 21 | ignore: DEFAULT_IGNORE_LIST, 22 | }; 23 | 24 | function throwJsonError(data: string, match: RegExp, message: string): never { 25 | const matched = data.match(match); 26 | const err = Error(message); 27 | if (matched) { 28 | if (matched.index) { 29 | const { line, column } = util.getFilePosition( 30 | data, 31 | matched.index + matched[0].length 32 | ); 33 | err.line = line; 34 | err.column = column; 35 | } 36 | } 37 | err.suppress = true; 38 | throw err; 39 | } 40 | 41 | function findUndupplicatedSet( 42 | dupPriority: (keyof T)[], 43 | obj: T, 44 | objs: T[] 45 | ): (number | string | symbol)[] { 46 | const dupmap: { [key: number | string | symbol]: Set } = {}; 47 | for (const prop of dupPriority) { 48 | const set = new Set(); 49 | const value = obj[prop]; 50 | for (const other of objs) { 51 | if (other === obj) continue; 52 | if (other[prop] === value) { 53 | set.add(other); 54 | break; 55 | } 56 | } 57 | dupmap[prop] = set; 58 | } 59 | 60 | function testDup(keys: (number | string | symbol)[]): boolean { 61 | _notdup: for (const other of objs) { 62 | if (other === obj) continue; 63 | for (const key of keys) { 64 | if (dupmap[key].has(other)) continue; 65 | continue _notdup; 66 | } 67 | return false; 68 | } 69 | return true; 70 | } 71 | 72 | const all = 1 << dupPriority.length; 73 | for (let i = 1; i < all; i++) { 74 | let v = i; 75 | const arr: (number | string | symbol)[] = []; 76 | for (const prop of dupPriority) { 77 | if (v & 1) arr.push(prop); 78 | v >>= 1; 79 | } 80 | if (testDup(arr)) return arr; 81 | } 82 | return []; 83 | } 84 | 85 | class FtpKrConfigClass extends ConfigContainer { 86 | public readonly path: File; 87 | 88 | constructor(workspaceDir: File) { 89 | super(FtpKrConfigProperties.keys); 90 | 91 | this.path = workspaceDir.child('./.vscode/ftp-kr.json'); 92 | 93 | this._configTypeClearing(); 94 | } 95 | 96 | dispose() {} 97 | 98 | private _serverTypeClearing(config: ServerConfig, index: number): void { 99 | if (typeof config.remotePath !== 'string') config.remotePath = '.'; 100 | else { 101 | config.remotePath = ftp_path.normalize(config.remotePath); 102 | if (config.remotePath === '/') config.remotePath = ''; 103 | } 104 | 105 | if (typeof config.protocol !== 'string') config.protocol = 'ftp'; 106 | if (typeof config.fileNameEncoding !== 'string') 107 | config.fileNameEncoding = 'utf8'; 108 | 109 | if (typeof config.host !== 'string') config.host = config.host + ''; 110 | if (typeof config.username !== 'string') 111 | config.username = config.username + ''; 112 | config.secure = config.secure === true; 113 | if ('port' in config) config.port = (config.port || 0) | 0; 114 | config.ignoreWrongFileEncoding = config.ignoreWrongFileEncoding === true; 115 | if ('name' in config) config.name = config.name + ''; 116 | 117 | if ('password' in config) config.password = config.password + ''; 118 | config.keepPasswordInMemory = config.keepPasswordInMemory !== false; 119 | 120 | if ('passphrase' in config) config.passphrase = config.passphrase + ''; 121 | config.connectionTimeout = Number(config.connectionTimeout); 122 | if (isNaN(config.connectionTimeout)) config.connectionTimeout = 60000; 123 | config.autoDownloadRefreshTime = config.refreshTime = Number( 124 | config.refreshTime || config.autoDownloadRefreshTime || 1000 125 | ); 126 | config.blockDetectingDuration = Number(config.blockDetectingDuration); 127 | if (isNaN(config.blockDetectingDuration)) 128 | config.blockDetectingDuration = 8000; 129 | if ('privateKey' in config) config.privateKey = config.privateKey + ''; 130 | config.showGreeting = config.showGreeting === true; 131 | if (typeof config.ftpOverride !== 'object') delete config.ftpOverride; 132 | if (typeof config.sftpOverride !== 'object') delete config.sftpOverride; 133 | 134 | // generate field 135 | config.index = index; 136 | let url = config.protocol; 137 | url += '://'; 138 | url += config.host; 139 | if (config.port) { 140 | url += ':'; 141 | url += config.port; 142 | } 143 | url += '/'; 144 | url += config.remotePath; 145 | config.url = url; 146 | 147 | let hostUrl = config.protocol; 148 | hostUrl += '://'; 149 | hostUrl += config.username; 150 | hostUrl += '@'; 151 | hostUrl += config.host; 152 | if (config.port) { 153 | hostUrl += ':'; 154 | hostUrl += config.port; 155 | } 156 | config.hostUrl = hostUrl; 157 | 158 | delete config.passwordInMemory; 159 | } 160 | 161 | private _configTypeClearing(): void { 162 | // x !== false : default is true 163 | // x === true : default is false 164 | 165 | const config = (this); 166 | if (!(config.ignore instanceof Array)) config.ignore = DEFAULT_IGNORE_LIST; 167 | config.autoUpload = config.autoUpload === true; 168 | config.autoDelete = config.autoDelete === true; 169 | config.autoDownload = config.autoDownload === true; 170 | if (!(config.altServer instanceof Array)) config.altServer = []; 171 | if (typeof config.localBasePath === 'string' && config.localBasePath) { 172 | // empty 173 | } else { 174 | delete config.localBasePath; 175 | } 176 | config.followLink = config.followLink === true; 177 | if ('autoDownloadAlways' in config) 178 | config.autoDownloadAlways = Number(config.autoDownloadAlways || 0); 179 | config.createSyncCache = config.createSyncCache !== false; 180 | switch (config.logLevel) { 181 | case 'VERBOSE': 182 | case 'NORMAL': 183 | case 'ERROR': 184 | break; 185 | default: 186 | config.logLevel = 'NORMAL'; 187 | break; 188 | } 189 | config.dontOpenOutput = config.dontOpenOutput === true; 190 | config.viewSizeLimit = Number(config.viewSizeLimit || 1024 * 1024 * 4); 191 | config.downloadTimeExtraThreshold = Number( 192 | config.downloadTimeExtraThreshold || 1000 193 | ); 194 | config.ignoreRemoteModification = config.ignoreRemoteModification === true; 195 | if (typeof config.noticeFileCount !== 'number') config.noticeFileCount = 10; 196 | else { 197 | config.noticeFileCount = +config.noticeFileCount; 198 | if (config.noticeFileCount < 0) config.noticeFileCount = 10; 199 | else if (!isFinite(config.noticeFileCount)) config.noticeFileCount = 10; 200 | else config.noticeFileCount = Math.floor(config.noticeFileCount); 201 | } 202 | config.ignoreJsonUploadCaution = config.ignoreJsonUploadCaution === true; 203 | config.includeAllAlwaysForAllCommand = 204 | config.includeAllAlwaysForAllCommand === true; 205 | switch (typeof config.showReportMessage) { 206 | case 'number': 207 | break; 208 | case 'boolean': 209 | if ((config.showReportMessage as any) === true) 210 | config.showReportMessage = 1000; 211 | break; 212 | default: 213 | config.showReportMessage = 1000; 214 | break; 215 | } 216 | 217 | delete config.name; 218 | 219 | this._serverTypeClearing(config, 0); 220 | for (let i = 0; i < config.altServer.length; ) { 221 | if (typeof config.altServer[i] !== 'object') { 222 | config.altServer.splice(i, 1); 223 | } else { 224 | const altcfg = config.altServer[i++]; 225 | this._serverTypeClearing(altcfg, i); 226 | } 227 | } 228 | } 229 | 230 | public set(data: string): void { 231 | let obj: FtpKrConfigProperties; 232 | try { 233 | obj = parseJson(data); 234 | } catch (err) { 235 | err.file = this.path; 236 | throw err; 237 | } 238 | if (!(obj instanceof Object)) { 239 | const error = new TypeError('Invalid json data type: ' + typeof obj); 240 | error.suppress = true; 241 | throw error; 242 | } 243 | if (typeof obj.host !== 'string') { 244 | throwJsonError( 245 | data, 246 | /"host"[ \t\r\n]*:[ \t\r\n]*/, 247 | 'host field must be string' 248 | ); 249 | } 250 | if (!obj.host) { 251 | throwJsonError(data, /"host"[ \t\r\n]*:[ \t\r\n]*/, 'Need host'); 252 | } 253 | if (typeof obj.username !== 'string') { 254 | throwJsonError( 255 | data, 256 | /"username"[ \t\r\n]*:[ \t\r\n]*/, 257 | 'username field must be string' 258 | ); 259 | } 260 | if (!obj.username) { 261 | throwJsonError( 262 | data, 263 | /"username"[ \t\r\n]*:/, 264 | 'username field must be string' 265 | ); 266 | } 267 | 268 | switch (obj.protocol) { 269 | case 'ftps': 270 | case 'sftp': 271 | case 'ftp': 272 | break; 273 | default: 274 | throwJsonError( 275 | data, 276 | /"username"[ \t\r\n]*:/, 277 | `Unsupported protocol "${obj.protocol}"` 278 | ); 279 | } 280 | this.clearConfig(); 281 | this.appendConfig(obj); 282 | 283 | const config = (this); 284 | 285 | if (!config.altServer || config.altServer.length === 0) { 286 | this._configTypeClearing(); 287 | return; 288 | } 289 | 290 | const dupPriority: (keyof ServerConfig)[] = [ 291 | 'name', 292 | 'host', 293 | 'protocol', 294 | 'port', 295 | 'remotePath', 296 | 'username', 297 | ]; 298 | const servers = config.altServer; 299 | 300 | function removeFullDupped(): void { 301 | const fulldupped = new Set(); 302 | _fullDupTest: for (const prop of dupPriority) { 303 | for (const server of servers) { 304 | if (!(prop in server)) continue; 305 | if (config[prop] !== server[prop]) continue _fullDupTest; 306 | } 307 | fulldupped.add(prop); 308 | } 309 | for (let i = 0; i < dupPriority.length; ) { 310 | if (fulldupped.has(dupPriority[i])) { 311 | dupPriority.splice(i, 1); 312 | } else { 313 | i++; 314 | } 315 | } 316 | } 317 | 318 | removeFullDupped(); 319 | 320 | for (const server of servers) { 321 | // copy main config 322 | for (const p of this.properties) { 323 | if (!(p in config)) continue; 324 | if (p in server) continue; 325 | (server)[p] = util.clone(config[p]); 326 | } 327 | 328 | // make dupmap 329 | const usedprop: (number | string | symbol)[] = findUndupplicatedSet( 330 | dupPriority, 331 | server, 332 | servers 333 | ); 334 | const nameidx = usedprop.indexOf('name'); 335 | if (nameidx !== -1) usedprop.splice(nameidx, 1); 336 | 337 | let altname = ''; 338 | if (usedprop.length !== 0) { 339 | if (server.host) altname = server.host; 340 | for (const prop of usedprop) { 341 | switch (prop) { 342 | case 'protocol': 343 | altname = server.protocol + '://' + altname; 344 | break; 345 | case 'port': 346 | altname += ':' + server.port; 347 | break; 348 | case 'remotePath': 349 | altname += '/' + server.remotePath; 350 | break; 351 | case 'username': 352 | altname += '@' + server.username; 353 | break; 354 | } 355 | } 356 | } 357 | if (altname) { 358 | if (server.name) server.name += `(${altname})`; 359 | else server.name = altname; 360 | } else { 361 | if (!server.name) server.name = server.host || ''; 362 | } 363 | } 364 | 365 | this._configTypeClearing(); 366 | } 367 | 368 | public async initJson(): Promise { 369 | let obj; 370 | let data = ''; 371 | let changed = false; 372 | try { 373 | data = await this.path.open(); 374 | obj = parseJson(data); 375 | for (const p in CONFIG_INIT) { 376 | if (p in obj) continue; 377 | obj[p] = CONFIG_INIT[p as keyof FtpKrConfigProperties]; 378 | changed = true; 379 | } 380 | } catch (err) { 381 | obj = CONFIG_INIT; 382 | changed = true; 383 | } 384 | if (changed) { 385 | data = JSON.stringify(obj, null, 4); 386 | await this.path.create(data); 387 | } 388 | // this.set(data); // no need to call. the watcher will catch it. 389 | } 390 | 391 | public async readJson(): Promise { 392 | let data: string; 393 | try { 394 | data = await this.path.open(); 395 | } catch (err) { 396 | throw 'NOTFOUND'; 397 | } 398 | this.set(data); 399 | } 400 | } 401 | 402 | export type FtpKrConfig = FtpKrConfigClass & FtpKrConfigProperties; 403 | export const FtpKrConfig = <{ new (workspaceDir: File): FtpKrConfig }>( 404 | (FtpKrConfigClass) 405 | ); 406 | -------------------------------------------------------------------------------- /src/ftpmgr.ts: -------------------------------------------------------------------------------- 1 | import { File } from 'krfile'; 2 | import { window } from 'vscode'; 3 | 4 | import { FileInfo } from './util/fileinfo'; 5 | import { ServerConfig } from './util/serverinfo'; 6 | 7 | import { FileInterface, FtpErrorCode } from './vsutil/fileinterface'; 8 | import { FtpConnection } from './vsutil/ftp'; 9 | import { Logger, StringError } from './vsutil/log'; 10 | import { SftpConnection } from './vsutil/sftp'; 11 | import { vsutil } from './vsutil/vsutil'; 12 | import { Task } from './vsutil/work'; 13 | import { Workspace } from './vsutil/ws'; 14 | 15 | import { Config } from './config'; 16 | import { promiseErrorWrap } from './util/util'; 17 | import { ftp_path } from './util/ftp_path'; 18 | 19 | function createClient( 20 | workspace: Workspace, 21 | config: ServerConfig 22 | ): FileInterface { 23 | let newclient: FileInterface; 24 | switch (config.protocol) { 25 | case 'sftp': 26 | newclient = new SftpConnection(workspace, config); 27 | break; 28 | case 'ftp': 29 | newclient = new FtpConnection(workspace, config); 30 | break; 31 | case 'ftps': 32 | newclient = new FtpConnection(workspace, config); 33 | break; 34 | default: 35 | throw Error(`Invalid protocol ${config.protocol}`); 36 | } 37 | return newclient; 38 | } 39 | 40 | export enum LoadError { 41 | NOTFOUND = 'NOTFOUND', 42 | CONNECTION_FAILED = 'CONNECTION_FAILED', 43 | PASSWORD_CANCEL = 'PASSWORD_CANCEL', 44 | AUTH_FAILED = 'AUTH_FAILED', 45 | } 46 | 47 | export function getLoadErrorMessage(err: LoadError): string { 48 | switch (err) { 49 | case LoadError.NOTFOUND: 50 | return 'Not Found'; 51 | case LoadError.CONNECTION_FAILED: 52 | return 'Connection Failed'; 53 | case LoadError.PASSWORD_CANCEL: 54 | return 'Password Cancel'; 55 | case LoadError.AUTH_FAILED: 56 | return 'Authentication Failed. Invalid username or password'; 57 | } 58 | } 59 | 60 | export class FtpManager { 61 | private client: FileInterface | null = null; 62 | private connectionInfo = ''; 63 | private destroyTimeout: NodeJS.Timer | null = null; 64 | private cancelBlockedCommand: (() => void) | null = null; 65 | private currentTask: Task | null = null; 66 | private connected = false; 67 | public home = ''; 68 | 69 | private readonly logger: Logger; 70 | 71 | constructor( 72 | public readonly workspace: Workspace, 73 | public readonly config: ServerConfig 74 | ) { 75 | this.logger = workspace.query(Logger); 76 | } 77 | 78 | private _cancelDestroyTimeout(): void { 79 | if (!this.destroyTimeout) return; 80 | 81 | clearTimeout(this.destroyTimeout); 82 | this.destroyTimeout = null; 83 | } 84 | 85 | private _updateDestroyTimeout(): void { 86 | this._cancelDestroyTimeout(); 87 | if (this.config.connectionTimeout !== 0) { 88 | this.destroyTimeout = setTimeout( 89 | () => this.disconnect(), 90 | this.config.connectionTimeout 91 | ); 92 | } 93 | } 94 | 95 | private _cancels(): void { 96 | this._cancelDestroyTimeout(); 97 | if (this.cancelBlockedCommand) { 98 | this.cancelBlockedCommand(); 99 | this.cancelBlockedCommand = null; 100 | this.currentTask = null; 101 | } 102 | } 103 | 104 | private _makeConnectionInfo(): string { 105 | const config = this.config; 106 | const usepk = config.protocol === 'sftp' && !!config.privateKey; 107 | const datas = [ 108 | config.protocol, 109 | config.username, 110 | config.password, 111 | config.host, 112 | config.port, 113 | usepk, 114 | usepk ? config.privateKey : '', 115 | usepk ? config.passphrase : '', 116 | ]; 117 | return JSON.stringify(datas); 118 | } 119 | 120 | private _blockTestWith(task: Task, prom: Promise): Promise { 121 | return task.with( 122 | new Promise((resolve, reject) => { 123 | if (this.cancelBlockedCommand) { 124 | const taskname = this.currentTask ? this.currentTask.name : 'none'; 125 | throw Error( 126 | `Multiple order at same time (previous: ${taskname}, current: ${task.name})` 127 | ); 128 | } 129 | let blockTimeout: NodeJS.Timer | null = null; 130 | 131 | if (this.config.blockDetectingDuration !== 0) { 132 | blockTimeout = setTimeout(() => { 133 | if (blockTimeout) { 134 | this.cancelBlockedCommand = null; 135 | this.currentTask = null; 136 | blockTimeout = null; 137 | const timeoutError = Error('timeout'); 138 | timeoutError.ftpCode = FtpErrorCode.REUQEST_RECONNECT_AND_RETRY; 139 | reject(timeoutError); 140 | } 141 | }, this.config.blockDetectingDuration); 142 | } 143 | const stopTimeout = () => { 144 | if (blockTimeout) { 145 | this.cancelBlockedCommand = null; 146 | this.currentTask = null; 147 | clearTimeout(blockTimeout); 148 | blockTimeout = null; 149 | return true; 150 | } 151 | return false; 152 | }; 153 | this.currentTask = task; 154 | this.cancelBlockedCommand = () => { 155 | if (stopTimeout()) reject(StringError.TASK_CANCEL); 156 | }; 157 | 158 | prom.then( 159 | (t) => { 160 | if (stopTimeout()) resolve(t); 161 | }, 162 | (err) => { 163 | if (stopTimeout()) reject(err); 164 | } 165 | ); 166 | }) 167 | ); 168 | } 169 | 170 | private _blockTestWrap( 171 | task: Task, 172 | callback: (client: FileInterface) => Promise 173 | ) { 174 | return promiseErrorWrap( 175 | this.connect(task).then(async (client) => { 176 | let tryCount = 0; 177 | for (;;) { 178 | tryCount = (tryCount + 1) | 0; 179 | this._cancelDestroyTimeout(); 180 | try { 181 | const t = await this._blockTestWith(task, callback(client)); 182 | this._updateDestroyTimeout(); 183 | return t; 184 | } catch (err) { 185 | this._updateDestroyTimeout(); 186 | if (err.ftpCode === FtpErrorCode.REUQEST_RECONNECT_AND_RETRY_ONCE) { 187 | this.terminate(); 188 | client = await this.connect(task); 189 | if (tryCount >= 2) throw StringError.TASK_CANCEL; 190 | } else if ( 191 | err.ftpCode === FtpErrorCode.REUQEST_RECONNECT_AND_RETRY 192 | ) { 193 | this.terminate(); 194 | client = await this.connect(task); 195 | } else { 196 | throw err; 197 | } 198 | } 199 | } 200 | }) 201 | ); 202 | } 203 | 204 | public resolvePath(ftppath: string): string { 205 | if (ftppath.startsWith('/')) { 206 | return ftp_path.normalize(ftppath); 207 | } else { 208 | return ftp_path.normalize(this.home + '/' + ftppath); 209 | } 210 | } 211 | 212 | public disconnect(): void { 213 | this._cancels(); 214 | 215 | if (this.client) { 216 | if (this.connected) { 217 | this.client.log('Disconnected'); 218 | this.connected = false; 219 | } 220 | this.client.disconnect(); 221 | this.client = null; 222 | } 223 | } 224 | 225 | public terminate(): void { 226 | this._cancels(); 227 | 228 | if (this.client) { 229 | if (this.connected) { 230 | this.client.log('Disconnected'); 231 | this.connected = false; 232 | } 233 | this.client.terminate(); 234 | this.client = null; 235 | } 236 | } 237 | 238 | public async connect(task: Task): Promise { 239 | const coninfo = this._makeConnectionInfo(); 240 | if (this.client) { 241 | if (coninfo === this.connectionInfo) { 242 | this._updateDestroyTimeout(); 243 | return Promise.resolve(this.client); 244 | } 245 | this.terminate(); 246 | this.config.passwordInMemory = undefined; 247 | } 248 | this.connectionInfo = coninfo; 249 | 250 | const config = this.config; 251 | const usepk = config.protocol === 'sftp' && !!config.privateKey; 252 | 253 | const tryToConnect = async ( 254 | password: string | undefined 255 | ): Promise => { 256 | try { 257 | for (;;) { 258 | const client = createClient(this.workspace, config); 259 | try { 260 | this.logger.message( 261 | `Trying to connect to ${config.url} with user ${config.username}` 262 | ); 263 | await this._blockTestWith(task, client.connect(password)); 264 | client.log('Connected'); 265 | this.client = client; 266 | return; 267 | } catch (err) { 268 | switch (err.ftpCode) { 269 | case FtpErrorCode.REUQEST_RECONNECT_AND_RETRY: 270 | client.terminate(); 271 | break; 272 | case FtpErrorCode.CONNECTION_REFUSED: 273 | throw LoadError.CONNECTION_FAILED; 274 | case FtpErrorCode.AUTH_FAILED: 275 | throw LoadError.AUTH_FAILED; 276 | default: 277 | throw err; 278 | } 279 | } 280 | } 281 | } catch (err) { 282 | this.terminate(); 283 | throw err; 284 | } 285 | }; 286 | 287 | if (!usepk && config.password === undefined) { 288 | if (this.config.passwordInMemory !== undefined) { 289 | await tryToConnect(this.config.passwordInMemory); 290 | if (task.cancelled) { 291 | this.terminate(); 292 | throw StringError.TASK_CANCEL; 293 | } 294 | } else { 295 | let errorMessage: string | undefined; 296 | for (;;) { 297 | const promptedPassword = await window.showInputBox({ 298 | prompt: 299 | 'ftp-kr: ' + 300 | (config.protocol || '').toUpperCase() + 301 | ' Password Request', 302 | password: true, 303 | ignoreFocusOut: true, 304 | placeHolder: errorMessage, 305 | }); 306 | if (task.cancelled) { 307 | this.terminate(); 308 | throw StringError.TASK_CANCEL; 309 | } 310 | if (promptedPassword === undefined) { 311 | this.terminate(); 312 | throw LoadError.PASSWORD_CANCEL; 313 | } 314 | try { 315 | await tryToConnect(promptedPassword); 316 | if (config.keepPasswordInMemory) { 317 | this.config.passwordInMemory = promptedPassword; 318 | } 319 | break; 320 | } catch (err) { 321 | switch (err) { 322 | case LoadError.AUTH_FAILED: 323 | errorMessage = getLoadErrorMessage(err); 324 | break; 325 | default: 326 | throw err; 327 | } 328 | } 329 | } 330 | } 331 | } else { 332 | try { 333 | await tryToConnect(config.password); 334 | } catch (err) { 335 | this.terminate(); 336 | throw err; 337 | } 338 | } 339 | 340 | if (!this.client) throw Error('Client is not created'); 341 | this.client.oninvalidencoding = (errfiles: string[]) => { 342 | this.logger 343 | .errorConfirm( 344 | 'Invalid encoding detected. Please set fileNameEncoding correctly\n' + 345 | errfiles.join('\n'), 346 | 'Open config', 347 | 'Ignore after' 348 | ) 349 | .then((res) => { 350 | switch (res) { 351 | case 'Open config': 352 | vsutil.open(this.workspace.query(Config).path); 353 | break; 354 | case 'Ignore after': 355 | return this.workspace 356 | .query(Config) 357 | .modifySave((cfg) => (cfg.ignoreWrongFileEncoding = true)); 358 | } 359 | }); 360 | }; 361 | this.home = await this.client.pwd(); 362 | if (this.home === '/') this.home = ''; 363 | 364 | this._updateDestroyTimeout(); 365 | return this.client; 366 | } 367 | 368 | public rmdir(task: Task, ftppath: string): Promise { 369 | return this._blockTestWrap(task, (client) => client.rmdir(ftppath)); 370 | } 371 | 372 | public remove(task: Task, ftppath: string): Promise { 373 | return this._blockTestWrap(task, (client) => client.delete(ftppath)); 374 | } 375 | 376 | public mkdir(task: Task, ftppath: string): Promise { 377 | return this._blockTestWrap(task, (client) => client.mkdir(ftppath)); 378 | } 379 | 380 | public upload(task: Task, ftppath: string, localpath: File): Promise { 381 | return this._blockTestWrap(task, (client) => 382 | client.upload(ftppath, localpath) 383 | ); 384 | } 385 | 386 | public download(task: Task, localpath: File, ftppath: string): Promise { 387 | return this._blockTestWrap(task, (client) => 388 | client.download(localpath, ftppath) 389 | ); 390 | } 391 | 392 | public view(task: Task, ftppath: string): Promise { 393 | return this._blockTestWrap(task, (client) => client.view(ftppath)); 394 | } 395 | 396 | public write(task: Task, ftppath: string, content: Buffer): Promise { 397 | return this._blockTestWrap(task, (client) => 398 | client.write(ftppath, content) 399 | ); 400 | } 401 | 402 | public list(task: Task, ftppath: string): Promise { 403 | return this._blockTestWrap(task, (client) => client.list(ftppath)); 404 | } 405 | 406 | public readlink( 407 | task: Task, 408 | fileinfo: FileInfo, 409 | ftppath: string 410 | ): Promise { 411 | return this._blockTestWrap(task, (client) => 412 | client.readlink(fileinfo, ftppath) 413 | ); 414 | } 415 | 416 | public rename( 417 | task: Task, 418 | ftppathFrom: string, 419 | ftppathTo: string 420 | ): Promise { 421 | return this._blockTestWrap(task, (client) => 422 | client.rename(ftppathFrom, ftppathTo) 423 | ); 424 | } 425 | } 426 | --------------------------------------------------------------------------------