diff --git a/README.md b/README.md index 7fcab33..7308d40 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ To list all commands with examples simply run `ftp-deploy` without any options. | `--server-dir` | No | `ftp.samkirkland.com/` | `./` | Folder to upload from, must end with trailing slash `/` | | `--state-name` | No | `folder/.sync-state.json` | `.ftp-deploy-sync-state.json` | ftp-deploy uses this file to track what's been deployed already, so only differences can be published. If you don't like the name or location you can customize it | | `--dry-run` | No | `true` | `false` | Prints which modifications will be made with current config options, but doesn't actually make any changes | +| `--sync-posix-modes` | No | `true` | `false` | Tries to sync posix file modes between host and server. | | `--dangerous-clean-slate` | No | `true` | `false` | Deletes ALL contents of server-dir, even items marked as `--exclude` argument | | `--exclude` | No | `nuclearLaunchCodes.txt` | `**/.git*` `**/.git*/**` `**/node_modules/**` | An array of glob patterns, these files will not be included in the publish/delete process | | `--log-level` | No | `info` | `info` | `minimal`: only important info, `standard`: important info and basic file changes, `verbose`: print everything the script is doing | diff --git a/src/cli.ts b/src/cli.ts index d5730d1..bace37d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ const argv = yargs.options({ "state-name": { type: "string", default: ".ftp-deploy-sync-state.json" }, "dry-run": { type: "boolean", default: false, description: "Prints which modifications will be made with current config options, but doesn't actually make any changes" }, "dangerous-clean-slate": { type: "boolean", default: false, description: "Deletes ALL contents of server-dir, even items in excluded with 'exclude' argument" }, + "sync-posix-modes": { type: "boolean", default: false, description: "Sync POSIX file modes to server for new files. (Note: Only supported on POSIX compatible FTP servers.)"}, "exclude": { type: "array", default: excludeDefaults, description: "An array of glob patterns, these files will not be included in the publish/delete process" }, "log-level": { choices: ["minimal", "standard", "verbose"], default: "standard", description: "How much information should print. minimal=only important info, standard=important info and basic file changes, verbose=print everything the script is doing" }, "security": { choices: ["strict", "loose"], default: "loose", description: "" } diff --git a/src/deploy.ts b/src/deploy.ts index 25421a8..6056c47 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -184,7 +184,7 @@ export async function deploy(args: IFtpDeployArgumentsWithDefaults, logger: ILog timings.start("upload"); try { - const syncProvider = new FTPSyncProvider(client, logger, timings, args["local-dir"], args["server-dir"], args["state-name"], args["dry-run"]); + const syncProvider = new FTPSyncProvider(client, logger, timings, args["local-dir"], args["server-dir"], args["state-name"], args["dry-run"], args["sync-posix-modes"]); await syncProvider.syncLocalToServer(diffs); } finally { @@ -213,4 +213,4 @@ export async function deploy(args: IFtpDeployArgumentsWithDefaults, logger: ILog logger.all(`----------------------------------------------------------------`); logger.all(`Total time: ${timings.getTimeFormatted("total")}`); logger.all(`----------------------------------------------------------------`); -} \ No newline at end of file +} diff --git a/src/main.test.ts b/src/main.test.ts index a5ce4ed..1d27dbc 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -266,7 +266,7 @@ describe("FTP sync commands", () => { ensureDir() { }, uploadFrom() { }, }; - const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false); + const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false, false); const spyRemoveFile = jest.spyOn(syncProvider, "uploadFile"); const mockClientUploadFrom = jest.spyOn(mockClient, "uploadFrom"); await syncProvider.syncLocalToServer(diffs); @@ -322,7 +322,7 @@ describe("FTP sync commands", () => { remove() { }, uploadFrom() { }, }; - const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false); + const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false, false); const spyUploadFile = jest.spyOn(syncProvider, "uploadFile"); const spyRemoveFile = jest.spyOn(syncProvider, "removeFile"); const mockClientUploadFrom = jest.spyOn(mockClient, "uploadFrom"); @@ -386,7 +386,7 @@ describe("FTP sync commands", () => { remove() { }, uploadFrom() { }, }; - const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false); + const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false, false); const spyUploadFile = jest.spyOn(syncProvider, "uploadFile"); const mockClientUploadFrom = jest.spyOn(mockClient, "uploadFrom"); await syncProvider.syncLocalToServer(diffs); @@ -435,7 +435,7 @@ describe("FTP sync commands", () => { remove() { }, uploadFrom() { }, }; - const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false); + const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false, false); const spyRemoveFile = jest.spyOn(syncProvider, "removeFile"); const mockClientRemove = jest.spyOn(mockClient, "remove"); const mockClientUploadFrom = jest.spyOn(mockClient, "uploadFrom"); @@ -493,7 +493,7 @@ describe("FTP sync commands", () => { uploadFrom() { }, cdup() { }, }; - const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false); + const syncProvider = new FTPSyncProvider(mockClient as any, mockedLogger, mockedTimings, "local-dir/", "server-dir/", "state-name", false, false); const spyRemoveFolder = jest.spyOn(syncProvider, "removeFolder"); const mockClientRemove = jest.spyOn(mockClient, "remove"); const mockClientUploadFrom = jest.spyOn(mockClient, "uploadFrom"); @@ -589,6 +589,7 @@ describe("getLocalFiles", () => { exclude: [], "log-level": "standard", security: "loose", + "sync-posix-modes": true, }); const mainYamlDiff = localDirDiffs.data.find(diff => diff.name === "workflows/main.yml")! as IFile; @@ -760,4 +761,4 @@ describe("Deploy", () => { ftpServer.close(); }, 30000); -}); \ No newline at end of file +}); diff --git a/src/syncProvider.ts b/src/syncProvider.ts index 1de126b..2d81e1e 100644 --- a/src/syncProvider.ts +++ b/src/syncProvider.ts @@ -1,8 +1,13 @@ +import fs from "fs"; +import util from "util"; + import prettyBytes from "pretty-bytes"; import type * as ftp from "basic-ftp"; -import { DiffResult, ErrorCode, IFilePath } from "./types"; +import { DiffResult, ErrorCode, IFilePath, Record } from "./types"; import { ILogger, pluralize, retryRequest, ITimings } from "./utilities"; +const stat = util.promisify(fs.stat); + export async function ensureDir(client: ftp.Client, logger: ILogger, timings: ITimings, folder: string): Promise { timings.start("changingDir"); logger.verbose(` changing dir to ${folder}`); @@ -29,7 +34,7 @@ interface ISyncProvider { } export class FTPSyncProvider implements ISyncProvider { - constructor(client: ftp.Client, logger: ILogger, timings: ITimings, localPath: string, serverPath: string, stateName: string, dryRun: boolean) { + constructor(client: ftp.Client, logger: ILogger, timings: ITimings, localPath: string, serverPath: string, stateName: string, dryRun: boolean, syncPosixModes: boolean) { this.client = client; this.logger = logger; this.timings = timings; @@ -37,6 +42,7 @@ export class FTPSyncProvider implements ISyncProvider { this.serverPath = serverPath; this.stateName = stateName; this.dryRun = dryRun; + this.syncPosixModes = syncPosixModes; } private client: ftp.Client; @@ -45,6 +51,7 @@ export class FTPSyncProvider implements ISyncProvider { private localPath: string; private serverPath: string; private dryRun: boolean; + private syncPosixModes: boolean; private stateName: string; @@ -146,6 +153,22 @@ export class FTPSyncProvider implements ISyncProvider { this.logger.verbose(` file ${typePast}`); } + async syncMode(file: Record) { + if (!this.syncPosixModes) { + return; + } + this.logger.verbose("Syncing posix mode for file " + file.name); + // https://www.martin-brennan.com/nodejs-file-permissions-fstat/ + let stats = await stat(this.localPath + file.name); + let mode: string = "0" + (stats.mode & parseInt('777', 8)).toString(8); + // https://github.com/patrickjuchli/basic-ftp/issues/9 + let command = "SITE CHMOD " + mode + " " + file.name + if (this.dryRun === false) { + await this.client.ftp.request(command); + } + this.logger.verbose("Setting file mode with command " + command); + } + async syncLocalToServer(diffs: DiffResult) { const totalCount = diffs.delete.length + diffs.upload.length + diffs.replace.length; @@ -157,17 +180,20 @@ export class FTPSyncProvider implements ISyncProvider { // create new folders for (const file of diffs.upload.filter(item => item.type === "folder")) { await this.createFolder(file.name); + await this.syncMode(file); } // upload new files for (const file of diffs.upload.filter(item => item.type === "file").filter(item => item.name !== this.stateName)) { await this.uploadFile(file.name, "upload"); + await this.syncMode(file); } // replace new files for (const file of diffs.replace.filter(item => item.type === "file").filter(item => item.name !== this.stateName)) { // note: FTP will replace old files with new files. We run replacements after uploads to limit downtime await this.uploadFile(file.name, "replace"); + await this.syncMode(file); } // delete old files diff --git a/src/types.ts b/src/types.ts index b825922..514755c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,13 @@ export interface IFtpDeployArguments { */ "dry-run"?: boolean; + /** + * Tries to sync posix file modes to server. Only works for new or updated files. + * Note: Not all FTP servers support settings POSIX file modes. + * @default false + */ + "sync-posix-modes"?: boolean; + /** * Deletes ALL contents of server-dir, even items in excluded with 'exclude' argument * @default false @@ -73,6 +80,7 @@ export interface IFtpDeployArgumentsWithDefaults { "server-dir": string; "state-name": string; "dry-run": boolean; + "sync-posix-modes": boolean; "dangerous-clean-slate": boolean; exclude: string[]; "log-level": "minimal" | "standard" | "verbose"; @@ -203,4 +211,4 @@ export enum ErrorCode { CannotConnectRefusedByServer = 10061, DirectoryNotEmpty = 10066, TooManyUsers = 10068, -}; \ No newline at end of file +}; diff --git a/src/utilities.ts b/src/utilities.ts index 5aed2a5..58c739f 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -188,6 +188,7 @@ export function getDefaultSettings(withoutDefaults: IFtpDeployArguments): IFtpDe "exclude": withoutDefaults.exclude ?? excludeDefaults, "log-level": withoutDefaults["log-level"] ?? "standard", "security": withoutDefaults.security ?? "loose", + "sync-posix-modes": withoutDefaults["sync-posix-modes"] ?? false, }; } @@ -209,4 +210,4 @@ export function applyExcludeFilter(stat: IStats, excludeFilters: Readonly