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.
package/dist/hsync ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "hsync-cli",
3
+ "version": "1.0.0",
4
+ "description": "hsync CLI for file sync operations",
5
+ "bin": {
6
+ "hsync": "./dist/bin.js"
7
+ },
8
+ "main": "./src/index.ts",
9
+ "scripts": {
10
+ "dev": "bun --watch src/index.ts",
11
+ "build": "bun build src/index.ts --compile --outfile dist/hsync",
12
+ "build:js": "bun scripts/build-js.ts",
13
+ "prepublishOnly": "bun run build:js",
14
+ "start": "bun src/index.ts",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "@file-sync/sync-engine": "workspace:*",
19
+ "chalk": "^5.3.0",
20
+ "commander": "^12.0.0",
21
+ "axios": "^1.6.5",
22
+ "inquirer": "^9.2.12",
23
+ "ora": "^8.0.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.1.0",
27
+ "@types/node": "^20.11.0",
28
+ "typescript": "^5.3.0"
29
+ }
30
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ async function buildJs() {
5
+ const entry = path.resolve(__dirname, '../src/index.ts');
6
+ const outfile = path.resolve(__dirname, '../dist/bin.js');
7
+
8
+ console.log('Bundling CLI into a pure JS bundle for Node...');
9
+
10
+ // 1. Build the JavaScript bundle with Bun targeting Node
11
+ const result = await Bun.build({
12
+ entrypoints: [entry],
13
+ target: 'node',
14
+ minify: false, // Keep it readable/debuggable for NPM users
15
+ });
16
+
17
+ if (!result.success) {
18
+ console.error('Bundle failed:', result.logs);
19
+ process.exit(1);
20
+ }
21
+
22
+ const rawCode = await result.outputs[0].text();
23
+
24
+ // 2. Prepend Node shebang and write with executable permissions (0755)
25
+ const shebang = '#!/usr/bin/env node\n\n';
26
+ await fs.mkdir(path.dirname(outfile), { recursive: true });
27
+ await fs.writeFile(outfile, shebang + rawCode, { mode: 0o755 });
28
+
29
+ console.log(`✓ Successfully created executable JS bundle: ${outfile}`);
30
+ }
31
+
32
+ buildJs();
@@ -0,0 +1,139 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { loadConfig, saveConfig } from '../utils/config';
4
+ import { getHttpClient } from '../utils/client';
5
+ import axios from 'axios';
6
+
7
+ export async function setServer(url: string): Promise<void> {
8
+ try {
9
+ const config = await loadConfig();
10
+ config.serverUrl = url;
11
+ await saveConfig(config);
12
+ console.log(chalk.green(`✔ Server URL successfully set to: ${url}`));
13
+ } catch (err: any) {
14
+ console.error(chalk.red(`Error saving server URL: ${err.message}`));
15
+ }
16
+ }
17
+
18
+ export async function login(options: { apiKey?: string }): Promise<void> {
19
+ const apiKeyFlag = options.apiKey;
20
+ if (apiKeyFlag) {
21
+ // Headless API Key Verification
22
+ try {
23
+ const config = await loadConfig();
24
+ config.apiKey = apiKeyFlag;
25
+ await saveConfig(config);
26
+
27
+ // Verify the key immediately
28
+ const client = await getHttpClient();
29
+ const res = await client.get('/api/auth/me');
30
+ const email = res.data.email;
31
+
32
+ console.log(chalk.green(`✔ Successfully authenticated headlessly!`));
33
+ console.log(chalk.cyan(`Account Email: ${email}`));
34
+ } catch (err: any) {
35
+ // Rollback configuration on failure
36
+ const config = await loadConfig();
37
+ config.apiKey = undefined;
38
+ await saveConfig(config);
39
+ console.error(chalk.red(`Error: API Key verification failed. Make sure server is reachable and API Key is valid. (${err.message})`));
40
+ process.exit(1);
41
+ }
42
+ return;
43
+ }
44
+
45
+ // Interactive Email / Password Flow
46
+ try {
47
+ const config = await loadConfig();
48
+ const serverUrl = config.serverUrl;
49
+ if (!serverUrl) {
50
+ console.error(chalk.red('Error: Server URL is not configured. Please run: hsync config set-server <url>'));
51
+ process.exit(1);
52
+ }
53
+
54
+ const answers = await inquirer.prompt([
55
+ {
56
+ type: 'input',
57
+ name: 'email',
58
+ message: 'Enter your email address:',
59
+ validate: (input) => (input.trim() !== '' ? true : 'Email cannot be empty.'),
60
+ },
61
+ {
62
+ type: 'password',
63
+ name: 'password',
64
+ message: 'Enter your password:',
65
+ mask: '*',
66
+ validate: (input) => (input.trim() !== '' ? true : 'Password cannot be empty.'),
67
+ },
68
+ ]);
69
+
70
+ const email = answers.email;
71
+ const password = answers.password;
72
+
73
+ console.log(chalk.cyan('Authenticating with server...'));
74
+ const loginRes = await axios.post(
75
+ `${serverUrl}/api/auth/login`,
76
+ { email: email, password: password },
77
+ { validateStatus: (status) => status === 200 }
78
+ );
79
+
80
+ const cookies = loginRes.headers['set-cookie'];
81
+ if (!cookies || cookies.length === 0) {
82
+ throw new Error('No session cookie returned by the server.');
83
+ }
84
+ const sessionCookie = cookies[0];
85
+
86
+ console.log(chalk.cyan('Generating secure persistent API key...'));
87
+ const keyRes = await axios.post(
88
+ `${serverUrl}/api/auth/keys`,
89
+ { name: 'hsync CLI Key' },
90
+ {
91
+ headers: { Cookie: sessionCookie },
92
+ validateStatus: (status) => status === 200,
93
+ }
94
+ );
95
+
96
+ const generatedKey = keyRes.data.apiKey;
97
+ config.apiKey = generatedKey;
98
+ await saveConfig(config);
99
+
100
+ console.log(chalk.green('✔ Successfully logged in! Secure API key has been registered locally.'));
101
+ } catch (err: any) {
102
+ let message = err.message;
103
+ if (err.response && err.response.data && err.response.data.error) {
104
+ message = err.response.data.error;
105
+ }
106
+ console.error(chalk.red(`Error: Authentication failed: ${message}`));
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ export async function showMe(): Promise<void> {
112
+ try {
113
+ const client = await getHttpClient();
114
+ const res = await client.get('/api/auth/me');
115
+ const profile = res.data;
116
+
117
+ console.log(chalk.cyan('=== Active CLI Auth Session ==='));
118
+ console.log(`Email: ${profile.email}`);
119
+ console.log(`Role: ${profile.role}`);
120
+ } catch (err: any) {
121
+ let message = err.message;
122
+ if (err.response && err.response.data && err.response.data.error) {
123
+ message = err.response.data.error;
124
+ }
125
+ console.error(chalk.red(`Error: Failed to retrieve profile: ${message}`));
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ export async function logout(): Promise<void> {
131
+ try {
132
+ const config = await loadConfig();
133
+ config.apiKey = undefined;
134
+ await saveConfig(config);
135
+ console.log(chalk.green('✔ Successfully logged out. API key removed.'));
136
+ } catch (err: any) {
137
+ console.error(chalk.red(`Error during logout: ${err.message}`));
138
+ }
139
+ }
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { getHttpClient } from '../utils/client';
4
+
5
+ export async function listBuckets(): Promise<void> {
6
+ try {
7
+ const client = await getHttpClient();
8
+ const res = await client.get('/api/buckets');
9
+ const buckets = res.data;
10
+
11
+ if (buckets.length === 0) {
12
+ console.log(chalk.yellow('No buckets found. Create one with: hsync bucket create <name> <slug> --provider <local|s3>'));
13
+ return;
14
+ }
15
+
16
+ console.log(chalk.cyan('\n=== Registered Storage Buckets ==='));
17
+ for (const b of buckets) {
18
+ console.log(`- ${chalk.bold(b.name)} (${chalk.cyan(b.slug)})`);
19
+ console.log(` Provider: ${b.providerType}`);
20
+ if (b.providerType === 'local') {
21
+ console.log(` Path: ${b.storageConfig.rootPath}`);
22
+ } else {
23
+ console.log(` S3 Bucket: ${b.storageConfig.bucket}`);
24
+ }
25
+ console.log();
26
+ }
27
+ } catch (err: any) {
28
+ let message = err.message;
29
+ if (err.response && err.response.data && err.response.data.error) {
30
+ message = err.response.data.error;
31
+ }
32
+ console.error(chalk.red(`Error: Failed to list buckets: ${message}`));
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ export async function createBucket(
38
+ name: string,
39
+ slug: string,
40
+ options: { provider: 'local' | 's3'; rootPath?: string; bucket?: string }
41
+ ): Promise<void> {
42
+ try {
43
+ const provider = options.provider;
44
+ const rootPath = options.rootPath;
45
+ const bucket = options.bucket;
46
+
47
+ const storageConfig: Record<string, any> = {};
48
+ if (provider === 'local') {
49
+ if (!rootPath) {
50
+ throw new Error('--rootPath is required when using local provider.');
51
+ }
52
+ storageConfig.rootPath = rootPath;
53
+ } else if (provider === 's3') {
54
+ if (!bucket) {
55
+ throw new Error('--bucket is required when using s3 provider.');
56
+ }
57
+ storageConfig.bucket = bucket;
58
+ } else {
59
+ throw new Error('Invalid provider type. Must be "local" or "s3".');
60
+ }
61
+
62
+ const client = await getHttpClient();
63
+ const res = await client.post('/api/buckets', {
64
+ name: name,
65
+ slug: slug,
66
+ providerType: provider,
67
+ storageConfig: storageConfig,
68
+ });
69
+
70
+ console.log(chalk.green(`✔ Bucket successfully created!`));
71
+ console.log(`Name: ${res.data.name}`);
72
+ console.log(`Slug: ${res.data.slug}`);
73
+ } catch (err: any) {
74
+ let message = err.message;
75
+ if (err.response && err.response.data && err.response.data.error) {
76
+ message = err.response.data.error;
77
+ }
78
+ console.error(chalk.red(`Error: Failed to create bucket: ${message}`));
79
+ process.exit(1);
80
+ }
81
+ }
82
+
83
+ export async function deleteBucket(slug: string): Promise<void> {
84
+ try {
85
+ const answers = await inquirer.prompt([
86
+ {
87
+ type: 'confirm',
88
+ name: 'confirm',
89
+ message: `Are you absolutely sure you want to delete the bucket "${slug}" and all cached metadata? This action CANNOT be undone!`,
90
+ default: false,
91
+ },
92
+ ]);
93
+
94
+ if (!answers.confirm) {
95
+ console.log(chalk.yellow('Bucket deletion cancelled.'));
96
+ return;
97
+ }
98
+
99
+ const client = await getHttpClient();
100
+ await client.delete(`/api/buckets/${slug}`);
101
+ console.log(chalk.green(`✔ Bucket "${slug}" successfully deleted.`));
102
+ } catch (err: any) {
103
+ let message = err.message;
104
+ if (err.response && err.response.data && err.response.data.error) {
105
+ message = err.response.data.error;
106
+ }
107
+ console.error(chalk.red(`Error: Failed to delete bucket: ${message}`));
108
+ process.exit(1);
109
+ }
110
+ }
@@ -0,0 +1,213 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { loadConfig, saveConfig } from '../utils/config';
7
+ import { SyncEngine } from '@file-sync/sync-engine';
8
+ import type { SyncJob, SyncEvent } from '@file-sync/sync-engine';
9
+
10
+ function parseRemoteTarget(target: string): { slug: string; remotePath: string } {
11
+ const parts = target.split(':');
12
+ const slug = parts[0];
13
+ const remotePath = (parts.slice(1).join(':') || '').replace(/^\/+|\/+$/g, '');
14
+ return { slug: slug, remotePath: remotePath };
15
+ }
16
+
17
+ export async function addSyncJob(
18
+ localPath: string,
19
+ remoteTarget: string,
20
+ options: {
21
+ name: string;
22
+ direction: 'push' | 'pull' | 'bidirectional';
23
+ strategy: 'local-wins' | 'server-wins' | 'newer-wins';
24
+ interval?: string;
25
+ noConflictCopy?: boolean;
26
+ }
27
+ ): Promise<void> {
28
+ try {
29
+ const config = await loadConfig();
30
+ const resolvedLocalPath = path.resolve(localPath);
31
+
32
+ // Verify local path exists
33
+ try {
34
+ await fs.access(resolvedLocalPath);
35
+ } catch {
36
+ console.error(chalk.red(`Error: Local path "${localPath}" does not exist.`));
37
+ process.exit(1);
38
+ }
39
+
40
+ const remote = parseRemoteTarget(remoteTarget);
41
+ const intervalSec = options.interval ? parseInt(options.interval, 10) : 10;
42
+ if (isNaN(intervalSec) || intervalSec <= 0) {
43
+ throw new Error('Interval must be a positive integer in seconds.');
44
+ }
45
+
46
+ // Verify unique name
47
+ const existing = config.syncJobs?.find((j) => j.name === options.name);
48
+ if (existing) {
49
+ throw new Error(`A sync job with the name "${options.name}" already exists.`);
50
+ }
51
+
52
+ const newJob: SyncJob = {
53
+ id: `job-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
54
+ name: options.name,
55
+ localPath: resolvedLocalPath,
56
+ remoteBucket: remote.slug,
57
+ remotePath: remote.remotePath,
58
+ direction: options.direction,
59
+ conflictStrategy: options.strategy,
60
+ conflictCopy: !options.noConflictCopy,
61
+ intervalMs: intervalSec * 1000,
62
+ enabled: true,
63
+ };
64
+
65
+ if (!config.syncJobs) {
66
+ config.syncJobs = [];
67
+ }
68
+ config.syncJobs.push(newJob);
69
+ await saveConfig(config);
70
+
71
+ console.log(chalk.green(`✔ Sync job "${options.name}" successfully registered!`));
72
+ console.log(`Local Path: ${newJob.localPath}`);
73
+ console.log(`Remote: ${newJob.remoteBucket}:${newJob.remotePath || ''}`);
74
+ console.log(`Direction: ${newJob.direction}`);
75
+ console.log(`Strategy: ${newJob.conflictStrategy}`);
76
+ console.log(`Interval: ${intervalSec} seconds`);
77
+ } catch (err: any) {
78
+ console.error(chalk.red(`Error: Failed to register sync job: ${err.message}`));
79
+ process.exit(1);
80
+ }
81
+ }
82
+
83
+ export async function listSyncJobs(): Promise<void> {
84
+ try {
85
+ const config = await loadConfig();
86
+ const jobs = config.syncJobs || [];
87
+
88
+ if (jobs.length === 0) {
89
+ console.log(chalk.yellow('No persistent sync jobs registered. Create one with: hsync sync add <localPath> <bucketSlug>:[remotePath] --name <jobName> ...'));
90
+ return;
91
+ }
92
+
93
+ console.log(chalk.cyan('\n=== Registered Sync Profiles ==='));
94
+ for (const j of jobs) {
95
+ console.log(`- Name: ${chalk.bold(j.name)} (${j.enabled ? chalk.green('Enabled') : chalk.red('Disabled')})`);
96
+ console.log(` Local Path: ${j.localPath}`);
97
+ console.log(` Remote Path: ${j.remoteBucket}:${j.remotePath || ''}`);
98
+ console.log(` Direction: ${j.direction}`);
99
+ console.log(` Strategy: ${j.conflictStrategy}`);
100
+ console.log(` Conflict Copy Backup: ${j.conflictCopy ? chalk.green('Yes') : chalk.red('No')}`);
101
+ console.log(` Interval: ${(j.intervalMs || 10000) / 1000} seconds`);
102
+ console.log();
103
+ }
104
+ } catch (err: any) {
105
+ console.error(chalk.red(`Error listing sync jobs: ${err.message}`));
106
+ }
107
+ }
108
+
109
+ export async function removeSyncJob(name: string): Promise<void> {
110
+ try {
111
+ const config = await loadConfig();
112
+ const jobs = config.syncJobs || [];
113
+ const index = jobs.findIndex((j) => j.name === name);
114
+
115
+ if (index === -1) {
116
+ throw new Error(`Sync job named "${name}" not found.`);
117
+ }
118
+
119
+ jobs.splice(index, 1);
120
+ config.syncJobs = jobs;
121
+ await saveConfig(config);
122
+
123
+ console.log(chalk.green(`✔ Sync job "${name}" successfully removed.`));
124
+ } catch (err: any) {
125
+ console.error(chalk.red(`Error removing sync job: ${err.message}`));
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ export async function startSyncScheduler(name?: string): Promise<void> {
131
+ try {
132
+ const config = await loadConfig();
133
+ const serverUrl = config.serverUrl;
134
+ const apiKey = config.apiKey;
135
+
136
+ if (!serverUrl || !apiKey) {
137
+ console.error(chalk.red('Error: CLI not authenticated. Please run: hsync config set-server <url> && hsync login'));
138
+ process.exit(1);
139
+ }
140
+
141
+ let jobs = config.syncJobs || [];
142
+ if (name) {
143
+ jobs = jobs.filter((j) => j.name === name);
144
+ if (jobs.length === 0) {
145
+ console.error(chalk.red(`Error: Sync job named "${name}" was not found or is disabled.`));
146
+ process.exit(1);
147
+ }
148
+ } else {
149
+ jobs = jobs.filter((j) => j.enabled);
150
+ }
151
+
152
+ if (jobs.length === 0) {
153
+ console.error(chalk.yellow('Warning: No active sync jobs to schedule. Register a sync job first.'));
154
+ return;
155
+ }
156
+
157
+ console.log(chalk.cyan(`\n=== Starting Sync Scheduler Service ===`));
158
+ console.log(chalk.cyan(`Target Server: ${serverUrl}`));
159
+ console.log(chalk.cyan(`Active Profiles: ${jobs.map((j) => j.name).join(', ')}\n`));
160
+
161
+ // Overrun Lock Protection Tracker
162
+ const activeJobs = new Set<string>();
163
+
164
+ for (const job of jobs) {
165
+ const intervalDelay = job.intervalMs || 10000;
166
+ const stateDbPath = path.join(os.homedir(), '.hsync', 'state', `${job.id}.db`);
167
+
168
+ const triggerSyncRun = async () => {
169
+ if (activeJobs.has(job.id)) {
170
+ console.log(chalk.yellow(`[${new Date().toLocaleTimeString()}] [Sync: ${job.name}] Skip run — previous run still in progress.`));
171
+ return;
172
+ }
173
+
174
+ activeJobs.add(job.id);
175
+ const engine = new SyncEngine(job, serverUrl, apiKey, stateDbPath);
176
+
177
+ engine.on((event: SyncEvent) => {
178
+ const time = new Date().toLocaleTimeString();
179
+ if (event.type === 'start') {
180
+ console.log(chalk.blue(`[${time}] [Sync: ${job.name}] Running scan...`));
181
+ } else if (event.type === 'scan-complete') {
182
+ console.log(chalk.blue(`[${time}] [Sync: ${job.name}] Scan completed.`));
183
+ } else if (event.type === 'upload-complete') {
184
+ console.log(chalk.green(`[${time}] [Sync: ${job.name}] ✔ Uploaded: ${event.path}`));
185
+ } else if (event.type === 'download-complete') {
186
+ console.log(chalk.green(`[${time}] [Sync: ${job.name}] ✔ Downloaded: ${event.path}`));
187
+ } else if (event.type === 'conflict') {
188
+ console.log(chalk.yellow(`[${time}] [Sync: ${job.name}] ⚠ Conflict resolved for: ${event.path}`));
189
+ } else if (event.type === 'error') {
190
+ console.error(chalk.red(`[${time}] [Sync: ${job.name}] ✘ Error on "${event.path}": ${event.error?.message}`));
191
+ } else if (event.type === 'complete') {
192
+ console.log(chalk.green(`[${time}] [Sync: ${job.name}] ✔ Sync pass completed successfully.`));
193
+ }
194
+ });
195
+
196
+ try {
197
+ await engine.run();
198
+ } catch (runErr: any) {
199
+ console.error(chalk.red(`[${new Date().toLocaleTimeString()}] [Sync: ${job.name}] Sync crash: ${runErr.message}`));
200
+ } finally {
201
+ activeJobs.delete(job.id);
202
+ }
203
+ };
204
+
205
+ // Trigger immediately once, then queue interval loops
206
+ triggerSyncRun();
207
+ setInterval(triggerSyncRun, intervalDelay);
208
+ }
209
+ } catch (err: any) {
210
+ console.error(chalk.red(`Scheduler crashed: ${err.message}`));
211
+ process.exit(1);
212
+ }
213
+ }