hsync-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { loadConfig } from '../utils/config';
7
+ import { OverwritePrompter } from '../utils/prompts';
8
+ import { RemoteClient } from '../../../sync-engine/src/RemoteClient';
9
+
10
+ interface LocalFile {
11
+ relativePath: string;
12
+ absolutePath: string;
13
+ hash: string;
14
+ size: number;
15
+ }
16
+
17
+ async function hashFile(absolutePath: string): Promise<string> {
18
+ const content = await fs.readFile(absolutePath);
19
+ return crypto.createHash('md5').update(content).digest('hex');
20
+ }
21
+
22
+ async function scanLocalPath(targetPath: string, rootDir: string): Promise<LocalFile[]> {
23
+ const stat = await fs.stat(targetPath);
24
+ if (stat.isFile()) {
25
+ const hash = await hashFile(targetPath);
26
+ return [{
27
+ relativePath: path.basename(targetPath),
28
+ absolutePath: targetPath,
29
+ hash: hash,
30
+ size: stat.size,
31
+ }];
32
+ }
33
+
34
+ const results: LocalFile[] = [];
35
+ async function recurse(currentPath: string) {
36
+ const entries = await fs.readdir(currentPath, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ const fullPath = path.join(currentPath, entry.name);
39
+ if (entry.isDirectory()) {
40
+ await recurse(fullPath);
41
+ } else if (entry.isFile()) {
42
+ const fileStat = await fs.stat(fullPath);
43
+ const hash = await hashFile(fullPath);
44
+ results.push({
45
+ relativePath: path.relative(rootDir, fullPath),
46
+ absolutePath: fullPath,
47
+ hash: hash,
48
+ size: fileStat.size,
49
+ });
50
+ }
51
+ }
52
+ }
53
+
54
+ await recurse(targetPath);
55
+ return results;
56
+ }
57
+
58
+ function parseRemoteTarget(target: string): { slug: string; remotePath: string } {
59
+ const parts = target.split(':');
60
+ const slug = parts[0];
61
+ const remotePath = (parts.slice(1).join(':') || '').replace(/^\/+|\/+$/g, '');
62
+ return { slug: slug, remotePath: remotePath };
63
+ }
64
+
65
+ export async function push(localPath: string, remoteTarget: string, options: { yes?: boolean }): Promise<void> {
66
+ const forceYes = options.yes || false;
67
+
68
+ try {
69
+ const config = await loadConfig();
70
+ const serverUrl = config.serverUrl;
71
+ const apiKey = config.apiKey;
72
+
73
+ if (!serverUrl || !apiKey) {
74
+ console.error(chalk.red('Error: CLI not authenticated. Please run: hsync config set-server <url> && hsync login'));
75
+ process.exit(1);
76
+ }
77
+
78
+ const remote = parseRemoteTarget(remoteTarget);
79
+ const resolvedLocalPath = path.resolve(localPath);
80
+
81
+ // Verify local path exists
82
+ try {
83
+ await fs.access(resolvedLocalPath);
84
+ } catch {
85
+ console.error(chalk.red(`Error: Local path "${localPath}" does not exist.`));
86
+ process.exit(1);
87
+ }
88
+
89
+ const spinner = ora('Scanning local files...').start();
90
+ const localFiles = await scanLocalPath(resolvedLocalPath, resolvedLocalPath);
91
+ spinner.text = 'Fetching remote file tree...';
92
+
93
+ const remoteClient = new RemoteClient(serverUrl, apiKey);
94
+ const remoteFiles = await remoteClient.getTree(remote.slug, remote.remotePath);
95
+ spinner.stop();
96
+
97
+ console.log(chalk.cyan(`Found ${localFiles.length} local files, ${remoteFiles.length} remote files.`));
98
+
99
+ const prompter = new OverwritePrompter(forceYes);
100
+ let pushCount = 0;
101
+ let skipCount = 0;
102
+
103
+ for (const localFile of localFiles) {
104
+ const remoteFile = remoteFiles.find((f) => f.path === localFile.relativePath);
105
+ const destinationPath = remote.remotePath
106
+ ? `${remote.remotePath}/${localFile.relativePath}`
107
+ : localFile.relativePath;
108
+
109
+ let shouldUpload = false;
110
+
111
+ if (!remoteFile) {
112
+ // New file
113
+ shouldUpload = true;
114
+ } else if (remoteFile.hash !== localFile.hash) {
115
+ // Conflict - content differs
116
+ const confirm = await prompter.shouldOverwrite(localFile.relativePath);
117
+ if (confirm) {
118
+ shouldUpload = true;
119
+ } else {
120
+ skipCount++;
121
+ }
122
+ } else {
123
+ // Identical hashes
124
+ skipCount++;
125
+ }
126
+
127
+ if (shouldUpload) {
128
+ const fileSpinner = ora(`Pushing ${localFile.relativePath}...`).start();
129
+ try {
130
+ await remoteClient.uploadFile(remote.slug, destinationPath, localFile.absolutePath);
131
+ fileSpinner.succeed(chalk.green(`Pushed ${localFile.relativePath}`));
132
+ pushCount++;
133
+ } catch (uploadErr: any) {
134
+ fileSpinner.fail(chalk.red(`Failed to push ${localFile.relativePath}: ${uploadErr.message}`));
135
+ }
136
+ }
137
+ }
138
+
139
+ console.log(chalk.green(`\n✔ Push complete. Pushed: ${pushCount}, Skipped: ${skipCount}`));
140
+ } catch (err: any) {
141
+ console.error(chalk.red(`Error: Push failed: ${err.message}`));
142
+ process.exit(1);
143
+ }
144
+ }
145
+
146
+ export async function pull(remoteTarget: string, localPath: string, options: { yes?: boolean }): Promise<void> {
147
+ const forceYes = options.yes || false;
148
+
149
+ try {
150
+ const config = await loadConfig();
151
+ const serverUrl = config.serverUrl;
152
+ const apiKey = config.apiKey;
153
+
154
+ if (!serverUrl || !apiKey) {
155
+ console.error(chalk.red('Error: CLI not authenticated. Please run: hsync config set-server <url> && hsync login'));
156
+ process.exit(1);
157
+ }
158
+
159
+ const remote = parseRemoteTarget(remoteTarget);
160
+ const resolvedLocalPath = path.resolve(localPath);
161
+
162
+ const spinner = ora('Fetching remote file tree...').start();
163
+ const remoteClient = new RemoteClient(serverUrl, apiKey);
164
+ const remoteFiles = await remoteClient.getTree(remote.slug, remote.remotePath);
165
+ spinner.text = 'Scanning local files...';
166
+
167
+ // Verify local path exists or create it
168
+ await fs.mkdir(resolvedLocalPath, { recursive: true });
169
+ const localFiles = await scanLocalPath(resolvedLocalPath, resolvedLocalPath);
170
+ spinner.stop();
171
+
172
+ console.log(chalk.cyan(`Found ${remoteFiles.length} remote files, ${localFiles.length} local files.`));
173
+
174
+ const prompter = new OverwritePrompter(forceYes);
175
+ let pullCount = 0;
176
+ let skipCount = 0;
177
+
178
+ for (const remoteFile of remoteFiles) {
179
+ const localFile = localFiles.find((f) => f.relativePath === remoteFile.path);
180
+ const localDestPath = path.join(resolvedLocalPath, remoteFile.path);
181
+ const sourcePath = remote.remotePath
182
+ ? `${remote.remotePath}/${remoteFile.path}`
183
+ : remoteFile.path;
184
+
185
+ let shouldDownload = false;
186
+
187
+ if (!localFile) {
188
+ // New remote file
189
+ shouldDownload = true;
190
+ } else if (localFile.hash !== remoteFile.hash) {
191
+ // Conflict - content differs
192
+ const confirm = await prompter.shouldOverwrite(remoteFile.path);
193
+ if (confirm) {
194
+ shouldDownload = true;
195
+ } else {
196
+ skipCount++;
197
+ }
198
+ } else {
199
+ // Identical hashes
200
+ skipCount++;
201
+ }
202
+
203
+ if (shouldDownload) {
204
+ const fileSpinner = ora(`Pulling ${remoteFile.path}...`).start();
205
+ try {
206
+ await remoteClient.downloadFile(remote.slug, sourcePath, localDestPath);
207
+ fileSpinner.succeed(chalk.green(`Pulled ${remoteFile.path}`));
208
+ pullCount++;
209
+ } catch (downloadErr: any) {
210
+ fileSpinner.fail(chalk.red(`Failed to pull ${remoteFile.path}: ${downloadErr.message}`));
211
+ }
212
+ }
213
+ }
214
+
215
+ console.log(chalk.green(`\n✔ Pull complete. Pulled: ${pullCount}, Skipped: ${skipCount}`));
216
+ } catch (err: any) {
217
+ console.error(chalk.red(`Error: Pull failed: ${err.message}`));
218
+ process.exit(1);
219
+ }
220
+ }
package/src/index.ts ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from 'commander';
3
+ import { setServer, login, showMe, logout } from './commands/auth';
4
+ import { listBuckets, createBucket, deleteBucket } from './commands/bucket';
5
+ import { push, pull } from './commands/transfer';
6
+ import { addSyncJob, listSyncJobs, removeSyncJob, startSyncScheduler } from './commands/sync';
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name('hsync')
12
+ .description('hsync CLI - Premium cross-device file synchronization client')
13
+ .version('1.0.0');
14
+
15
+ // === Auth Commands ===
16
+ const configCmd = program.command('config').description('Configure CLI settings');
17
+ configCmd
18
+ .command('set-server <url>')
19
+ .description('Set target API server URL')
20
+ .action(async (url) => {
21
+ await setServer(url);
22
+ });
23
+
24
+ program
25
+ .command('login')
26
+ .description('Authenticate and register local API Key')
27
+ .option('-k, --api-key <key>', 'Skip interactive prompts and login directly using a pre-generated API Key')
28
+ .action(async (options) => {
29
+ await login(options);
30
+ });
31
+
32
+ program
33
+ .command('me')
34
+ .description('Show profile details of currently authenticated account')
35
+ .action(async () => {
36
+ await showMe();
37
+ });
38
+
39
+ program
40
+ .command('logout')
41
+ .description('De-authenticate and remove local API Key')
42
+ .action(async () => {
43
+ await logout();
44
+ });
45
+
46
+ // === Bucket Commands ===
47
+ const bucketCmd = program.command('bucket').description('Manage remote storage buckets');
48
+
49
+ bucketCmd
50
+ .command('list')
51
+ .description('List all storage buckets registered on your account')
52
+ .action(async () => {
53
+ await listBuckets();
54
+ });
55
+
56
+ bucketCmd
57
+ .command('create <name> <slug>')
58
+ .description('Create a new storage bucket')
59
+ .requiredOption('-p, --provider <local|s3>', 'Storage provider type ("local" or "s3")')
60
+ .option('-r, --rootPath <path>', 'Physical folder path on host (required for local provider)')
61
+ .option('-b, --bucket <name>', 'S3 bucket name (required for s3 provider)')
62
+ .action(async (name, slug, options) => {
63
+ await createBucket(name, slug, options);
64
+ });
65
+
66
+ bucketCmd
67
+ .command('delete <slug>')
68
+ .description('Permanently delete a storage bucket')
69
+ .action(async (slug) => {
70
+ await deleteBucket(slug);
71
+ });
72
+
73
+ // === Transfer Commands ===
74
+ program
75
+ .command('push <localPath> <remoteTarget>')
76
+ .description('Upload folder or file to a remote bucket')
77
+ .option('-y, --yes', 'Automatically overwrite conflicts without interactive prompts')
78
+ .action(async (localPath, remoteTarget, options) => {
79
+ await push(localPath, remoteTarget, options);
80
+ });
81
+
82
+ program
83
+ .command('pull <remoteTarget> <localPath>')
84
+ .description('Download folder or file from a remote bucket')
85
+ .option('-y, --yes', 'Automatically overwrite conflicts without interactive prompts')
86
+ .action(async (remoteTarget, localPath, options) => {
87
+ await pull(remoteTarget, localPath, options);
88
+ });
89
+
90
+ // === Sync Scheduler Commands ===
91
+ const syncCmd = program.command('sync').description('Manage background synchronization schedules');
92
+
93
+ syncCmd
94
+ .command('add <localPath> <remoteTarget>')
95
+ .description('Register a new persistent sync job profile')
96
+ .requiredOption('-n, --name <jobName>', 'Unique identifier label for the sync job')
97
+ .requiredOption('-d, --direction <push|pull|bidirectional>', 'Sync transfer direction mapping')
98
+ .requiredOption('-s, --strategy <local-wins|server-wins|newer-wins>', 'Conflict resolution strategy rules')
99
+ .option('-i, --interval <seconds>', 'Synchronization scheduling delay frequency in seconds', '10')
100
+ .option('--no-conflict-copy', 'Disable keeping backup copies (.conflict-YYYY-MM-DD) on conflict resolutions')
101
+ .action(async (localPath, remoteTarget, options) => {
102
+ await addSyncJob(localPath, remoteTarget, options);
103
+ });
104
+
105
+ syncCmd
106
+ .command('list')
107
+ .description('List all registered local sync profiles')
108
+ .action(async () => {
109
+ await listSyncJobs();
110
+ });
111
+
112
+ syncCmd
113
+ .command('remove <name>')
114
+ .description('Remove a registered local sync job')
115
+ .action(async (name) => {
116
+ await removeSyncJob(name);
117
+ });
118
+
119
+ syncCmd
120
+ .command('start [name]')
121
+ .description('Start interval synchronization scheduler service')
122
+ .action(async (name) => {
123
+ await startSyncScheduler(name);
124
+ });
125
+
126
+ program.parse(process.argv);
package/src/types.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { SyncJob } from '@file-sync/sync-engine';
2
+
3
+ export interface CliConfig {
4
+ serverUrl?: string;
5
+ apiKey?: string;
6
+ syncJobs?: SyncJob[];
7
+ }
@@ -0,0 +1,24 @@
1
+ import axios from 'axios';
2
+ import type { AxiosInstance } from 'axios';
3
+ import { loadConfig } from './config';
4
+
5
+ export async function getHttpClient(): Promise<AxiosInstance> {
6
+ const config = await loadConfig();
7
+ const serverUrl = config.serverUrl;
8
+ const apiKey = config.apiKey;
9
+
10
+ if (!serverUrl) {
11
+ throw new Error('Server URL is not set. Please run: hsync config set-server <url>');
12
+ }
13
+
14
+ const headers: Record<string, string> = {};
15
+ if (apiKey) {
16
+ headers['X-API-Key'] = apiKey;
17
+ }
18
+
19
+ return axios.create({
20
+ baseURL: serverUrl,
21
+ headers: headers,
22
+ validateStatus: (status) => status >= 200 && status < 300,
23
+ });
24
+ }
@@ -0,0 +1,25 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import type { CliConfig } from '../types';
5
+
6
+ const configDir = path.join(os.homedir(), '.hsync');
7
+ const configPath = path.join(configDir, 'config.json');
8
+
9
+ export async function loadConfig(): Promise<CliConfig> {
10
+ try {
11
+ const raw = await fs.readFile(configPath, 'utf-8');
12
+ const parsed = JSON.parse(raw);
13
+ if (!parsed.syncJobs) {
14
+ parsed.syncJobs = [];
15
+ }
16
+ return parsed;
17
+ } catch (err) {
18
+ return { syncJobs: [] };
19
+ }
20
+ }
21
+
22
+ export async function saveConfig(config: CliConfig): Promise<void> {
23
+ await fs.mkdir(configDir, { recursive: true });
24
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
25
+ }
@@ -0,0 +1,51 @@
1
+ import inquirer from 'inquirer';
2
+
3
+ export class OverwritePrompter {
4
+ private overrideDecision: 'overwrite-all' | 'skip-all' | null = null;
5
+ private forceYes: boolean;
6
+
7
+ constructor(forceYes: boolean = false) {
8
+ this.forceYes = forceYes;
9
+ }
10
+
11
+ async shouldOverwrite(filePath: string): Promise<boolean> {
12
+ if (this.forceYes || this.overrideDecision === 'overwrite-all') {
13
+ return true;
14
+ }
15
+ if (this.overrideDecision === 'skip-all') {
16
+ return false;
17
+ }
18
+
19
+ const answer = await inquirer.prompt([
20
+ {
21
+ type: 'list',
22
+ name: 'decision',
23
+ message: `Conflict detected on "${filePath}". Local and remote content differ. How do you want to proceed?`,
24
+ choices: [
25
+ { name: 'Overwrite', value: 'overwrite' },
26
+ { name: 'Skip', value: 'skip' },
27
+ { name: 'Overwrite All', value: 'overwrite-all' },
28
+ { name: 'Skip All', value: 'skip-all' },
29
+ ],
30
+ },
31
+ ]);
32
+
33
+ const choice = answer.decision;
34
+ if (choice === 'overwrite') {
35
+ return true;
36
+ }
37
+ if (choice === 'skip') {
38
+ return false;
39
+ }
40
+ if (choice === 'overwrite-all') {
41
+ this.overrideDecision = 'overwrite-all';
42
+ return true;
43
+ }
44
+ if (choice === 'skip-all') {
45
+ this.overrideDecision = 'skip-all';
46
+ return false;
47
+ }
48
+
49
+ return false;
50
+ }
51
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }