@lifestreamdynamics/vault-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/LICENSE +21 -0
- package/README.md +759 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +79 -0
- package/dist/commands/admin.d.ts +2 -0
- package/dist/commands/admin.js +263 -0
- package/dist/commands/audit.d.ts +2 -0
- package/dist/commands/audit.js +119 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +256 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +130 -0
- package/dist/commands/connectors.d.ts +2 -0
- package/dist/commands/connectors.js +224 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +194 -0
- package/dist/commands/hooks.d.ts +2 -0
- package/dist/commands/hooks.js +159 -0
- package/dist/commands/keys.d.ts +2 -0
- package/dist/commands/keys.js +165 -0
- package/dist/commands/publish.d.ts +2 -0
- package/dist/commands/publish.js +138 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +61 -0
- package/dist/commands/shares.d.ts +2 -0
- package/dist/commands/shares.js +121 -0
- package/dist/commands/subscription.d.ts +2 -0
- package/dist/commands/subscription.js +166 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +565 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +322 -0
- package/dist/commands/user.d.ts +2 -0
- package/dist/commands/user.js +48 -0
- package/dist/commands/vaults.d.ts +2 -0
- package/dist/commands/vaults.js +157 -0
- package/dist/commands/versions.d.ts +2 -0
- package/dist/commands/versions.js +219 -0
- package/dist/commands/webhooks.d.ts +2 -0
- package/dist/commands/webhooks.js +181 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/lib/credential-manager.d.ts +48 -0
- package/dist/lib/credential-manager.js +101 -0
- package/dist/lib/encrypted-config.d.ts +20 -0
- package/dist/lib/encrypted-config.js +102 -0
- package/dist/lib/keychain.d.ts +8 -0
- package/dist/lib/keychain.js +82 -0
- package/dist/lib/migration.d.ts +31 -0
- package/dist/lib/migration.js +92 -0
- package/dist/lib/profiles.d.ts +43 -0
- package/dist/lib/profiles.js +104 -0
- package/dist/sync/config.d.ts +32 -0
- package/dist/sync/config.js +100 -0
- package/dist/sync/conflict.d.ts +30 -0
- package/dist/sync/conflict.js +60 -0
- package/dist/sync/daemon-worker.d.ts +1 -0
- package/dist/sync/daemon-worker.js +128 -0
- package/dist/sync/daemon.d.ts +44 -0
- package/dist/sync/daemon.js +174 -0
- package/dist/sync/diff.d.ts +43 -0
- package/dist/sync/diff.js +166 -0
- package/dist/sync/engine.d.ts +41 -0
- package/dist/sync/engine.js +233 -0
- package/dist/sync/ignore.d.ts +16 -0
- package/dist/sync/ignore.js +72 -0
- package/dist/sync/remote-poller.d.ts +23 -0
- package/dist/sync/remote-poller.js +145 -0
- package/dist/sync/state.d.ts +32 -0
- package/dist/sync/state.js +98 -0
- package/dist/sync/types.d.ts +68 -0
- package/dist/sync/types.js +4 -0
- package/dist/sync/watcher.d.ts +23 -0
- package/dist/sync/watcher.js +207 -0
- package/dist/utils/flags.d.ts +18 -0
- package/dist/utils/flags.js +31 -0
- package/dist/utils/format.d.ts +2 -0
- package/dist/utils/format.js +22 -0
- package/dist/utils/output.d.ts +87 -0
- package/dist/utils/output.js +229 -0
- package/package.json +62 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync configuration persistence.
|
|
3
|
+
* Manages ~/.lsvault/syncs.json — the list of all configured sync pairs.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
const CONFIG_DIR = path.join(os.homedir(), '.lsvault');
|
|
10
|
+
const SYNCS_FILE = path.join(CONFIG_DIR, 'syncs.json');
|
|
11
|
+
/**
|
|
12
|
+
* Read all sync configurations from disk.
|
|
13
|
+
*/
|
|
14
|
+
export function loadSyncConfigs() {
|
|
15
|
+
if (!fs.existsSync(SYNCS_FILE)) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(SYNCS_FILE, 'utf-8');
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!Array.isArray(parsed))
|
|
22
|
+
return [];
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Write all sync configurations to disk.
|
|
31
|
+
*/
|
|
32
|
+
export function saveSyncConfigs(configs) {
|
|
33
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
34
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
fs.writeFileSync(SYNCS_FILE, JSON.stringify(configs, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Find a sync config by its ID.
|
|
40
|
+
*/
|
|
41
|
+
export function getSyncConfig(id) {
|
|
42
|
+
return loadSyncConfigs().find(c => c.id === id);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Find a sync config by vault ID.
|
|
46
|
+
* Returns the first match (a vault should typically only have one sync config).
|
|
47
|
+
*/
|
|
48
|
+
export function getSyncConfigByVaultId(vaultId) {
|
|
49
|
+
return loadSyncConfigs().find(c => c.vaultId === vaultId);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a new sync configuration.
|
|
53
|
+
* Returns the created config with a generated ID.
|
|
54
|
+
*/
|
|
55
|
+
export function createSyncConfig(opts) {
|
|
56
|
+
const configs = loadSyncConfigs();
|
|
57
|
+
// Check for duplicate vault+path combinations
|
|
58
|
+
const existing = configs.find(c => c.vaultId === opts.vaultId && c.localPath === opts.localPath);
|
|
59
|
+
if (existing) {
|
|
60
|
+
throw new Error(`Sync already exists for vault ${opts.vaultId} at ${opts.localPath} (id: ${existing.id})`);
|
|
61
|
+
}
|
|
62
|
+
const config = {
|
|
63
|
+
id: crypto.randomUUID(),
|
|
64
|
+
vaultId: opts.vaultId,
|
|
65
|
+
localPath: opts.localPath,
|
|
66
|
+
mode: opts.mode ?? 'sync',
|
|
67
|
+
onConflict: opts.onConflict ?? 'newer',
|
|
68
|
+
ignore: opts.ignore ?? ['.git', '.DS_Store', 'node_modules'],
|
|
69
|
+
lastSyncAt: new Date(0).toISOString(),
|
|
70
|
+
syncInterval: opts.syncInterval,
|
|
71
|
+
autoSync: opts.autoSync ?? false,
|
|
72
|
+
};
|
|
73
|
+
configs.push(config);
|
|
74
|
+
saveSyncConfigs(configs);
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Delete a sync configuration by ID.
|
|
79
|
+
* Returns true if the config was found and deleted.
|
|
80
|
+
*/
|
|
81
|
+
export function deleteSyncConfig(id) {
|
|
82
|
+
const configs = loadSyncConfigs();
|
|
83
|
+
const index = configs.findIndex(c => c.id === id);
|
|
84
|
+
if (index === -1)
|
|
85
|
+
return false;
|
|
86
|
+
configs.splice(index, 1);
|
|
87
|
+
saveSyncConfigs(configs);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Update the lastSyncAt timestamp for a sync config.
|
|
92
|
+
*/
|
|
93
|
+
export function updateLastSync(id, timestamp) {
|
|
94
|
+
const configs = loadSyncConfigs();
|
|
95
|
+
const config = configs.find(c => c.id === id);
|
|
96
|
+
if (!config)
|
|
97
|
+
throw new Error(`Sync config not found: ${id}`);
|
|
98
|
+
config.lastSyncAt = timestamp ?? new Date().toISOString();
|
|
99
|
+
saveSyncConfigs(configs);
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FileState, ConflictStrategy } from './types.js';
|
|
2
|
+
export interface ConflictInfo {
|
|
3
|
+
/** Document path (relative) */
|
|
4
|
+
docPath: string;
|
|
5
|
+
/** Local file state */
|
|
6
|
+
local: FileState;
|
|
7
|
+
/** Remote file state */
|
|
8
|
+
remote: FileState;
|
|
9
|
+
/** Previous known state (from last sync) */
|
|
10
|
+
lastKnown: FileState | undefined;
|
|
11
|
+
}
|
|
12
|
+
export type ConflictResolution = 'local' | 'remote';
|
|
13
|
+
/**
|
|
14
|
+
* Detect if a file has a bidirectional conflict.
|
|
15
|
+
* A conflict exists when both local and remote have changed since last sync.
|
|
16
|
+
*/
|
|
17
|
+
export declare function detectConflict(local: FileState, remote: FileState, lastLocal: FileState | undefined, lastRemote: FileState | undefined): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a conflict using the specified strategy.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveConflict(strategy: ConflictStrategy, local: FileState, remote: FileState): ConflictResolution;
|
|
22
|
+
/**
|
|
23
|
+
* Create a conflict backup file with a timestamped name.
|
|
24
|
+
* Returns the path of the created conflict file.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createConflictFile(localPath: string, docPath: string, content: string, source: 'local' | 'remote'): string;
|
|
27
|
+
/**
|
|
28
|
+
* Format a conflict log entry.
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatConflictLog(docPath: string, resolution: ConflictResolution, conflictFilePath: string | null): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict detection and resolution for bidirectional sync.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Detect if a file has a bidirectional conflict.
|
|
8
|
+
* A conflict exists when both local and remote have changed since last sync.
|
|
9
|
+
*/
|
|
10
|
+
export function detectConflict(local, remote, lastLocal, lastRemote) {
|
|
11
|
+
if (!lastLocal || !lastRemote) {
|
|
12
|
+
// First sync — conflict if hashes differ
|
|
13
|
+
return local.hash !== remote.hash;
|
|
14
|
+
}
|
|
15
|
+
const localChanged = local.hash !== lastLocal.hash;
|
|
16
|
+
const remoteChanged = remote.hash !== lastRemote.hash;
|
|
17
|
+
return localChanged && remoteChanged;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a conflict using the specified strategy.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveConflict(strategy, local, remote) {
|
|
23
|
+
switch (strategy) {
|
|
24
|
+
case 'local':
|
|
25
|
+
return 'local';
|
|
26
|
+
case 'remote':
|
|
27
|
+
return 'remote';
|
|
28
|
+
case 'newer':
|
|
29
|
+
return new Date(local.mtime) >= new Date(remote.mtime) ? 'local' : 'remote';
|
|
30
|
+
case 'ask':
|
|
31
|
+
// 'ask' cannot be resolved automatically — caller must handle interactively.
|
|
32
|
+
// Default to 'newer' as fallback when non-interactive.
|
|
33
|
+
return new Date(local.mtime) >= new Date(remote.mtime) ? 'local' : 'remote';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a conflict backup file with a timestamped name.
|
|
38
|
+
* Returns the path of the created conflict file.
|
|
39
|
+
*/
|
|
40
|
+
export function createConflictFile(localPath, docPath, content, source) {
|
|
41
|
+
const ext = path.extname(docPath);
|
|
42
|
+
const base = docPath.slice(0, -ext.length);
|
|
43
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
44
|
+
const conflictPath = `${base}.conflicted.${source}.${timestamp}${ext}`;
|
|
45
|
+
const absPath = path.join(localPath, conflictPath);
|
|
46
|
+
const dir = path.dirname(absPath);
|
|
47
|
+
if (!fs.existsSync(dir)) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(absPath, content, 'utf-8');
|
|
51
|
+
return conflictPath;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Format a conflict log entry.
|
|
55
|
+
*/
|
|
56
|
+
export function formatConflictLog(docPath, resolution, conflictFilePath) {
|
|
57
|
+
const ts = new Date().toISOString();
|
|
58
|
+
const conflictNote = conflictFilePath ? ` (backup: ${conflictFilePath})` : '';
|
|
59
|
+
return `[${ts}] CONFLICT ${docPath}: resolved=${resolution}${conflictNote}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon worker process.
|
|
3
|
+
* Runs as a detached background process, managing watchers for all autoSync syncs.
|
|
4
|
+
* Designed to be spawned by daemon.ts startDaemon().
|
|
5
|
+
*/
|
|
6
|
+
import { loadSyncConfigs } from './config.js';
|
|
7
|
+
import { resolveIgnorePatterns } from './ignore.js';
|
|
8
|
+
import { createWatcher } from './watcher.js';
|
|
9
|
+
import { createRemotePoller } from './remote-poller.js';
|
|
10
|
+
import { removePid } from './daemon.js';
|
|
11
|
+
import { loadConfig } from '../config.js';
|
|
12
|
+
import { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
|
|
13
|
+
const managed = [];
|
|
14
|
+
function log(msg) {
|
|
15
|
+
const ts = new Date().toISOString();
|
|
16
|
+
process.stdout.write(`[${ts}] ${msg}\n`);
|
|
17
|
+
}
|
|
18
|
+
function createClient() {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
if (!config.apiKey) {
|
|
21
|
+
throw new Error('No API key configured. Run `lsvault auth login` first.');
|
|
22
|
+
}
|
|
23
|
+
return new LifestreamVaultClient({
|
|
24
|
+
baseUrl: config.apiUrl,
|
|
25
|
+
apiKey: config.apiKey,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async function start() {
|
|
29
|
+
log('Daemon starting...');
|
|
30
|
+
const configs = loadSyncConfigs().filter(c => c.autoSync);
|
|
31
|
+
if (configs.length === 0) {
|
|
32
|
+
log('No auto-sync configurations found. Daemon has nothing to do.');
|
|
33
|
+
removePid();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
log(`Found ${configs.length} auto-sync configuration(s)`);
|
|
37
|
+
const client = createClient();
|
|
38
|
+
for (const config of configs) {
|
|
39
|
+
try {
|
|
40
|
+
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
41
|
+
const { watcher, stop: stopWatcher } = createWatcher(client, config, {
|
|
42
|
+
ignorePatterns,
|
|
43
|
+
onLog: (msg) => log(msg),
|
|
44
|
+
onConflictLog: (msg) => log(`CONFLICT: ${msg}`),
|
|
45
|
+
onError: (err) => log(`ERROR [${config.id.slice(0, 8)}]: ${err.message}`),
|
|
46
|
+
});
|
|
47
|
+
let stopPoller;
|
|
48
|
+
if (config.mode === 'sync') {
|
|
49
|
+
const pollIntervalMs = parseSyncInterval(config.syncInterval) || 30000;
|
|
50
|
+
const poller = createRemotePoller(client, config, {
|
|
51
|
+
ignorePatterns,
|
|
52
|
+
intervalMs: pollIntervalMs,
|
|
53
|
+
onLog: (msg) => log(msg),
|
|
54
|
+
onConflictLog: (msg) => log(`CONFLICT: ${msg}`),
|
|
55
|
+
onError: (err) => log(`ERROR [${config.id.slice(0, 8)}]: ${err.message}`),
|
|
56
|
+
});
|
|
57
|
+
stopPoller = poller.stop;
|
|
58
|
+
}
|
|
59
|
+
managed.push({ syncId: config.id, watcher, stopWatcher, stopPoller });
|
|
60
|
+
log(`Started sync: ${config.id.slice(0, 8)} (${config.localPath})`);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
64
|
+
log(`Failed to start sync ${config.id.slice(0, 8)}: ${msg}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (managed.length === 0) {
|
|
68
|
+
log('No syncs could be started. Exiting.');
|
|
69
|
+
removePid();
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
log(`Daemon running with ${managed.length} sync(s)`);
|
|
73
|
+
}
|
|
74
|
+
async function shutdown() {
|
|
75
|
+
log('Daemon shutting down...');
|
|
76
|
+
for (const sync of managed) {
|
|
77
|
+
try {
|
|
78
|
+
sync.stopPoller?.();
|
|
79
|
+
await sync.stopWatcher();
|
|
80
|
+
log(`Stopped sync: ${sync.syncId.slice(0, 8)}`);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
log(`Error stopping sync ${sync.syncId.slice(0, 8)}: ${msg}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
removePid();
|
|
88
|
+
log('Daemon stopped.');
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse a human-readable sync interval string to milliseconds.
|
|
93
|
+
* Supports: "30s", "5m", "1h", or plain number (ms).
|
|
94
|
+
*/
|
|
95
|
+
function parseSyncInterval(interval) {
|
|
96
|
+
if (!interval)
|
|
97
|
+
return null;
|
|
98
|
+
const match = interval.match(/^(\d+)(s|m|h)?$/);
|
|
99
|
+
if (!match)
|
|
100
|
+
return null;
|
|
101
|
+
const value = parseInt(match[1], 10);
|
|
102
|
+
switch (match[2]) {
|
|
103
|
+
case 's': return value * 1000;
|
|
104
|
+
case 'm': return value * 60 * 1000;
|
|
105
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
106
|
+
default: return value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Signal handlers
|
|
110
|
+
process.on('SIGTERM', shutdown);
|
|
111
|
+
process.on('SIGINT', shutdown);
|
|
112
|
+
// Uncaught error recovery
|
|
113
|
+
process.on('uncaughtException', (err) => {
|
|
114
|
+
log(`UNCAUGHT ERROR: ${err.message}`);
|
|
115
|
+
log(err.stack ?? '');
|
|
116
|
+
// Try to recover — don't exit
|
|
117
|
+
});
|
|
118
|
+
process.on('unhandledRejection', (reason) => {
|
|
119
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
120
|
+
log(`UNHANDLED REJECTION: ${msg}`);
|
|
121
|
+
// Try to recover — don't exit
|
|
122
|
+
});
|
|
123
|
+
// Start
|
|
124
|
+
start().catch((err) => {
|
|
125
|
+
log(`FATAL: ${err.message}`);
|
|
126
|
+
removePid();
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
declare const DAEMON_DIR: string;
|
|
2
|
+
declare const PID_FILE: string;
|
|
3
|
+
declare const LOG_FILE: string;
|
|
4
|
+
export interface DaemonStatus {
|
|
5
|
+
running: boolean;
|
|
6
|
+
pid: number | null;
|
|
7
|
+
logFile: string;
|
|
8
|
+
uptime: number | null;
|
|
9
|
+
startedAt: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read the daemon PID from the PID file.
|
|
13
|
+
*/
|
|
14
|
+
export declare function readPid(): number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Write the daemon PID to the PID file.
|
|
17
|
+
*/
|
|
18
|
+
export declare function writePid(pid: number): void;
|
|
19
|
+
/**
|
|
20
|
+
* Remove the PID file.
|
|
21
|
+
*/
|
|
22
|
+
export declare function removePid(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Check if a process with the given PID is running.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isProcessRunning(pid: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Get the current daemon status.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getDaemonStatus(): DaemonStatus;
|
|
31
|
+
/**
|
|
32
|
+
* Rotate log files if they exceed the max size.
|
|
33
|
+
*/
|
|
34
|
+
export declare function rotateLogIfNeeded(logFile?: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Start the daemon as a detached child process.
|
|
37
|
+
* Returns the PID of the spawned process.
|
|
38
|
+
*/
|
|
39
|
+
export declare function startDaemon(logFile?: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Stop the running daemon.
|
|
42
|
+
*/
|
|
43
|
+
export declare function stopDaemon(): boolean;
|
|
44
|
+
export { DAEMON_DIR, PID_FILE, LOG_FILE };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background daemon process management.
|
|
3
|
+
* Manages starting, stopping, and checking the status of the sync daemon.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
const DAEMON_DIR = path.join(os.homedir(), '.lsvault', 'daemon');
|
|
10
|
+
const PID_FILE = path.join(DAEMON_DIR, 'daemon.pid');
|
|
11
|
+
const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
|
|
12
|
+
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
|
|
13
|
+
const MAX_LOG_AGE_DAYS = 7;
|
|
14
|
+
/**
|
|
15
|
+
* Ensure the daemon directory exists.
|
|
16
|
+
*/
|
|
17
|
+
function ensureDaemonDir() {
|
|
18
|
+
if (!fs.existsSync(DAEMON_DIR)) {
|
|
19
|
+
fs.mkdirSync(DAEMON_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the daemon PID from the PID file.
|
|
24
|
+
*/
|
|
25
|
+
export function readPid() {
|
|
26
|
+
if (!fs.existsSync(PID_FILE))
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(PID_FILE, 'utf-8').trim();
|
|
30
|
+
const pid = parseInt(content, 10);
|
|
31
|
+
return isNaN(pid) ? null : pid;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Write the daemon PID to the PID file.
|
|
39
|
+
*/
|
|
40
|
+
export function writePid(pid) {
|
|
41
|
+
ensureDaemonDir();
|
|
42
|
+
fs.writeFileSync(PID_FILE, String(pid) + '\n');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Remove the PID file.
|
|
46
|
+
*/
|
|
47
|
+
export function removePid() {
|
|
48
|
+
if (fs.existsSync(PID_FILE)) {
|
|
49
|
+
fs.unlinkSync(PID_FILE);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if a process with the given PID is running.
|
|
54
|
+
*/
|
|
55
|
+
export function isProcessRunning(pid) {
|
|
56
|
+
try {
|
|
57
|
+
process.kill(pid, 0); // Signal 0 doesn't kill, just checks
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get the current daemon status.
|
|
66
|
+
*/
|
|
67
|
+
export function getDaemonStatus() {
|
|
68
|
+
const pid = readPid();
|
|
69
|
+
const running = pid !== null && isProcessRunning(pid);
|
|
70
|
+
// Clean up stale PID file
|
|
71
|
+
if (pid !== null && !running) {
|
|
72
|
+
removePid();
|
|
73
|
+
}
|
|
74
|
+
let startedAt = null;
|
|
75
|
+
let uptime = null;
|
|
76
|
+
if (running && pid !== null) {
|
|
77
|
+
try {
|
|
78
|
+
const pidStat = fs.statSync(PID_FILE);
|
|
79
|
+
startedAt = pidStat.birthtime.toISOString();
|
|
80
|
+
uptime = Math.floor((Date.now() - pidStat.birthtimeMs) / 1000);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore stat errors
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
running,
|
|
88
|
+
pid: running ? pid : null,
|
|
89
|
+
logFile: LOG_FILE,
|
|
90
|
+
uptime,
|
|
91
|
+
startedAt,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Rotate log files if they exceed the max size.
|
|
96
|
+
*/
|
|
97
|
+
export function rotateLogIfNeeded(logFile) {
|
|
98
|
+
const targetLog = logFile ?? LOG_FILE;
|
|
99
|
+
if (!fs.existsSync(targetLog))
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
const stat = fs.statSync(targetLog);
|
|
103
|
+
if (stat.size > MAX_LOG_SIZE) {
|
|
104
|
+
const rotated = `${targetLog}.${Date.now()}.old`;
|
|
105
|
+
fs.renameSync(targetLog, rotated);
|
|
106
|
+
}
|
|
107
|
+
// Clean up old rotated logs
|
|
108
|
+
const dir = path.dirname(targetLog);
|
|
109
|
+
const baseName = path.basename(targetLog);
|
|
110
|
+
const entries = fs.readdirSync(dir);
|
|
111
|
+
const maxAge = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.startsWith(baseName + '.') && entry.endsWith('.old')) {
|
|
114
|
+
const entryPath = path.join(dir, entry);
|
|
115
|
+
const entryStat = fs.statSync(entryPath);
|
|
116
|
+
if (Date.now() - entryStat.mtimeMs > maxAge) {
|
|
117
|
+
fs.unlinkSync(entryPath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Ignore rotation errors
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Start the daemon as a detached child process.
|
|
128
|
+
* Returns the PID of the spawned process.
|
|
129
|
+
*/
|
|
130
|
+
export function startDaemon(logFile) {
|
|
131
|
+
const status = getDaemonStatus();
|
|
132
|
+
if (status.running) {
|
|
133
|
+
throw new Error(`Daemon is already running (PID: ${status.pid})`);
|
|
134
|
+
}
|
|
135
|
+
ensureDaemonDir();
|
|
136
|
+
const targetLog = logFile ?? LOG_FILE;
|
|
137
|
+
rotateLogIfNeeded(targetLog);
|
|
138
|
+
const logFd = fs.openSync(targetLog, 'a');
|
|
139
|
+
// Spawn the daemon worker as a detached process
|
|
140
|
+
const workerPath = path.join(import.meta.dirname, 'daemon-worker.js');
|
|
141
|
+
const child = spawn(process.execPath, [workerPath], {
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: ['ignore', logFd, logFd],
|
|
144
|
+
env: { ...process.env, LSVAULT_DAEMON: '1' },
|
|
145
|
+
});
|
|
146
|
+
if (!child.pid) {
|
|
147
|
+
fs.closeSync(logFd);
|
|
148
|
+
throw new Error('Failed to spawn daemon process');
|
|
149
|
+
}
|
|
150
|
+
writePid(child.pid);
|
|
151
|
+
child.unref();
|
|
152
|
+
fs.closeSync(logFd);
|
|
153
|
+
return child.pid;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Stop the running daemon.
|
|
157
|
+
*/
|
|
158
|
+
export function stopDaemon() {
|
|
159
|
+
const pid = readPid();
|
|
160
|
+
if (pid === null || !isProcessRunning(pid)) {
|
|
161
|
+
removePid();
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
process.kill(pid, 'SIGTERM');
|
|
166
|
+
removePid();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
removePid();
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export { DAEMON_DIR, PID_FILE, LOG_FILE };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff generation for sync operations.
|
|
3
|
+
* Compares local and remote file states to determine what actions are needed.
|
|
4
|
+
*/
|
|
5
|
+
import type { FileState, SyncState } from './types.js';
|
|
6
|
+
export type SyncAction = 'create' | 'update' | 'delete';
|
|
7
|
+
export type SyncDirection = 'upload' | 'download';
|
|
8
|
+
export interface SyncDiffEntry {
|
|
9
|
+
/** Document path (relative, forward slashes) */
|
|
10
|
+
path: string;
|
|
11
|
+
/** What needs to happen */
|
|
12
|
+
action: SyncAction;
|
|
13
|
+
/** Direction of the operation */
|
|
14
|
+
direction: SyncDirection;
|
|
15
|
+
/** File size in bytes (for progress reporting) */
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
/** Human-readable reason for this change */
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncDiff {
|
|
21
|
+
/** Files to upload (local -> remote) */
|
|
22
|
+
uploads: SyncDiffEntry[];
|
|
23
|
+
/** Files to download (remote -> local) */
|
|
24
|
+
downloads: SyncDiffEntry[];
|
|
25
|
+
/** Files to delete */
|
|
26
|
+
deletes: SyncDiffEntry[];
|
|
27
|
+
/** Total bytes to transfer */
|
|
28
|
+
totalBytes: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compute the diff between local and remote state for a pull operation.
|
|
32
|
+
* Pull = download remote changes to local.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computePullDiff(localFiles: Record<string, FileState>, remoteFiles: Record<string, FileState>, lastState: SyncState): SyncDiff;
|
|
35
|
+
/**
|
|
36
|
+
* Compute the diff between local and remote state for a push operation.
|
|
37
|
+
* Push = upload local changes to remote.
|
|
38
|
+
*/
|
|
39
|
+
export declare function computePushDiff(localFiles: Record<string, FileState>, remoteFiles: Record<string, FileState>, lastState: SyncState): SyncDiff;
|
|
40
|
+
/**
|
|
41
|
+
* Format a diff for human-readable display.
|
|
42
|
+
*/
|
|
43
|
+
export declare function formatDiff(diff: SyncDiff): string;
|