botsync 0.1.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/README.md +37 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +102 -0
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.js +151 -0
- package/dist/commands/join.d.ts +13 -0
- package/dist/commands/join.js +106 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/stop.d.ts +6 -0
- package/dist/commands/stop.js +52 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +55 -0
- package/dist/passphrase.d.ts +31 -0
- package/dist/passphrase.js +89 -0
- package/dist/syncthing.d.ts +80 -0
- package/dist/syncthing.js +312 -0
- package/dist/ui.d.ts +47 -0
- package/dist/ui.js +148 -0
- package/package.json +45 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* passphrase.ts — Human-readable pairing codes via relay.
|
|
3
|
+
*
|
|
4
|
+
* Instead of encoding the full device ID into a massive base58 string,
|
|
5
|
+
* we now use a 4-word code like "castle-river-falcon-dawn". The device
|
|
6
|
+
* ID is stored temporarily on a Cloudflare Worker relay, and the code
|
|
7
|
+
* is the lookup key.
|
|
8
|
+
*
|
|
9
|
+
* Falls back to the old base58 encoding if the relay is unreachable
|
|
10
|
+
* (offline/airgap mode).
|
|
11
|
+
*/
|
|
12
|
+
export interface PassphraseData {
|
|
13
|
+
deviceId: string;
|
|
14
|
+
folders: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Register a device ID with the relay and get a short code.
|
|
18
|
+
* Falls back to base58 encoding if the relay is unreachable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createCode(data: PassphraseData): Promise<{
|
|
21
|
+
code: string;
|
|
22
|
+
isRelay: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a pairing code to connection data.
|
|
26
|
+
* If it looks like a word code (contains dashes, all alpha), try the relay.
|
|
27
|
+
* Otherwise treat it as a base58 offline passphrase.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveCode(code: string): Promise<PassphraseData>;
|
|
30
|
+
export declare function encode(data: PassphraseData): string;
|
|
31
|
+
export declare function decode(passphrase: string): PassphraseData;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* passphrase.ts — Human-readable pairing codes via relay.
|
|
4
|
+
*
|
|
5
|
+
* Instead of encoding the full device ID into a massive base58 string,
|
|
6
|
+
* we now use a 4-word code like "castle-river-falcon-dawn". The device
|
|
7
|
+
* ID is stored temporarily on a Cloudflare Worker relay, and the code
|
|
8
|
+
* is the lookup key.
|
|
9
|
+
*
|
|
10
|
+
* Falls back to the old base58 encoding if the relay is unreachable
|
|
11
|
+
* (offline/airgap mode).
|
|
12
|
+
*/
|
|
13
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
14
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createCode = createCode;
|
|
18
|
+
exports.resolveCode = resolveCode;
|
|
19
|
+
exports.encode = encode;
|
|
20
|
+
exports.decode = decode;
|
|
21
|
+
const base_x_1 = __importDefault(require("base-x"));
|
|
22
|
+
const RELAY_URL = "https://relay.botsync.io";
|
|
23
|
+
// Keep base58 as fallback for offline use
|
|
24
|
+
const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
25
|
+
const bs58 = (0, base_x_1.default)(BASE58);
|
|
26
|
+
/**
|
|
27
|
+
* Register a device ID with the relay and get a short code.
|
|
28
|
+
* Falls back to base58 encoding if the relay is unreachable.
|
|
29
|
+
*/
|
|
30
|
+
async function createCode(data) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${RELAY_URL}/pair`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ deviceId: data.deviceId }),
|
|
36
|
+
signal: AbortSignal.timeout(5000),
|
|
37
|
+
});
|
|
38
|
+
if (res.ok) {
|
|
39
|
+
const { code } = (await res.json());
|
|
40
|
+
return { code, isRelay: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Relay unreachable — fall back to offline mode
|
|
45
|
+
}
|
|
46
|
+
// Offline fallback: base58 encode everything
|
|
47
|
+
const json = JSON.stringify(data);
|
|
48
|
+
const buf = Buffer.from(json, "utf-8");
|
|
49
|
+
return { code: bs58.encode(buf), isRelay: false };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a pairing code to connection data.
|
|
53
|
+
* If it looks like a word code (contains dashes, all alpha), try the relay.
|
|
54
|
+
* Otherwise treat it as a base58 offline passphrase.
|
|
55
|
+
*/
|
|
56
|
+
async function resolveCode(code) {
|
|
57
|
+
// Word codes contain dashes and only letters
|
|
58
|
+
const isWordCode = code.includes("-") && /^[a-z-]+$/.test(code);
|
|
59
|
+
if (isWordCode) {
|
|
60
|
+
const res = await fetch(`${RELAY_URL}/pair/${code}`, {
|
|
61
|
+
signal: AbortSignal.timeout(5000),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const body = (await res.json());
|
|
65
|
+
throw new Error(body.error || `Relay returned ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
const { deviceId } = (await res.json());
|
|
68
|
+
// Folders are always the standard set — no need to encode them
|
|
69
|
+
return {
|
|
70
|
+
deviceId,
|
|
71
|
+
folders: ["botsync-shared", "botsync-deliverables", "botsync-inbox"],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Legacy base58 fallback
|
|
75
|
+
const buf = Buffer.from(bs58.decode(code));
|
|
76
|
+
const json = buf.toString("utf-8");
|
|
77
|
+
return JSON.parse(json);
|
|
78
|
+
}
|
|
79
|
+
// Keep old exports for backward compat during transition
|
|
80
|
+
function encode(data) {
|
|
81
|
+
const json = JSON.stringify(data);
|
|
82
|
+
const buf = Buffer.from(json, "utf-8");
|
|
83
|
+
return bs58.encode(buf);
|
|
84
|
+
}
|
|
85
|
+
function decode(passphrase) {
|
|
86
|
+
const buf = Buffer.from(bs58.decode(passphrase));
|
|
87
|
+
const json = buf.toString("utf-8");
|
|
88
|
+
return JSON.parse(json);
|
|
89
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* syncthing.ts — Syncthing lifecycle manager.
|
|
3
|
+
*
|
|
4
|
+
* Handles everything about the Syncthing binary and daemon:
|
|
5
|
+
* - Downloading the right binary for the current platform
|
|
6
|
+
* - Generating the initial config.xml with sane defaults
|
|
7
|
+
* - Starting/stopping the daemon as a background process
|
|
8
|
+
* - REST API wrapper for runtime configuration
|
|
9
|
+
*
|
|
10
|
+
* We generate config.xml from a template string rather than parsing XML.
|
|
11
|
+
* This is intentional — Syncthing's config format is stable, and template
|
|
12
|
+
* generation is way simpler than XML manipulation for an MVP.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Get the path to the Syncthing binary.
|
|
16
|
+
* Checks: 1) our cached binary, 2) system PATH, 3) needs download.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getSyncthingBin(): string;
|
|
19
|
+
/**
|
|
20
|
+
* Download the Syncthing binary for the current platform.
|
|
21
|
+
* Extracts from the GitHub release and stores in ~/.botsync/bin/.
|
|
22
|
+
*
|
|
23
|
+
* Skips download if we already have a binary OR one exists on the system PATH.
|
|
24
|
+
* macOS releases are .zip, Linux releases are .tar.gz.
|
|
25
|
+
*/
|
|
26
|
+
export declare function downloadSyncthing(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Generate the Syncthing config.xml with our desired settings.
|
|
29
|
+
*
|
|
30
|
+
* Key decisions:
|
|
31
|
+
* - GUI disabled: agents don't need a web UI, and it avoids port conflicts
|
|
32
|
+
* - Global discovery disabled: we pair explicitly via passphrase, no phone-home
|
|
33
|
+
* - Local discovery enabled: allows finding peers on the same LAN without relay
|
|
34
|
+
* - Relaying disabled: MVP keeps it simple, direct connections only
|
|
35
|
+
* - Random API port: avoids conflicts with other Syncthing instances
|
|
36
|
+
*/
|
|
37
|
+
export declare function generateConfig(apiKey: string, apiPort: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Start the Syncthing daemon as a background process.
|
|
40
|
+
* Returns the child process PID.
|
|
41
|
+
*
|
|
42
|
+
* We detach the process and unref it so our CLI can exit while
|
|
43
|
+
* Syncthing keeps running. The PID is saved to disk so `botsync stop`
|
|
44
|
+
* can find and kill it later.
|
|
45
|
+
*/
|
|
46
|
+
export declare function startDaemon(): number;
|
|
47
|
+
/**
|
|
48
|
+
* Make an API call to the local Syncthing REST API.
|
|
49
|
+
* All config changes go through this — Syncthing's REST API is the
|
|
50
|
+
* canonical way to modify a running instance's configuration.
|
|
51
|
+
*/
|
|
52
|
+
export declare function apiCall<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
|
|
53
|
+
/**
|
|
54
|
+
* Get this device's Syncthing device ID.
|
|
55
|
+
* The device ID is derived from the TLS certificate Syncthing generates
|
|
56
|
+
* on first run — it's essentially a public key fingerprint.
|
|
57
|
+
*/
|
|
58
|
+
export declare function getDeviceId(): Promise<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Wait for Syncthing to become responsive.
|
|
61
|
+
* On first run, Syncthing needs a moment to generate its TLS cert and
|
|
62
|
+
* start the API server. We poll until it responds.
|
|
63
|
+
*/
|
|
64
|
+
export declare function waitForStart(maxRetries?: number): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Add a remote device to Syncthing's config.
|
|
67
|
+
* This is how we "pair" — we tell our Syncthing about the other device's ID.
|
|
68
|
+
*/
|
|
69
|
+
export declare function addDevice(deviceId: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Add a device to a specific shared folder.
|
|
72
|
+
* Both sides need to share the same folder IDs with each other's device IDs
|
|
73
|
+
* for sync to work.
|
|
74
|
+
*/
|
|
75
|
+
export declare function addDeviceToFolder(folderId: string, deviceId: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Stop the Syncthing daemon by reading the PID file and sending SIGTERM.
|
|
78
|
+
* Returns true if a process was stopped, false if nothing was running.
|
|
79
|
+
*/
|
|
80
|
+
export declare function stopDaemon(): boolean;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* syncthing.ts — Syncthing lifecycle manager.
|
|
4
|
+
*
|
|
5
|
+
* Handles everything about the Syncthing binary and daemon:
|
|
6
|
+
* - Downloading the right binary for the current platform
|
|
7
|
+
* - Generating the initial config.xml with sane defaults
|
|
8
|
+
* - Starting/stopping the daemon as a background process
|
|
9
|
+
* - REST API wrapper for runtime configuration
|
|
10
|
+
*
|
|
11
|
+
* We generate config.xml from a template string rather than parsing XML.
|
|
12
|
+
* This is intentional — Syncthing's config format is stable, and template
|
|
13
|
+
* generation is way simpler than XML manipulation for an MVP.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.getSyncthingBin = getSyncthingBin;
|
|
17
|
+
exports.downloadSyncthing = downloadSyncthing;
|
|
18
|
+
exports.generateConfig = generateConfig;
|
|
19
|
+
exports.startDaemon = startDaemon;
|
|
20
|
+
exports.apiCall = apiCall;
|
|
21
|
+
exports.getDeviceId = getDeviceId;
|
|
22
|
+
exports.waitForStart = waitForStart;
|
|
23
|
+
exports.addDevice = addDevice;
|
|
24
|
+
exports.addDeviceToFolder = addDeviceToFolder;
|
|
25
|
+
exports.stopDaemon = stopDaemon;
|
|
26
|
+
const child_process_1 = require("child_process");
|
|
27
|
+
const fs_1 = require("fs");
|
|
28
|
+
const path_1 = require("path");
|
|
29
|
+
const https_1 = require("https");
|
|
30
|
+
const config_js_1 = require("./config.js");
|
|
31
|
+
const SYNCTHING_VERSION = "2.0.15";
|
|
32
|
+
/**
|
|
33
|
+
* Follow redirects for HTTPS GET — needed because GitHub releases
|
|
34
|
+
* redirect from the download URL to a CDN. Node's https.get doesn't
|
|
35
|
+
* follow redirects by default, which is frankly annoying.
|
|
36
|
+
*/
|
|
37
|
+
function httpsGetFollowRedirects(url) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
(0, https_1.get)(url, (res) => {
|
|
40
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
41
|
+
// Follow the redirect
|
|
42
|
+
httpsGetFollowRedirects(res.headers.location).then(resolve, reject);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
resolve(res);
|
|
46
|
+
}
|
|
47
|
+
}).on("error", reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Find an existing syncthing binary on the system PATH, or return null.
|
|
52
|
+
* Prefer system-installed syncthing over downloading — avoids duplication
|
|
53
|
+
* and works better when the user already has it (e.g., via Homebrew).
|
|
54
|
+
*/
|
|
55
|
+
function findSystemSyncthing() {
|
|
56
|
+
try {
|
|
57
|
+
const result = (0, child_process_1.execSync)("which syncthing", { encoding: "utf-8" }).trim();
|
|
58
|
+
if (result && (0, fs_1.existsSync)(result))
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not found on PATH
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the path to the Syncthing binary.
|
|
68
|
+
* Checks: 1) our cached binary, 2) system PATH, 3) needs download.
|
|
69
|
+
*/
|
|
70
|
+
function getSyncthingBin() {
|
|
71
|
+
if ((0, fs_1.existsSync)(config_js_1.SYNCTHING_BIN))
|
|
72
|
+
return config_js_1.SYNCTHING_BIN;
|
|
73
|
+
const system = findSystemSyncthing();
|
|
74
|
+
if (system)
|
|
75
|
+
return system;
|
|
76
|
+
return config_js_1.SYNCTHING_BIN; // Will need download
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Download the Syncthing binary for the current platform.
|
|
80
|
+
* Extracts from the GitHub release and stores in ~/.botsync/bin/.
|
|
81
|
+
*
|
|
82
|
+
* Skips download if we already have a binary OR one exists on the system PATH.
|
|
83
|
+
* macOS releases are .zip, Linux releases are .tar.gz.
|
|
84
|
+
*/
|
|
85
|
+
async function downloadSyncthing() {
|
|
86
|
+
// Check if we already have a usable binary (cached or system)
|
|
87
|
+
if ((0, fs_1.existsSync)(config_js_1.SYNCTHING_BIN))
|
|
88
|
+
return;
|
|
89
|
+
if (findSystemSyncthing()) {
|
|
90
|
+
return; // Using system Syncthing
|
|
91
|
+
}
|
|
92
|
+
// Map Node's platform/arch to Syncthing's naming convention
|
|
93
|
+
const platform = process.platform === "darwin" ? "macos" : "linux";
|
|
94
|
+
const arch = process.arch === "arm64" ? "arm64" : "amd64";
|
|
95
|
+
const slug = `syncthing-${platform}-${arch}-v${SYNCTHING_VERSION}`;
|
|
96
|
+
// macOS uses .zip, Linux uses .tar.gz
|
|
97
|
+
const ext = process.platform === "darwin" ? "zip" : "tar.gz";
|
|
98
|
+
const url = `https://github.com/syncthing/syncthing/releases/download/v${SYNCTHING_VERSION}/${slug}.${ext}`;
|
|
99
|
+
// Download message handled by caller's UI
|
|
100
|
+
(0, fs_1.mkdirSync)(config_js_1.SYNCTHING_BIN_DIR, { recursive: true });
|
|
101
|
+
const archivePath = (0, path_1.join)(config_js_1.SYNCTHING_BIN_DIR, `syncthing.${ext}`);
|
|
102
|
+
// Use curl for downloads — it handles redirects, TLS, and retries
|
|
103
|
+
// better than Node's https.get, and every Mac/Linux has it.
|
|
104
|
+
(0, child_process_1.execSync)(`curl -fsSL -o "${archivePath}" "${url}"`, { stdio: "inherit" });
|
|
105
|
+
if (ext === "zip") {
|
|
106
|
+
// macOS: unzip, then move binary out
|
|
107
|
+
(0, child_process_1.execSync)(`unzip -o "${archivePath}" -d "${config_js_1.SYNCTHING_BIN_DIR}"`, { stdio: "ignore" });
|
|
108
|
+
const extracted = (0, path_1.join)(config_js_1.SYNCTHING_BIN_DIR, slug, "syncthing");
|
|
109
|
+
if ((0, fs_1.existsSync)(extracted)) {
|
|
110
|
+
(0, child_process_1.execSync)(`mv "${extracted}" "${config_js_1.SYNCTHING_BIN}"`);
|
|
111
|
+
// Clean up extracted directory
|
|
112
|
+
(0, child_process_1.execSync)(`rm -rf "${(0, path_1.join)(config_js_1.SYNCTHING_BIN_DIR, slug)}"`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Linux: tar extract
|
|
117
|
+
const tar = await import("tar");
|
|
118
|
+
await tar.extract({
|
|
119
|
+
file: archivePath,
|
|
120
|
+
cwd: config_js_1.SYNCTHING_BIN_DIR,
|
|
121
|
+
strip: 1,
|
|
122
|
+
filter: (path) => path.endsWith("/syncthing"),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
(0, fs_1.chmodSync)(config_js_1.SYNCTHING_BIN, 0o755);
|
|
126
|
+
// Clean up the archive
|
|
127
|
+
const { unlinkSync } = await import("fs");
|
|
128
|
+
unlinkSync(archivePath);
|
|
129
|
+
// Download complete — caller handles messaging
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Generate the Syncthing config.xml with our desired settings.
|
|
133
|
+
*
|
|
134
|
+
* Key decisions:
|
|
135
|
+
* - GUI disabled: agents don't need a web UI, and it avoids port conflicts
|
|
136
|
+
* - Global discovery disabled: we pair explicitly via passphrase, no phone-home
|
|
137
|
+
* - Local discovery enabled: allows finding peers on the same LAN without relay
|
|
138
|
+
* - Relaying disabled: MVP keeps it simple, direct connections only
|
|
139
|
+
* - Random API port: avoids conflicts with other Syncthing instances
|
|
140
|
+
*/
|
|
141
|
+
function generateConfig(apiKey, apiPort) {
|
|
142
|
+
// Build folder XML blocks from our standard folder list
|
|
143
|
+
const folderXml = config_js_1.FOLDERS.map((f) => `
|
|
144
|
+
<folder id="${f.id}" label="${f.id}" path="${f.path}" type="sendreceive"
|
|
145
|
+
rescanIntervalS="10" fsWatcherEnabled="true" fsWatcherDelayS="1">
|
|
146
|
+
<filesystemType>basic</filesystemType>
|
|
147
|
+
<minDiskFree unit="%">1</minDiskFree>
|
|
148
|
+
</folder>`).join("\n");
|
|
149
|
+
return `<configuration version="37">
|
|
150
|
+
${folderXml}
|
|
151
|
+
|
|
152
|
+
<gui enabled="true" tls="false" debugging="false">
|
|
153
|
+
<address>127.0.0.1:${apiPort}</address>
|
|
154
|
+
<apikey>${apiKey}</apikey>
|
|
155
|
+
<theme>default</theme>
|
|
156
|
+
</gui>
|
|
157
|
+
|
|
158
|
+
<options>
|
|
159
|
+
<listenAddress>default</listenAddress>
|
|
160
|
+
<globalAnnounceEnabled>true</globalAnnounceEnabled>
|
|
161
|
+
<localAnnounceEnabled>true</localAnnounceEnabled>
|
|
162
|
+
<relaysEnabled>true</relaysEnabled>
|
|
163
|
+
<startBrowser>false</startBrowser>
|
|
164
|
+
<natEnabled>true</natEnabled>
|
|
165
|
+
<urAccepted>-1</urAccepted>
|
|
166
|
+
<autoUpgradeIntervalH>0</autoUpgradeIntervalH>
|
|
167
|
+
</options>
|
|
168
|
+
|
|
169
|
+
<defaults>
|
|
170
|
+
<device>
|
|
171
|
+
<autoAcceptFolders>true</autoAcceptFolders>
|
|
172
|
+
</device>
|
|
173
|
+
</defaults>
|
|
174
|
+
</configuration>
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Start the Syncthing daemon as a background process.
|
|
179
|
+
* Returns the child process PID.
|
|
180
|
+
*
|
|
181
|
+
* We detach the process and unref it so our CLI can exit while
|
|
182
|
+
* Syncthing keeps running. The PID is saved to disk so `botsync stop`
|
|
183
|
+
* can find and kill it later.
|
|
184
|
+
*/
|
|
185
|
+
function startDaemon() {
|
|
186
|
+
(0, fs_1.mkdirSync)(config_js_1.SYNCTHING_CONFIG_DIR, { recursive: true });
|
|
187
|
+
const bin = getSyncthingBin();
|
|
188
|
+
const child = (0, child_process_1.spawn)(bin, [
|
|
189
|
+
"--no-browser",
|
|
190
|
+
"--no-upgrade",
|
|
191
|
+
`--home=${config_js_1.SYNCTHING_CONFIG_DIR}`,
|
|
192
|
+
], {
|
|
193
|
+
detached: true,
|
|
194
|
+
stdio: "ignore", // Don't inherit our stdio — it's a background daemon
|
|
195
|
+
});
|
|
196
|
+
child.unref(); // Let the parent process exit without waiting
|
|
197
|
+
const pid = child.pid;
|
|
198
|
+
if (!pid)
|
|
199
|
+
throw new Error("Failed to start Syncthing daemon");
|
|
200
|
+
// Save PID so we can stop it later
|
|
201
|
+
(0, fs_1.writeFileSync)(config_js_1.PID_FILE, pid.toString());
|
|
202
|
+
return pid;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Make an API call to the local Syncthing REST API.
|
|
206
|
+
* All config changes go through this — Syncthing's REST API is the
|
|
207
|
+
* canonical way to modify a running instance's configuration.
|
|
208
|
+
*/
|
|
209
|
+
async function apiCall(method, path, body) {
|
|
210
|
+
const config = (0, config_js_1.readConfig)();
|
|
211
|
+
if (!config)
|
|
212
|
+
throw new Error("botsync not initialized. Run `botsync init` first.");
|
|
213
|
+
const url = `http://127.0.0.1:${config.apiPort}${path}`;
|
|
214
|
+
const headers = {
|
|
215
|
+
"X-API-Key": config.apiKey,
|
|
216
|
+
"Content-Type": "application/json",
|
|
217
|
+
};
|
|
218
|
+
const res = await fetch(url, {
|
|
219
|
+
method,
|
|
220
|
+
headers,
|
|
221
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const text = await res.text();
|
|
225
|
+
throw new Error(`Syncthing API error: ${res.status} ${text}`);
|
|
226
|
+
}
|
|
227
|
+
// Some endpoints return no body (204, etc.)
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
if (!text)
|
|
230
|
+
return undefined;
|
|
231
|
+
return JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get this device's Syncthing device ID.
|
|
235
|
+
* The device ID is derived from the TLS certificate Syncthing generates
|
|
236
|
+
* on first run — it's essentially a public key fingerprint.
|
|
237
|
+
*/
|
|
238
|
+
async function getDeviceId() {
|
|
239
|
+
const status = await apiCall("GET", "/rest/system/status");
|
|
240
|
+
return status.myID;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Wait for Syncthing to become responsive.
|
|
244
|
+
* On first run, Syncthing needs a moment to generate its TLS cert and
|
|
245
|
+
* start the API server. We poll until it responds.
|
|
246
|
+
*/
|
|
247
|
+
async function waitForStart(maxRetries = 30) {
|
|
248
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
249
|
+
try {
|
|
250
|
+
await apiCall("GET", "/rest/system/status");
|
|
251
|
+
return; // It's alive!
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Not ready yet — wait and retry
|
|
255
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
throw new Error("Syncthing failed to start within 30 seconds");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Add a remote device to Syncthing's config.
|
|
262
|
+
* This is how we "pair" — we tell our Syncthing about the other device's ID.
|
|
263
|
+
*/
|
|
264
|
+
async function addDevice(deviceId) {
|
|
265
|
+
// Get current config, add the device, PUT it back
|
|
266
|
+
const config = await apiCall("GET", "/rest/config");
|
|
267
|
+
// Don't add if already present
|
|
268
|
+
const exists = config.devices.some((d) => d.deviceID === deviceId);
|
|
269
|
+
if (exists)
|
|
270
|
+
return;
|
|
271
|
+
config.devices.push({
|
|
272
|
+
deviceID: deviceId,
|
|
273
|
+
});
|
|
274
|
+
await apiCall("PUT", "/rest/config", config);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Add a device to a specific shared folder.
|
|
278
|
+
* Both sides need to share the same folder IDs with each other's device IDs
|
|
279
|
+
* for sync to work.
|
|
280
|
+
*/
|
|
281
|
+
async function addDeviceToFolder(folderId, deviceId) {
|
|
282
|
+
const config = await apiCall("GET", "/rest/config");
|
|
283
|
+
const folder = config.folders.find((f) => f.id === folderId);
|
|
284
|
+
if (!folder)
|
|
285
|
+
throw new Error(`Folder ${folderId} not found in config`);
|
|
286
|
+
// Don't add if already shared with this device
|
|
287
|
+
const exists = folder.devices?.some((d) => d.deviceID === deviceId);
|
|
288
|
+
if (exists)
|
|
289
|
+
return;
|
|
290
|
+
if (!folder.devices)
|
|
291
|
+
folder.devices = [];
|
|
292
|
+
folder.devices.push({ deviceID: deviceId });
|
|
293
|
+
await apiCall("PUT", "/rest/config", config);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Stop the Syncthing daemon by reading the PID file and sending SIGTERM.
|
|
297
|
+
* Returns true if a process was stopped, false if nothing was running.
|
|
298
|
+
*/
|
|
299
|
+
function stopDaemon() {
|
|
300
|
+
try {
|
|
301
|
+
const pid = parseInt((0, fs_1.readFileSync)(config_js_1.PID_FILE, "utf-8").trim(), 10);
|
|
302
|
+
process.kill(pid, "SIGTERM");
|
|
303
|
+
// Clean up PID file
|
|
304
|
+
const { unlinkSync } = require("fs");
|
|
305
|
+
unlinkSync(config_js_1.PID_FILE);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// No PID file or process already dead — that's fine
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ui.ts — Pretty terminal output for botsync.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Claude Code's minimal, elegant CLI style:
|
|
5
|
+
* - 2-space indent on everything
|
|
6
|
+
* - Muted colors, not a rainbow
|
|
7
|
+
* - Box-drawing for important info (passphrases)
|
|
8
|
+
* - Spinners for async waits
|
|
9
|
+
* - Clean status tables with alignment
|
|
10
|
+
*
|
|
11
|
+
* Uses chalk@4 (CJS-compatible) and ora@5 (CJS-compatible).
|
|
12
|
+
*/
|
|
13
|
+
import { Ora } from "ora";
|
|
14
|
+
/** The botsync diamond header — printed once at the top of every command. */
|
|
15
|
+
export declare function header(): void;
|
|
16
|
+
/** A step that completed successfully. */
|
|
17
|
+
export declare function stepDone(label: string): void;
|
|
18
|
+
/** A step that failed. */
|
|
19
|
+
export declare function stepFail(label: string): void;
|
|
20
|
+
/** Print an info line (indented, dimmed). */
|
|
21
|
+
export declare function info(text: string): void;
|
|
22
|
+
/** Print a blank line. */
|
|
23
|
+
export declare function gap(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Display the passphrase in a box so it visually pops.
|
|
26
|
+
* The box auto-sizes to the passphrase length (with wrapping for long ones).
|
|
27
|
+
*/
|
|
28
|
+
export declare function passphraseBox(passphrase: string, command: string): void;
|
|
29
|
+
/** Start a spinner. Returns the ora instance so the caller can stop it. */
|
|
30
|
+
export declare function spinner(text: string): Ora;
|
|
31
|
+
/** Print the "paired" success message. */
|
|
32
|
+
export declare function paired(deviceId: string): void;
|
|
33
|
+
/** Print connection success for the join side. */
|
|
34
|
+
export declare function connected(deviceId: string): void;
|
|
35
|
+
/** Print the status table. */
|
|
36
|
+
export declare function statusTable(peers: number, deviceId: string, folders: Array<{
|
|
37
|
+
name: string;
|
|
38
|
+
synced: boolean;
|
|
39
|
+
state: string;
|
|
40
|
+
lastChange?: string;
|
|
41
|
+
}>): void;
|
|
42
|
+
/** Stopped message. */
|
|
43
|
+
export declare function stopped(): void;
|
|
44
|
+
/** Not running message. */
|
|
45
|
+
export declare function notRunning(): void;
|
|
46
|
+
/** Error message. */
|
|
47
|
+
export declare function error(msg: string): void;
|