Library API
The engine behind the command line is a library. The VS Code extension uses it in-process, and so can your own program.
The API reference lists every export with its types. This page shows how the parts fit.
Install and import
npm install @isocialpractice/cmd-sync-servers
The package is ESM only and needs Node.js 22.14 or later. It has one entry point, plus an optional one for the operating system's credential store:
| Import | Holds |
|---|---|
@isocialpractice/cmd-sync-servers | Everything else: the site registry, sessions, plans, credentials, errors and the logger. |
@isocialpractice/cmd-sync-servers/credentials/keyring | The credential store backed by Windows Credential Manager, the macOS Keychain or the Secret Service keyring. |
@isocialpractice/cmd-sync-servers/schema/site.schema.json | The JSON Schema of sites.json. |
Stability. The API follows semantic versioning: a breaking change to it, or to sites.json, comes only in a new major version. Names marked experimental in the reference, such as the transports, the connection pool and the classification helpers, are outside that promise and may change in a minor version. SiteRegistry, SyncSession and SyncPlan are the parts to build on.
A first synchronization
This program puts newer local files of My Site to its Remote server and prints what happened. Save it as put.mjs and run it with node put.mjs.
import { MemoryCredentialStore, SiteRegistry, SyncSession, isSyncError } from '@isocialpractice/cmd-sync-servers';
const registry = await SiteRegistry.open();
const site = registry.resolve({ name: 'My Site' }) ?? registry.resolve({ path: process.cwd() });
if (site === undefined) {
throw new Error('Add the site first: sync-servers site add');
}
// What the engine needs from the program around it.
const host = {
credentials: new MemoryCredentialStore(),
interactive: false, // never ask: unknown host keys are refused
confirmHostKey: async () => 'reject',
promptSecret: async () => undefined, // the password comes from SYNC_SERVERS_PASSWORD
};
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
const session = await SyncSession.open({ registry, siteId: site.id, host });
try {
const plan = await session.plan({ direction: 'put', signal: controller.signal });
console.log(plan.summaryLine());
const result = await session.execute(plan, {
signal: controller.signal,
onProgress: (event) => {
if (event.type === 'transfer') {
console.log(`${event.completed}/${event.total} ${event.outcome} ${event.path}`);
}
},
});
console.log(`${result.done} done, ${result.failed} failed, ${result.unresolved} waiting for a decision`);
} catch (error) {
if (isSyncError(error, 'E_HOST_KEY_MISMATCH')) {
console.error('The server is not the one that was trusted. Check its host key.');
}
throw error;
} finally {
await session.close();
}
Run it after trusting the server's host key once with sync-servers server test, which pins it in sites.json.
Sites and servers
SiteRegistry reads and writes sites.json, the same list the command line and the extension use. Every write takes the lock file, re-reads the file, validates the result and replaces the file atomically, so concurrent programs never lose each other's changes.
const registry = await SiteRegistry.open(); // or open({ configDir, stateDir })
const site = await registry.add({ name: 'My Site', localRoot: '/home/<user>/sites/my-site' });
const server = await registry.addServer(site.id, {
name: 'Production',
protocol: 'sftp',
host: 'sftp.example.com',
username: 'demo-user',
remoteRoot: '/var/www/my-site',
});
await registry.update(site.id, (draft) => {
draft.cloaking.patterns.push('.psd');
});
console.log(registry.list().map((item) => item.name));
A site's first server becomes its Remote server. resolve() finds a site by id, by name ignoring case, or by a path inside its local folder.
The global cloaking patterns, which every site uses before its own unless its cloaking.useGlobalPatterns is false, are a GlobalCloaking: registry.globalCloaking reads them and updateGlobalCloaking() changes them through the same safe write. siteCloak() takes them as its third argument and answers whether a site-relative path is cloaked; a session passes them for you.
import { siteCloak } from '@isocialpractice/cmd-sync-servers';
await registry.updateGlobalCloaking((cloaking) => {
cloaking.patterns.push('.git/', 'node_modules/');
});
const cloak = siteCloak(site, process.platform, registry.globalCloaking.patterns);
console.log(cloak.isCloaked('node_modules', true)); // true, unless the site re-includes it
The host adapter
A HostAdapter is what the engine asks of the program it runs in:
| Member | Answers |
|---|---|
credentials | Where saved passwords and key passphrases are read and kept. |
confirmHostKey(info) | Whether to trust an SSH host key seen for the first time: 'accept-and-pin', 'accept-once' or 'reject'. |
confirmCertificate(info) | Optional. What to do about an FTPS certificate that did not verify, given a CertificateInfo: a CertificateDecision, described below. Without it, such a certificate is refused. |
promptSecret(request) | A password typed by a person, or undefined. request.note, when set, says why it is asked, such as a saved password that was not sent because the server changed. |
secretAccepted(request, secret) | Optional. Called once after the server accepts a secret a person typed, so the program may offer to save it. Never called for a secret from the store or the environment. |
interactive | false when nobody can answer, so new host keys and unverified certificates are refused without asking. |
logger | Where the engine logs, with every secret already removed. |
A CertificateInfo carries the host and port, the names the certificate lists, its issuer and validity dates, its SHA-256 fingerprint, the problem (name-mismatch, untrusted-issuer, self-signed, expired, not-yet-valid, changed or other), and coveredNames: host names that lead to the same machine and that the certificate covers. Every string from the certificate is already escaped. The decision is one of:
| Decision | Does |
|---|---|
{ action: 'use-host', host } | Connects to a name from coveredNames instead, with full verification, and saves it as the server's host. |
{ action: 'check-as', servername } | Keeps the host and checks the certificate as a name from coveredNames, saved as options.tls.servername. |
{ action: 'accept-and-pin' } | Trusts this certificate and pins its fingerprint in options.tls.trustedCertificate. |
{ action: 'accept-once' } | Trusts it for this session only. |
{ action: 'reject' } | Refuses it with E_TLS_CERT. |
Expired and not-yet-valid certificates are refused before the adapter is asked. Every decision is made before the user name or password is sent.
To use the operating system's credential store, as the command line does:
import { openKeyringStore } from '@isocialpractice/cmd-sync-servers/credentials/keyring';
import { MemoryCredentialStore } from '@isocialpractice/cmd-sync-servers';
const credentials = (await openKeyringStore()) ?? new MemoryCredentialStore();
openKeyringStore() resolves undefined when the optional @napi-rs/keyring package has no build for the platform.
Plans
session.plan() scans both sides and returns a SyncPlan; it transfers nothing. The plan holds one row per file with a difference, as the preview shows them, and can be changed before it runs:
const plan = await session.plan({ direction: 'both', deleteOrphans: false, scope: { kind: 'paths', paths: ['css'] } });
plan.summary; // { update: 3, delete: 0, skip: 41, resolve: 1, guarded: 0, errors: 0 }
for (const item of plan.items) {
console.log(item.id, item.action, item.checked, item.path, item.status);
}
plan.allowedActions('css/site.css'); // ['put', 'get', 'skip', 'synced', ...]
plan.setAction('css/site.css', 'get'); // a Resolve row needs an action before it can run
plan.setChecked('css/print.css', false);
const saved = JSON.stringify(plan.toJSON()); // run it later with SyncPlan.fromJSON and applySaved
Synchronization explains which action each change gets.
Other session calls
| Method | Does |
|---|---|
testConnection(signal) | Connects, signs in, checks the root directory and reports what the server offers, including startingFolder, the folder the account opens in. |
status(options) | What changed on each side since the last sync, without a plan. |
put(paths), get(paths) | Moves single files or folders at once; returns a plan instead when a decision is needed. |
compare(path, signal) | Both copies of a file for a diff; the server copy is a private temporary file removed by dispose(). |
markSynchronized(paths, signal) | Records files as in sync without moving them. |
resetState() | Forgets the sync records of the site and server. |
close() | Saves the records and closes every connection. |
Setting up a server
Two exports help a program set a server up before it is saved. parseServerAddress reads what a person typed in a Host field: a host name or IP address, host:port, [IPv6]:port, user@host, or an sftp://, ftpes://, ftps:// or ftp:// address.
import { parseServerAddress } from '@isocialpractice/cmd-sync-servers';
const result = parseServerAddress('demo-user@example.com@server1.hosting.example.net:21');
if (result.ok) {
// { host: 'server1.hosting.example.net', port: 21, username: 'demo-user@example.com' }
console.log(result.address, result.address.warning ?? '');
} else {
console.error(result.problem); // such as: That is a web address. Enter the FTP or SFTP host ...
}
ServerProbe (experimental) connects with settings that are not saved yet, goes through the host key and certificate decisions, and lists folders by absolute path, so the remote root can be picked from what the server shows. Give a new server its final id up front, so a password typed now can be saved under it once the server accepts it.
import { randomUUID } from 'node:crypto';
import { ServerProbe, serverSchema } from '@isocialpractice/cmd-sync-servers';
const server = serverSchema.parse({
id: randomUUID(),
name: 'Production',
protocol: 'ftps',
host: 'server1.hosting.example.net',
username: 'demo-user@example.com',
remoteRoot: '/',
});
const probe = await ServerProbe.open({ site: { id: site.id, name: site.name }, server, host });
try {
const start = probe.startingFolder ?? '/'; // where the account opens, such as "/"
const listing = await probe.folders(start); // { path: '/', folders: ['css', 'images', 'js'], files: 3 }
console.log(listing.folders, await probe.exists('/images'));
const settings = probe.server; // the settings, with any decision applied
} finally {
await probe.close();
}
Progress and cancelling
Every call that can take long takes an AbortSignal. Aborting a run starts no new transfer, removes partial files and records what finished; the result says cancelled: true. Aborting a scan rejects with E_CANCELLED.
Progress events carry counts and bytes, never file contents:
| Type | Carries |
|---|---|
scan-local | files found so far |
scan-remote | files and folders found so far |
compare | files compared |
transfer | path, action, outcome, completed, total and bytes |
done | completed and failed |
Errors
Failures are SyncError instances. Their code is stable across versions and their message never contains a secret. transient says whether trying again on a fresh connection may help. details holds facts to act on: for E_AUTH, reason is rejected, missing or saved-for-elsewhere; for E_TLS_CERT, certificate is the CertificateInfo; for E_REMOTE_ROOT, startingFolder and folders name where the account opens and what it holds.
import { isSyncError } from '@isocialpractice/cmd-sync-servers';
try {
await session.testConnection();
} catch (error) {
if (isSyncError(error, 'E_AUTH') && error.details?.reason === 'rejected') {
// the server refused it: ask for the user name and password again
} else if (isSyncError(error) && error.transient) {
// try again later
} else {
throw error;
}
}
Every code, with what to do about it, is listed in Troubleshooting.