From e22e313f921f131147f5c9031fb027f13efb6cc2 Mon Sep 17 00:00:00 2001 From: Khalil Al Hooti Date: Sat, 10 Jan 2026 20:37:22 +0400 Subject: [PATCH 1/3] Make phio CLI cross-platform with Bun build Build and ship JS output for the CLI so it runs on Windows. - add Bun build + prepack scripts - point bin to a Node wrapper (bin/phio.js) - update CLI entry for Node shebang and JSON version loading - publish dist/bin instead of src --- bin/phio.js | 2 ++ bun.lock | 1 + package.json | 15 +++++++++------ src/cli.ts | 7 +++++-- 4 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 bin/phio.js diff --git a/bin/phio.js b/bin/phio.js new file mode 100644 index 0000000..b91240a --- /dev/null +++ b/bin/phio.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import '../dist/cli.js' diff --git a/bun.lock b/bun.lock index 9b3384d..1297e65 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "phio", diff --git a/package.json b/package.json index 9bb9a6c..3edf517 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "bugs": { "url": "https://github.com/pockethost/phio/issues" }, - "main": "src/index.ts", - "module": "src/index.ts", + "main": "dist/index.js", + "module": "dist/index.js", "type": "module", - "types": "src/index.ts", + "types": "dist/index.d.ts", "devDependencies": { "@changesets/cli": "^2.28.1", "@types/bun": "^1.2.3", @@ -29,13 +29,16 @@ "prettier-plugin-organize-imports": "^4.1.0" }, "scripts": { - "dev": "tsx ./src/cli.ts" + "dev": "tsx ./src/cli.ts", + "build": "bun build src/cli.ts src/index.ts --outdir dist --target node --format esm", + "prepack": "bun run build" }, "bin": { - "phio": "src/cli.ts" + "phio": "bin/phio.js" }, "files": [ - "src" + "dist", + "bin" ], "dependencies": { "@inquirer/prompts": "^7.3.2", diff --git a/src/cli.ts b/src/cli.ts index e68990e..a1641a8 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ -#!/usr/bin/env tsx +#!/usr/bin/env node import { program } from 'commander' -import { version } from '../package.json' +import { createRequire } from 'node:module' import { DeployCommand } from './commands/DeployCommand' import { DevCommand } from './commands/DevCommand' import { InfoCommand } from './commands/InfoCommand' @@ -11,6 +11,9 @@ import { LogoutCommand } from './commands/LogoutCommand' import { LogsCommand } from './commands/LogsCommand' import { WhoAmICommand } from './commands/WhoAmICommand' +const require = createRequire(import.meta.url) +const { version } = require('../package.json') as { version: string } + program .name(`PocketHost CLI`) .version(version) From 596ecff672cabe4709c32350ee72c3e3cc2be076 Mon Sep 17 00:00:00 2001 From: Khalil Al Hooti Date: Sun, 11 Jan 2026 03:38:18 +0400 Subject: [PATCH 2/3] fix(windows): make FTP deploy path-safe - Normalize globbed Windows paths ('\\') to POSIX ('/') before diff/upload - Prevents invalid remote dirs/duplicated paths on Linux FTP servers - Used by deploy/dev flow on Windows --- src/commands/DevCommand.ts | 7 +- src/lib/ftpDeployWindowsPathSafe.ts | 543 ++++++++++++++++++++++++++++ 2 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 src/lib/ftpDeployWindowsPathSafe.ts diff --git a/src/commands/DevCommand.ts b/src/commands/DevCommand.ts index 4aec3a9..ee08827 100644 --- a/src/commands/DevCommand.ts +++ b/src/commands/DevCommand.ts @@ -115,7 +115,12 @@ export async function deployMyCode( 'log-level': verbose ? 'verbose' : 'minimal', } - await deploy(args) + if (process.platform === 'win32') { + const { deployFixed } = await import('../lib/ftpDeployWindowsPathSafe') + await deployFixed(args) + } else { + await deploy(args) + } console.log('πŸš€ Deploy done!') } diff --git a/src/lib/ftpDeployWindowsPathSafe.ts b/src/lib/ftpDeployWindowsPathSafe.ts new file mode 100644 index 0000000..fa7a81a --- /dev/null +++ b/src/lib/ftpDeployWindowsPathSafe.ts @@ -0,0 +1,543 @@ +import * as ftp from 'basic-ftp' +import fs from 'fs' +import { globSync } from 'glob' +import prettyBytes from 'pretty-bytes' + +import { fileHash, HashDiff } from '@samkirkland/ftp-deploy/src/HashDiff' +import { prettyError } from '@samkirkland/ftp-deploy/src/errorHandling' +import { + ensureDir, + FTPSyncProvider, +} from '@samkirkland/ftp-deploy/src/syncProvider' +import { + currentSyncFileVersion, + syncFileDescription, + type IDiff, + type IFileList, + type IFtpDeployArguments, + type IFtpDeployArgumentsWithDefaults, + type Record, +} from '@samkirkland/ftp-deploy/src/types' +import { + applyExcludeFilter, + formatNumber, + getDefaultSettings, + Logger, + retryRequest, + Timings, + type ILogger, + type ITimings, +} from '@samkirkland/ftp-deploy/src/utilities' + +function normalizeToPosixPath(p: string): string { + return p.replace(/\\/g, '/') +} + +let didWarnOnWindowsSeparators = false + +function assertPosixPath(p: string) { + if (p.includes('\\')) { + throw new Error( + `Internal error: expected POSIX '/' path separators but got a Windows path: ${p}` + ) + } +} + +async function getLocalFilesNormalized( + args: IFtpDeployArgumentsWithDefaults, + logger: ILogger +): Promise { + const rawFiles = globSync(args.include, { + ignore: args.exclude, + cwd: args['local-dir'], + }) + + const shouldNormalize = rawFiles.some((p) => p.includes('\\')) + + if (didWarnOnWindowsSeparators === false && shouldNormalize) { + didWarnOnWindowsSeparators = true + logger.standard( + `⚠️ Detected Windows-style path separators (\\) from glob; normalizing to POSIX '/' for FTP.` + ) + } + + const files = shouldNormalize ? rawFiles.map(normalizeToPosixPath) : rawFiles + if (shouldNormalize) { + files.forEach(assertPosixPath) + } + logger.verbose(`Local files:`, JSON.stringify({ files }, null, 2)) + + const records: Record[] = [] + + for (const filePath of files) { + if (shouldNormalize) { + assertPosixPath(filePath) + } + const stat = fs.lstatSync(`${args['local-dir']}${filePath}`) + + if (stat.isDirectory()) { + records.push({ + type: 'folder', + name: filePath, + size: undefined, + }) + continue + } + + if (stat.isFile()) { + records.push({ + type: 'file', + name: filePath, + size: stat.size, + hash: await fileHash(`${args['local-dir']}${filePath}`, 'sha256'), + }) + continue + } + + if (stat.isSymbolicLink()) { + console.warn( + 'This script is currently unable to handle symbolic links - please add a feature request if you need this' + ) + } + } + + return { + description: syncFileDescription, + version: currentSyncFileVersion, + generatedTime: new Date().getTime(), + data: records, + } +} + +async function downloadFileList( + client: ftp.Client, + logger: ILogger, + path: string +): Promise { + const maxAttempts = 3 + + const remoteSize: number | null = await (async () => { + try { + return await retryRequest(logger, async () => await client.size(path)) + } catch { + return null + } + })() + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const tempFileName = `.ftp-deploy-sync-server-state-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}.json` + + try { + await retryRequest( + logger, + async () => await client.downloadTo(tempFileName, path) + ) + + const localSize = fs.statSync(tempFileName).size + if (remoteSize !== null && localSize !== remoteSize) { + logger.standard( + `Downloaded state file size mismatch (remote ${remoteSize} bytes, local ${localSize} bytes). Retrying (${attempt}/${maxAttempts})...` + ) + continue + } + + const fileAsString = fs.readFileSync(tempFileName, { encoding: 'utf-8' }) + try { + const fileAsObject = JSON.parse(fileAsString) as IFileList + return fileAsObject + } catch (parseError) { + const previewStart = fileAsString.slice(0, 200).replace(/\r?\n/g, '\\n') + const previewEnd = fileAsString + .slice(Math.max(0, fileAsString.length - 200)) + .replace(/\r?\n/g, '\\n') + logger.standard( + `Downloaded state file is not valid JSON. Retrying (${attempt}/${maxAttempts})...` + ) + logger.verbose(`State file preview (start): ${previewStart}`) + logger.verbose(`State file preview (end): ${previewEnd}`) + + if (attempt === maxAttempts) { + throw parseError + } + } + } finally { + try { + if (fs.existsSync(tempFileName)) { + fs.unlinkSync(tempFileName) + } + } catch { + // best-effort cleanup + } + } + } + + throw new Error( + 'Failed to download and parse server state file after retries' + ) +} + +function createLocalState( + localFiles: IFileList, + logger: ILogger, + args: IFtpDeployArgumentsWithDefaults +): void { + logger.verbose( + `Creating local state at ${args['local-dir']}${args['state-name']}` + ) + fs.writeFileSync( + `${args['local-dir']}${args['state-name']}`, + JSON.stringify(localFiles, undefined, 4), + { encoding: 'utf8' } + ) + logger.verbose('Local state created') +} + +function readLocalStateFileIfPresent( + logger: ILogger, + localStatePath: string +): IFileList | null { + try { + if (!fs.existsSync(localStatePath)) return null + const raw = fs.readFileSync(localStatePath, { encoding: 'utf-8' }) + const parsed = JSON.parse(raw) as IFileList + if ( + parsed && + typeof parsed === 'object' && + parsed.description === syncFileDescription && + parsed.version === currentSyncFileVersion && + Array.isArray(parsed.data) + ) { + return parsed + } + return null + } catch (e) { + logger.verbose(`Failed to read local sync state cache: ${e}`) + return null + } +} + +async function connect( + client: ftp.Client, + args: IFtpDeployArgumentsWithDefaults, + logger: ILogger +) { + let secure: boolean | 'implicit' = false + if (args.protocol === 'ftps') { + secure = true + } else if (args.protocol === 'ftps-legacy') { + secure = 'implicit' + } + + client.ftp.verbose = args['log-level'] === 'verbose' + + const rejectUnauthorized = args.security === 'strict' + + try { + await client.access({ + host: args.server, + user: args.username, + password: args.password, + port: args.port, + secure, + secureOptions: { + rejectUnauthorized, + }, + }) + } catch (error) { + logger.all( + `Failed to connect, are you sure your server works via FTP or FTPS? Users sometimes get this error when the server only supports SFTP.` + ) + throw error + } + + if (args['log-level'] === 'verbose') { + client.trackProgress((info) => { + logger.verbose( + `${info.type} progress for "${info.name}". Progress: ${info.bytes} bytes of ${info.bytesOverall} bytes` + ) + }) + } +} + +async function getServerFiles( + client: ftp.Client, + logger: ILogger, + timings: ITimings, + args: IFtpDeployArgumentsWithDefaults +): Promise { + try { + await ensureDir(client, logger, timings, args['server-dir']) + + if (args['dangerous-clean-slate']) { + logger.all( + `----------------------------------------------------------------` + ) + logger.all( + `πŸ—‘οΈ Removing all files on the server because 'dangerous-clean-slate' was set, this will make the deployment very slow...` + ) + if (args['dry-run'] === false) { + await client.clearWorkingDir() + } + logger.all('Clear complete') + + throw new Error('dangerous-clean-slate was run') + } + + const serverFiles = await downloadFileList( + client, + logger, + args['state-name'] + ) + logger.all( + `----------------------------------------------------------------` + ) + logger.all( + `Last published on πŸ“… ${new Date( + serverFiles.generatedTime + ).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + })}` + ) + + if (args.exclude.length > 0) { + const filteredData = serverFiles.data.filter((item) => + applyExcludeFilter( + { path: item.name, isDirectory: () => item.type === 'folder' }, + args.exclude + ) + ) + serverFiles.data = filteredData + } + + return serverFiles + } catch (error) { + const maybeCode = (error as any)?.code + const message = String((error as any)?.message ?? '') + + const isMissingStateFile = + maybeCode === 550 || + /no such file or directory/i.test(message) || + /not found/i.test(message) + + if (isMissingStateFile) { + logger.all( + `----------------------------------------------------------------` + ) + logger.all( + `No file exists on the server "${ + args['server-dir'] + args['state-name'] + }" - this must be your first publish! πŸŽ‰` + ) + logger.all( + `The first publish will take a while... but once the initial sync is done only differences are published!` + ) + logger.all( + `If you get this message and its NOT your first publish, something is wrong.` + ) + + return { + description: syncFileDescription, + version: currentSyncFileVersion, + generatedTime: new Date().getTime(), + data: [], + } + } + + logger.all( + `----------------------------------------------------------------` + ) + logger.all( + `Failed to read existing server state file "${ + args['server-dir'] + args['state-name'] + }"; refusing to do a full re-sync.` + ) + logger.all(error) + throw error + } +} + +async function deployWithDefaults( + args: IFtpDeployArgumentsWithDefaults, + logger: ILogger, + timings: ITimings +): Promise { + timings.start('total') + + logger.all(`----------------------------------------------------------------`) + logger.all(`πŸš€ Thanks for using ftp-deploy. Let's deploy some stuff! `) + logger.all(`----------------------------------------------------------------`) + logger.all(`If you found this project helpful, please support it`) + logger.all( + `by giving it a ⭐ on Github --> https://github.com/SamKirkland/FTP-Deploy-Action` + ) + logger.all( + `or add a badge 🏷️ to your projects readme --> https://github.com/SamKirkland/FTP-Deploy-Action#badge` + ) + logger.verbose( + `Using the following include filters: ${JSON.stringify(args.include)}` + ) + logger.verbose( + `Using the following excludes filters: ${JSON.stringify(args.exclude)}` + ) + + timings.start('hash') + const localFiles = await getLocalFilesNormalized(args, logger) + timings.stop('hash') + + // Cache the previous run's local sync state before we overwrite it. + // This is useful as a fallback when the remote state file can't be downloaded + // reliably (seen under Bun/basic-ftp on Windows). + const localStatePath = `${args['local-dir']}${args['state-name']}` + const cachedServerFiles = readLocalStateFileIfPresent(logger, localStatePath) + + createLocalState(localFiles, logger, args) + + const client = new ftp.Client(args.timeout) + + // Preserve the upstream global-based reconnect hook. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).reconnect = async function () { + timings.start('connecting') + await connect(client, args, logger) + timings.stop('connecting') + } + + let totalBytesUploaded = 0 + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (globalThis as any).reconnect() + + const serverFiles = await (async () => { + try { + return await getServerFiles(client, logger, timings, args) + } catch (e) { + if (cachedServerFiles) { + logger.standard( + `⚠️ Falling back to local sync-state cache because remote state could not be read. This may miss out-of-band server changes.` + ) + return cachedServerFiles + } + throw e + } + })() + + timings.start('logging') + const diffTool: IDiff = new HashDiff() + + logger.standard( + `----------------------------------------------------------------` + ) + logger.standard(`Local Files:\t${formatNumber(localFiles.data.length)}`) + logger.standard(`Server Files:\t${formatNumber(serverFiles.data.length)}`) + logger.standard( + `----------------------------------------------------------------` + ) + logger.standard(`Calculating differences between client & server`) + logger.standard( + `----------------------------------------------------------------` + ) + logger.verbose(`Local files:`, JSON.stringify(localFiles, null, 2)) + logger.verbose(`Server files:`, JSON.stringify(serverFiles, null, 2)) + + const diffs = diffTool.getDiffs(localFiles, serverFiles) + + diffs.upload + .filter((itemUpload) => itemUpload.type === 'folder') + .map((itemUpload) => { + logger.standard(`πŸ“ Create: ${itemUpload.name}`) + }) + + diffs.upload + .filter((itemUpload) => itemUpload.type === 'file') + .map((itemUpload) => { + logger.standard(`πŸ“„ Upload: ${itemUpload.name}`) + }) + + diffs.replace.map((itemReplace) => { + logger.standard(`πŸ” File replace: ${itemReplace.name}`) + }) + + diffs.delete + .filter((itemUpload) => itemUpload.type === 'file') + .map((itemDelete) => { + logger.standard(`πŸ“„ Delete: ${itemDelete.name} `) + }) + + diffs.delete + .filter((itemUpload) => itemUpload.type === 'folder') + .map((itemDelete) => { + logger.standard(`πŸ“ Delete: ${itemDelete.name} `) + }) + + diffs.same.map((itemSame) => { + if (itemSame.type === 'file') { + logger.standard( + `βš–οΈ File content is the same, doing nothing: ${itemSame.name}` + ) + } + }) + timings.stop('logging') + + totalBytesUploaded = diffs.sizeUpload + diffs.sizeReplace + + timings.start('upload') + try { + const syncProvider = new FTPSyncProvider( + client, + logger, + timings, + args['local-dir'], + args['server-dir'], + args['state-name'], + args['dry-run'] + ) + await syncProvider.syncLocalToServer(diffs) + } finally { + timings.stop('upload') + } + } catch (error) { + prettyError(logger, args, error) + throw error + } finally { + client.close() + timings.stop('total') + } + + const uploadSpeed = prettyBytes( + totalBytesUploaded / (timings.getTime('upload') / 1000) + ) + + logger.all(`----------------------------------------------------------------`) + logger.all(`Time spent hashing: ${timings.getTimeFormatted('hash')}`) + logger.all( + `Time spent connecting to server: ${timings.getTimeFormatted('connecting')}` + ) + logger.all( + `Time spent deploying: ${timings.getTimeFormatted( + 'upload' + )} (${uploadSpeed}/second)` + ) + logger.all(` - changing dirs: ${timings.getTimeFormatted('changingDir')}`) + logger.all(` - logging: ${timings.getTimeFormatted('logging')}`) + logger.all(`----------------------------------------------------------------`) + logger.all(`Total time: ${timings.getTimeFormatted('total')}`) + logger.all(`----------------------------------------------------------------`) +} + +/** + * Drop-in replacement for `@samkirkland/ftp-deploy` that normalizes Windows `\\` + * paths to POSIX `/` before computing diffs and issuing FTP commands. + */ +export async function deployFixed(args: IFtpDeployArguments): Promise { + const argsWithDefaults = getDefaultSettings(args) + const logger = new Logger(argsWithDefaults['log-level']) + const timings = new Timings() + await deployWithDefaults(argsWithDefaults, logger, timings) +} From 8d1fb08e2d22f606e40e363d5a19efada0442e96 Mon Sep 17 00:00:00 2001 From: Khalil Al Hooti Date: Sun, 11 Jan 2026 05:21:51 +0400 Subject: [PATCH 3/3] fix(packaging): build dist on git installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "prepare": "bun run build" so installs from git/file dependencies build dist/ automatically. - Without this, npm installs like: npm install -D "file:D:/[folder]/[folder]/phio" and npm install -D github:[username]/phio#fix/[branch] won’t generate dist/ --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3edf517..249d12c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "scripts": { "dev": "tsx ./src/cli.ts", "build": "bun build src/cli.ts src/index.ts --outdir dist --target node --format esm", + "prepare": "bun run build", "prepack": "bun run build" }, "bin": {